Files
TREK/client/scripts/i18n-keys.mjs
T
Maurice 33f004ed04 fix(client): a wide template key needs a reviewed allow-list entry
lint:i18n-keys let any template key through as long as its fixed parts
matched one en key, and each interpolation could stand for any run of
characters, dots included. `settings.${mode}` therefore reached all 574
settings keys and `help.guide.${id}.${part}` nearly 2000, so a wrong
suffix behind such a prefix could never fail, and none of the 109
templates had been looked at.

An interpolation now stands for one key segment, and a template passes
by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30)
en keys, the size of an enum such as the docsync error codes. A wider
one fails unless DYNAMIC_ALLOWED has an entry for that file and template
saying what bounds the value. The twelve wide templates in use today
get one each: the help key builders (checked by the registry test),
literal tuples beside the call, typed unions, the generated plugin
permissions and the seeded photo provider fields. An entry whose file no
longer uses the template, or whose template has become narrow enough to
pass alone, fails, so the list stays short and current.

Listing all 109 templates instead was considered and left out: the
narrow ones would only restate their own prefix, and the bound already
keeps a mistake inside one small namespace.
2026-10-08 13:04:42 +02:00

347 lines
16 KiB
JavaScript

/*
* lint:i18n-keys: every translation key the client names exists in en.
*
* `t()` takes any string and returns the key itself when en has no such
* entry, so a typo or a key deleted from the locales renders as raw
* "budget.addCategory" text and nothing fails. Locale parity cannot see it:
* it compares the locales with en, not en with the code. This check reads
* src/ (tests aside) and resolves what it finds against shared/src/i18n/en:
*
* t('x.y'), tHtml('x.y'), tr('x.y') every string literal in the first
* argument, so both sides of a
* `cond ? 'a.b' : 'c.d'` are checked
* <TransHtml html="x.y" /> the markup variant
* translateApiError(t, err, 'x.y') the fallback key
* labelKey: 'x.y', titleKey="x.y" any property or prop named *Key that
* holds a dotted key, the tables
* components feed to t() later
* const guideKey = (id) => `help.${id}` a key builder (help/registry.ts):
* a template returned by a const
* named *Key, read as a template key
*
* A key exists when en declares it, or declares `key.other` (a plural group
* whose general form is spelled out). A template literal key such as
* t(`budget.category.${id}`), or a prefix with the rest appended
* (t('costs.filter.' + f)), cannot be resolved. It is read as a pattern in
* which each interpolation stands for one key segment (no dot), and it passes
* on its own only when that pattern is narrow: it matches at least one en key
* and at most MAX_IMPLICIT_MATCHES of them, the size of an enum such as the
* docsync error codes. A pattern that matches nothing fails. A wider one
* (`settings.${mode}` reaches every settings key, so a wrong suffix would
* pass unseen) and one without a fixed dotted prefix need an entry in
* DYNAMIC_ALLOWED, keyed by file and template, saying what bounds the value.
* Listing every narrow template instead would be a hundred entries restating
* their own prefix; the bound is what keeps those honest.
* A key held in a variable (`t(opt.label)`) is out of reach unless the table
* names it in a *Key property.
*
* npm run lint:i18n-keys check (CI)
* npm run lint:i18n-keys -- --unused also list en keys no literal or pattern reaches
*
* The unused list is information, not a gate: keys reached through a
* 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'
// 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')
/** How many en keys a template may reach and still pass without an entry below. */
export const MAX_IMPLICIT_MATCHES = 30
/**
* Template keys the check cannot bound by itself: wider than
* MAX_IMPLICIT_MATCHES, or without a fixed dotted prefix. Each names the file
* it sits in and why the interpolated value can only be a real key; one with
* no fixed prefix also lists the keys it resolves to, which must exist. An
* entry nothing uses any more fails, so the list cannot rot.
*/
export const DYNAMIC_ALLOWED = [
{
file: 'components/Trips/TripShareDialog.tsx',
template: '${opt.label}Hint',
because:
'TripShareDialog appends Hint to each SHARE_OPTIONS label (useTripShare.ts); share.optTravelOnlyHint ' +
'and share.optHideImagesHint exist, and the template has no fixed part to match them by',
resolves: ['share.optTravelOnlyHint', 'share.optHideImagesHint'],
},
...[
'help.ctx.${id}.${part}',
'help.ctx.${id}.bullet.${n}',
'help.guide.${id}.${part}',
'help.guide.${id}.step.${n}',
'help.guide.${id}.tip.${n}',
].map((template) => ({
file: 'help/registry.ts',
template,
because:
'a key builder over the registered help contexts and guides; src/help/registry.test.ts resolves every ' +
'key of every registered entry against en',
})),
{
file: 'mobile/screens/dashboard/MUserMenu.tsx',
template: 'settings.${mode}',
because: "mode is a ThemeMode from resolveMode(), 'light' | 'dark' | 'auto', and settings.light/dark/auto exist",
},
...['components/Studio/StudioElementsPanel.tsx', 'components/Studio/StudioInspector.tsx'].map((file) => ({
file,
template: 'journey.studio.${key}',
because: 'key comes from an `as const` tuple of frame and stroke styles written beside the call, typed literals',
})),
...['mobile/screens/trip/sheets/MReservationSheet.tsx', 'mobile/screens/trip/sheets/MTransportFormSheet.tsx'].map(
(file) => ({
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,
template: 'admin.plugins.perm.${perm}',
because:
'PermLabel translates a permission only when PERM_KEYS (generated PLUGIN_PERMISSIONS) holds it and shows ' +
'the raw code otherwise; the plugin-facts gate keeps the permission keys in step with en',
})),
...['components/Settings/PhotoProvidersSection.tsx', 'mobile/screens/settings/MPhotoProvidersSection.tsx'].flatMap(
(file) =>
['memories.${field.label}', 'memories.${field.hint}'].map((template) => ({
file,
template,
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 = /(?<![\w$.])(?:t|tHtml|tr)\(|(?<=[\w$]\.)(?:t|tHtml)\(/g
const HTML_PROP_RE = /<TransHtml\b[^>]*?\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)
}
return files
}
const lineAt = (text, index) => text.slice(0, index).split('\n').length
/**
* The source of the first argument of the call whose `(` sits at `open`:
* everything up to the top-level comma or the closing parenthesis, with
* strings, template literals and nested brackets skipped as units.
*/
export function firstArgument(text, open) {
let depth = 0
for (let i = open + 1; i < text.length; i++) {
const c = text[i]
if (c === "'" || c === '"' || c === '`') {
for (i++; i < text.length && text[i] !== c; i++) {
if (text[i] === '\\') i++
else if (c === '`' && text[i] === '$' && text[i + 1] === '{') {
// Skip the interpolation, counting its braces.
let braces = 0
for (; i < text.length; i++) {
if (text[i] === '{') braces++
else if (text[i] === '}' && --braces === 0) break
}
}
}
continue
}
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)
}
return text.slice(open + 1)
}
/** The quoted literals and template literals of an argument's source. */
export function literalsIn(arg) {
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])
}
return { quoted, templates }
}
/**
* A template literal's key pattern: its fixed parts, one key segment (no dot)
* 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('[^.]+')}$`)
}
/** Every key reference in one file's source. */
export function scanSource(text, file = '<source>') {
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)
for (const key of quoted) {
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}\${…}`) })
}
for (const template of templates) {
if (!template.includes('${')) {
if (KEY_RE.test(template)) literal.push({ file, line, key: template })
continue
}
dynamic.push({ file, line, template, pattern: templatePattern(template) })
}
}
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 })
}
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] })
}
}
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
}
export const hasKey = (enKeys, key) => enKeys.has(key) || enKeys.has(`${key}.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()
for (const k of enKeys) {
if (pattern.test(k)) reached.add(k)
else if (pattern.test(base(k))) reached.add(base(k))
}
return reached.size
}
/**
* The verdict over a scan: literal keys en lacks, template keys no en key
* matches, templates too wide to pass without an allow-list entry,
* allow-list entries that no file uses any more or that the check no longer
* 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 reach = (template, pattern) => {
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.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.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
// 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 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 }
}
export function scanTree(root = SRC) {
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)
}
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)
if (isCli) {
try {
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`)
}
for (const { file, line, template } of unmatched) {
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',
)
}
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',
)
}
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)
} catch (err) {
console.error(`FAIL ${err.message}`)
process.exit(1)
}
}