verify-client-ui-i18n.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * Reject product UI copy embedded directly in Client source.
  3. *
  4. * Locale dictionaries are the only source files allowed to own translated
  5. * text. Presentation code receives copy through its typed `t` seat or through
  6. * an already-localized prop. This check covers JSX text and copy-bearing
  7. * attributes, plus the common data/helper forms that feed them.
  8. */
  9. import { globSync, readFileSync } from 'node:fs'
  10. import { resolve } from 'node:path'
  11. import ts from 'typescript'
  12. const root = resolve(import.meta.dirname, '..')
  13. const MINIMUM_CLIENT_UI_SOURCES = 450
  14. const COPY_ATTRIBUTES = new Set([
  15. 'alt',
  16. 'aria-description',
  17. 'aria-label',
  18. 'aria-valuetext',
  19. 'cancelLabel',
  20. 'closeLabel',
  21. 'confirmLabel',
  22. 'copyLabel',
  23. 'description',
  24. 'emptyLabel',
  25. 'label',
  26. 'placeholder',
  27. 'title',
  28. 'truncatedLabel',
  29. ])
  30. const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Placeholder|Summary|Text|Title|Tooltip)$/
  31. const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i
  32. const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i
  33. const IMMUTABLE_LANGUAGE_TOKENS = new Set([
  34. 'Function',
  35. 'K',
  36. 'M',
  37. 'MB',
  38. 'Symbol',
  39. 'false',
  40. 'function()',
  41. 'n',
  42. 'null',
  43. 'true',
  44. 'undefined',
  45. ])
  46. const LOCALE_KEY = /^[a-z][a-zA-Z0-9]*(?:[._-][a-zA-Z0-9]+)+$/
  47. /** One hard-coded product-copy occurrence. */
  48. export interface UiI18nViolation {
  49. /** One-based source column. */
  50. column: number
  51. /** Repository-relative source path. */
  52. file: string
  53. /** One-based source line. */
  54. line: number
  55. /** Why this literal is treated as product copy. */
  56. reason: string
  57. /** Compact literal text for the diagnostic. */
  58. text: string
  59. }
  60. function localeOwner(file: string): boolean {
  61. const normalized = file.replaceAll('\\', '/')
  62. const base = normalized.slice(normalized.lastIndexOf('/') + 1)
  63. return base === 'locale.ts'
  64. || base === 'locales.ts'
  65. || normalized.includes('/locales/')
  66. }
  67. function containsProductText(text: string): boolean {
  68. const normalized = text.replace(/\s+/g, ' ').trim()
  69. return normalized !== ''
  70. && !IMMUTABLE_LANGUAGE_TOKENS.has(normalized)
  71. && !LOCALE_KEY.test(normalized)
  72. && /\p{L}/u.test(normalized)
  73. }
  74. function propertyName(node: ts.PropertyName | ts.BindingName): string | undefined {
  75. return ts.isIdentifier(node) || ts.isStringLiteral(node) ? node.text : undefined
  76. }
  77. function copyAttribute(name: string): boolean {
  78. return !name.endsWith('Key')
  79. && (COPY_ATTRIBUTES.has(name) || COPY_ATTRIBUTE_SUFFIX.test(name))
  80. }
  81. function compactText(text: string): string {
  82. const normalized = text.replace(/\s+/g, ' ').trim()
  83. return normalized.length <= 80 ? normalized : `${normalized.slice(0, 77)}...`
  84. }
  85. function looksLikeNaturalText(text: string): boolean {
  86. const normalized = text.replace(/\s+/g, ' ').trim()
  87. return /\s|[\u3400-\u9fff]/u.test(normalized) || /^[A-Z]/.test(normalized)
  88. }
  89. /**
  90. * Find hard-coded product copy in one Client source file.
  91. * @param file - repository-relative path used in diagnostics.
  92. * @param sourceText - TypeScript or TSX source.
  93. * @returns violations in source order.
  94. */
  95. export function findUiI18nViolations(file: string, sourceText: string): UiI18nViolation[] {
  96. if (localeOwner(file)) return []
  97. const source = ts.createSourceFile(
  98. file,
  99. sourceText,
  100. ts.ScriptTarget.Latest,
  101. true,
  102. file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
  103. )
  104. const violations = new Map<number, UiI18nViolation>()
  105. const report = (
  106. node: ts.Node,
  107. text: string,
  108. reason: string,
  109. naturalOnly = false,
  110. ): void => {
  111. if (
  112. !containsProductText(text)
  113. || (naturalOnly && !looksLikeNaturalText(text))
  114. || violations.has(node.getStart(source))
  115. ) return
  116. const position = source.getLineAndCharacterOfPosition(node.getStart(source))
  117. violations.set(node.getStart(source), {
  118. column: position.character + 1,
  119. file,
  120. line: position.line + 1,
  121. reason,
  122. text: compactText(text),
  123. })
  124. }
  125. const collectExpression = (
  126. node: ts.Expression,
  127. reason: string,
  128. naturalOnly = false,
  129. ): void => {
  130. if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
  131. report(node, node.text, reason, naturalOnly)
  132. return
  133. }
  134. if (ts.isTemplateExpression(node)) {
  135. report(
  136. node,
  137. [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join(''),
  138. reason,
  139. naturalOnly,
  140. )
  141. return
  142. }
  143. if (ts.isCallExpression(node)) {
  144. // A call result is dynamic; copy-bearing arguments are visited through their own syntax.
  145. return
  146. }
  147. if (
  148. ts.isParenthesizedExpression(node)
  149. || ts.isAsExpression(node)
  150. || ts.isSatisfiesExpression(node)
  151. || ts.isNonNullExpression(node)
  152. ) {
  153. collectExpression(node.expression, reason, naturalOnly)
  154. return
  155. }
  156. if (ts.isConditionalExpression(node)) {
  157. collectExpression(node.whenTrue, reason, naturalOnly)
  158. collectExpression(node.whenFalse, reason, naturalOnly)
  159. return
  160. }
  161. if (ts.isBinaryExpression(node)) {
  162. if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
  163. collectExpression(node.right, reason, naturalOnly)
  164. } else if (
  165. node.operatorToken.kind === ts.SyntaxKind.PlusToken
  166. || node.operatorToken.kind === ts.SyntaxKind.BarBarToken
  167. || node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
  168. ) {
  169. collectExpression(node.left, reason, naturalOnly)
  170. collectExpression(node.right, reason, naturalOnly)
  171. }
  172. return
  173. }
  174. if (ts.isArrayLiteralExpression(node)) {
  175. for (const element of node.elements) {
  176. if (ts.isExpression(element)) collectExpression(element, reason, naturalOnly)
  177. }
  178. return
  179. }
  180. if (ts.isObjectLiteralExpression(node)) {
  181. for (const property of node.properties) {
  182. if (ts.isPropertyAssignment(property)) {
  183. const name = propertyName(property.name)
  184. const propertyOwnsCopy = name !== undefined
  185. && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))
  186. collectExpression(property.initializer, reason, naturalOnly || !propertyOwnsCopy)
  187. }
  188. }
  189. }
  190. }
  191. const enclosingFunctionName = (node: ts.Node): string | undefined => {
  192. let current = node.parent
  193. while (!ts.isSourceFile(current)) {
  194. if (ts.isFunctionDeclaration(current) || ts.isMethodDeclaration(current)) {
  195. return current.name === undefined ? undefined : propertyName(current.name)
  196. }
  197. if (ts.isArrowFunction(current) || ts.isFunctionExpression(current)) {
  198. const parent = current.parent
  199. return ts.isVariableDeclaration(parent) ? propertyName(parent.name) : undefined
  200. }
  201. current = current.parent
  202. }
  203. return undefined
  204. }
  205. const hasExplicitStringReturn = (node: ts.Node): boolean => {
  206. let current = node.parent
  207. while (!ts.isSourceFile(current)) {
  208. if (
  209. ts.isFunctionDeclaration(current)
  210. || ts.isMethodDeclaration(current)
  211. || ts.isArrowFunction(current)
  212. || ts.isFunctionExpression(current)
  213. ) return current.type?.kind === ts.SyntaxKind.StringKeyword
  214. current = current.parent
  215. }
  216. return false
  217. }
  218. const visit = (node: ts.Node): void => {
  219. if (ts.isJsxText(node)) report(node, node.text, 'JSX text')
  220. if (ts.isJsxAttribute(node)) {
  221. const name = node.name.getText(source)
  222. if (copyAttribute(name) && node.initializer !== undefined) {
  223. if (ts.isStringLiteral(node.initializer)) report(node.initializer, node.initializer.text, `${name} attribute`)
  224. else if (ts.isJsxExpression(node.initializer) && node.initializer.expression !== undefined) {
  225. collectExpression(node.initializer.expression, `${name} attribute`)
  226. }
  227. }
  228. }
  229. if (
  230. ts.isJsxExpression(node)
  231. && node.expression !== undefined
  232. && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
  233. ) collectExpression(node.expression, 'JSX child')
  234. if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) {
  235. const name = propertyName(node.name)
  236. if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
  237. collectExpression(node.initializer, `${name} property`)
  238. }
  239. }
  240. if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
  241. const name = propertyName(node.name)
  242. if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
  243. collectExpression(node.initializer, `${name} value`)
  244. }
  245. }
  246. if (ts.isBindingElement(node) && node.initializer !== undefined) {
  247. const name = propertyName(node.name)
  248. if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
  249. collectExpression(node.initializer, `${name} default value`)
  250. }
  251. }
  252. if (ts.isReturnStatement(node) && node.expression !== undefined) {
  253. const name = enclosingFunctionName(node)
  254. if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
  255. collectExpression(node.expression, `${name} return value`)
  256. } else if (file.endsWith('.tsx') && hasExplicitStringReturn(node)) {
  257. collectExpression(node.expression, 'string return value', true)
  258. }
  259. }
  260. ts.forEachChild(node, visit)
  261. }
  262. visit(source)
  263. return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column)
  264. }
  265. /**
  266. * Resolve the normalized Client source root containing one TSX component.
  267. * @param file - Glob result using native or POSIX separators.
  268. * @returns Repository-relative `src/client` root, or undefined outside that tree.
  269. */
  270. export function clientSourceRoot(file: string): string | undefined {
  271. const normalized = file.replaceAll('\\', '/')
  272. const marker = '/src/client/'
  273. const index = normalized.indexOf(marker)
  274. return index < 0 ? undefined : normalized.slice(0, index + marker.length - 1)
  275. }
  276. function sourceFiles(): string[] {
  277. const clientComponentRoots = new Set(
  278. globSync('packages/*/*/src/client/**/*.tsx', { cwd: root })
  279. .map(clientSourceRoot)
  280. .filter((clientRoot): clientRoot is string => clientRoot !== undefined),
  281. )
  282. return [...new Set([
  283. ...globSync('packages/client/*/src/**/*.tsx', { cwd: root }),
  284. ...globSync('packages/client/ui-*/src/**/*.{ts,tsx}', { cwd: root }),
  285. ...[...clientComponentRoots].flatMap(clientRoot =>
  286. globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })),
  287. ...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }),
  288. ])]
  289. .map(file => file.replaceAll('\\', '/'))
  290. .filter(file => !file.endsWith('.d.ts'))
  291. .sort()
  292. }
  293. function main(): void {
  294. const files = sourceFiles()
  295. if (files.length < MINIMUM_CLIENT_UI_SOURCES) {
  296. throw new Error(
  297. `verify-client-ui-i18n: discovery narrowed to ${files.length} source file(s); expected at least ${MINIMUM_CLIENT_UI_SOURCES}.`,
  298. )
  299. }
  300. const violations = files.flatMap(file =>
  301. findUiI18nViolations(file, readFileSync(resolve(root, file), 'utf8')))
  302. if (violations.length > 0) {
  303. console.error(`verify-client-ui-i18n: ${violations.length} hard-coded UI string(s):`)
  304. for (const violation of violations) {
  305. console.error(
  306. ` ${violation.file}:${violation.line}:${violation.column} ${violation.reason}: ${JSON.stringify(violation.text)}`,
  307. )
  308. }
  309. process.exitCode = 1
  310. return
  311. }
  312. console.log(`verify-client-ui-i18n: ${files.length} Client UI source file(s) use locale-owned copy.`)
  313. }
  314. if (import.meta.filename === resolve(process.argv[1] ?? '')) main()