#!/usr/bin/env nodeimport { 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", "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 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"); 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); // 1. Missing keys const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key)); if (missingKeys.length > 0) { if (strictLocales.includes(locale)) { console.error(`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale.\x1b[0m`); hasErrors = true; } else { console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys.\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; } } // 2. Identical values count let identicalCount = 0; for (const key of englishKeys) { if (!catalog[key]?.trim()) { if (strictLocales.includes(locale) && catalogKeys.includes(key)) { console.error(`\x1b[31m[${locale}] Error: Blank value for strict locale key "${key}".\x1b[0m`); hasErrors = true; } 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`); }}
#!/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", "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 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");
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);
// 1. Missing keys
const missingKeys = englishKeys.filter((key) => !catalogKeys.includes(key));
if (missingKeys.length > 0) {
if (strictLocales.includes(locale)) {
console.error(`\x1b[31m[${locale}] Error: Missing ${missingKeys.length} keys in strict locale.\x1b[0m`);
hasErrors = true;
} else {
console.warn(`\x1b[33m[${locale}] Warning: Missing ${missingKeys.length} keys.\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;
}
}
// 2. Identical values count
let identicalCount = 0;
for (const key of englishKeys) {
if (!catalog[key]?.trim()) {
if (strictLocales.includes(locale) && catalogKeys.includes(key)) {
console.error(`\x1b[31m[${locale}] Error: Blank value for strict locale key "${key}".\x1b[0m`);
hasErrors = true;
}
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`);
}
}