feat: localizations
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user