Files
server/scripts/l10n-extract.mjs
2026-07-07 17:57:43 -04:00

185 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* L10N catalog extraction (v1)
*
* Scans source files for hybrid-style translation calls
*
* t('some.key', 'Default English text')
* t('some.key', 'Hello {name}', { name })
*
* and generates the source-locale catalog (en.json) from the inline
* defaults, so the file sent to translators always matches the code.
* Keys are authored WITHOUT the namespace prefix; the runtime adds the
* namespace (module handle / 'core') when merging catalogs.
*
* Entries that can only be referenced with dynamic keys can be maintained
* by hand in en.manual.json next to the target catalog; they are merged in.
*
* Usage:
* node scripts/l10n-extract.mjs # write core/l10n/en.json
* node scripts/l10n-extract.mjs --check # exit 1 if the file is stale
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function discoverModuleTargets() {
const modulesDir = path.join(rootDir, 'modules');
if (!existsSync(modulesDir)) return [];
const discovered = [];
for (const handle of readdirSync(modulesDir)) {
const moduleDir = path.join(modulesDir, handle);
const sourceDir = path.join(moduleDir, 'src');
if (!existsSync(sourceDir) || !statSync(moduleDir).isDirectory()) continue;
const optedIn = existsSync(path.join(moduleDir, 'l10n'))
|| collectSourceFiles(sourceDir).some((file) => readFileSync(file, 'utf8').includes('useL10n('));
if (optedIn) {
discovered.push({
name: handle,
sourceDir,
catalogFile: path.join(moduleDir, 'l10n/en.json'),
});
}
}
return discovered;
}
const checkMode = process.argv.includes('--check');
// Matches t('key', 'default' / t("key", "default" — not preceded by a
// word character or '.', so i18n.global.t(...) and foo.t(...) are ignored.
const CALL_PATTERN = /(?<![\w$.])t\(\s*(['"])((?:\\.|(?!\1).)+?)\1\s*,\s*(['"])((?:\\.|(?!\3).)*?)\3/g;
const SOURCE_EXTENSIONS = new Set(['.ts', '.vue']);
const SKIP_DIRS = new Set(['node_modules', 'l10n', 'static', 'dist']);
function collectSourceFiles(dir, files = []) {
for (const entry of readdirSync(dir)) {
const fullPath = path.join(dir, entry);
if (statSync(fullPath).isDirectory()) {
if (!SKIP_DIRS.has(entry)) collectSourceFiles(fullPath, files);
} else if (SOURCE_EXTENSIONS.has(path.extname(entry))) {
files.push(fullPath);
}
}
return files;
}
function unescape(literal) {
return literal.replace(/\\(['"\\])/g, '$1');
}
function extractTarget(target) {
const entries = new Map(); // key -> { default, file }
let hasErrors = false;
for (const file of collectSourceFiles(target.sourceDir)) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(CALL_PATTERN)) {
const key = unescape(match[2]);
const defaultText = unescape(match[4]);
const relFile = path.relative(rootDir, file);
if (!/^[\w-]+(\.[\w-]+)*$/.test(key)) {
console.error(`${relFile}: invalid key "${key}" (use dot-separated word segments)`);
hasErrors = true;
continue;
}
const existing = entries.get(key);
if (existing && existing.default !== defaultText) {
console.error(
`✗ key "${key}" has conflicting defaults:\n` +
` "${existing.default}" (${existing.file})\n` +
` "${defaultText}" (${relFile})`
);
hasErrors = true;
continue;
}
entries.set(key, { default: defaultText, file: relFile });
}
}
// Nest dotted keys into an object tree
const catalog = {};
for (const key of [...entries.keys()].sort()) {
const segments = key.split('.');
let node = catalog;
for (const segment of segments.slice(0, -1)) {
if (typeof node[segment] === 'string') {
console.error(`✗ key "${key}" nests under "${segment}", which is already a message`);
hasErrors = true;
node = null;
break;
}
node = node[segment] ??= {};
}
if (node) node[segments.at(-1)] = entries.get(key).default;
}
// Merge hand-maintained entries for dynamic keys
const manualFile = path.join(path.dirname(target.catalogFile), 'en.manual.json');
if (existsSync(manualFile)) {
deepMerge(catalog, JSON.parse(readFileSync(manualFile, 'utf8')));
}
return { catalog, count: entries.size, hasErrors };
}
function deepMerge(base, extra) {
for (const [key, value] of Object.entries(extra)) {
if (value && typeof value === 'object' && base[key] && typeof base[key] === 'object') {
deepMerge(base[key], value);
} else {
base[key] = value;
}
}
}
function sortDeep(node) {
if (typeof node !== 'object' || node === null) return node;
return Object.fromEntries(
Object.keys(node).sort().map((key) => [key, sortDeep(node[key])])
);
}
// Extraction targets: source tree -> catalog file. Core always; a module is
// a target once it opts in — has an l10n/ dir or calls useL10n in its src/.
const targets = [
{
name: 'core',
sourceDir: path.join(rootDir, 'core/src'),
catalogFile: path.join(rootDir, 'core/l10n/en.json'),
},
...discoverModuleTargets(),
];
let failed = false;
for (const target of targets) {
const { catalog, count, hasErrors } = extractTarget(target);
if (hasErrors) {
failed = true;
continue;
}
const output = JSON.stringify(sortDeep(catalog), null, 2) + '\n';
const relCatalog = path.relative(rootDir, target.catalogFile);
if (checkMode) {
const current = existsSync(target.catalogFile) ? readFileSync(target.catalogFile, 'utf8') : '';
if (current !== output) {
console.error(`${relCatalog} is out of sync with source — run: npm run l10n:extract`);
failed = true;
} else {
console.log(`${relCatalog} in sync (${count} keys)`);
}
} else {
mkdirSync(path.dirname(target.catalogFile), { recursive: true });
writeFileSync(target.catalogFile, output);
console.log(`✓ wrote ${relCatalog} (${count} keys)`);
}
}
process.exit(failed ? 1 : 0);