From f00c354d0d35ecd455329cf9c8d73103d6295c54 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:52:44 +0200 Subject: [PATCH] fix(i18n): add the two keys the client named without en having them BudgetPanel titled its add button with budget.addCategory (since April) and the trip form labelled its cover remove button with common.remove (since August). Neither key was ever in en, and t() returns an unknown key unchanged, so both rendered as the raw key in every language. Both are added to all 27 locales. Locale parity compares the locales with en, never en with the code, so nothing could catch this. client/scripts/i18n-keys.mjs (npm run lint:i18n-keys) now resolves every literal key src/ names against en: the first argument of t(), tHtml() and tr() (both sides of a conditional included), , the fallback of translateApiError and any *Key property or prop. A template key, a prefix with the rest appended and the help registry's key builders pass when their fixed parts match an en key; the one template with no fixed part is on an allow-list that names the keys it resolves to, and an entry nothing uses fails. With --unused it lists the en keys nothing reaches, as information only. en is read with shared/scripts/i18n-catalog.mjs, a reader for the locale tables as text that the parity tooling will share. It refuses a value that is not one string literal instead of skipping the key. --- client/CLAUDE.md | 3 +- client/package.json | 1 + client/scripts/i18n-keys.mjs | 240 ++++++++++++++++++ client/tests/unit/i18n/i18nKeysScript.test.ts | 109 ++++++++ shared/CLAUDE.md | 1 + shared/scripts/fixtures/i18n/de/a.ts | 14 + shared/scripts/fixtures/i18n/en/a.ts | 14 + shared/scripts/i18n-catalog.mjs | 103 ++++++++ shared/src/i18n/ar/budget.ts | 1 + shared/src/i18n/ar/common.ts | 1 + shared/src/i18n/az/budget.ts | 1 + shared/src/i18n/az/common.ts | 1 + shared/src/i18n/br/budget.ts | 1 + shared/src/i18n/br/common.ts | 1 + shared/src/i18n/ca/budget.ts | 1 + shared/src/i18n/ca/common.ts | 1 + shared/src/i18n/cs/budget.ts | 1 + shared/src/i18n/cs/common.ts | 1 + shared/src/i18n/de/budget.ts | 1 + shared/src/i18n/de/common.ts | 1 + shared/src/i18n/en/budget.ts | 1 + shared/src/i18n/en/common.ts | 1 + shared/src/i18n/es/budget.ts | 1 + shared/src/i18n/es/common.ts | 1 + shared/src/i18n/et/budget.ts | 1 + shared/src/i18n/et/common.ts | 1 + shared/src/i18n/fr/budget.ts | 1 + shared/src/i18n/fr/common.ts | 1 + shared/src/i18n/gr/budget.ts | 1 + shared/src/i18n/gr/common.ts | 1 + shared/src/i18n/hu/budget.ts | 1 + shared/src/i18n/hu/common.ts | 1 + shared/src/i18n/i18n-catalog.spec.ts | 58 +++++ shared/src/i18n/id/budget.ts | 1 + shared/src/i18n/id/common.ts | 1 + shared/src/i18n/it/budget.ts | 1 + shared/src/i18n/it/common.ts | 1 + shared/src/i18n/ja/budget.ts | 1 + shared/src/i18n/ja/common.ts | 1 + shared/src/i18n/ko/budget.ts | 1 + shared/src/i18n/ko/common.ts | 1 + shared/src/i18n/nl/budget.ts | 1 + shared/src/i18n/nl/common.ts | 1 + shared/src/i18n/pl/budget.ts | 1 + shared/src/i18n/pl/common.ts | 1 + shared/src/i18n/ru/budget.ts | 1 + shared/src/i18n/ru/common.ts | 1 + shared/src/i18n/sk/budget.ts | 1 + shared/src/i18n/sk/common.ts | 1 + shared/src/i18n/sv/budget.ts | 1 + shared/src/i18n/sv/common.ts | 1 + shared/src/i18n/th/budget.ts | 1 + shared/src/i18n/th/common.ts | 1 + shared/src/i18n/tr/budget.ts | 1 + shared/src/i18n/tr/common.ts | 1 + shared/src/i18n/uk/budget.ts | 1 + shared/src/i18n/uk/common.ts | 1 + shared/src/i18n/vi/budget.ts | 1 + shared/src/i18n/vi/common.ts | 1 + shared/src/i18n/zh-TW/budget.ts | 1 + shared/src/i18n/zh-TW/common.ts | 1 + shared/src/i18n/zh/budget.ts | 1 + shared/src/i18n/zh/common.ts | 1 + 63 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 client/scripts/i18n-keys.mjs create mode 100644 client/tests/unit/i18n/i18nKeysScript.test.ts create mode 100644 shared/scripts/fixtures/i18n/de/a.ts create mode 100644 shared/scripts/fixtures/i18n/en/a.ts create mode 100644 shared/scripts/i18n-catalog.mjs create mode 100644 shared/src/i18n/i18n-catalog.spec.ts diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 32aeb884a..6e67e1af9 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -17,6 +17,7 @@ npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update) npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update) npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips) +npm run lint:i18n-keys # every translation key src/ names exists in shared en (CI gate; --unused lists en keys nothing reaches) npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report) npm run shots # Playwright screenshot project (shots:promote to accept) @@ -63,7 +64,7 @@ The offline core is flagship work surrounded by a periphery that ignores it. New ## Big-picture pieces - **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other. -- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. +- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, ``, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template key passes when its fixed parts match an en key, or through `DYNAMIC_ALLOWED` with a reason. - **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below. - **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill. - **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there. diff --git a/client/package.json b/client/package.json index 41266417c..f9ae1b0f2 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "lint:layers": "node scripts/layer-lint.mjs", "lint:offline": "node scripts/offline-lint.mjs", "lint:skips": "node scripts/skip-lint.mjs", + "lint:i18n-keys": "node scripts/i18n-keys.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", diff --git a/client/scripts/i18n-keys.mjs b/client/scripts/i18n-keys.mjs new file mode 100644 index 000000000..a1bbfcfe1 --- /dev/null +++ b/client/scripts/i18n-keys.mjs @@ -0,0 +1,240 @@ +/* + * 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 + * 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 passes when its fixed + * parts match at least one en key, and otherwise only through DYNAMIC_ALLOWED. + * 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') + +/** + * Template literal keys whose fixed parts match no en key, each with the + * reason it still resolves and the keys it resolves to, which must exist. + * Keep this short: every entry is a key the check cannot follow. + */ +export const DYNAMIC_ALLOWED = [ + { + 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'], + }, +] + +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) + } + 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, anything in between. 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 = '') { + 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`) + +/** + * The verdict over a scan: literal keys en lacks, template keys no en key + * matches (unless allowed), allow-list entries nothing uses any more, and + * the en keys nothing reaches. + */ +export function evaluate(scan, enKeys, allowed = DYNAMIC_ALLOWED) { + const missing = scan.literal.filter(({ key }) => !hasKey(enKeys, key)) + const allowedTemplates = new Set(allowed.map((a) => a.template)) + const unmatched = scan.dynamic.filter( + ({ template, pattern }) => + !allowedTemplates.has(template) && !(pattern && [...enKeys].some((k) => pattern.test(k))), + ) + const usedTemplates = new Set(scan.dynamic.map((d) => d.template)) + const stale = allowed.filter((a) => !usedTemplates.has(a.template) || a.resolves.some((k) => !hasKey(enKeys, k))) + 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 base = (k) => k.replace(/\.(?:zero|one|two|few|many|other)$/, '') + 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, 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, 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 { template } of stale) { + console.error(`FAIL DYNAMIC_ALLOWED lists \`${template}\`, which no file uses any more: remove 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 || stale.length) process.exit(1) + } catch (err) { + 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 new file mode 100644 index 000000000..b240d3180 --- /dev/null +++ b/client/tests/unit/i18n/i18nKeysScript.test.ts @@ -0,0 +1,109 @@ +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, + evaluate, + firstArgument, + readEnKeys, + scanSource, + scanTree, + templatePattern, +} from '../../../scripts/i18n-keys.mjs' + +// FE-I18N-KEYS-001 to FE-I18N-KEYS-010: 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']) + +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')) + expect(literal.map((l) => l.key)).toEqual([ + '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'") + }) + + 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() + }) + + 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']) + }) + + 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 = [{ 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 = [{ template: '${gone}', because: 'test', resolves: ['budget.title'] }] + expect(evaluate(scan, EN, unused).stale).toEqual(unused) + const wrong = [{ template: '${opt.label}Hint', because: 'test', resolves: ['share.nope'] }] + expect(evaluate(scan, EN, wrong).stale).toEqual(wrong) + }) + + 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']) + }) + + it('FE-I18N-KEYS-008: every allow-list entry carries a reason and the keys it resolves to', () => { + for (const entry of DYNAMIC_ALLOWED) { + expect(entry.because.length).toBeGreaterThan(20) + expect(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-')) + try { + expect(() => scanTree(empty)).toThrow(/scanner is broken/) + } finally { + 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, stale } = evaluate(scanTree(), enKeys) + expect(missing).toEqual([]) + expect(unmatched).toEqual([]) + expect(stale).toEqual([]) + }) +}) diff --git a/shared/CLAUDE.md b/shared/CLAUDE.md index 300a0605b..df8ad7dd7 100644 --- a/shared/CLAUDE.md +++ b/shared/CLAUDE.md @@ -50,6 +50,7 @@ The parity rule governs **existing** routes; it is not a license to mint new deb - **Every locale gets a real translation — an English placeholder is not acceptable.** Parity only checks that the key exists, so copying the `en` string passes CI while shipping English to those users. Write native phrasing with the locale's own punctuation and the same `{placeholders}` as `en`; if you genuinely cannot translate one, say so instead of filling it with English. - **Count-bearing strings use plural forms, never `count === 1` in the caller.** The key holds the general form (the CLDR "other" category) and `key.zero|one|two|few|many` hold the forms the language's `Intl.PluralRules` selects for whole numbers (`plural.ts`); `t(key, { count })` (or `{ n }`) picks one. Pass the count as a number. Russian needs `.one`, `.few` and `.many`, Arabic all five, Japanese none. `i18n:parity` checks that every locale has exactly the forms its language reaches with whole numbers and none it never selects. A form may spell the number out (Arabic "ملف واحد") only where its category is that one number; French `one` also covers 0 and Russian `one` covers 21, so they keep `{count}`. - **Further i18n specs run in `npm test`** and fail changes that parity lets through: placeholder parity (every `{placeholder}` in an `en` string must appear in each translation), no `en` string may call TREK "self-hosted" (the same build runs on managed installs; exemptions live in the spec), and a wording regression test for plugin permission descriptions (currently one string — add a case there when a permission's real behavior changes). None of these are part of `i18n:parity`, which checks key sets and plural forms only. +- **en holds every key the client names.** `npm run lint:i18n-keys` in `client/` resolves the keys `src/` passes to `t()` and its helpers against `en/`, read through `scripts/i18n-catalog.mjs`. That reader takes the tables as text: a value must be one string literal on the key's line or the next, and anything else (a concatenation, a template literal) is an error rather than a skipped key. Its spec uses the fixtures in `scripts/fixtures/`. ## Sanitization (`src/sanitize/sanitize.ts`) diff --git a/shared/scripts/fixtures/i18n/de/a.ts b/shared/scripts/fixtures/i18n/de/a.ts new file mode 100644 index 000000000..161ef0d78 --- /dev/null +++ b/shared/scripts/fixtures/i18n/de/a.ts @@ -0,0 +1,14 @@ +// Fixture: two unmarked English copies (a.title, a.multi), one marked (a.lone). +const a = { + 'a.title': 'Trip settings', + 'a.count': '{count} Orte', + 'a.count.one': '{count} Ort', + 'a.lone': 'Remove {count} items', // en-fallback + 'a.day': 'Tag {n}', + 'a.brand': 'Google Maps', + 'a.code': 'GPX', + 'a.quote': 'Es ist hier', + 'a.multi': + 'A long sentence that wraps onto the next line', +}; +export default a; diff --git a/shared/scripts/fixtures/i18n/en/a.ts b/shared/scripts/fixtures/i18n/en/a.ts new file mode 100644 index 000000000..83affa1e6 --- /dev/null +++ b/shared/scripts/fixtures/i18n/en/a.ts @@ -0,0 +1,14 @@ +// Fixture for the i18n script specs: a canonical table. +const a = { + 'a.title': 'Trip settings', + 'a.count': '{count} places', + 'a.count.one': '{count} place', + 'a.lone': 'Remove {count} items', + 'a.day': 'Day {n}', + 'a.brand': 'Google Maps', + 'a.code': 'GPX', + 'a.quote': "It's here", + 'a.multi': + 'A long sentence that wraps onto the next line', +}; +export default a; diff --git a/shared/scripts/i18n-catalog.mjs b/shared/scripts/i18n-catalog.mjs new file mode 100644 index 000000000..bcb2a035c --- /dev/null +++ b/shared/scripts/i18n-catalog.mjs @@ -0,0 +1,103 @@ +/** + * Reads the locale tables as text, for the i18n scripts beside this file. + * + * Plain .mjs outside `src/` for the reason i18n-parity.mjs gives: shared's + * typecheck runs without @types/node, and the CI parity job runs these scripts + * with no install at all. The tables are parsed rather than imported so the + * checks stay synchronous and see the `// en-fallback` markers, which an + * import would strip. + * + * A value is one string literal, single or double quoted, on the key's line or + * the next one (the two shapes Prettier writes). Anything else after a key (a + * concatenation, a template literal, a call) is an error rather than a skipped + * key: a check that silently reads less than the file holds passes for the + * wrong reason. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const I18N_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n'); + +export const FALLBACK_MARKER = '// en-fallback'; + +/** A root given as a file URL (what a spec can build without node types) or a path. */ +export const asPath = (root) => (root instanceof URL ? fileURLToPath(root) : root); + +/** Every locale folder (externalNotifications is a barrel module, not a locale). */ +export function listLocales(root = I18N_ROOT) { + const dir = asPath(root); + return readdirSync(dir) + .filter((name) => statSync(join(dir, name)).isDirectory()) + .filter((name) => name !== 'externalNotifications'); +} + +/** The domain files of one locale, sorted. */ +export function listDomainFiles(locale, root = I18N_ROOT) { + return readdirSync(join(asPath(root), locale)) + .filter((f) => f.endsWith('.ts') && f !== 'index.ts') + .sort(); +} + +const KEY_LINE_RE = /^\s*'([a-z][a-zA-Z0-9.\-_:]*)'\s*:(.*)$/; + +const ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }; + +/** The value of a JS string literal body (between the quotes). */ +export function decodeLiteral(body) { + return body.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|\r?\n|[\s\S])/g, (_, esc) => { + if (esc[0] === 'u' && esc.length > 1) return String.fromCodePoint(parseInt(esc.replace(/[u{}]/g, ''), 16)); + if (esc[0] === 'x' && esc.length === 3) return String.fromCharCode(parseInt(esc.slice(1), 16)); + if (esc === '\n' || esc === '\r\n') return ''; + return ESCAPES[esc] ?? esc; + }); +} + +/** Reads one quoted literal at the start of `text`; null when `text` does not start with one. */ +function readLiteral(text) { + const quote = text[0]; + if (quote !== "'" && quote !== '"') return null; + for (let i = 1; i < text.length; i++) { + if (text[i] === '\\') { + i++; + continue; + } + if (text[i] === '\n') return null; + if (text[i] === quote) return { value: decodeLiteral(text.slice(1, i)), rest: text.slice(i + 1) }; + } + return null; +} + +/** + * Every top-level entry of one table's source: key, value, whether the + * declaration carries the fallback marker, and the line it starts on. + * Throws with file and line on anything it cannot read. + */ +export function parseCatalog(source, label = 'catalogue') { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const entries = []; + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(KEY_LINE_RE); + if (!match) continue; + const [, key, after] = match; + let text = after.trim(); + let end = i; + if (text === '') { + end = i + 1; + text = (lines[end] ?? '').trim(); + } + const literal = readLiteral(text); + const tail = literal?.rest.trim() ?? ''; + if (!literal || !/^(,|\})?\s*(\/\/.*)?$/.test(tail)) { + throw new Error(`${label}:${i + 1}: '${key}' is not a single string literal`); + } + entries.push({ key, value: literal.value, marked: tail.includes(FALLBACK_MARKER), line: i + 1 }); + i = end; + } + return entries; +} + +/** The entries of `locale/file`, read from disk. */ +export function readCatalog(locale, file, root = I18N_ROOT) { + return parseCatalog(readFileSync(join(asPath(root), locale, file), 'utf8'), `${locale}/${file}`); +} diff --git a/shared/src/i18n/ar/budget.ts b/shared/src/i18n/ar/budget.ts index 6b4843b07..9dda87cfe 100644 --- a/shared/src/i18n/ar/budget.ts +++ b/shared/src/i18n/ar/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'إنشاء فئة', 'budget.category': 'الفئة', 'budget.categoryName': 'اسم الفئة', + 'budget.addCategory': 'إضافة فئة', 'budget.table.name': 'الاسم', 'budget.table.total': 'الإجمالي', 'budget.table.persons': 'الأشخاص', diff --git a/shared/src/i18n/ar/common.ts b/shared/src/i18n/ar/common.ts index a0437246b..0f57ab6d3 100644 --- a/shared/src/i18n/ar/common.ts +++ b/shared/src/i18n/ar/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'إلغاء', 'common.clear': 'مسح', 'common.delete': 'حذف', + 'common.remove': 'إزالة', 'common.preview': 'معاينة', 'common.edit': 'تعديل', 'common.add': 'إضافة', diff --git a/shared/src/i18n/az/budget.ts b/shared/src/i18n/az/budget.ts index af9712794..40cb34705 100644 --- a/shared/src/i18n/az/budget.ts +++ b/shared/src/i18n/az/budget.ts @@ -12,6 +12,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kateqoriya yarat', 'budget.category': 'Kateqoriya', 'budget.categoryName': 'Kateqoriyanın adı', + 'budget.addCategory': 'Kateqoriya əlavə et', 'budget.table.name': 'Ad', 'budget.table.total': 'Ümumi', diff --git a/shared/src/i18n/az/common.ts b/shared/src/i18n/az/common.ts index 8c4b1f9ec..ba3dcdf44 100644 --- a/shared/src/i18n/az/common.ts +++ b/shared/src/i18n/az/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Ləğv et', 'common.clear': 'Təmizlə', 'common.delete': 'Sil', + 'common.remove': 'Sil', 'common.preview': 'Önizləmə', 'common.edit': 'Redaktə et', 'common.add': 'Əlavə et', diff --git a/shared/src/i18n/br/budget.ts b/shared/src/i18n/br/budget.ts index 7f900deb1..8fc24cc45 100644 --- a/shared/src/i18n/br/budget.ts +++ b/shared/src/i18n/br/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Criar categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nome da categoria', + 'budget.addCategory': 'Adicionar categoria', 'budget.table.name': 'Nome', 'budget.table.total': 'Total', 'budget.table.persons': 'Pessoas', diff --git a/shared/src/i18n/br/common.ts b/shared/src/i18n/br/common.ts index a81b0b76f..d8e3eb391 100644 --- a/shared/src/i18n/br/common.ts +++ b/shared/src/i18n/br/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancelar', 'common.clear': 'Limpar', 'common.delete': 'Excluir', + 'common.remove': 'Remover', 'common.preview': 'Prévia', 'common.edit': 'Editar', 'common.add': 'Adicionar', diff --git a/shared/src/i18n/ca/budget.ts b/shared/src/i18n/ca/budget.ts index d9dc8d887..39655f0a3 100644 --- a/shared/src/i18n/ca/budget.ts +++ b/shared/src/i18n/ca/budget.ts @@ -9,6 +9,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crea una categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nom de la categoria', + 'budget.addCategory': 'Afegeix una categoria', 'budget.table.name': 'Nom', 'budget.table.total': 'Total', 'budget.table.persons': 'Persones', diff --git a/shared/src/i18n/ca/common.ts b/shared/src/i18n/ca/common.ts index dab02cd50..acfcf430c 100644 --- a/shared/src/i18n/ca/common.ts +++ b/shared/src/i18n/ca/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancel·lar', 'common.clear': 'Esborrar', 'common.delete': 'Eliminar', + 'common.remove': 'Elimina', 'common.preview': 'Vista prèvia', 'common.edit': 'Editar', 'common.add': 'Afegir', diff --git a/shared/src/i18n/cs/budget.ts b/shared/src/i18n/cs/budget.ts index 77b87fb55..b7de4453d 100644 --- a/shared/src/i18n/cs/budget.ts +++ b/shared/src/i18n/cs/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Vytvořit kategorii', 'budget.category': 'Kategorie', 'budget.categoryName': 'Název kategorie', + 'budget.addCategory': 'Přidat kategorii', 'budget.table.name': 'Název', 'budget.table.total': 'Celkem', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/cs/common.ts b/shared/src/i18n/cs/common.ts index 9f692890a..07d4fe8bf 100644 --- a/shared/src/i18n/cs/common.ts +++ b/shared/src/i18n/cs/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Zrušit', 'common.clear': 'Vymazat', 'common.delete': 'Smazat', + 'common.remove': 'Odstranit', 'common.preview': 'Náhled', 'common.edit': 'Upravit', 'common.add': 'Přidat', diff --git a/shared/src/i18n/de/budget.ts b/shared/src/i18n/de/budget.ts index f0643a486..fd71b1cef 100644 --- a/shared/src/i18n/de/budget.ts +++ b/shared/src/i18n/de/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategorie erstellen', 'budget.category': 'Kategorie', 'budget.categoryName': 'Kategoriename', + 'budget.addCategory': 'Kategorie hinzufügen', 'budget.table.name': 'Name', 'budget.table.total': 'Gesamt', 'budget.table.persons': 'Personen', diff --git a/shared/src/i18n/de/common.ts b/shared/src/i18n/de/common.ts index f9d35366d..1db340ba2 100644 --- a/shared/src/i18n/de/common.ts +++ b/shared/src/i18n/de/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Abbrechen', 'common.clear': 'Löschen', 'common.delete': 'Löschen', + 'common.remove': 'Entfernen', 'common.preview': 'Vorschau', 'common.edit': 'Bearbeiten', 'common.add': 'Hinzufügen', diff --git a/shared/src/i18n/en/budget.ts b/shared/src/i18n/en/budget.ts index c7deeed8f..9f23b3179 100644 --- a/shared/src/i18n/en/budget.ts +++ b/shared/src/i18n/en/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Create Category', 'budget.category': 'Category', 'budget.categoryName': 'Category Name', + 'budget.addCategory': 'Add category', 'budget.table.name': 'Name', 'budget.table.total': 'Total', 'budget.table.persons': 'Persons', diff --git a/shared/src/i18n/en/common.ts b/shared/src/i18n/en/common.ts index 23d1c6aad..004fa17ff 100644 --- a/shared/src/i18n/en/common.ts +++ b/shared/src/i18n/en/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancel', 'common.clear': 'Clear', 'common.delete': 'Delete', + 'common.remove': 'Remove', 'common.preview': 'Preview', 'common.edit': 'Edit', 'common.add': 'Add', diff --git a/shared/src/i18n/es/budget.ts b/shared/src/i18n/es/budget.ts index 9747e8e10..99bced521 100644 --- a/shared/src/i18n/es/budget.ts +++ b/shared/src/i18n/es/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crear categoría', 'budget.category': 'Categoría', 'budget.categoryName': 'Nombre de la categoría', + 'budget.addCategory': 'Añadir categoría', 'budget.table.name': 'Nombre', 'budget.table.total': 'Total', 'budget.table.persons': 'Personas', diff --git a/shared/src/i18n/es/common.ts b/shared/src/i18n/es/common.ts index 2ba40ca78..f79e6b99b 100644 --- a/shared/src/i18n/es/common.ts +++ b/shared/src/i18n/es/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Cancelar', 'common.clear': 'Borrar', 'common.delete': 'Eliminar', + 'common.remove': 'Eliminar', 'common.preview': 'Vista previa', 'common.edit': 'Editar', 'common.add': 'Añadir', diff --git a/shared/src/i18n/et/budget.ts b/shared/src/i18n/et/budget.ts index 1d80ca82c..a632b1d45 100644 --- a/shared/src/i18n/et/budget.ts +++ b/shared/src/i18n/et/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Loo kategooria', 'budget.category': 'Kategooria', 'budget.categoryName': 'Kategooria nimi', + 'budget.addCategory': 'Lisa kategooria', 'budget.table.name': 'Nimi', 'budget.table.total': 'Kokku', 'budget.table.persons': 'Inimesi', diff --git a/shared/src/i18n/et/common.ts b/shared/src/i18n/et/common.ts index 3d1f34651..62708e0d8 100644 --- a/shared/src/i18n/et/common.ts +++ b/shared/src/i18n/et/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Tühista', 'common.clear': 'Tühjenda', 'common.delete': 'Kustuta', + 'common.remove': 'Eemalda', 'common.preview': 'Eelvaade', 'common.edit': 'Muuda', 'common.add': 'Lisa', diff --git a/shared/src/i18n/fr/budget.ts b/shared/src/i18n/fr/budget.ts index 6b57f6df6..65a07adca 100644 --- a/shared/src/i18n/fr/budget.ts +++ b/shared/src/i18n/fr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Créer une catégorie', 'budget.category': 'Catégorie', 'budget.categoryName': 'Nom de la catégorie', + 'budget.addCategory': 'Ajouter une catégorie', 'budget.table.name': 'Nom', 'budget.table.total': 'Total', 'budget.table.persons': 'Personnes', diff --git a/shared/src/i18n/fr/common.ts b/shared/src/i18n/fr/common.ts index 90fb4d3be..6e14e015f 100644 --- a/shared/src/i18n/fr/common.ts +++ b/shared/src/i18n/fr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annuler', 'common.clear': 'Effacer', 'common.delete': 'Supprimer', + 'common.remove': 'Supprimer', 'common.preview': 'Aperçu', 'common.edit': 'Modifier', 'common.add': 'Ajouter', diff --git a/shared/src/i18n/gr/budget.ts b/shared/src/i18n/gr/budget.ts index f4d13ea65..1ccc669fb 100644 --- a/shared/src/i18n/gr/budget.ts +++ b/shared/src/i18n/gr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Δημιουργία Κατηγορίας', 'budget.category': 'Κατηγορία', 'budget.categoryName': 'Όνομα Κατηγορίας', + 'budget.addCategory': 'Προσθήκη κατηγορίας', 'budget.table.name': 'Όνομα', 'budget.table.total': 'Σύνολο', 'budget.table.persons': 'Άτομα', diff --git a/shared/src/i18n/gr/common.ts b/shared/src/i18n/gr/common.ts index cf4782db5..d9c1f353e 100644 --- a/shared/src/i18n/gr/common.ts +++ b/shared/src/i18n/gr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Ακύρωση', 'common.clear': 'Εκκαθάριση', 'common.delete': 'Διαγραφή', + 'common.remove': 'Αφαίρεση', 'common.preview': 'Προεπισκόπηση', 'common.edit': 'Επεξεργασία', 'common.add': 'Προσθήκη', diff --git a/shared/src/i18n/hu/budget.ts b/shared/src/i18n/hu/budget.ts index 459421679..4d6999e5a 100644 --- a/shared/src/i18n/hu/budget.ts +++ b/shared/src/i18n/hu/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategória létrehozása', 'budget.category': 'Kategória', 'budget.categoryName': 'Kategória neve', + 'budget.addCategory': 'Kategória hozzáadása', 'budget.table.name': 'Név', 'budget.table.total': 'Összesen', 'budget.table.persons': 'Személyek', diff --git a/shared/src/i18n/hu/common.ts b/shared/src/i18n/hu/common.ts index 43a6e09d8..877030f6c 100644 --- a/shared/src/i18n/hu/common.ts +++ b/shared/src/i18n/hu/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Mégse', 'common.clear': 'Törlés', 'common.delete': 'Törlés', + 'common.remove': 'Eltávolítás', 'common.preview': 'Előnézet', 'common.edit': 'Szerkesztés', 'common.add': 'Hozzáadás', diff --git a/shared/src/i18n/i18n-catalog.spec.ts b/shared/src/i18n/i18n-catalog.spec.ts new file mode 100644 index 000000000..83d9d9782 --- /dev/null +++ b/shared/src/i18n/i18n-catalog.spec.ts @@ -0,0 +1,58 @@ +// @ts-expect-error: plain .mjs script with no .d.ts; import as JS module. +import { listDomainFiles, listLocales, parseCatalog, readCatalog } from '../../scripts/i18n-catalog.mjs'; + +import { describe, it, expect } from 'vitest'; + +/** + * The reader the i18n scripts (parity, the untranslated ratchet, the client's + * key check) share. It reads the tables as text, so it sees the + * `// en-fallback` markers, and it refuses what it cannot read rather than + * skipping it. + */ +const ROOT = new URL('../../scripts/fixtures/i18n/', import.meta.url); + +type Entry = { key: string; value: string; marked: boolean; line: number }; + +describe('i18n catalogue reader', () => { + it('reads single-line, wrapped, double-quoted and escaped values with their markers', () => { + const entries = parseCatalog( + [ + " 'a.one': 'One',", + " 'a.two':", + " 'Two, wrapped', // en-fallback", + ` 'a.three': "It's",`, + " 'a.four': 'Don\\'t \\u00e9',", + ].join('\r\n'), + ); + expect(entries.map((e: Entry) => [e.key, e.value, e.marked])).toEqual([ + ['a.one', 'One', false], + ['a.two', 'Two, wrapped', true], + ['a.three', "It's", false], + ['a.four', "Don't é", false], + ]); + }); + + it('refuses a value that is not one string literal instead of skipping it', () => { + expect(() => parseCatalog(" 'a.one': 'One' + 'Two',", 'x.ts')).toThrow( + /x\.ts:1: 'a\.one' is not a single string literal/, + ); + expect(() => parseCatalog(" 'a.one': `One`,", 'x.ts')).toThrow(/not a single string literal/); + expect(() => parseCatalog(" 'a.one': 'unterminated,", 'x.ts')).toThrow(/not a single string literal/); + }); + + it('lists locales and domain files and reads a table from disk', () => { + expect(listLocales(ROOT).sort()).toEqual(['de', 'en']); + expect(listDomainFiles('en', ROOT)).toEqual(['a.ts']); + const de: Entry[] = readCatalog('de', 'a.ts', ROOT); + expect(de.find((e) => e.key === 'a.lone')).toMatchObject({ value: 'Remove {count} items', marked: true }); + expect(de.find((e) => e.key === 'a.multi')).toMatchObject({ + value: 'A long sentence that wraps onto the next line', + }); + }); + + it('reads every real locale table', () => { + for (const locale of listLocales()) { + for (const file of listDomainFiles(locale)) expect(() => readCatalog(locale, file)).not.toThrow(); + } + }); +}); diff --git a/shared/src/i18n/id/budget.ts b/shared/src/i18n/id/budget.ts index f23297c6c..6ada99ed4 100644 --- a/shared/src/i18n/id/budget.ts +++ b/shared/src/i18n/id/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Buat Kategori', 'budget.category': 'Kategori', 'budget.categoryName': 'Nama Kategori', + 'budget.addCategory': 'Tambah kategori', 'budget.table.name': 'Nama', 'budget.table.total': 'Total', 'budget.table.persons': 'Orang', diff --git a/shared/src/i18n/id/common.ts b/shared/src/i18n/id/common.ts index eb0642d40..5cf8e179c 100644 --- a/shared/src/i18n/id/common.ts +++ b/shared/src/i18n/id/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Batal', 'common.clear': 'Hapus', 'common.delete': 'Hapus', + 'common.remove': 'Hapus', 'common.preview': 'Pratinjau', 'common.edit': 'Sunting', 'common.add': 'Tambah', diff --git a/shared/src/i18n/it/budget.ts b/shared/src/i18n/it/budget.ts index ef9ac2b02..726730c41 100644 --- a/shared/src/i18n/it/budget.ts +++ b/shared/src/i18n/it/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Crea categoria', 'budget.category': 'Categoria', 'budget.categoryName': 'Nome categoria', + 'budget.addCategory': 'Aggiungi categoria', 'budget.table.name': 'Nome', 'budget.table.total': 'Totale', 'budget.table.persons': 'Persone', diff --git a/shared/src/i18n/it/common.ts b/shared/src/i18n/it/common.ts index a2ecf2858..4fd14f38a 100644 --- a/shared/src/i18n/it/common.ts +++ b/shared/src/i18n/it/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annulla', 'common.clear': 'Cancella', 'common.delete': 'Elimina', + 'common.remove': 'Rimuovi', 'common.preview': 'Anteprima', 'common.edit': 'Modifica', 'common.add': 'Aggiungi', diff --git a/shared/src/i18n/ja/budget.ts b/shared/src/i18n/ja/budget.ts index b9cefcdad..f0f183980 100644 --- a/shared/src/i18n/ja/budget.ts +++ b/shared/src/i18n/ja/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'カテゴリを作成', 'budget.category': 'カテゴリ', 'budget.categoryName': 'カテゴリ名', + 'budget.addCategory': 'カテゴリを追加', 'budget.table.name': '名前', 'budget.table.total': '合計', 'budget.table.persons': '人数', diff --git a/shared/src/i18n/ja/common.ts b/shared/src/i18n/ja/common.ts index dbd28f10c..22236a113 100644 --- a/shared/src/i18n/ja/common.ts +++ b/shared/src/i18n/ja/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'キャンセル', 'common.clear': 'クリア', 'common.delete': '削除', + 'common.remove': '削除', 'common.preview': 'プレビュー', 'common.edit': '編集', 'common.add': '追加', diff --git a/shared/src/i18n/ko/budget.ts b/shared/src/i18n/ko/budget.ts index 3db01e063..58e378e0b 100644 --- a/shared/src/i18n/ko/budget.ts +++ b/shared/src/i18n/ko/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '카테고리 만들기', 'budget.category': '카테고리', 'budget.categoryName': '카테고리 이름', + 'budget.addCategory': '카테고리 추가', 'budget.table.name': '이름', 'budget.table.total': '합계', 'budget.table.persons': '인원', diff --git a/shared/src/i18n/ko/common.ts b/shared/src/i18n/ko/common.ts index b99a6b02f..0cebdb73f 100644 --- a/shared/src/i18n/ko/common.ts +++ b/shared/src/i18n/ko/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '취소', 'common.clear': '지우기', 'common.delete': '삭제', + 'common.remove': '제거', 'common.preview': '미리보기', 'common.edit': '편집', 'common.add': '추가', diff --git a/shared/src/i18n/nl/budget.ts b/shared/src/i18n/nl/budget.ts index c6cbb1ef1..9f99e795e 100644 --- a/shared/src/i18n/nl/budget.ts +++ b/shared/src/i18n/nl/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Categorie aanmaken', 'budget.category': 'Categorie', 'budget.categoryName': 'Categorienaam', + 'budget.addCategory': 'Categorie toevoegen', 'budget.table.name': 'Naam', 'budget.table.total': 'Totaal', 'budget.table.persons': 'Personen', diff --git a/shared/src/i18n/nl/common.ts b/shared/src/i18n/nl/common.ts index b09128a8a..172cd8912 100644 --- a/shared/src/i18n/nl/common.ts +++ b/shared/src/i18n/nl/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Annuleren', 'common.clear': 'Wissen', 'common.delete': 'Verwijderen', + 'common.remove': 'Verwijderen', 'common.preview': 'Voorbeeld', 'common.edit': 'Bewerken', 'common.add': 'Toevoegen', diff --git a/shared/src/i18n/pl/budget.ts b/shared/src/i18n/pl/budget.ts index 5894e8dc2..ba35955c2 100644 --- a/shared/src/i18n/pl/budget.ts +++ b/shared/src/i18n/pl/budget.ts @@ -10,6 +10,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Utwórz kategorię', 'budget.category': 'Kategoria', 'budget.categoryName': 'Nazwa kategorii', + 'budget.addCategory': 'Dodaj kategorię', 'budget.table.name': 'Nazwa', 'budget.table.total': 'Łącznie', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/pl/common.ts b/shared/src/i18n/pl/common.ts index f50dc479a..87f6dae65 100644 --- a/shared/src/i18n/pl/common.ts +++ b/shared/src/i18n/pl/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Anuluj', 'common.clear': 'Wyczyść', 'common.delete': 'Usuń', + 'common.remove': 'Usuń', 'common.preview': 'Podgląd', 'common.edit': 'Edytuj', 'common.add': 'Dodaj', diff --git a/shared/src/i18n/ru/budget.ts b/shared/src/i18n/ru/budget.ts index 2d32cb5fb..02fe11758 100644 --- a/shared/src/i18n/ru/budget.ts +++ b/shared/src/i18n/ru/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Создать категорию', 'budget.category': 'Категория', 'budget.categoryName': 'Название категории', + 'budget.addCategory': 'Добавить категорию', 'budget.table.name': 'Название', 'budget.table.total': 'Итого', 'budget.table.persons': 'Человек', diff --git a/shared/src/i18n/ru/common.ts b/shared/src/i18n/ru/common.ts index ff8beaf7b..7db600594 100644 --- a/shared/src/i18n/ru/common.ts +++ b/shared/src/i18n/ru/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Отмена', 'common.clear': 'Очистить', 'common.delete': 'Удалить', + 'common.remove': 'Удалить', 'common.preview': 'Предпросмотр', 'common.edit': 'Редактировать', 'common.add': 'Добавить', diff --git a/shared/src/i18n/sk/budget.ts b/shared/src/i18n/sk/budget.ts index a2f1d7b49..680363bdc 100644 --- a/shared/src/i18n/sk/budget.ts +++ b/shared/src/i18n/sk/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Vytvoriť kategóriu', 'budget.category': 'Kategória', 'budget.categoryName': 'Názov kategórie', + 'budget.addCategory': 'Pridať kategóriu', 'budget.table.name': 'Názov', 'budget.table.total': 'Spolu', 'budget.table.persons': 'Osoby', diff --git a/shared/src/i18n/sk/common.ts b/shared/src/i18n/sk/common.ts index 2d93b96a1..2a0b5b284 100644 --- a/shared/src/i18n/sk/common.ts +++ b/shared/src/i18n/sk/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Zrušiť', 'common.clear': 'Vymazať', 'common.delete': 'Odstrániť', + 'common.remove': 'Odstrániť', 'common.preview': 'Náhľad', 'common.edit': 'Upraviť', 'common.add': 'Pridať', diff --git a/shared/src/i18n/sv/budget.ts b/shared/src/i18n/sv/budget.ts index df6ae4578..89b9c9426 100644 --- a/shared/src/i18n/sv/budget.ts +++ b/shared/src/i18n/sv/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Skapa kategori', 'budget.category': 'Kategori', 'budget.categoryName': 'Kategorinamn', + 'budget.addCategory': 'Lägg till kategori', 'budget.table.name': 'Namn', 'budget.table.total': 'Totalt', 'budget.table.persons': 'Personer', diff --git a/shared/src/i18n/sv/common.ts b/shared/src/i18n/sv/common.ts index 57efdcff2..987645a2c 100644 --- a/shared/src/i18n/sv/common.ts +++ b/shared/src/i18n/sv/common.ts @@ -19,6 +19,7 @@ const common: TranslationStrings = { 'common.cancel': 'Avbryt', 'common.clear': 'Rensa', 'common.delete': 'Ta bort', + 'common.remove': 'Ta bort', 'common.preview': 'Förhandsvisning', 'common.edit': 'Redigera', 'common.add': 'Lägg till', diff --git a/shared/src/i18n/th/budget.ts b/shared/src/i18n/th/budget.ts index 75fadc1be..a1e9a9992 100644 --- a/shared/src/i18n/th/budget.ts +++ b/shared/src/i18n/th/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'สร้างหมวดหมู่', 'budget.category': 'หมวดหมู่', 'budget.categoryName': 'ชื่อหมวดหมู่', + 'budget.addCategory': 'เพิ่มหมวดหมู่', 'budget.table.name': 'ชื่อ', 'budget.table.total': 'รวม', 'budget.table.persons': 'บุคคล', diff --git a/shared/src/i18n/th/common.ts b/shared/src/i18n/th/common.ts index bfb50ad26..95a2230d2 100644 --- a/shared/src/i18n/th/common.ts +++ b/shared/src/i18n/th/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'ยกเลิก', 'common.clear': 'ล้าง', 'common.delete': 'ลบ', + 'common.remove': 'ลบ', 'common.preview': 'ดูตัวอย่าง', 'common.edit': 'แก้ไข', 'common.add': 'เพิ่ม', diff --git a/shared/src/i18n/tr/budget.ts b/shared/src/i18n/tr/budget.ts index ab937e569..d6f20c73d 100644 --- a/shared/src/i18n/tr/budget.ts +++ b/shared/src/i18n/tr/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Kategori Oluştur', 'budget.category': 'Kategori', 'budget.categoryName': 'Kategori Adı', + 'budget.addCategory': 'Kategori ekle', 'budget.table.name': 'İsim', 'budget.table.total': 'Toplam', 'budget.table.persons': 'Kişiler', diff --git a/shared/src/i18n/tr/common.ts b/shared/src/i18n/tr/common.ts index aaee60a54..8b527d6bb 100644 --- a/shared/src/i18n/tr/common.ts +++ b/shared/src/i18n/tr/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'İptal', 'common.clear': 'Temizle', 'common.delete': 'Sil', + 'common.remove': 'Kaldır', 'common.preview': 'Önizleme', 'common.edit': 'Düzenle', 'common.add': 'Ekle', diff --git a/shared/src/i18n/uk/budget.ts b/shared/src/i18n/uk/budget.ts index 7b771fa6a..2f0041e00 100644 --- a/shared/src/i18n/uk/budget.ts +++ b/shared/src/i18n/uk/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Створити категорію', 'budget.category': 'Категорія', 'budget.categoryName': 'Назва категорії', + 'budget.addCategory': 'Додати категорію', 'budget.table.name': 'Назва', 'budget.table.total': 'Всього', 'budget.table.persons': 'Осіб', diff --git a/shared/src/i18n/uk/common.ts b/shared/src/i18n/uk/common.ts index 3474b0c92..fdf6f018c 100644 --- a/shared/src/i18n/uk/common.ts +++ b/shared/src/i18n/uk/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': 'Скасувати', 'common.clear': 'Очистити', 'common.delete': 'Видалити', + 'common.remove': 'Видалити', 'common.preview': 'Перегляд', 'common.edit': 'Редагувати', 'common.add': 'Додати', diff --git a/shared/src/i18n/vi/budget.ts b/shared/src/i18n/vi/budget.ts index d069b8167..f9473700d 100644 --- a/shared/src/i18n/vi/budget.ts +++ b/shared/src/i18n/vi/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': 'Tạo danh mục', 'budget.category': 'Loại', 'budget.categoryName': 'Tên danh mục', + 'budget.addCategory': 'Thêm danh mục', 'budget.table.name': 'Tên', 'budget.table.total': 'Tổng cộng', 'budget.table.persons': 'Người', diff --git a/shared/src/i18n/vi/common.ts b/shared/src/i18n/vi/common.ts index 78523743d..ad42a6759 100644 --- a/shared/src/i18n/vi/common.ts +++ b/shared/src/i18n/vi/common.ts @@ -19,6 +19,7 @@ const common: TranslationStrings = { 'common.cancel': 'Hủy', 'common.clear': 'Xóa', 'common.delete': 'Xóa bỏ', + 'common.remove': 'Gỡ bỏ', 'common.preview': 'Xem trước', 'common.edit': 'Chỉnh sửa', 'common.add': 'Thêm', diff --git a/shared/src/i18n/zh-TW/budget.ts b/shared/src/i18n/zh-TW/budget.ts index d99f83747..58484d09c 100644 --- a/shared/src/i18n/zh-TW/budget.ts +++ b/shared/src/i18n/zh-TW/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '建立分類', 'budget.category': '分類', 'budget.categoryName': '分類名稱', + 'budget.addCategory': '新增分類', 'budget.table.name': '名稱', 'budget.table.total': '合計', 'budget.table.persons': '人數', diff --git a/shared/src/i18n/zh-TW/common.ts b/shared/src/i18n/zh-TW/common.ts index 0d7cda513..1834d5513 100644 --- a/shared/src/i18n/zh-TW/common.ts +++ b/shared/src/i18n/zh-TW/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '取消', 'common.clear': '清除', 'common.delete': '刪除', + 'common.remove': '刪除', 'common.preview': '預覽', 'common.edit': '編輯', 'common.add': '新增', diff --git a/shared/src/i18n/zh/budget.ts b/shared/src/i18n/zh/budget.ts index c49d1385e..afa1b93cc 100644 --- a/shared/src/i18n/zh/budget.ts +++ b/shared/src/i18n/zh/budget.ts @@ -11,6 +11,7 @@ const budget: TranslationStrings = { 'budget.createCategory': '创建分类', 'budget.category': '分类', 'budget.categoryName': '分类名称', + 'budget.addCategory': '添加分类', 'budget.table.name': '名称', 'budget.table.total': '合计', 'budget.table.persons': '人数', diff --git a/shared/src/i18n/zh/common.ts b/shared/src/i18n/zh/common.ts index 3aeaf542e..8814437f5 100644 --- a/shared/src/i18n/zh/common.ts +++ b/shared/src/i18n/zh/common.ts @@ -7,6 +7,7 @@ const common: TranslationStrings = { 'common.cancel': '取消', 'common.clear': '清除', 'common.delete': '删除', + 'common.remove': '删除', 'common.preview': '预览', 'common.edit': '编辑', 'common.add': '添加',