200 lines
7.2 KiB
JavaScript
200 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const resources = path.join(repoRoot, "Sources/CodexBar/Resources");
|
|
const english = readCatalog("en");
|
|
const englishKeys = Object.keys(english).sort();
|
|
const strictLocales = ["ar", "ca", "fa", "th"];
|
|
const languageKeys = ["language_arabic", "language_persian", "language_thai"];
|
|
const isTest = process.argv.includes("--test");
|
|
|
|
function readCatalog(locale) {
|
|
const file = path.join(resources, `${locale}.lproj/Localizable.strings`);
|
|
if (!fs.existsSync(file)) return null;
|
|
const output = execFileSync("plutil", ["-convert", "json", "-o", "-", file], { encoding: "utf8" });
|
|
return JSON.parse(output);
|
|
}
|
|
|
|
function tokenSignature(value) {
|
|
// Exclude explicit `%%`, which does not consume an argument.
|
|
const withoutEscapedPercents = value.replace(/%%/g, "");
|
|
const printfRaw = withoutEscapedPercents.match(/%(?:\d+\$)?(?:\.\d+)?(?:@|d|f)/g) ?? [];
|
|
|
|
const printf = {};
|
|
let implicitIndex = 1;
|
|
for (const token of printfRaw) {
|
|
const match = token.match(/%(\d+)\$.*?([@df])/);
|
|
if (match) {
|
|
printf[Number.parseInt(match[1], 10)] = match[2];
|
|
} else {
|
|
printf[implicitIndex] = token.at(-1);
|
|
implicitIndex += 1;
|
|
}
|
|
}
|
|
|
|
return { printf, swift: swiftInterpolationTokens(value).sort() };
|
|
}
|
|
|
|
function formatKeyList(keys, limit = 12) {
|
|
const shown = keys.slice(0, limit).join(", ");
|
|
const remaining = keys.length - limit;
|
|
return remaining > 0 ? `${shown}, ... +${remaining} more` : shown;
|
|
}
|
|
|
|
function blankKeys(catalog, referenceKeys) {
|
|
return referenceKeys.filter((key) => Object.hasOwn(catalog, key) && !catalog[key]?.trim());
|
|
}
|
|
|
|
function swiftInterpolationTokens(value) {
|
|
const tokens = [];
|
|
for (let index = 0; index < value.length - 1; index += 1) {
|
|
if (value[index] !== "\\" || value[index + 1] !== "(") continue;
|
|
|
|
const start = index;
|
|
let depth = 1;
|
|
index += 2;
|
|
while (index < value.length && depth > 0) {
|
|
if (value[index] === "(") depth += 1;
|
|
if (value[index] === ")") depth -= 1;
|
|
index += 1;
|
|
}
|
|
tokens.push(value.slice(start, index));
|
|
index -= 1;
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
if (isTest) {
|
|
assertEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%2$d · %1$@"), "positional reorder");
|
|
assertNotEqual(tokenSignature("%1$@ · %2$d"), tokenSignature("%1$d · %2$@"), "positional type swap");
|
|
assertEqual(tokenSignature("%.0f%% used"), tokenSignature("%.0f%% verbraucht"), "escaped percent");
|
|
assertNotEqual(tokenSignature("\\(name): \\(usage)"), tokenSignature("\\(name): \\(value)"), "Swift tokens");
|
|
assertEqual(
|
|
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
|
tokenSignature("Fehler: \\(self.store.metadata(for: self.provider).displayName)"),
|
|
"nested Swift interpolation");
|
|
assertNotEqual(
|
|
tokenSignature("\\(self.store.metadata(for: self.provider).displayName) failed"),
|
|
tokenSignature("\\(self.store.metadata(for: self.provider) failed"),
|
|
"truncated Swift interpolation");
|
|
assertEqual(formatKeyList(["alpha", "beta"]), "alpha, beta", "short key list");
|
|
assertEqual(
|
|
formatKeyList(["alpha", "beta", "gamma", "delta"], 2),
|
|
"alpha, beta, ... +2 more",
|
|
"truncated key list");
|
|
assertEqual(
|
|
blankKeys({ alpha: "", beta: " ", gamma: "ok" }, ["alpha", "beta", "gamma", "delta"]),
|
|
["alpha", "beta"],
|
|
"blank keys");
|
|
console.log("app locale checker tests OK");
|
|
process.exit(0);
|
|
}
|
|
|
|
let hasErrors = false;
|
|
let checkedCount = 0;
|
|
|
|
for (const strictLocale of strictLocales) {
|
|
const dirPath = path.join(resources, `${strictLocale}.lproj`);
|
|
if (!fs.existsSync(dirPath)) {
|
|
console.error(`\x1b[31mError: Required strict locale catalog is completely missing: ${strictLocale}.lproj\x1b[0m`);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
for (const directory of fs.readdirSync(resources).filter((name) => name.endsWith(".lproj"))) {
|
|
const locale = directory.replace(/\.lproj$/, "");
|
|
if (locale === "en" || locale === "Base") continue;
|
|
|
|
const catalog = readCatalog(locale);
|
|
if (!catalog) continue;
|
|
|
|
checkedCount++;
|
|
const catalogKeys = Object.keys(catalog);
|
|
const emptyKeys = blankKeys(catalog, englishKeys);
|
|
|
|
// 1. Missing keys
|
|
const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key));
|
|
if (missingKeys.length > 0) {
|
|
const missingKeyList = formatKeyList(missingKeys);
|
|
if (strictLocales.includes(locale)) {
|
|
console.error(
|
|
`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale: ${missingKeyList}.\x1b[0m`);
|
|
hasErrors = true;
|
|
} else {
|
|
console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys: ${missingKeyList}.\x1b[0m`);
|
|
}
|
|
}
|
|
|
|
const extraKeys = catalogKeys.filter((key) => !englishKeys.includes(key));
|
|
if (strictLocales.includes(locale) && extraKeys.length > 0) {
|
|
console.error(`\x1b[31m[${locale}] Error: Found ${extraKeys.length} extra keys in strict locale.\x1b[0m`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
// Ensure critical language keys are present in ALL locales
|
|
for (const key of languageKeys) {
|
|
if (!catalog[key] || !catalog[key].trim()) {
|
|
console.error(`\x1b[31m[${locale}] Error: Missing critical language key "${key}".\x1b[0m`);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
if (emptyKeys.length > 0) {
|
|
console.error(
|
|
`\x1b[31m[${locale}] Error: Blank values for ${emptyKeys.length} keys: ${formatKeyList(emptyKeys)}.\x1b[0m`);
|
|
hasErrors = true;
|
|
}
|
|
|
|
// 2. Identical values count
|
|
let identicalCount = 0;
|
|
|
|
for (const key of englishKeys) {
|
|
if (!catalog[key]?.trim()) {
|
|
continue;
|
|
}
|
|
|
|
if (catalog[key] === english[key]) {
|
|
identicalCount++;
|
|
}
|
|
|
|
// 3. Format placeholder mismatch
|
|
const tEn = tokenSignature(english[key]);
|
|
const tLoc = tokenSignature(catalog[key]);
|
|
if (JSON.stringify(tEn) !== JSON.stringify(tLoc)) {
|
|
console.error(`\x1b[31m[${locale}] Error: Token mismatch for key "${key}"\x1b[0m`);
|
|
console.error(` en: ${english[key]} Tokens: ${JSON.stringify(tEn)}`);
|
|
console.error(` ${locale}: ${catalog[key]} Tokens: ${JSON.stringify(tLoc)}`);
|
|
hasErrors = true;
|
|
}
|
|
}
|
|
|
|
// Warn if identical translation count exceeds 15% of the total keys (approx > 150 out of 1050)
|
|
const identicalRatio = identicalCount / englishKeys.length;
|
|
if (identicalRatio > 0.15) {
|
|
console.warn(`\x1b[33m[${locale}] Warning: High number of identical translations: ${identicalCount}/${englishKeys.length} (${(identicalRatio * 100).toFixed(1)}%)\x1b[0m`);
|
|
}
|
|
}
|
|
|
|
if (hasErrors) {
|
|
console.error("\n\x1b[31mApp locale checks failed.\x1b[0m");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`\n\x1b[32mApp locales OK: Checked ${checkedCount} catalogs against ${englishKeys.length} English keys.\x1b[0m`);
|
|
|
|
function assertEqual(actual, expected, label) {
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
}
|
|
}
|
|
|
|
function assertNotEqual(actual, expected, label) {
|
|
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
|
throw new Error(`${label}: signatures unexpectedly match`);
|
|
}
|
|
}
|