mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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>
This commit is contained in:
25
.github/workflows/check-i18n.yml
vendored
Normal file
25
.github/workflows/check-i18n.yml
vendored
Normal file
@@ -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
|
||||||
145
web/scripts/check-i18n.mjs
Normal file
145
web/scripts/check-i18n.mjs
Normal file
@@ -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 <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();
|
||||||
Reference in New Issue
Block a user