feat(i18n): add Simplified Chinese locale (#2243)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -16,18 +16,21 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
discoverRealLocales,
|
||||
formatParityIssue,
|
||||
readLocaleNamespaces,
|
||||
realLocaleParityIssues,
|
||||
} from "./lib/i18n-catalogs.mjs";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const LOCALES = path.join(ROOT, "src", "locales");
|
||||
const STRICT_ORPHANS = process.argv.includes("--strict-orphans");
|
||||
|
||||
function readCatalog(locale) {
|
||||
const dir = path.join(LOCALES, locale);
|
||||
const out = new Map(); // "ns:key" -> value
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
||||
const ns = file.replace(/\.json$/, "");
|
||||
const entries = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
for (const [key, value] of Object.entries(entries)) out.set(`${ns}:${key}`, value);
|
||||
for (const [namespace, messages] of readLocaleNamespaces(LOCALES, locale)) {
|
||||
for (const [key, value] of messages) out.set(`${namespace}:${key}`, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -101,6 +104,11 @@ const enKeys = new Set(en.keys());
|
||||
const pseudoKeys = new Set(pseudo.keys());
|
||||
const pseudoMissing = [...enKeys].filter((k) => !pseudoKeys.has(k));
|
||||
const pseudoExtra = [...pseudoKeys].filter((k) => !enKeys.has(k));
|
||||
const sourceNamespaces = readLocaleNamespaces(LOCALES, "en");
|
||||
const realLocales = discoverRealLocales(LOCALES);
|
||||
const realLocaleIssues = realLocales.flatMap((locale) =>
|
||||
realLocaleParityIssues(sourceNamespaces, readLocaleNamespaces(LOCALES, locale), locale),
|
||||
);
|
||||
|
||||
let failed = false;
|
||||
|
||||
@@ -122,6 +130,12 @@ if (pseudoMissing.length || pseudoExtra.length) {
|
||||
);
|
||||
}
|
||||
|
||||
if (realLocaleIssues.length) {
|
||||
failed = true;
|
||||
console.error(`\n✖ ${realLocaleIssues.length} real-locale catalog parity issue(s):`);
|
||||
for (const issue of realLocaleIssues) console.error(` ${formatParityIssue(issue)}`);
|
||||
}
|
||||
|
||||
if (orphans.length) {
|
||||
const label = STRICT_ORPHANS ? "✖" : "⚠";
|
||||
console[STRICT_ORPHANS ? "error" : "warn"](
|
||||
@@ -135,7 +149,7 @@ if (orphans.length) {
|
||||
if (!failed) {
|
||||
console.log(
|
||||
`✓ i18n keys OK — ${used.size} key(s) referenced, ${en.size} en entr(ies), ` +
|
||||
`${orphans.length} orphan(s), pseudo in sync.`,
|
||||
`${orphans.length} orphan(s), pseudo and ${realLocales.join(", ") || "no real locales"} in sync.`,
|
||||
);
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Namespace = Record<string, string | number>;
|
||||
type Locale = Record<string, Namespace>;
|
||||
type Fixture = Record<string, Locale>;
|
||||
|
||||
const ZH_CN = "zh-cn";
|
||||
|
||||
const SCRIPT = path.resolve(import.meta.dirname, "check-i18n-keys.mjs");
|
||||
const CATALOG_HELPER = path.resolve(import.meta.dirname, "lib", "i18n-catalogs.mjs");
|
||||
|
||||
function runFixture(fixture: Fixture) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "kandev-i18n-keys-"));
|
||||
try {
|
||||
const scriptsDir = path.join(root, "scripts");
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
const script = path.join(scriptsDir, "check-i18n-keys.mjs");
|
||||
copyFileSync(SCRIPT, script);
|
||||
const libDir = path.join(scriptsDir, "lib");
|
||||
mkdirSync(libDir, { recursive: true });
|
||||
copyFileSync(CATALOG_HELPER, path.join(libDir, "i18n-catalogs.mjs"));
|
||||
|
||||
for (const [locale, namespaces] of Object.entries(fixture)) {
|
||||
const localeDir = path.join(root, "src", "locales", locale);
|
||||
mkdirSync(localeDir, { recursive: true });
|
||||
for (const [namespace, messages] of Object.entries(namespaces)) {
|
||||
writeFileSync(path.join(localeDir, `${namespace}.json`), JSON.stringify(messages));
|
||||
}
|
||||
}
|
||||
|
||||
return spawnSync(process.execPath, [script], { encoding: "utf8" });
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const completeFixture = (): Fixture => ({
|
||||
en: { common: { first: "First", second: "Second" }, settings: { third: "Third" } },
|
||||
pseudo: { common: { first: "Ƒîřśţ", second: "Śēćøńđ" }, settings: { third: "Ţĥîřđ" } },
|
||||
[ZH_CN]: { common: { first: "第一", second: "第二" }, settings: { third: "第三" } },
|
||||
});
|
||||
|
||||
describe("real locale catalog parity", () => {
|
||||
it("accepts a complete real locale", () => {
|
||||
const result = runFixture(completeFixture());
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts an identical translated value for a technical literal", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Kandev";
|
||||
fixture[ZH_CN].common.first = "Kandev";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a missing namespace", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture[ZH_CN].settings;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("settings");
|
||||
expect(result.stderr).toContain("missing namespace");
|
||||
});
|
||||
|
||||
it("rejects an extra namespace", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].extra = { surprise: "意外" };
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("extra");
|
||||
expect(result.stderr).toContain("extra namespace");
|
||||
});
|
||||
|
||||
it("rejects a missing key", () => {
|
||||
const fixture = completeFixture();
|
||||
delete fixture[ZH_CN].common.second;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("missing key: second");
|
||||
});
|
||||
|
||||
it("rejects an extra key", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.surprise = "意外";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("extra key: surprise");
|
||||
});
|
||||
|
||||
it("rejects a translation that drops an interpolation placeholder", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open {{url}} as {{user}}";
|
||||
fixture[ZH_CN].common.first = "以 {{user}} 身份打开";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("interpolation placeholder mismatch: first");
|
||||
});
|
||||
|
||||
it("rejects a translation that drops numeric Trans tags", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open <1>{{url}}</1>";
|
||||
fixture[ZH_CN].common.first = "打开 {{url}}";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("Trans tag structure mismatch: first");
|
||||
});
|
||||
|
||||
it("rejects a translation that changes a numeric Trans tag index", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture.en.common.first = "Open <1>{{url}}</1>";
|
||||
fixture[ZH_CN].common.first = "打开 <2>{{url}}</2>";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("Trans tag structure mismatch: first");
|
||||
});
|
||||
});
|
||||
|
||||
describe("translated value validation", () => {
|
||||
it("rejects an empty translated value", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = "";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("empty translated value: first");
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only translated value", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = " \t\n ";
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("empty translated value: first");
|
||||
});
|
||||
|
||||
it("rejects a non-string translated value", () => {
|
||||
const fixture = completeFixture();
|
||||
fixture[ZH_CN].common.first = 42;
|
||||
const result = runFixture(fixture);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(ZH_CN);
|
||||
expect(result.stderr).toContain("common");
|
||||
expect(result.stderr).toContain("non-string translated value: first");
|
||||
expect(result.stderr).not.toContain("matchAll is not a function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function readLocaleNamespaces(localesDir, locale) {
|
||||
const dir = path.join(localesDir, locale);
|
||||
const namespaces = new Map();
|
||||
if (!fs.existsSync(dir)) return namespaces;
|
||||
for (const file of fs
|
||||
.readdirSync(dir)
|
||||
.filter((entry) => entry.endsWith(".json"))
|
||||
.sort()) {
|
||||
const namespace = file.replace(/\.json$/, "");
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
|
||||
namespaces.set(namespace, new Map(Object.entries(messages)));
|
||||
}
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
export function discoverRealLocales(localesDir) {
|
||||
if (!fs.existsSync(localesDir)) return [];
|
||||
return fs
|
||||
.readdirSync(localesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !["en", "pseudo"].includes(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function interpolationPlaceholders(message) {
|
||||
return [
|
||||
...new Set([...message.matchAll(/\{\{\s*-?\s*([^},\s]+)[^}]*\}\}/g)].map((match) => match[1])),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function numericTagStructure(message) {
|
||||
const stack = [];
|
||||
const nodes = [];
|
||||
for (const match of message.matchAll(/<(\/)?(\d+)>/g)) {
|
||||
const closing = Boolean(match[1]);
|
||||
const tag = match[2];
|
||||
if (closing) {
|
||||
if (stack.at(-1) !== tag) return null;
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
nodes.push([...stack, tag].join("/"));
|
||||
stack.push(tag);
|
||||
}
|
||||
return stack.length === 0 ? nodes.sort() : null;
|
||||
}
|
||||
|
||||
function sameItems(left, right) {
|
||||
return left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function translatedValueIssue(value, context) {
|
||||
if (typeof value !== "string") {
|
||||
return { ...context, type: "non-string translated value" };
|
||||
}
|
||||
if (value.trim().length === 0) {
|
||||
return { ...context, type: "empty translated value" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function messageParityIssues(sourceMessage, translatedMessage, context) {
|
||||
const issues = [];
|
||||
if (
|
||||
!sameItems(
|
||||
interpolationPlaceholders(sourceMessage),
|
||||
interpolationPlaceholders(translatedMessage),
|
||||
)
|
||||
) {
|
||||
issues.push({ ...context, type: "interpolation placeholder mismatch" });
|
||||
}
|
||||
if (!sameItems(numericTagStructure(sourceMessage), numericTagStructure(translatedMessage))) {
|
||||
issues.push({ ...context, type: "Trans tag structure mismatch" });
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function translatedValueIssues(translated, locale) {
|
||||
const issues = [];
|
||||
for (const [namespace, translatedMessages] of translated) {
|
||||
for (const [key, value] of translatedMessages) {
|
||||
const valueIssue = translatedValueIssue(value, { locale, namespace, key });
|
||||
if (valueIssue) issues.push(valueIssue);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function namespaceParityIssues(source, translated, locale) {
|
||||
const issues = [];
|
||||
for (const namespace of source.keys()) {
|
||||
if (!translated.has(namespace)) {
|
||||
issues.push({ locale, namespace, type: "missing namespace" });
|
||||
continue;
|
||||
}
|
||||
const sourceMessages = source.get(namespace);
|
||||
const translatedMessages = translated.get(namespace);
|
||||
issues.push(
|
||||
...messageParityIssuesForNamespace(sourceMessages, translatedMessages, locale, namespace),
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function messageParityIssuesForNamespace(sourceMessages, translatedMessages, locale, namespace) {
|
||||
const issues = [];
|
||||
for (const key of sourceMessages.keys()) {
|
||||
if (!translatedMessages.has(key)) {
|
||||
issues.push({ locale, namespace, type: "missing key", key });
|
||||
continue;
|
||||
}
|
||||
const sourceMessage = sourceMessages.get(key);
|
||||
const translatedMessage = translatedMessages.get(key);
|
||||
if (typeof sourceMessage !== "string" || typeof translatedMessage !== "string") continue;
|
||||
issues.push(
|
||||
...messageParityIssues(sourceMessage, translatedMessage, { locale, namespace, key }),
|
||||
);
|
||||
}
|
||||
for (const key of translatedMessages.keys()) {
|
||||
if (!sourceMessages.has(key)) {
|
||||
issues.push({ locale, namespace, type: "extra key", key });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function extraNamespaceIssues(source, translated, locale) {
|
||||
const issues = [];
|
||||
for (const namespace of translated.keys()) {
|
||||
if (!source.has(namespace)) {
|
||||
issues.push({ locale, namespace, type: "extra namespace" });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function realLocaleParityIssues(source, translated, locale) {
|
||||
return [
|
||||
...translatedValueIssues(translated, locale),
|
||||
...namespaceParityIssues(source, translated, locale),
|
||||
...extraNamespaceIssues(source, translated, locale),
|
||||
];
|
||||
}
|
||||
|
||||
export function formatParityIssue(issue) {
|
||||
const suffix = issue.key ? `: ${issue.key}` : "";
|
||||
return `${issue.locale} / ${issue.namespace}: ${issue.type}${suffix}`;
|
||||
}
|
||||
Reference in New Issue
Block a user