From 49239e0e08ee0e4a8d5fd7f437a849216c653947 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:41:12 +0800 Subject: [PATCH] feat(ci): add i18n key consistency check for frontend locales (#2133) * feat(ci): add i18n key consistency check workflow Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/c7bf50da-189b-49a5-9671-dbe8e70ff9d0 Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> * feat(ci): replace eval with line-by-line parser, add permissions block Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/c7bf50da-189b-49a5-9671-dbe8e70ff9d0 Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com> --- .github/workflows/check-i18n.yml | 25 ++++++ web/scripts/check-i18n.mjs | 145 +++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 .github/workflows/check-i18n.yml create mode 100644 web/scripts/check-i18n.mjs diff --git a/.github/workflows/check-i18n.yml b/.github/workflows/check-i18n.yml new file mode 100644 index 00000000..688a5c6d --- /dev/null +++ b/.github/workflows/check-i18n.yml @@ -0,0 +1,25 @@ +name: Check i18n Keys + +on: + push: + branches: + - main + - master + +jobs: + check-i18n: + name: Check i18n Key Consistency + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Check i18n keys against en-US reference + run: node web/scripts/check-i18n.mjs diff --git a/web/scripts/check-i18n.mjs b/web/scripts/check-i18n.mjs new file mode 100644 index 00000000..2d52b643 --- /dev/null +++ b/web/scripts/check-i18n.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Check that all i18n locale files have the same keys as en-US.ts (the reference). + * Reports missing keys (present in en-US but absent in the locale) and + * extra keys (present in the locale but absent in en-US). + * Exits with code 1 if any mismatch is found. + * + * Keys are extracted using a line-by-line parser that handles the known format + * of the locale files (no eval or dynamic code execution is used). + */ + +import { readFileSync, readdirSync } from 'fs'; +import { resolve, dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const LOCALES_DIR = resolve(__dirname, '../src/i18n/locales'); +const REFERENCE = 'en-US.ts'; + +/** + * Extract all dot-notation leaf keys from a TypeScript locale file. + * + * The expected file format is: + * const = { + * key: 'value', + * nested: { + * subKey: 'value', + * }, + * }; + * export default ; + * + * The parser tracks indentation depth to build dot-separated key paths and + * never executes the file content. + */ +function extractKeys(filePath) { + let src = readFileSync(filePath, 'utf8'); + + // Remove UTF-8 BOM if present + if (src.charCodeAt(0) === 0xfeff) { + src = src.slice(1); + } + + const lines = src.split('\n'); + const keys = []; + // Stack of { key, indent } pairs representing the current nesting path + const stack = []; + + // Matches an object key at the start of a line (identifier or quoted string) + // Captures: [indent, keyName, hasOpenBrace] + const KEY_RE = /^(\s+)([\w]+)\s*:/; + const OPEN_BRACE_RE = /\{\s*$/; + const CLOSE_BRACE_RE = /^\s*\},?\s*$/; + + for (const line of lines) { + if (CLOSE_BRACE_RE.test(line)) { + // Pop the stack when we encounter a closing brace line + const lineIndent = line.match(/^(\s*)/)[1].length; + while (stack.length > 0 && stack[stack.length - 1].indent >= lineIndent) { + stack.pop(); + } + continue; + } + + const m = line.match(KEY_RE); + if (!m) continue; + + const indent = m[1].length; + const keyName = m[2]; + + // Pop stack entries that are at the same or deeper indent level + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + const prefix = stack.map((e) => e.key).join('.'); + const fullKey = prefix ? `${prefix}.${keyName}` : keyName; + + if (OPEN_BRACE_RE.test(line)) { + // This is a parent (nested object) key — push onto stack, don't record as leaf + stack.push({ key: keyName, indent }); + } else { + // This is a leaf key + keys.push(fullKey); + } + } + + return keys; +} + +function main() { + const files = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.ts')); + + if (!files.includes(REFERENCE)) { + console.error(`Reference file ${REFERENCE} not found in ${LOCALES_DIR}`); + process.exit(1); + } + + const refKeys = new Set(extractKeys(join(LOCALES_DIR, REFERENCE))); + let hasError = false; + + for (const file of files) { + if (file === REFERENCE) continue; + + const locale = file.replace('.ts', ''); + let localeKeys; + try { + localeKeys = new Set(extractKeys(join(LOCALES_DIR, file))); + } catch (e) { + console.error(`[${locale}] Failed to parse file: ${e.message}`); + hasError = true; + continue; + } + + const missing = [...refKeys].filter((k) => !localeKeys.has(k)); + const extra = [...localeKeys].filter((k) => !refKeys.has(k)); + + if (missing.length === 0 && extra.length === 0) { + console.log(`[${locale}] ✅ All keys match.`); + } else { + hasError = true; + console.log(`\n[${locale}] ❌ Key mismatch detected:`); + if (missing.length > 0) { + console.log(` Missing keys (in en-US but not in ${locale}):`); + for (const k of missing) { + console.log(` - ${k}`); + } + } + if (extra.length > 0) { + console.log(` Extra keys (in ${locale} but not in en-US):`); + for (const k of extra) { + console.log(` + ${k}`); + } + } + } + } + + if (hasError) { + console.log('\n❌ i18n key check failed. Please fix the mismatches above.'); + process.exit(1); + } else { + console.log('\n✅ All i18n locale files have matching keys.'); + } +} + +main();