scripts/validate-locales.mjs
Rama main · réplica de lectura del repositorio original.
import { readdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
const localesRoot = join(root, "public", "_locales");
const fail = (message) => {
throw new Error(`Locale validation failed: ${message}`);
};
const readCatalog = (locale) => {
const path = join(localesRoot, locale, "messages.json");
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
fail(`${locale}/messages.json is missing or invalid JSON (${error.message})`);
}
};
const actualLocales = readdirSync(localesRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
if (!actualLocales.includes("en")) fail("the default en locale directory is missing");
const source = readCatalog("en");
const sourceKeys = Object.keys(source).sort();
if (!sourceKeys.length) fail("the English source catalog is empty");
const namedTokens = (message) => [...message.matchAll(/\$([a-zA-Z][a-zA-Z0-9_]*)\$/g)].map((match) => match[1].toLowerCase()).sort();
const placeholderSchema = (entry) =>
Object.fromEntries(
Object.entries(entry.placeholders ?? {})
.map(([name, value]) => [name.toLowerCase(), value.content])
.sort(([left], [right]) => left.localeCompare(right)),
);
const validateEntry = (locale, key, entry, sourceEntry) => {
if (!entry || typeof entry.message !== "string" || !entry.message.trim()) fail(`${locale}.${key} must have a non-empty message`);
const expectedSchema = placeholderSchema(sourceEntry);
const actualSchema = placeholderSchema(entry);
if (JSON.stringify(actualSchema) !== JSON.stringify(expectedSchema)) fail(`${locale}.${key} has a mismatched placeholder schema`);
const tokens = namedTokens(entry.message);
const placeholders = Object.keys(actualSchema).sort();
if (tokens.join("\0") !== placeholders.join("\0")) fail(`${locale}.${key} message and placeholder names do not match`);
};
const pluralSourceKeyForExtra = (key, validCategories) => {
const match = /^(.*)_([a-z]+)$/.exec(key);
if (!match) return null;
const [, base, category] = match;
const sourceOtherKey = `${base}_other`;
if (!(sourceOtherKey in source) || !validCategories.has(category)) return null;
return sourceOtherKey;
};
for (const locale of actualLocales) {
const catalog = readCatalog(locale);
const keys = Object.keys(catalog).sort();
const missing = sourceKeys.filter((key) => !(key in catalog));
const extra = keys.filter((key) => !(key in source));
if (missing.length) fail(`${locale} is missing source keys [${missing.join(", ")}]`);
for (const key of sourceKeys) {
validateEntry(locale, key, catalog[key], source[key]);
}
const localeTag = locale.replaceAll("_", "-");
const validPluralCategories = new Set(new Intl.PluralRules(localeTag).resolvedOptions().pluralCategories);
for (const key of extra) {
const sourceKey = pluralSourceKeyForExtra(key, validPluralCategories);
if (!sourceKey) fail(`${locale} has unsupported extra key ${key}`);
validateEntry(locale, key, catalog[key], source[sourceKey]);
}
}
const manifestSource = readFileSync(join(root, "manifest.config.ts"), "utf8");
for (const match of manifestSource.matchAll(/__MSG_([a-zA-Z0-9_@]+)__/g)) {
if (!(match[1] in source)) fail(`manifest references unknown message ${match[1]}`);
}
if (!/default_locale:\s*"en"/.test(manifestSource)) fail('manifest default_locale must be "en"');
console.log(`Validated ${sourceKeys.length} messages across ${actualLocales.length} locales.`);