Files
productcatalogue_frontend/scripts/audit-i18n.cjs
T

110 lines
4.7 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const srcDir = path.join(root, 'src');
const localeDir = path.join(srcDir, 'i18n', 'locales');
const uiTextFile = path.join(srcDir, 'i18n', 'uiText.ts');
const langs = ['ar', 'fr', 'hi', 'ms'];
const read = (file) => fs.readFileSync(file, 'utf8');
const readJson = (file) => JSON.parse(read(file));
const walk = (dir, out = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(tsx?|jsx?)$/.test(entry.name)) out.push(full);
}
return out;
};
const flatten = (source, prefix = '', out = {}) => {
for (const [key, value] of Object.entries(source)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, fullKey, out);
else out[fullKey] = value;
}
return out;
};
const normalize = (value) => value.trim().replace(/\s+/g, ' ');
const isUiPhrase = (value) =>
/[A-Za-z]/.test(value) &&
!/[{}()[\]=<>]/.test(value) &&
!/^(https?:|\/api\/|[.#]?[a-z0-9_-]+$)/i.test(value) &&
!/\.(tsx?|jsx?|css|png|jpg|svg|xlsx?)$/i.test(value);
const allowedSame = new Set([
'API', 'CSV', 'DN', 'GRN', 'GST', 'GSTIN', 'HSN', 'HSN/SAC', 'IFSC / SWIFT', 'PDF', 'PO', 'POS', 'QR', 'RFQ', 'SKU', 'SMS', 'SSO', 'UPI', 'VAT',
'CGST', 'SGST', 'IGST', 'UPI ID', 'IFSC Code:', 'SWIFT Code:', 'UPI ID:', 'CGST (+)', 'SGST (+)', 'IGST (+)', 'CGST (+):', 'SGST (+):', 'IGST (+):',
'Status:', 'Item:', 'Unit *', 'Ref #', 'ⓘ info', '→ stock',
]);
const isCodeLikeLocaleValue = (value) =>
!isUiPhrase(value) ||
allowedSame.has(value.trim()) ||
/[{}()[\]=<>]/.test(value) ||
/\b[a-z]+[A-Z][A-Za-z]*\b/.test(value) ||
/[&=<>]/.test(value) ||
/\b(currentData|totalRow|enableDelete|pageSize|filteredProducts|sessionSales|grandTotal|canCompare|onPayDue|soList|stats)\b/.test(value) ||
/^e\.g\./i.test(value) ||
/^https?:/i.test(value) ||
/^[A-Z0-9_ /:+#.-]+$/.test(value);
const en = readJson(path.join(localeDir, 'en.json'));
const enFlat = flatten(en);
const enValues = new Set(Object.values(enFlat).filter((value) => typeof value === 'string').map(normalize));
const uiText = read(uiTextFile);
const uiTextValues = new Set([...uiText.matchAll(/'([^']+)':\s*'[^']+'/g)].map((match) => normalize(match[1])));
const headerCandidates = new Map();
const addCandidate = (value, file) => {
const text = normalize(value);
if (!isUiPhrase(text)) return;
if (!headerCandidates.has(text)) headerCandidates.set(text, new Set());
headerCandidates.get(text).add(path.relative(root, file));
};
for (const file of walk(srcDir)) {
const source = read(file);
for (const match of source.matchAll(/\bheaders\s*=\s*\{?\s*\[([\s\S]*?)\]\s*\}?/g)) {
const body = match[1];
for (const stringMatch of body.matchAll(/['"`]([^'"`\n]+)['"`]/g)) addCandidate(stringMatch[1], file);
}
for (const match of source.matchAll(/\b(?:headers|columns)\s*=\s*\[([\s\S]*?)\]/g)) {
const body = match[1];
for (const stringMatch of body.matchAll(/['"`]([^'"`\n]+)['"`]/g)) addCandidate(stringMatch[1], file);
}
}
const missingHeaderMappings = [...headerCandidates.entries()]
.filter(([text]) => !uiTextValues.has(text) && !enValues.has(text))
.sort((a, b) => b[1].size - a[1].size || a[0].localeCompare(b[0]))
.map(([text, files]) => ({ text, files: [...files] }));
const untranslatedByLang = {};
const translatableUntranslatedByLang = {};
for (const lang of langs) {
const flat = flatten(readJson(path.join(localeDir, `${lang}.json`)));
untranslatedByLang[lang] = [];
translatableUntranslatedByLang[lang] = [];
for (const [key, enValue] of Object.entries(enFlat)) {
if (typeof enValue !== 'string') continue;
if (flat[key] === enValue && /[A-Za-z]/.test(enValue)) {
const item = { key, value: enValue };
untranslatedByLang[lang].push(item);
if (!isCodeLikeLocaleValue(enValue)) translatableUntranslatedByLang[lang].push(item);
}
}
}
console.log(JSON.stringify({
missingHeaderMappingsCount: missingHeaderMappings.length,
missingHeaderMappings: missingHeaderMappings.slice(0, 250),
untranslatedCounts: Object.fromEntries(Object.entries(untranslatedByLang).map(([lang, items]) => [lang, items.length])),
translatableUntranslatedCounts: Object.fromEntries(Object.entries(translatableUntranslatedByLang).map(([lang, items]) => [lang, items.length])),
untranslatedSamples: Object.fromEntries(Object.entries(untranslatedByLang).map(([lang, items]) => [lang, items.slice(0, 120)])),
translatableUntranslatedSamples: Object.fromEntries(Object.entries(translatableUntranslatedByLang).map(([lang, items]) => [lang, items.slice(0, 160)])),
}, null, 2));