chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
#!/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`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user