Parity proves a locale has every key, not that the key holds the
locale's language: copying the en string passes it, so a feature could
add 26 English copies and stay green. Nothing counted them either. Today
the locales hold 3157 strings marked `// en-fallback` and 1252 unmarked
values identical to en.
scripts/i18n-untranslated.mjs counts both per locale and file and holds
them at scripts/i18n-untranslated-baseline.json, recorded from today's
tree. i18n-parity --strict runs it, so the existing CI step enforces it.
The two counts are separate on purpose: dropping a marker without
translating moves the string from one count to the other and still
fails. --update lowers the baseline to the current counts and never
raises an entry. A missing or malformed baseline, a locale lacking one
of en's files or a value the reader cannot parse is an error, not a
pass.
An identical value is not counted when it reads the same in every
language: after dropping placeholders, markup, URLs, e-mail addresses,
paths and a short list of multi-word names (Google Maps, Home
Assistant), at most one plain word may be left, where acronyms, single
letters and names with an inner capital are not plain words. That
excuses "{count} km", "GPX", "OAuth" and "Budget" and keeps "Source
code" counted.
9.3 KiB
CLAUDE.md
Scope: the @trek/shared workspace — the single source of truth for API contracts (Zod) and all i18n strings; server and client both import from it. See the repo-root CLAUDE.md for the monorepo picture.
Commands (run from shared/)
npm run build # tsdown → dist/ (CJS + ESM + .d.ts). REQUIRED before server/client typecheck or run
npm run build:watch # what the root `npm run dev` runs
npm run typecheck # tsc --noEmit
npm run lint # eslint --fix (rewrites files)
npm run test # vitest run — co-located *.spec.ts files
npm run i18n:parity # audit locale drift, exits 0
npm run i18n:parity:strict # CI gate, exits 1 on any drift (also runs the untranslated ratchet below)
npm run i18n:untranslated # the untranslated-strings ratchet on its own
npm run i18n:untranslated:update # lower scripts/i18n-untranslated-baseline.json after translating; never raises it
npm run contracts:open # CI gate (after build): open shapes in request schemas may only shrink
Single test: npx vitest run src/i18n/i18n-parity.spec.ts, or npx vitest run -t "rejects extra keys".
Always rebuild after changing a schema or locale — the consumers import the compiled dist/, not src/. If a server/client typecheck can't see a new export, you forgot npm run build here.
Build & exports
tsdown.config.ts emits the root barrel, the i18n metadata barrel, and one entry per locale so each locale is its own lazy-loadable chunk (the client dynamic-imports them). zod is never bundled. The exports map exposes @trek/shared (contracts), @trek/shared/i18n (metadata/types) and @trek/shared/i18n/<locale>.
Contracts (src/<domain>/<domain>.schema.ts)
One folder per domain exporting Zod schemas plus inferred types (export type X = z.infer<typeof xSchema>), re-exported from the root barrel. Domain-agnostic primitives (idSchema, idParamSchema, nonEmptyString, pagination) live in src/common/. A few pure isomorphic helpers live beside the schemas because both sides need the same answer; they follow the same rules as everything here.
src/plugin-permissions.ts is machine-generated from the server's plugin protocol — never hand-edit it; npm run gen:plugin-facts from server/ regenerates it and check:plugin-facts is the CI drift gate.
Schemas mirror the exact wire behavior of existing routes (weather/weather.schema.ts is the example): strings stay strings if the route never coerced them, optional fields reflect partial response subsets, and bespoke 4xx error strings are reproduced in the server controller, not derived from the schema. Don't "tidy up" a schema to be stricter than the contract it documents.
Rules for new contracts
The parity rule governs existing routes; it is not a license to mint new debt:
- The contract describes the wire, not the storage engine. New booleans are
z.boolean()— convert SQLite 0/1 at the service boundary, never add az.union([z.boolean(), z.number()]). New ids useidSchema/idParamSchema, nevernumber | string. Request and response types for a field must agree. - Use the shared primitives instead of re-declaring bare
z.number()/z.string()per domain. Narrow an existing open object rather than adding a newz.record(...)/passthrough body, because "any object" gives zero drift protection.contracts:open(scripts/open-request-schemas.mjs) counts the open shapes in every exported*RequestSchema(a loose object, a record of unknown, az.unknown()/z.any()field or body, az.custom()/z.instanceof(), each counted once per path it sits at) againstscripts/open-request-schemas-baseline.json: a new request schema may hold none, an existing one no more than its entry. It reads the builtdist/;src/open-request-schemas.spec.tsruns the same check oversrc/innpm test. After narrowing a schema,npm run contracts:open -- --updatelowers the baseline (it never raises or adds an entry). The suffix is only a convention, so the server holds the schemas it really binds to the same count:npm run contracts:dto-open --workspace=serverwalks everycreateZodDtoclass, whatever its schema is called, againstserver/scripts/dto-open-shapes-baseline.json. - Add a schema, add a spec — especially for lenient parsers fed by untrusted or LLM input.
- Stay lean and isomorphic: zero imports from client/server, no
node:APIs, effectively no new runtime dependencies (the contract layer approacheszod-only; i18n is a separable concern — don't couple new contract code to it). - Never hand-copy a locale list.
SUPPORTED_LANGUAGESinsrc/i18n/languages.tsis the one registry — derive barrels, loaders and spec maps from it. - This is the monorepo's strict-TypeScript template (
strict+noUncheckedIndexedAccess). No newany, no new suppressions.
i18n (src/i18n/)
languages.ts—SUPPORTED_LANGUAGESis the canonical registry and the source ofSupportedLanguageCode. Adding a language starts here.<locale>/— one folder per language, one file per UI domain plus anindex.tsbarrel; each file exports a flat map of dot-namespaced keys typed asTranslationStrings. The runtimet(key)only resolves these top-level keys.en/is canonical. Every other locale must have the identical file set and top-level keys —i18n:parity:strictenforces it in CI. When you add or rename a key, update every locale.- Every locale gets a real translation; an English placeholder is not acceptable. Write native phrasing with the locale's own punctuation and the same
{placeholders}asen; if you genuinely cannot translate one, say so instead of filling it with English. Parity only checks that the key exists, soi18n:parity:strictalso runs a ratchet (scripts/i18n-untranslated.mjs): per locale and file, the// en-fallbackmarkers and the unmarked values identical toenmay not grow pastscripts/i18n-untranslated-baseline.json. An identical value is excused only when, after dropping placeholders, markup, URLs/e-mail/paths and the multi-word names inBRAND_NAMES, at most one plain word is left (a word with a capital after its first letter, an acronym or a single letter does not count), so "{count} km", "GPX", "OAuth" and "Budget" pass and "Source code" does not. Deleting a marker without translating moves the string from one count to the other and still fails. After translating, runnpm run i18n:untranslated:updateto lower the baseline; nothing raises it. - Count-bearing strings use plural forms, never
count === 1in the caller. The key holds the general form (the CLDR "other" category) andkey.zero|one|two|few|manyhold the forms the language'sIntl.PluralRulesselects for whole numbers (plural.ts);t(key, { count })(or{ n }) picks one. Pass the count as a number. Russian needs.one,.fewand.many, Arabic all five, Japanese none.i18n:paritychecks 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; Frenchonealso covers 0 and Russianonecovers 21, so they keep{count}. A string whose wording changes with the number (the old "Copy to trip" for one place, "Copy {count} to trip" for more) is a plural group whoseonedrops the number where the language allows it, not two keys switched in the caller. - An
enstring carrying{count}or{n}must be a plural group, even when the forms read the same (a label such as "Notes: {count}"), so every language can inflect.i18n:parityrefuses one that is not, unlessNOT_PLURALinscripts/i18n-parity.mjslists it with a reason: only for a number that is never a quantity (a day number, a step, a multiplier). An entry that stops matching fails too. - Further i18n specs run in
npm testand fail changes that parity lets through: placeholder parity (every{placeholder}in anenstring must appear in each translation), noenstring 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 ofi18n:parity, which checks key sets, plural forms, count strings and the untranslated ratchet. - The i18n scripts read the tables as text through
scripts/i18n-catalog.mjs: 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. Their specs use the fixtures inscripts/fixtures/. That en holds every key the client names is checked on the client side (npm run lint:i18n-keysinclient/).
Sanitization (src/sanitize/sanitize.ts)
isomorphic-dompurify with a minimal inline-only allow-list (a rich-text variant already exists there for when rich text ships). sanitizeInlineHtml backs the client's HTML translation path; escapeHtml is used wherever the client builds markup from user strings (map popups, account screens). This is meant to be the one home for escaping. No lint rule enforces that yet and a handful of client files still carry a local escapeHtml — that is debt to fold in, not a pattern to copy.