Files
LangBot/web/scripts/check-i18n.mjs
Copilot 49239e0e08 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>
2026-04-17 18:41:12 +08:00

146 lines
4.2 KiB
JavaScript

#!/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 <varName> = {
* key: 'value',
* nested: {
* subKey: 'value',
* },
* };
* export default <varName>;
*
* 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();