Hacky demo changes
This commit is contained in:
parent
7cba369308
commit
ea7afd618c
39 changed files with 2041 additions and 745 deletions
259
frontend/scripts/check-translations.mjs
Normal file
259
frontend/scripts/check-translations.mjs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
#!/usr/bin/env node
|
||||
// Validates that every translation file under src/i18n is complete and consistent.
|
||||
//
|
||||
// Checks:
|
||||
// 1. Locales declared in SUPPORTED_LANGUAGES (index.ts) match the files in locales/
|
||||
// and the language records in descriptions.ts / details.ts.
|
||||
// 2. Every leaf key in en.ts is present and non-empty in every other locale.
|
||||
// 3. Every {{placeholder}} and HTML tag in an English string also appears,
|
||||
// with the same multiset, in the translated string.
|
||||
// 4. descriptions.ts and details.ts: the union of feature-name keys across
|
||||
// languages is treated as canonical; every language must cover all of them.
|
||||
//
|
||||
// The script parses the TypeScript source with the compiler API and walks the
|
||||
// AST — no runtime import, no transpilation, no temp files. Run it with:
|
||||
// node frontend/scripts/check-translations.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
|
||||
const LOCALES_DIR = join(I18N_DIR, 'locales');
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
|
||||
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const fail = (msg) => errors.push(msg);
|
||||
const warn = (msg) => warnings.push(msg);
|
||||
|
||||
function parseFile(path) {
|
||||
const src = readFileSync(path, 'utf8');
|
||||
return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
|
||||
}
|
||||
|
||||
// Recursively turn a TS literal expression into a plain JS value.
|
||||
// Returns undefined for nodes we don't understand — callers must check.
|
||||
function literalToJs(node) {
|
||||
if (!node) return undefined;
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
|
||||
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
|
||||
return literalToJs(node.expression);
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const out = {};
|
||||
for (const prop of node.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) continue;
|
||||
const k = prop.name;
|
||||
let key;
|
||||
if (ts.isIdentifier(k)) key = k.text;
|
||||
else if (ts.isStringLiteral(k) || ts.isNoSubstitutionTemplateLiteral(k)) key = k.text;
|
||||
else continue;
|
||||
out[key] = literalToJs(prop.initializer);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(node)) {
|
||||
return node.elements.map((e) => literalToJs(e));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findVarInitializer(sourceFile, name) {
|
||||
let result;
|
||||
function visit(node) {
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const decl of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
|
||||
result = decl.initializer;
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sourceFile);
|
||||
return result;
|
||||
}
|
||||
|
||||
function readSupportedLanguages() {
|
||||
const sf = parseFile(join(I18N_DIR, 'index.ts'));
|
||||
const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES');
|
||||
if (!init) throw new Error('Could not find SUPPORTED_LANGUAGES in index.ts');
|
||||
const arr = literalToJs(init);
|
||||
if (!Array.isArray(arr)) throw new Error('SUPPORTED_LANGUAGES is not an array literal');
|
||||
return arr.map((entry) => entry.code);
|
||||
}
|
||||
|
||||
function readLocale(code) {
|
||||
const path = join(LOCALES_DIR, `${code}.ts`);
|
||||
const sf = parseFile(path);
|
||||
const init = findVarInitializer(sf, code);
|
||||
if (!init) throw new Error(`Could not find const ${code} in ${path}`);
|
||||
const obj = literalToJs(init);
|
||||
if (!obj || typeof obj !== 'object') throw new Error(`${code}.ts: not an object literal`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
function readNamedRecord(file, varName) {
|
||||
const sf = parseFile(join(I18N_DIR, file));
|
||||
const init = findVarInitializer(sf, varName);
|
||||
if (!init) throw new Error(`Could not find ${varName} in ${file}`);
|
||||
const obj = literalToJs(init);
|
||||
if (!obj || typeof obj !== 'object') throw new Error(`${file}: ${varName} is not an object`);
|
||||
return obj;
|
||||
}
|
||||
|
||||
function flatten(obj, prefix = '', out = new Map()) {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||
flatten(v, path, out);
|
||||
} else {
|
||||
out.set(path, v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function tokenMultiset(s, re) {
|
||||
const matches = String(s).match(re) || [];
|
||||
// Normalise whitespace inside placeholders so '{{ count }}' == '{{count}}'.
|
||||
return matches.map((t) => t.replace(/\s+/g, '')).sort();
|
||||
}
|
||||
|
||||
function multisetsEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkLeafConsistency(path, enValue, trValue, lang) {
|
||||
if (typeof trValue !== 'string') {
|
||||
fail(`[${lang}] ${path}: missing translation`);
|
||||
return;
|
||||
}
|
||||
if (trValue.trim() === '') {
|
||||
fail(`[${lang}] ${path}: empty translation`);
|
||||
return;
|
||||
}
|
||||
for (const [re, label] of [
|
||||
[PLACEHOLDER_RE, 'placeholder'],
|
||||
[HTML_TAG_RE, 'HTML tag'],
|
||||
]) {
|
||||
const want = tokenMultiset(enValue, re);
|
||||
const got = tokenMultiset(trValue, re);
|
||||
if (!multisetsEqual(want, got)) {
|
||||
fail(
|
||||
`[${lang}] ${path}: ${label} mismatch — en=${JSON.stringify(want)} ` +
|
||||
`${lang}=${JSON.stringify(got)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkLocales(supportedCodes) {
|
||||
const localeFiles = readdirSync(LOCALES_DIR)
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
.map((f) => f.replace(/\.ts$/, ''));
|
||||
|
||||
for (const code of supportedCodes) {
|
||||
if (!localeFiles.includes(code)) {
|
||||
fail(`SUPPORTED_LANGUAGES lists "${code}" but locales/${code}.ts is missing`);
|
||||
}
|
||||
}
|
||||
for (const code of localeFiles) {
|
||||
if (!supportedCodes.includes(code)) {
|
||||
warn(`locales/${code}.ts exists but is not listed in SUPPORTED_LANGUAGES`);
|
||||
}
|
||||
}
|
||||
|
||||
const en = flatten(readLocale('en'));
|
||||
for (const code of supportedCodes) {
|
||||
if (code === 'en') continue;
|
||||
if (!localeFiles.includes(code)) continue;
|
||||
const tr = flatten(readLocale(code));
|
||||
for (const [path, enValue] of en) {
|
||||
checkLeafConsistency(path, enValue, tr.get(path), code);
|
||||
}
|
||||
for (const path of tr.keys()) {
|
||||
if (!en.has(path)) warn(`[${code}] ${path}: extra key not in en.ts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
|
||||
const record = readNamedRecord(file, varName);
|
||||
const expected = supportedCodes.filter((c) => c !== 'en');
|
||||
const present = Object.keys(record);
|
||||
|
||||
for (const code of expected) {
|
||||
if (!present.includes(code)) {
|
||||
fail(`${file}: missing language record "${code}"`);
|
||||
}
|
||||
}
|
||||
for (const code of present) {
|
||||
if (!expected.includes(code)) {
|
||||
warn(`${file}: unexpected language record "${code}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the union of feature-name keys across languages as canonical.
|
||||
const union = new Set();
|
||||
for (const code of expected) {
|
||||
if (record[code]) for (const k of Object.keys(record[code])) union.add(k);
|
||||
}
|
||||
|
||||
for (const code of expected) {
|
||||
const langKeys = new Set(Object.keys(record[code] ?? {}));
|
||||
for (const key of union) {
|
||||
if (!langKeys.has(key)) {
|
||||
fail(`${file} [${code}]: missing translation for "${key}"`);
|
||||
} else {
|
||||
const v = record[code][key];
|
||||
if (typeof v !== 'string' || v.trim() === '') {
|
||||
fail(`${file} [${code}]: empty translation for "${key}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every key here must also be a translatable feature name in en.ts > server.
|
||||
// Otherwise the description is unreachable — ts() looks up server.${name}.
|
||||
for (const key of union) {
|
||||
if (!serverKeys.has(key)) {
|
||||
fail(`${file}: key "${key}" has no matching entry in en.ts > server`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
let supportedCodes;
|
||||
try {
|
||||
supportedCodes = readSupportedLanguages();
|
||||
} catch (e) {
|
||||
console.error(`fatal: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
checkLocales(supportedCodes);
|
||||
const en = readLocale('en');
|
||||
const serverKeys = new Set(Object.keys(en.server ?? {}));
|
||||
checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
|
||||
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
|
||||
|
||||
for (const w of warnings) console.warn(`warn: ${w}`);
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) console.error(`error: ${e}`);
|
||||
console.error(`\n${errors.length} translation error(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Add table
Add a link
Reference in a new issue