verify-client-ui-i18n.ts 12 KB

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