diff --git a/.github/workflows/frontend-i18n.yml b/.github/workflows/frontend-i18n.yml new file mode 100644 index 00000000..da77f597 --- /dev/null +++ b/.github/workflows/frontend-i18n.yml @@ -0,0 +1,48 @@ +name: frontend i18n + +on: + pull_request: + paths: + - '.github/workflows/frontend-i18n.yml' + - 'frontend/package.json' + - 'frontend/pnpm-lock.yaml' + - 'frontend/scripts/check-i18n-literals.mjs' + - 'frontend/src/**/*.ts' + - 'frontend/src/**/*.tsx' + - 'frontend/packages/api/src/**/*.ts' + - 'frontend/packages/api/src/**/*.tsx' + - 'frontend/packages/biz-ui/src/**/*.ts' + - 'frontend/packages/biz-ui/src/**/*.tsx' + - 'frontend/tests/fixtures/i18n-key-checker/**' + - 'frontend/tests/i18n/i18n-key-checker.test.ts' + +concurrency: + group: frontend-i18n-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.9 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.14.0 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Check translation literals and keys + run: pnpm check:i18n diff --git a/frontend/scripts/check-i18n-literals.mjs b/frontend/scripts/check-i18n-literals.mjs index 93e29664..8a02cc7b 100644 --- a/frontend/scripts/check-i18n-literals.mjs +++ b/frontend/scripts/check-i18n-literals.mjs @@ -14,11 +14,80 @@ const collectSourceFiles = (directory) => fs.readdirSync(directory, { withFileTy // App and shared-package code are both checked. Locale resources and tests are // excluded because their literals are intentionally not rendered directly. -const migratedFiles = [ - ...collectSourceFiles(path.join(frontendRoot, 'src')), - ...collectSourceFiles(path.join(frontendRoot, 'packages', 'api', 'src')), - ...collectSourceFiles(path.join(frontendRoot, 'packages', 'biz-ui', 'src')), -].sort() +const sourceArgumentIndex = process.argv.indexOf('--source') +const requestedSource = sourceArgumentIndex >= 0 ? process.argv[sourceArgumentIndex + 1] : null +const migratedFiles = requestedSource + ? [requestedSource] + : [ + ...collectSourceFiles(path.join(frontendRoot, 'src')), + ...collectSourceFiles(path.join(frontendRoot, 'packages', 'api', 'src')), + ...collectSourceFiles(path.join(frontendRoot, 'packages', 'biz-ui', 'src')), + ].sort() +const localeNames = ['zh-CN', 'en-US'] + +const unwrapExpression = (expression) => { + let current = expression + while (current && ( + ts.isAsExpression(current) + || ts.isSatisfiesExpression(current) + || ts.isParenthesizedExpression(current) + || ts.isNonNullExpression(current) + || ts.isTypeAssertionExpression(current) + )) { + current = current.expression + } + return current +} + +const propertyNameText = (name) => ( + ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) + ? name.text + : null +) + +const collectResourcePaths = (expression, prefix, catalog) => { + const value = unwrapExpression(expression) + if (!value || !ts.isObjectLiteralExpression(value)) { + if (prefix) catalog.leaves.add(prefix) + return + } + if (prefix) catalog.branches.add(prefix) + for (const property of value.properties) { + if (!ts.isPropertyAssignment(property)) continue + const name = propertyNameText(property.name) + if (!name) continue + const childPath = prefix ? `${prefix}.${name}` : name + collectResourcePaths(property.initializer, childPath, catalog) + } +} + +const loadResourceCatalog = (locale) => { + const localeDirectory = path.join(frontendRoot, 'src', 'i18n', 'locales', locale) + const namespaces = new Map() + for (const entry of fs.readdirSync(localeDirectory, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) continue + const absolutePath = path.join(localeDirectory, entry.name) + const source = ts.createSourceFile( + absolutePath, + fs.readFileSync(absolutePath, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue + const catalog = { leaves: new Set(), branches: new Set() } + collectResourcePaths(declaration.initializer, '', catalog) + namespaces.set(declaration.name.text, catalog) + } + } + } + return namespaces +} + +const resourceCatalogs = new Map(localeNames.map((locale) => [locale, loadResourceCatalog(locale)])) const hanPattern = /[\u3400-\u9fff]/u const fixedEnglishFallbackPattern = /\b(?:build position|do not open|entry plan|unknown|watch)\b/i const englishPhrasePattern = /\b[A-Za-z]{3,}(?:[ -][A-Za-z]{3,})+\b/ @@ -162,6 +231,201 @@ const isTechnicalLiteral = (text) => ( ) const failures = [] +const keyFailures = [] + +const namespaceArguments = (call) => { + const argument = call.arguments[0] + if (!argument) return ['common'] + if (ts.isStringLiteralLike(argument)) return [argument.text] + if (ts.isArrayLiteralExpression(argument)) { + const namespaces = argument.elements + .filter(ts.isStringLiteralLike) + .map((element) => element.text) + return namespaces.length > 0 ? namespaces : ['common'] + } + return ['common'] +} + +const bindingNames = (name) => { + if (ts.isIdentifier(name)) return [name.text] + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + return name.elements.flatMap((element) => ts.isBindingElement(element) ? bindingNames(element.name) : []) + } + return [] +} + +const translatorForKey = (binding, key) => { + const rawKey = `${binding.prefix}${key}` + const separator = rawKey.indexOf(':') + if (separator >= 0) { + return { + namespace: rawKey.slice(0, separator), + path: rawKey.slice(separator + 1), + prefix: '', + } + } + return { namespace: binding.namespace, path: rawKey, prefix: '' } +} + +const formatKeyFailure = (source, node, message) => { + const position = source.getLineAndCharacterOfPosition(node.getStart(source)) + return `${path.relative(frontendRoot, source.fileName)}:${position.line + 1}:${position.character + 1} ${message}` +} + +const validateResourcePath = (source, node, binding, key, dynamic) => { + const target = translatorForKey(binding, key) + const resourcePath = dynamic ? target.path.replace(/\.$/, '') : target.path + if (!resourcePath) return + for (const locale of localeNames) { + const namespace = resourceCatalogs.get(locale)?.get(target.namespace) + if (!namespace) { + keyFailures.push(formatKeyFailure(source, node, `unknown ${locale} namespace "${target.namespace}"`)) + continue + } + const exists = dynamic + ? namespace.branches.has(resourcePath) + : namespace.leaves.has(resourcePath) + if (!exists) { + const kind = dynamic ? 'dynamic key prefix' : 'translation key' + keyFailures.push(formatKeyFailure( + source, + node, + `missing ${locale} ${kind} "${target.namespace}:${resourcePath}${dynamic ? '.*' : ''}"`, + )) + } + } +} + +const validateTranslationKeys = (source) => { + const scopes = [new Map()] + const currentScope = () => scopes[scopes.length - 1] + const resolveBinding = (name) => { + for (let index = scopes.length - 1; index >= 0; index -= 1) { + if (scopes[index].has(name)) return scopes[index].get(name) + } + return null + } + + const translatorFromCall = (call) => { + const callee = unwrapExpression(call.expression) + if (ts.isIdentifier(callee)) return resolveBinding(callee.text) + if (ts.isPropertyAccessExpression(callee) + && callee.name.text === 't' + && ts.isIdentifier(callee.expression) + && ['i18n', 'i18nInstance'].includes(callee.expression.text)) { + return { namespace: 'common', prefix: '' } + } + return null + } + + const returnedCall = (initializer) => { + const value = unwrapExpression(initializer) + if (!value || (!ts.isArrowFunction(value) && !ts.isFunctionExpression(value))) return null + const body = unwrapExpression(value.body) + if (body && ts.isCallExpression(body)) return { fn: value, call: body } + if (!ts.isBlock(value.body)) return null + const returned = value.body.statements.find(ts.isReturnStatement) + const expression = returned?.expression ? unwrapExpression(returned.expression) : null + return expression && ts.isCallExpression(expression) ? { fn: value, call: expression } : null + } + + const deriveWrapperBinding = (initializer) => { + const returned = returnedCall(initializer) + if (!returned || returned.fn.parameters.length === 0) return null + const keyParameter = returned.fn.parameters[0].name + if (!ts.isIdentifier(keyParameter)) return null + const sourceBinding = translatorFromCall(returned.call) + const keyArgument = returned.call.arguments[0] + if (!sourceBinding || !keyArgument) return null + + if (ts.isIdentifier(keyArgument) && keyArgument.text === keyParameter.text) return sourceBinding + if (!ts.isTemplateExpression(keyArgument) || keyArgument.templateSpans.length !== 1) return null + const span = keyArgument.templateSpans[0] + if (!ts.isIdentifier(span.expression) + || span.expression.text !== keyParameter.text + || span.literal.text !== '') return null + const target = translatorForKey(sourceBinding, keyArgument.head.text) + return { namespace: target.namespace, prefix: target.path } + } + + const registerDeclaration = (declaration) => { + for (const name of bindingNames(declaration.name)) currentScope().set(name, null) + if (!declaration.initializer) return + const initializer = unwrapExpression(declaration.initializer) + + if (ts.isObjectBindingPattern(declaration.name) + && initializer + && ts.isCallExpression(initializer) + && ts.isIdentifier(initializer.expression) + && initializer.expression.text === 'useTranslation') { + const namespace = namespaceArguments(initializer)[0] + for (const element of declaration.name.elements) { + const propertyName = element.propertyName ? propertyNameText(element.propertyName) : propertyNameText(element.name) + if (propertyName === 't' && ts.isIdentifier(element.name)) { + currentScope().set(element.name.text, { namespace, prefix: '' }) + } + } + return + } + + if (!ts.isIdentifier(declaration.name) || !initializer) return + if (ts.isIdentifier(initializer)) { + const binding = resolveBinding(initializer.text) + if (binding) currentScope().set(declaration.name.text, binding) + return + } + if (ts.isCallExpression(initializer) + && ts.isPropertyAccessExpression(initializer.expression) + && initializer.expression.name.text === 'getFixedT') { + const namespace = initializer.arguments[1] + if (namespace && ts.isStringLiteralLike(namespace)) { + currentScope().set(declaration.name.text, { namespace: namespace.text, prefix: '' }) + } + return + } + const wrapper = deriveWrapperBinding(declaration.initializer) + if (wrapper) currentScope().set(declaration.name.text, wrapper) + } + + const validateCall = (call) => { + const binding = translatorFromCall(call) + const key = call.arguments[0] + if (!binding || !key) return + if (ts.isStringLiteralLike(key)) { + validateResourcePath(source, key, binding, key.text, false) + return + } + if (ts.isTemplateExpression(key) && key.head.text) { + validateResourcePath(source, key, binding, key.head.text, true) + } + } + + const visit = (node) => { + const createsScope = node !== source && ( + ts.isFunctionLike(node) + || ts.isBlock(node) + || ts.isCatchClause(node) + ) + if (createsScope) { + scopes.push(new Map()) + if (ts.isFunctionLike(node)) { + for (const parameter of node.parameters) { + for (const name of bindingNames(parameter.name)) currentScope().set(name, null) + } + } else if (ts.isCatchClause(node) && node.variableDeclaration) { + for (const name of bindingNames(node.variableDeclaration.name)) currentScope().set(name, null) + } + } + + if (ts.isVariableDeclaration(node)) registerDeclaration(node) + if (ts.isCallExpression(node)) validateCall(node) + ts.forEachChild(node, visit) + + if (createsScope) scopes.pop() + } + + visit(source) +} for (const relativePath of migratedFiles) { const absolutePath = path.join(frontendRoot, relativePath) @@ -196,12 +460,19 @@ for (const relativePath of migratedFiles) { } inspect(source) + validateTranslationKeys(source) } -if (failures.length > 0) { - console.error('Migrated UI files contain direct or indirect untranslated literals:') - for (const failure of failures) console.error(`- ${failure}`) +if (failures.length > 0 || keyFailures.length > 0) { + if (failures.length > 0) { + console.error('Migrated UI files contain direct or indirect untranslated literals:') + for (const failure of failures) console.error(`- ${failure}`) + } + if (keyFailures.length > 0) { + console.error('Translation calls reference missing resource keys:') + for (const failure of keyFailures) console.error(`- ${failure}`) + } process.exitCode = 1 } else { - console.log(`i18n literal check passed for ${migratedFiles.length} migrated files`) + console.log(`i18n literal and key checks passed for ${migratedFiles.length} migrated files`) } diff --git a/frontend/tests/fixtures/i18n-key-checker/missing.tsx b/frontend/tests/fixtures/i18n-key-checker/missing.tsx new file mode 100644 index 00000000..fb8c6ae3 --- /dev/null +++ b/frontend/tests/fixtures/i18n-key-checker/missing.tsx @@ -0,0 +1,13 @@ +declare function useTranslation(namespace: string): { + t: (key: string, options?: Record) => string +} + +declare const reason: string + +const { t } = useTranslation('configuration') +const message = (key: string, options?: Record) => + t(`p4.paperTrading.messages.${key}`, options) + +t('errors.timeout') +message(`exitReasons.${reason}`) + diff --git a/frontend/tests/fixtures/i18n-key-checker/valid.tsx b/frontend/tests/fixtures/i18n-key-checker/valid.tsx new file mode 100644 index 00000000..42f68ed2 --- /dev/null +++ b/frontend/tests/fixtures/i18n-key-checker/valid.tsx @@ -0,0 +1,13 @@ +declare function useTranslation(namespace: string): { + t: (key: string, options?: Record) => string +} + +declare const market: string + +const { t } = useTranslation('configuration') +const opportunityT = (key: string, options?: Record) => + t(`opportunities.${key}`, options) + +opportunityT('errors.timeout') +opportunityT(`markets.${market}`) + diff --git a/frontend/tests/i18n/i18n-key-checker.test.ts b/frontend/tests/i18n/i18n-key-checker.test.ts new file mode 100644 index 00000000..16dcd159 --- /dev/null +++ b/frontend/tests/i18n/i18n-key-checker.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from 'node:child_process' +import path from 'node:path' + +const frontendRoot = process.cwd() +const checker = path.join(frontendRoot, 'scripts/check-i18n-literals.mjs') + +function runChecker(fixture: string) { + return spawnSync( + process.execPath, + [checker, '--source', `tests/fixtures/i18n-key-checker/${fixture}`], + { cwd: frontendRoot, encoding: 'utf8' }, + ) +} + +describe('i18n key checker', () => { + it('accepts static and dynamic keys under a valid scoped translator', () => { + const result = runChecker('valid.tsx') + + expect(result.status).toBe(0) + expect(result.stdout).toContain('i18n literal and key checks passed') + }) + + it('rejects missing static keys and dynamic prefixes', () => { + const result = runChecker('missing.tsx') + const output = `${result.stdout}\n${result.stderr}` + + expect(result.status).toBe(1) + expect(output).toContain('configuration:errors.timeout') + expect(output).toContain('configuration:p4.paperTrading.messages.exitReasons.*') + }) +})