LGTM
This commit is contained in:
parent
9248e26af2
commit
f2a2651b8a
95 changed files with 3993 additions and 1471 deletions
|
|
@ -9,19 +9,23 @@
|
|||
// with the same multiset, in the translated string.
|
||||
// 4. descriptions.ts and details.ts: the union of feature-name keys across
|
||||
// languages is treated as canonical; every language must cover all of them.
|
||||
// 5. The lazy locale loader map covers every non-English supported language.
|
||||
// 6. Selected visible UI strings that previously slipped through are not
|
||||
// hardcoded outside the i18n files.
|
||||
//
|
||||
// The script parses the TypeScript source with the compiler API and walks the
|
||||
// AST — no runtime import, no transpilation, no temp files. Run it with:
|
||||
// node frontend/scripts/check-translations.mjs
|
||||
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { dirname, join, relative } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
|
||||
const LOCALES_DIR = join(I18N_DIR, 'locales');
|
||||
const SRC_DIR = join(__dirname, '..', 'src');
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
|
||||
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
|
||||
|
|
@ -31,6 +35,71 @@ const warnings = [];
|
|||
const fail = (msg) => errors.push(msg);
|
||||
const warn = (msg) => warnings.push(msg);
|
||||
|
||||
const SAME_AS_EN_PATH_ALLOWLIST = new Set([
|
||||
'streetView.title',
|
||||
'home.showcaseMinutes',
|
||||
'home.showcaseStep2Sources',
|
||||
'format.lessThanMin',
|
||||
'format.moreThanMin',
|
||||
'learnPage.attrOglLink',
|
||||
'learnPage.attrOsmContrib',
|
||||
'learnPage.attrOsmLicenseLink',
|
||||
'home.showcaseTopThree',
|
||||
'learnPage.dsTravelOrigin',
|
||||
'learnPage.dsParkOrigin',
|
||||
'learnPage.dsCrimeOrigin',
|
||||
'learnPage.dsDemographicsOrigin',
|
||||
'learnPage.dsElectionOrigin',
|
||||
'learnPage.dsPoiOrigin',
|
||||
'learnPage.dsEthnicityOrigin',
|
||||
'learnPage.dsBroadbandOrigin',
|
||||
]);
|
||||
|
||||
const SAME_AS_EN_PATH_ALLOWLIST_RE = [/^learnPage\.ds[A-Za-z0-9]+Origin$/];
|
||||
|
||||
const SAME_AS_EN_VALUE_ALLOWLIST = new Set([
|
||||
'Perfect Postcode',
|
||||
'Land Registry',
|
||||
'HM Land Registry',
|
||||
'ONS',
|
||||
'OpenStreetMap',
|
||||
'Ofsted',
|
||||
'Rightmove',
|
||||
'Zoopla',
|
||||
'Google',
|
||||
'Excel',
|
||||
'UK',
|
||||
'Reform UK',
|
||||
'Labour',
|
||||
'Conservative',
|
||||
'Liberal Democrat',
|
||||
]);
|
||||
|
||||
const FORBIDDEN_VISIBLE_STRINGS = [
|
||||
['without this filter', 'filters.withoutThisFilter'],
|
||||
['Connecting to server...', 'common.connectingToServer'],
|
||||
['Property saved!', 'toasts.propertySaved'],
|
||||
['View saved', 'toasts.viewSaved'],
|
||||
["Don't show again", 'toasts.dontShowAgain'],
|
||||
['Close pane', 'common.closePane'],
|
||||
['Points of interest', 'poiPane.pointsOfInterest'],
|
||||
['No data', 'common.noData'],
|
||||
['All low', 'common.allLow'],
|
||||
['School type', 'filters.schoolType'],
|
||||
['School rating', 'filters.schoolRating'],
|
||||
['School distance', 'filters.schoolDistance'],
|
||||
['Crime type', 'filters.crimeType'],
|
||||
['POI type', 'filters.poiType'],
|
||||
['Matching homes', 'home.showcaseMatchingHomesLabel'],
|
||||
['Journey routes', 'home.showcaseJourneyRoutes'],
|
||||
['...and lots more', 'home.showcaseLotsMore'],
|
||||
['Send the shortlist', 'home.showcaseSendShortlist'],
|
||||
['Download .xlsx', 'home.showcaseDownloadXlsx'],
|
||||
['Product demo', 'home.productDemoLabel'],
|
||||
['Play product demo', 'home.playProductDemo'],
|
||||
['Scroll to product demo', 'home.scrollToProductDemo'],
|
||||
];
|
||||
|
||||
function parseFile(path) {
|
||||
const src = readFileSync(path, 'utf8');
|
||||
return ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
|
||||
|
|
@ -44,6 +113,9 @@ function literalToJs(node) {
|
|||
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
|
||||
return literalToJs(node.expression);
|
||||
}
|
||||
if (ts.isSatisfiesExpression?.(node)) {
|
||||
return literalToJs(node.expression);
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const out = {};
|
||||
for (const prop of node.properties) {
|
||||
|
|
@ -79,6 +151,31 @@ function findVarInitializer(sourceFile, name) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function propertyNameText(name) {
|
||||
if (ts.isIdentifier(name)) return name.text;
|
||||
if (ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) return name.text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectLiteralKeys(node) {
|
||||
if (!node) return undefined;
|
||||
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
|
||||
return objectLiteralKeys(node.expression);
|
||||
}
|
||||
if (ts.isSatisfiesExpression?.(node)) {
|
||||
return objectLiteralKeys(node.expression);
|
||||
}
|
||||
if (!ts.isObjectLiteralExpression(node)) return undefined;
|
||||
const keys = [];
|
||||
for (const prop of node.properties) {
|
||||
if (ts.isPropertyAssignment(prop)) {
|
||||
const key = propertyNameText(prop.name);
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function readSupportedLanguages() {
|
||||
const sf = parseFile(join(I18N_DIR, 'index.ts'));
|
||||
const init = findVarInitializer(sf, 'SUPPORTED_LANGUAGES');
|
||||
|
|
@ -88,6 +185,15 @@ function readSupportedLanguages() {
|
|||
return arr.map((entry) => entry.code);
|
||||
}
|
||||
|
||||
function readLocaleLoaderCodes() {
|
||||
const sf = parseFile(join(I18N_DIR, 'index.ts'));
|
||||
const init = findVarInitializer(sf, 'localeLoaders');
|
||||
if (!init) throw new Error('Could not find localeLoaders in index.ts');
|
||||
const keys = objectLiteralKeys(init);
|
||||
if (!keys) throw new Error('localeLoaders is not an object literal');
|
||||
return keys;
|
||||
}
|
||||
|
||||
function readLocale(code) {
|
||||
const path = join(LOCALES_DIR, `${code}.ts`);
|
||||
const sf = parseFile(path);
|
||||
|
|
@ -140,6 +246,9 @@ function checkLeafConsistency(path, enValue, trValue, lang) {
|
|||
fail(`[${lang}] ${path}: empty translation`);
|
||||
return;
|
||||
}
|
||||
if (isSuspiciousSameAsEnglish(path, enValue, trValue)) {
|
||||
warn(`[${lang}] ${path}: same as English; verify this is intentional`);
|
||||
}
|
||||
for (const [re, label] of [
|
||||
[PLACEHOLDER_RE, 'placeholder'],
|
||||
[HTML_TAG_RE, 'HTML tag'],
|
||||
|
|
@ -155,6 +264,47 @@ function checkLeafConsistency(path, enValue, trValue, lang) {
|
|||
}
|
||||
}
|
||||
|
||||
function isSuspiciousSameAsEnglish(path, enValue, trValue) {
|
||||
if (typeof enValue !== 'string' || typeof trValue !== 'string') return false;
|
||||
if (enValue.trim() !== trValue.trim()) return false;
|
||||
if (SAME_AS_EN_PATH_ALLOWLIST.has(path)) return false;
|
||||
if (SAME_AS_EN_PATH_ALLOWLIST_RE.some((re) => re.test(path))) return false;
|
||||
if (SAME_AS_EN_VALUE_ALLOWLIST.has(enValue.trim())) return false;
|
||||
if (path.startsWith('server.')) return false;
|
||||
|
||||
const text = enValue.trim();
|
||||
if (text.length < 8) return false;
|
||||
if (!/[A-Za-z]/.test(text) || !/[a-z]/.test(text)) return false;
|
||||
if (!/\s/.test(text)) return false;
|
||||
if (/^https?:\/\//i.test(text)) return false;
|
||||
if (/^[A-Z0-9 .&/()%+-]+$/.test(text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkLocaleLoaders(supportedCodes) {
|
||||
let loaderCodes;
|
||||
try {
|
||||
loaderCodes = readLocaleLoaderCodes();
|
||||
} catch (e) {
|
||||
fail(e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = supportedCodes.filter((code) => code !== 'en');
|
||||
for (const code of expected) {
|
||||
if (!loaderCodes.includes(code)) {
|
||||
fail(`localeLoaders is missing non-English supported language "${code}"`);
|
||||
}
|
||||
}
|
||||
for (const code of loaderCodes) {
|
||||
if (code === 'en') {
|
||||
fail('localeLoaders should not include "en"; English is imported eagerly');
|
||||
} else if (!expected.includes(code)) {
|
||||
fail(`localeLoaders includes "${code}" but it is not in SUPPORTED_LANGUAGES`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkLocales(supportedCodes) {
|
||||
const localeFiles = readdirSync(LOCALES_DIR)
|
||||
.filter((f) => f.endsWith('.ts'))
|
||||
|
|
@ -230,6 +380,57 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
|
|||
}
|
||||
}
|
||||
|
||||
function collectSourceFiles(dir, out = []) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
const rel = relative(SRC_DIR, path).replace(/\\/g, '/');
|
||||
if (entry.isDirectory()) {
|
||||
if (rel === 'i18n' || rel.includes('/__tests__') || entry.name === '__tests__') continue;
|
||||
collectSourceFiles(path, out);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
|
||||
if (/\.d\.ts$/.test(entry.name) || /\.(test|spec)\.(ts|tsx)$/.test(entry.name)) continue;
|
||||
out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lineNumberAt(src, index) {
|
||||
return src.slice(0, index).split(/\r\n|\r|\n/).length;
|
||||
}
|
||||
|
||||
function checkForbiddenVisibleStrings() {
|
||||
for (const file of collectSourceFiles(SRC_DIR)) {
|
||||
const rel = relative(join(__dirname, '..'), file).replace(/\\/g, '/');
|
||||
const src = readFileSync(file, 'utf8');
|
||||
const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
|
||||
function checkText(textNode, value) {
|
||||
for (const [text, key] of FORBIDDEN_VISIBLE_STRINGS) {
|
||||
if (!value.includes(text)) continue;
|
||||
fail(
|
||||
`${rel}:${lineNumberAt(src, textNode.getStart(sf))}: hardcoded visible string ` +
|
||||
`"${text}" should use "${key}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
checkText(node, node.text);
|
||||
} else if (ts.isJsxText(node)) {
|
||||
checkText(node, node.getText(sf));
|
||||
} else if (ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) {
|
||||
checkText(node, node.text);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sf);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
let supportedCodes;
|
||||
try {
|
||||
|
|
@ -240,10 +441,12 @@ function main() {
|
|||
}
|
||||
|
||||
checkLocales(supportedCodes);
|
||||
checkLocaleLoaders(supportedCodes);
|
||||
const en = readLocale('en');
|
||||
const serverKeys = new Set(Object.keys(en.server ?? {}));
|
||||
checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
|
||||
checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
|
||||
checkForbiddenVisibleStrings();
|
||||
|
||||
for (const w of warnings) console.warn(`warn: ${w}`);
|
||||
if (errors.length > 0) {
|
||||
|
|
@ -251,9 +454,7 @@ function main() {
|
|||
console.error(`\n${errors.length} translation error(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`
|
||||
);
|
||||
console.log(`i18n OK — ${supportedCodes.length} languages, ${warnings.length} warning(s).`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue