#!/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 = /(? { 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);