0cc255a087
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
185 lines
6.1 KiB
JavaScript
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/src/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/src/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);
|