From 13584d1bf62e3ea11d58b8b960be4382e253da3d Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 13:27:38 +0200 Subject: [PATCH] style(client): format the i18n key check and its test The new lint:format ratchet admits no unformatted file outside its baseline, and these two arrived after it was taken. --- client/scripts/i18n-keys.mjs | 357 +++++++++--------- client/tests/unit/i18n/i18nKeysScript.test.ts | 251 ++++++------ 2 files changed, 321 insertions(+), 287 deletions(-) diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs index 8724b7c4a..7beb4feef 100644 --- a/client/scripts/i18n-keys.mjs +++ b/client/scripts/i18n-keys.mjs @@ -49,18 +49,18 @@ * variable (`t(item.labelKey)` with the table elsewhere, server-sent error * keys) look unused here. */ -import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' -import { dirname, join, relative } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; // Plain paths rather than `new URL(..., import.meta.url)`: under the test runner's DOM environment URL is // jsdom's, which fileURLToPath refuses. -const HERE = dirname(fileURLToPath(import.meta.url)) -const SRC = join(HERE, '..', 'src') -const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts') +const HERE = dirname(fileURLToPath(import.meta.url)); +const SRC = join(HERE, '..', 'src'); +const SHARED_SCRIPTS = join(HERE, '..', '..', 'shared', 'scripts'); /** How many en keys a template may reach and still pass without an entry below. */ -export const MAX_IMPLICIT_MATCHES = 30 +export const MAX_IMPLICIT_MATCHES = 30; /** * Template keys the check cannot bound by itself: wider than @@ -106,7 +106,7 @@ export const DYNAMIC_ALLOWED = [ file, template: 'reservations.${s}', because: "s iterates the literal ['pending', 'confirmed'] as const; reservations.pending/confirmed exist", - }), + }) ), ...['components/Admin/AdminPluginsPanel.tsx', 'mobile/screens/admin/MAdminPluginsPanel.tsx'].map((file) => ({ file, @@ -123,33 +123,33 @@ export const DYNAMIC_ALLOWED = [ because: 'field label and hint names come from the provider field rows PhotoProviderSeeder writes, each a ' + 'memories.* key in en', - })), + })) ), { file: 'components/Tours/planner/TourPlannerPanels.tsx', template: 'tours.planner.${role}', because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist", }, -] +]; -const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/ -const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/ -const CALL_RE = /(?]*?\bhtml=(["'])([^"'\n]+)\1/g -const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g -const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g -const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g +const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/; +const PREFIX_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)*\.$/; +const CALL_RE = /(?]*?\bhtml=(["'])([^"'\n]+)\1/g; +const API_ERROR_RE = /\btranslateApiError\(\s*t\s*,[^,()]*(?:\([^()]*\))?[^,()]*,\s*(['"])([^'"\n]+)\1/g; +const KEY_BUILDER_RE = /\bconst\s+[a-zA-Z]*Key\s*=\s*\([^)]*\)\s*(?::\s*[^=]+?)?=>\s*`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g; +const KEY_PROP_RE = /\b[a-zA-Z]*Key\s*(?::|=\{?)\s*(['"])([a-z][a-zA-Z0-9_]*\.[^'"\s]+)\1/g; function walk(dir, files = []) { for (const name of readdirSync(dir)) { - const path = join(dir, name) - if (statSync(path).isDirectory()) walk(path, files) - else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path) + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, files); + else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name) && !name.endsWith('.d.ts')) files.push(path); } - return files + return files; } -const lineAt = (text, index) => text.slice(0, index).split('\n').length +const lineAt = (text, index) => text.slice(0, index).split('\n').length; /** * The source of the first argument of the call whose `(` sits at `open`: @@ -157,41 +157,41 @@ const lineAt = (text, index) => text.slice(0, index).split('\n').length * strings, template literals and nested brackets skipped as units. */ export function firstArgument(text, open) { - let depth = 0 + let depth = 0; for (let i = open + 1; i < text.length; i++) { - const c = text[i] + const c = text[i]; if (c === "'" || c === '"' || c === '`') { for (i++; i < text.length && text[i] !== c; i++) { - if (text[i] === '\\') i++ + if (text[i] === '\\') i++; else if (c === '`' && text[i] === '$' && text[i + 1] === '{') { // Skip the interpolation, counting its braces. - let braces = 0 + let braces = 0; for (; i < text.length; i++) { - if (text[i] === '{') braces++ - else if (text[i] === '}' && --braces === 0) break + if (text[i] === '{') braces++; + else if (text[i] === '}' && --braces === 0) break; } } } - continue + continue; } - if (c === '(' || c === '[' || c === '{') depth++ + if (c === '(' || c === '[' || c === '{') depth++; else if (c === ')' || c === ']' || c === '}') { - if (depth === 0) return text.slice(open + 1, i) - depth-- - } else if (c === ',' && depth === 0) return text.slice(open + 1, i) + if (depth === 0) return text.slice(open + 1, i); + depth--; + } else if (c === ',' && depth === 0) return text.slice(open + 1, i); } - return text.slice(open + 1) + return text.slice(open + 1); } /** The quoted literals and template literals of an argument's source. */ export function literalsIn(arg) { - const quoted = [] - const templates = [] + const quoted = []; + const templates = []; for (const m of arg.matchAll(/(['"])((?:\\.|(?!\1)[^\\\n])*)\1|`((?:\\.|\$\{[^}]*\}|[^`\\])*)`/g)) { - if (m[3] !== undefined) templates.push(m[3]) - else quoted.push(m[2]) + if (m[3] !== undefined) templates.push(m[3]); + else quoted.push(m[2]); } - return { quoted, templates } + return { quoted, templates }; } /** @@ -199,10 +199,10 @@ export function literalsIn(arg) { * for each interpolation. Null when it has no fixed dotted prefix. */ export function templatePattern(template) { - const parts = template.split(/\$\{[^}]*\}/) - if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null - const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - return new RegExp(`^${parts.map(escape).join('[^.]+')}$`) + const parts = template.split(/\$\{[^}]*\}/); + if (!/^[a-z][a-zA-Z0-9_]*\./.test(parts[0] ?? '')) return null; + const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${parts.map(escape).join('[^.]+')}$`); } /** @@ -211,36 +211,36 @@ export function templatePattern(template) { * `?.` is optional chaining. */ function topLevelOperators(expr) { - const ops = [] - let depth = 0 + const ops = []; + let depth = 0; for (let i = 0; i < expr.length; i++) { - const c = expr[i] + const c = expr[i]; if (c === "'" || c === '"' || c === '`') { - for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++ - } else if (c === '(' || c === '[' || c === '{') depth++ - else if (c === ')' || c === ']' || c === '}') depth-- - else if (depth > 0) continue - else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ }) - else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i }) - else if (c === ':') ops.push({ op: ':', at: i }) + for (i++; i < expr.length && expr[i] !== c; i++) if (expr[i] === '\\') i++; + } else if (c === '(' || c === '[' || c === '{') depth++; + else if (c === ')' || c === ']' || c === '}') depth--; + else if (depth > 0) continue; + else if ((c === '?' && expr[i + 1] === '?') || (c === '|' && expr[i + 1] === '|')) ops.push({ op: c + c, at: i++ }); + else if (c === '?' && expr[i + 1] !== '.') ops.push({ op: '?', at: i }); + else if (c === ':') ops.push({ op: ':', at: i }); } - return ops + return ops; } -const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/ +const QUOTED_RE = /^(['"])((?:\\.|(?!\1)[^\\\n])*)\1$/; /** Whether `e` is one parenthesized expression, its first `(` closing at its last character. */ function wrapsWhole(e) { - if (!e.startsWith('(') || !e.endsWith(')')) return false - let depth = 0 + if (!e.startsWith('(') || !e.endsWith(')')) return false; + let depth = 0; for (let i = 0; i < e.length; i++) { - const c = e[i] + const c = e[i]; if (c === "'" || c === '"' || c === '`') { - for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++ - } else if (c === '(') depth++ - else if (c === ')' && --depth === 0) return i === e.length - 1 + for (i++; i < e.length && e[i] !== c; i++) if (e[i] === '\\') i++; + } else if (c === '(') depth++; + else if (c === ')' && --depth === 0) return i === e.length - 1; } - return false + return false; } /** @@ -251,42 +251,42 @@ function wrapsWhole(e) { * never a result. */ export function interpolationResults(expr) { - let e = expr.trim() - while (wrapsWhole(e)) e = e.slice(1, -1).trim() - const ops = topLevelOperators(e) - const q = ops.findIndex((o) => o.op === '?') + let e = expr.trim(); + while (wrapsWhole(e)) e = e.slice(1, -1).trim(); + const ops = topLevelOperators(e); + const q = ops.findIndex((o) => o.op === '?'); if (q >= 0) { // The matching `:` of the first `?`: nested ternaries in the consequent raise the count. - let open = 0 + let open = 0; for (const o of ops.slice(q + 1)) { - if (o.op === '?') open++ + if (o.op === '?') open++; else if (o.op === ':' && open-- === 0) { - const a = interpolationResults(e.slice(ops[q].at + 1, o.at)) - const b = interpolationResults(e.slice(o.at + 1)) - return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete } + const a = interpolationResults(e.slice(ops[q].at + 1, o.at)); + const b = interpolationResults(e.slice(o.at + 1)); + return { literals: [...a.literals, ...b.literals], complete: a.complete && b.complete }; } } - return { literals: [], complete: false } + return { literals: [], complete: false }; } - const fallback = ops.filter((o) => o.op === '??' || o.op === '||') + const fallback = ops.filter((o) => o.op === '??' || o.op === '||'); if (fallback.length) { - const operands = [] - let from = 0 + const operands = []; + let from = 0; for (const o of fallback) { - operands.push(e.slice(from, o.at)) - from = o.at + 2 + operands.push(e.slice(from, o.at)); + from = o.at + 2; } - operands.push(e.slice(from)) + operands.push(e.slice(from)); // The left operands are tested values; only the literal ones among them could be produced. - const literals = operands.flatMap((x) => interpolationResults(x).literals) - return { literals, complete: false } + const literals = operands.flatMap((x) => interpolationResults(x).literals); + return { literals, complete: false }; } - const m = QUOTED_RE.exec(e) - return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false } + const m = QUOTED_RE.exec(e); + return m ? { literals: [m[2]], complete: true } : { literals: [], complete: false }; } /** How many keys a template with only literal choices may expand to before it is read as a pattern instead. */ -const MAX_EXPANSION = 64 +const MAX_EXPANSION = 64; /** * The references one template literal makes. Interpolations that can only @@ -297,91 +297,92 @@ const MAX_EXPANSION = 64 * that interpolation fixed, under the original template's allow-list site. */ function templateRefs(file, line, template) { - const parts = template.split(/\$\{([^}]*)\}/) - const fixed = parts.filter((_, i) => i % 2 === 0) - const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults) - const literal = [] - const dynamic = [] - const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join('') - const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1) + const parts = template.split(/\$\{([^}]*)\}/); + const fixed = parts.filter((_, i) => i % 2 === 0); + const results = parts.filter((_, i) => i % 2 === 1).map(interpolationResults); + const literal = []; + const dynamic = []; + const build = (choices) => fixed.map((f, i) => f + (i < choices.length ? choices[i] : '')).join(''); + const size = results.reduce((n, r) => n * Math.max(r.literals.length, 1), 1); if (results.every((r) => r.complete) && size <= MAX_EXPANSION) { - let keys = [''] + let keys = ['']; results.forEach((r, i) => { - keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit)) - }) - for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] }) - return { literal, dynamic } + keys = keys.flatMap((k) => r.literals.map((lit) => k + fixed[i] + lit)); + }); + for (const k of keys) literal.push({ file, line, key: k + fixed[fixed.length - 1] }); + return { literal, dynamic }; } - dynamic.push({ file, line, template, pattern: templatePattern(template) }) - const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`) + dynamic.push({ file, line, template, pattern: templatePattern(template) }); + const raw = parts.filter((_, i) => i % 2 === 1).map((x) => `\${${x}}`); results.forEach((r, i) => { for (const lit of r.literals) { - const choices = raw.map((x, j) => (j === i ? lit : x)) - const derived = build(choices) - if (!derived.includes('${')) literal.push({ file, line, key: derived }) - else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template }) + const choices = raw.map((x, j) => (j === i ? lit : x)); + const derived = build(choices); + if (!derived.includes('${')) literal.push({ file, line, key: derived }); + else dynamic.push({ file, line, template: derived, pattern: templatePattern(derived), site: template }); } - }) - return { literal, dynamic } + }); + return { literal, dynamic }; } /** Every key reference in one file's source. */ export function scanSource(text, file = '') { - const literal = [] - const dynamic = [] + const literal = []; + const dynamic = []; for (const m of text.matchAll(CALL_RE)) { - const open = m.index + m[0].length - 1 - const { quoted, templates } = literalsIn(firstArgument(text, open)) - const line = lineAt(text, m.index) + const open = m.index + m[0].length - 1; + const { quoted, templates } = literalsIn(firstArgument(text, open)); + const line = lineAt(text, m.index); for (const key of quoted) { - if (KEY_RE.test(key)) literal.push({ file, line, key }) + if (KEY_RE.test(key)) literal.push({ file, line, key }); // t('costs.filter.' + f): a prefix with the rest appended, read like a template. - else if (PREFIX_RE.test(key)) dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) }) + else if (PREFIX_RE.test(key)) + dynamic.push({ file, line, template: `${key}\${…}`, pattern: templatePattern(`${key}\${…}`) }); } for (const template of templates) { if (!template.includes('${')) { - if (KEY_RE.test(template)) literal.push({ file, line, key: template }) - continue + if (KEY_RE.test(template)) literal.push({ file, line, key: template }); + continue; } - const refs = templateRefs(file, line, template) - literal.push(...refs.literal) - dynamic.push(...refs.dynamic) + const refs = templateRefs(file, line, template); + literal.push(...refs.literal); + dynamic.push(...refs.dynamic); } } for (const m of text.matchAll(KEY_BUILDER_RE)) { // Builders of other strings (cache keys) have no fixed dotted prefix and are not translation keys. - const pattern = templatePattern(m[1]) - if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern }) + const pattern = templatePattern(m[1]); + if (pattern) dynamic.push({ file, line: lineAt(text, m.index), template: m[1], pattern }); } for (const re of [HTML_PROP_RE, API_ERROR_RE, KEY_PROP_RE]) { for (const m of text.matchAll(re)) { - if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] }) + if (KEY_RE.test(m[2])) literal.push({ file, line: lineAt(text, m.index), key: m[2] }); } } - return { literal, dynamic } + return { literal, dynamic }; } /** en's keys, read from the locale sources with the shared parity tooling's reader. */ export async function readEnKeys() { - const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href) - const keys = new Set() - for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) keys.add(key) - if (keys.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken') - return keys + const { listDomainFiles, readCatalog } = await import(pathToFileURL(join(SHARED_SCRIPTS, 'i18n-catalog.mjs')).href); + const keys = new Set(); + for (const file of listDomainFiles('en')) for (const { key } of readCatalog('en', file)) keys.add(key); + if (keys.size === 0) throw new Error('shared/src/i18n/en holds no keys: the reader or the path is broken'); + return keys; } -export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`) +export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.other`); -const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '') +const base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, ''); /** How many en keys a pattern reaches, a plural group counted once. */ export function countMatches(pattern, enKeys) { - const reached = new Set() + const reached = new Set(); for (const k of enKeys) { - if (pattern.test(k)) reached.add(k) - else if (pattern.test(base(k))) reached.add(base(k)) + if (pattern.test(k)) reached.add(k); + else if (pattern.test(base(k))) reached.add(base(k)); } - return reached.size + return reached.size; } /** @@ -391,85 +392,87 @@ export function countMatches(pattern, enKeys) { * needs, and the en keys nothing reaches. */ export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED) { - const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key)) - const site = (file, template) => `${file}\n${template}` - const allowedSites = new Set(allowed.map((a) => site(a.file, a.template))) - const counts = new Map() + const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key)); + const site = (file, template) => `${file}\n${template}`; + const allowedSites = new Set(allowed.map((a) => site(a.file, a.template))); + const counts = new Map(); const reach = (template, pattern) => { - if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null) - return counts.get(template) - } - const unmatched = [] - const broad = [] + if (!counts.has(template)) counts.set(template, pattern ? countMatches(pattern, enKeys) : null); + return counts.get(template); + }; + const unmatched = []; + const broad = []; for (const d of scan.dynamic) { - const n = reach(d.template, d.pattern) - const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template)) - if (n === 0 || (n === null && !isAllowed)) unmatched.push(d) - else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n }) + const n = reach(d.template, d.pattern); + const isAllowed = allowedSites.has(site(d.file, d.site ?? d.template)); + if (n === 0 || (n === null && !isAllowed)) unmatched.push(d); + else if (n > MAX_IMPLICIT_MATCHES && !isAllowed) broad.push({ ...d, matches: n }); } - const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template))) + const usedSites = new Set(scan.dynamic.map((d) => site(d.file, d.site ?? d.template))); const stale = allowed.filter((a) => { - if (!usedSites.has(site(a.file, a.template))) return true - if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true + if (!usedSites.has(site(a.file, a.template))) return true; + if ((a.resolves ?? []).some((k) => !hasKey(enKeys, k))) return true; // An entry for a template narrow enough to pass by itself only lengthens the list. - const n = reach(a.template, templatePattern(a.template)) - return n !== null && n <= MAX_IMPLICIT_MATCHES - }) - const reached = new Set([...scan.literal.map((l) => l.key), ...allowed.flatMap((a) => a.resolves ?? [])]) - const patterns = scan.dynamic.map((d) => d.pattern).filter(Boolean) + const n = reach(a.template, templatePattern(a.template)); + return n !== null && n <= MAX_IMPLICIT_MATCHES; + }); + const reached = new Set([...scan.literal.map((l) => l.key), ...allowed.flatMap((a) => a.resolves ?? [])]); + const patterns = scan.dynamic.map((d) => d.pattern).filter(Boolean); const unused = [...enKeys].filter( - (k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k))), - ) - return { missing, unmatched, broad, stale, unused } + (k) => !reached.has(k) && !reached.has(base(k)) && !patterns.some((p) => p.test(k) || p.test(base(k))) + ); + return { missing, unmatched, broad, stale, unused }; } export function scanTree(root = SRC) { - if (!existsSync(root)) throw new Error(`${root} does not exist`) - const scan = { literal: [], dynamic: [] } + if (!existsSync(root)) throw new Error(`${root} does not exist`); + const scan = { literal: [], dynamic: [] }; for (const path of walk(root)) { - const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/')) - scan.literal.push(...found.literal) - scan.dynamic.push(...found.dynamic) + const found = scanSource(readFileSync(path, 'utf8'), relative(root, path).split('\\').join('/')); + scan.literal.push(...found.literal); + scan.dynamic.push(...found.dynamic); } - if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`) - return scan + if (scan.literal.length === 0) throw new Error(`no translation key found under ${root}: the scanner is broken`); + return scan; } // Compared by real path, so the check still runs when the script is started through a symlink. const isCli = - Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) + Boolean(process.argv[1]) && + existsSync(process.argv[1]) && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); if (isCli) { try { - const scan = scanTree() - const enKeys = await readEnKeys() - const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys) + const scan = scanTree(); + const enKeys = await readEnKeys(); + const { missing, unmatched, broad, stale, unused } = evaluate(scan, enKeys); for (const { file, line, key } of missing) { - console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`) + console.error(`FAIL ${file}:${line}: '${key}' is not a key in shared/src/i18n/en`); } for (const { file, line, template } of unmatched) { - console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`) + console.error(`FAIL ${file}:${line}: \`${template}\` matches no key in shared/src/i18n/en`); } for (const { file, line, template, matches } of broad) { console.error( `FAIL ${file}:${line}: \`${template}\` reaches ${matches} en keys, more than ${MAX_IMPLICIT_MATCHES}: ` + - 'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value', - ) + 'narrow the prefix, or add a DYNAMIC_ALLOWED entry saying what bounds the value' + ); } for (const { file, template } of stale) { console.error( `FAIL DYNAMIC_ALLOWED lists \`${template}\` in ${file}, which that file no longer uses, whose keys en ` + - 'lacks, or which is narrow enough to pass without it: remove or fix the entry', - ) + 'lacks, or which is narrow enough to pass without it: remove or fix the entry' + ); } - if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`) + if (process.argv.includes('--unused')) for (const key of unused.sort()) console.log(`unused ${key}`); console.log( `i18n keys: ${new Set(scan.literal.map((l) => l.key)).size} literal and ${scan.dynamic.length} template ` + `reference(s) checked against ${enKeys.size} en keys; ${unused.length} en key(s) reached by neither ` + - '(information, --unused lists them)', - ) - if (missing.length || unmatched.length || broad.length || stale.length) process.exit(1) + '(information, --unused lists them)' + ); + if (missing.length || unmatched.length || broad.length || stale.length) process.exit(1); } catch (err) { - console.error(`FAIL ${err.message}`) - process.exit(1) + console.error(`FAIL ${err.message}`); + process.exit(1); } } diff --git a/client/tests/unit/i18n/i18nKeysScript.test.ts b/client/tests/unit/i18n/i18nKeysScript.test.ts index 13aabd7b4..34182ea37 100644 --- a/client/tests/unit/i18n/i18nKeysScript.test.ts +++ b/client/tests/unit/i18n/i18nKeysScript.test.ts @@ -1,7 +1,7 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; import { DYNAMIC_ALLOWED, MAX_IMPLICIT_MATCHES, @@ -13,154 +13,185 @@ import { scanSource, scanTree, templatePattern, -} from '../../../scripts/i18n-keys.mjs' +} from '../../../scripts/i18n-keys.mjs'; // FE-I18N-KEYS-001 to FE-I18N-KEYS-015: the client key check (scripts/i18n-keys.mjs). -const EN = new Set(['budget.title', 'places.count', 'places.count.one', 'trips.total.other', 'trips.total.one', 'costs.filter.all']) +const EN = new Set([ + 'budget.title', + 'places.count', + 'places.count.one', + 'trips.total.other', + 'trips.total.one', + 'costs.filter.all', +]); describe('i18n key check', () => { it('FE-I18N-KEYS-001: finds literal keys in t, tHtml, tr, TransHtml, translateApiError and *Key props', () => { - const { literal } = scanSource([ - "t('budget.title')", - 'props.t("places.count", { count })', - "tHtml('a.html')", - "tr('pdf.mapTitle')", - '', - "translateApiError(t, err, 'files.uploadError')", - "const tabs = [{ labelKey: 'tabs.one' }]", - '', - ].join('\n')) + const { literal } = scanSource( + [ + "t('budget.title')", + 'props.t("places.count", { count })', + "tHtml('a.html')", + "tr('pdf.mapTitle')", + '', + "translateApiError(t, err, 'files.uploadError')", + "const tabs = [{ labelKey: 'tabs.one' }]", + '', + ].join('\n') + ); expect(literal.map((l) => l.key)).toEqual([ - 'budget.title', 'places.count', 'a.html', 'pdf.mapTitle', 'journey.text', 'files.uploadError', 'tabs.one', 'hint.title', - ]) - }) + 'budget.title', + 'places.count', + 'a.html', + 'pdf.mapTitle', + 'journey.text', + 'files.uploadError', + 'tabs.one', + 'hint.title', + ]); + }); it('FE-I18N-KEYS-002: checks both sides of a conditional key and ignores later arguments', () => { - const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })") - expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed']) - expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'") - }) + const { literal } = scanSource("t(open ? 'a.open' : 'a.closed', { name: 'x.y' })"); + expect(literal.map((l) => l.key)).toEqual(['a.open', 'a.closed']); + expect(firstArgument("t(fn(a, b) ? 'x.y' : 'z.w', 1)", 1)).toBe("fn(a, b) ? 'x.y' : 'z.w'"); + }); it('FE-I18N-KEYS-003: reads templates, appended prefixes and key builders as patterns', () => { - const { literal, dynamic } = scanSource([ - 't(`costs.filter.${f}`)', - "t('costs.filter.' + f)", - 't(`plain.key`)', - 'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`', - 'const cacheKey = (id: number): string => `${id} x`', - "const other = notATranslation('x.y')", - ].join('\n')) - expect(literal.map((l) => l.key)).toEqual(['plain.key']) - expect(dynamic.map((d) => d.template)).toEqual(['costs.filter.${f}', 'costs.filter.${…}', 'help.guide.${id}.title']) - expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true) - expect(templatePattern('${opt.label}Hint')).toBeNull() + const { literal, dynamic } = scanSource( + [ + 't(`costs.filter.${f}`)', + "t('costs.filter.' + f)", + 't(`plain.key`)', + 'export const guideKey = (id: string): string =>\n `help.guide.${id}.title`', + 'const cacheKey = (id: number): string => `${id} x`', + "const other = notATranslation('x.y')", + ].join('\n') + ); + expect(literal.map((l) => l.key)).toEqual(['plain.key']); + expect(dynamic.map((d) => d.template)).toEqual([ + 'costs.filter.${f}', + 'costs.filter.${…}', + 'help.guide.${id}.title', + ]); + expect(templatePattern('costs.filter.${f}')!.test('costs.filter.all')).toBe(true); + expect(templatePattern('${opt.label}Hint')).toBeNull(); // An interpolation is one key segment: costs.filter.${f} does not reach a nested costs.filter.x.y. - expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false) - }) + expect(templatePattern('costs.filter.${f}')!.test('costs.filter.x.y')).toBe(false); + }); it('FE-I18N-KEYS-004: fails a literal key en lacks and accepts a plural group by its general form', () => { - const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })") - const { missing } = evaluate(scan, EN, []) - expect(missing.map((m) => m.key)).toEqual(['budget.addCategory']) - }) + const scan = scanSource("t('budget.addCategory'); t('places.count', { count }); t('trips.total', { count })"); + const { missing } = evaluate(scan, EN, []); + expect(missing.map((m) => m.key)).toEqual(['budget.addCategory']); + }); it('FE-I18N-KEYS-005: fails a template no en key matches unless it is allowed with the keys it resolves to', () => { - const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)') - expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint']) - const allowed = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }] - const result = evaluate(scan, EN, allowed) - expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']) - expect(result.stale).toEqual([]) - }) + const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`); t(`${opt.label}Hint`)'); + expect(evaluate(scan, EN, []).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}', '${opt.label}Hint']); + const allowed = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }]; + const result = evaluate(scan, EN, allowed); + expect(result.unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']); + expect(result.stale).toEqual([]); + }); it('FE-I18N-KEYS-006: refuses an allow-list entry nothing uses or whose keys en lacks', () => { - const scan = scanSource('t(`${opt.label}Hint`)') - const unused = [{ file: '', template: '${gone}', because: 'test', resolves: ['budget.title'] }] - expect(evaluate(scan, EN, unused).stale).toEqual(unused) - const wrong = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }] - expect(evaluate(scan, EN, wrong).stale).toEqual(wrong) - const elsewhere = [{ file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }] - expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere) - expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint']) - }) + const scan = scanSource('t(`${opt.label}Hint`)'); + const unused = [{ file: '', template: '${gone}', because: 'test', resolves: ['budget.title'] }]; + expect(evaluate(scan, EN, unused).stale).toEqual(unused); + const wrong = [{ file: '', template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }]; + expect(evaluate(scan, EN, wrong).stale).toEqual(wrong); + const elsewhere = [ + { file: 'other.tsx', template: '${opt.label}Hint', because: 'test', resolves: ['budget.title'] }, + ]; + expect(evaluate(scan, EN, elsewhere).stale).toEqual(elsewhere); + expect(evaluate(scan, EN, elsewhere).unmatched.map((u) => u.template)).toEqual(['${opt.label}Hint']); + }); it('FE-I18N-KEYS-007: reports en keys neither a literal nor a pattern reaches', () => { - const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)") - expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one']) - }) + const scan = scanSource("t('budget.title'); t('places.count', { count }); t(`costs.filter.${f}`)"); + expect(evaluate(scan, EN, []).unused).toEqual(['trips.total.other', 'trips.total.one']); + }); it('FE-I18N-KEYS-008: every allow-list entry names its file and a reason, and a prefix-less one its keys', () => { for (const entry of DYNAMIC_ALLOWED) { - expect(entry.file).toMatch(/\.tsx?$/) - expect(entry.because.length).toBeGreaterThan(20) - if (templatePattern(entry.template) === null) expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0) + expect(entry.file).toMatch(/\.tsx?$/); + expect(entry.because.length).toBeGreaterThan(20); + if (templatePattern(entry.template) === null) + expect('resolves' in entry && entry.resolves.length).toBeGreaterThan(0); } - }) + }); it('FE-I18N-KEYS-009: fails closed on a missing source tree or one without any key', () => { - expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/) - const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-')) + expect(() => scanTree(join(tmpdir(), 'trek-i18n-keys-does-not-exist'))).toThrow(/does not exist/); + const empty = mkdtempSync(join(tmpdir(), 'trek-i18n-keys-')); try { - expect(() => scanTree(empty)).toThrow(/scanner is broken/) + expect(() => scanTree(empty)).toThrow(/scanner is broken/); } finally { - rmSync(empty, { recursive: true, force: true }) + rmSync(empty, { recursive: true, force: true }); } - }) + }); it('FE-I18N-KEYS-010: the client source names no key en lacks today', async () => { - const enKeys = await readEnKeys() - const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys) - expect(missing).toEqual([]) - expect(unmatched).toEqual([]) - expect(broad).toEqual([]) - expect(stale).toEqual([]) - }) + const enKeys = await readEnKeys(); + const { missing, unmatched, broad, stale } = evaluate(scanTree(), enKeys); + expect(missing).toEqual([]); + expect(unmatched).toEqual([]); + expect(broad).toEqual([]); + expect(stale).toEqual([]); + }); // A table of en keys wider than the implicit bound: wide.k0 to wide.k. - const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)]) + const WIDE = new Set([...EN, ...Array.from({ length: MAX_IMPLICIT_MATCHES + 1 }, (_, i) => `wide.k${i}`)]); it('FE-I18N-KEYS-011: counts a plural group once when measuring what a template reaches', () => { - expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1) - expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1) - }) + expect(countMatches(templatePattern('trips.${x}')!, EN)).toBe(1); + expect(countMatches(templatePattern('wide.${x}')!, WIDE)).toBe(MAX_IMPLICIT_MATCHES + 1); + }); it('FE-I18N-KEYS-012: fails a template wider than the bound unless its file has an entry for it', () => { - const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx') - const { broad, unmatched } = evaluate(scan, WIDE, []) - expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]]) - expect(unmatched).toEqual([]) - const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }] - expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1) - const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }] - expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] }) - }) + const scan = scanSource('t(`wide.${x}`); t(`costs.filter.${f}`)', 'a.tsx'); + const { broad, unmatched } = evaluate(scan, WIDE, []); + expect(broad.map((b) => [b.template, b.matches])).toEqual([['wide.${x}', MAX_IMPLICIT_MATCHES + 1]]); + expect(unmatched).toEqual([]); + const otherFile = [{ file: 'b.tsx', template: 'wide.${x}', because: 'test' }]; + expect(evaluate(scan, WIDE, otherFile).broad).toHaveLength(1); + const ownFile = [{ file: 'a.tsx', template: 'wide.${x}', because: 'test' }]; + expect(evaluate(scan, WIDE, ownFile)).toMatchObject({ broad: [], stale: [] }); + }); it('FE-I18N-KEYS-013: refuses an entry for a narrow template, and an allowed template that matches nothing', () => { - const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx') - const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }] - expect(evaluate(scan, EN, narrow).stale).toEqual(narrow) - const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }] - expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']) - }) + const scan = scanSource('t(`costs.filter.${f}`); t(`gone.prefix.${x}`)', 'a.tsx'); + const narrow = [{ file: 'a.tsx', template: 'costs.filter.${f}', because: 'test' }]; + expect(evaluate(scan, EN, narrow).stale).toEqual(narrow); + const gone = [{ file: 'a.tsx', template: 'gone.prefix.${x}', because: 'test' }]; + expect(evaluate(scan, EN, gone).unmatched.map((u) => u.template)).toEqual(['gone.prefix.${x}']); + }); it('FE-I18N-KEYS-014: checks each literal branch of a template as a key, so a typo in one fails', () => { - const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx') - expect(scan.dynamic).toEqual([]) - expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll']) - expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll']) + const scan = scanSource("t(`costs.filter.${on ? 'all' : 'alll'}`)", 'a.tsx'); + expect(scan.dynamic).toEqual([]); + expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.all', 'costs.filter.alll']); + expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.alll']); // Nested ternaries, parentheses and optional chaining in the condition; a literal in a comparison is no result. - expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({ literals: ['p', 'q', 'r'], complete: true }) - expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false }) - }) + expect(interpolationResults("(a?.b === 'x') ? 'p' : c ? ('q') : 'r'")).toEqual({ + literals: ['p', 'q', 'r'], + complete: true, + }); + expect(interpolationResults("fn('a.b')")).toEqual({ literals: [], complete: false }); + }); it('FE-I18N-KEYS-015: keeps a template with data in it a pattern and still checks its literal fallback', () => { - const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx') - expect(scan.dynamic.map((d) => d.template)).toEqual(["costs.filter.${role ?? 'nope'}", "budget.${p === 'x' ? 'title' : p}"]) - expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title']) - expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope']) + const scan = scanSource("t(`costs.filter.${role ?? 'nope'}`); t(`budget.${p === 'x' ? 'title' : p}`)", 'a.tsx'); + expect(scan.dynamic.map((d) => d.template)).toEqual([ + "costs.filter.${role ?? 'nope'}", + "budget.${p === 'x' ? 'title' : p}", + ]); + expect(scan.literal.map((l) => l.key)).toEqual(['costs.filter.nope', 'budget.title']); + expect(evaluate(scan, EN, []).missing.map((m) => m.key)).toEqual(['costs.filter.nope']); // A derived pattern is judged under its source template's allow-list entry. - const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx') - expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"]) - }) -}) + const two = scanSource("t(`wide.${a ?? 'k0'}.${b}`)", 'a.tsx'); + expect(two.dynamic.map((d) => d.site)).toEqual([undefined, "wide.${a ?? 'k0'}.${b}"]); + }); +});