Shop-facing language (i18n)
The app renders its own chrome — page titles, nav, labels, buttons, descriptive prose, toast/error messages — in one language per deployment, chosen by the shop. This is shop-facing: a Taiwan shop runs the whole app in Traditional Chinese, a Japanese shop in Japanese. It is not per-user and not switchable at runtime. User-generated content (event titles, notes) and legacy EO_* catalog data are never translated.
How selection works
The language is a build-time constant, read from the config seam exactly like locale.currency:
// fundive.config.ts
locale: {
timezone: 'Asia/Taipei',
currency: 'TWD',
currencyLabel: 'NTD',
language: 'en', // 'en' | 'zh-TW' | 'ja'
units: 'metric', // 'metric' | 'imperial' — see below
},locale.units is a separate field on purpose. It picks which side of the height/weight toggle a diver sees first (src/lib/units.ts), and it is not derivable from language: a shop can render in English from a metric country, so inferring imperial from 'en' would be wrong. Storage stays metric — profiles.height_cm / profiles.weight_kg — whichever way the toggle is set, and each diver's choice is remembered per browser.
Because it's a constant, there is no React context or provider. The catalog is resolved once at module load in src/i18n/index.ts:
import { t } from '../i18n'
<h1>{t.dashboard.title}</h1>
<span>{t.shell.pending(count)}</span>t is a plain object — the message catalog for the configured language.
The catalogs
src/i18n/
index.ts # resolves `t` from siteConfig.locale.language
messages/
en.ts # source of truth; its type IS the contract
zh-TW.ts # const zhTW: Messages = { … }
ja.ts # const ja: Messages = { … }
messages.test.ts # runtime shape-parity backstopen.ts exports the catalog and its inferred type: export type Messages = typeof en. Every other catalog is annotated const zhTW: Messages = { … }, so a missing or misnamed key fails the TypeScript build — English is the schema. messages.test.ts re-checks the same shape at runtime and confirms the configured language resolves.
Rules for catalog files:
Pure data / plain functions only. No imports from
src/configor React. This keeps a catalog portable to the Deno edge-function runtime (see "Not yet covered" below).No
as conston the catalog object — values must widen tostringso a translation can supply a different literal for the same key.Interpolation is a function. A string that needs a runtime value is a function; its parameter names and types propagate to every translation through the shared
Messagestype:ts// en.ts greeting: (name: string) => `Hi ${name}`, // zh-TW.ts — signature is enforced greeting: (name: string) => `${name} 您好`,
Catalogs are namespaced by area (nav, common, shell, dashboard, register, admin.*, …). Keep a key where its text is used.
Adding a string
- Add it to
src/i18n/messages/en.tsunder the right namespace. - Add the same key to
zh-TW.tsandja.ts(the build fails until you do). - Replace the inline literal in the component with
t.namespace.key.
Module-scope constants (e.g. a nav array) can reference t directly — t is resolved at import time, before those constants initialize.
Adding a language
- Extend
SupportedLanguageinsrc/config/site.ts. - Extend the zod enum in
src/config/site.schema.ts. - Add
src/i18n/messages/<tag>.ts(const xx: Messages = { … }) and register it insrc/i18n/index.ts. - Bump
CONFIG_CONTRACT_VERSIONonly if you also changed the config shape — adding an enum member does not require a bump.
Here the deployment's
fundive.config.tsreaches the app through thevirtual:fundive-configmodule, typed asSiteConfig, so the literal is validated by zod (not the TypeScript literal check) and needs noas const. A deployment repo that imports the config file directly does addas constto narrow the literal to the union.
Keeping the two repos in sync
The src/i18n module and all three catalogs are identical in app-fundivers and the upstream fundive repo — only the config wiring differs (direct import vs the virtual:fundive-config plugin). Port catalog changes to both.
Translation quality
Shipped translations are a strong first pass. Dive terminology and a shop's local register deserve a native review before that shop flips language away from en in production. The English build is unaffected while translations are reviewed.
Currency and language reach the app through the build
Both are compiled in: the language decides which catalog t resolves to and the currency is read by vite.config.ts and the service worker as well as the app, so neither can change while the app is running. A production build therefore asks the database what the shop chose — it reads shop_profile over PostgREST with the anon key and overlays locale.currency, locale.currencyLabel and locale.language before bundling, logging every value it changed.
That makes the Shop Profile page's promise true: save, deploy, done — nothing to hand-edit. It costs the build its hermeticity, which is the trade being made deliberately. Three consequences worth knowing:
- A production build talks to your production Supabase. That is where the shop's choice lives.
- A build that cannot read the row fails, rather than quietly shipping the previous value — the failure this mechanism exists to remove. Set
FUNDIVE_SKIP_PROFILE_SYNC=1to build fromfundive.config.tsalone. - A database with no
shop_profiletable yet is not an error. It warns and builds from the config, so the very deploy that introduces the table is not blocked by its own migration.
A language the build ships no catalog for is ignored rather than applied: an app with no strings in it would be worse than a visible disagreement on the Shop Profile page. See src/vite/shop-profile-overlay.ts.
Not yet covered (separate surfaces)
Emails — done. The Deno edge functions read the same catalogs through
supabase/functions/_shared/i18n.ts, a Deno-safe selector (the browser'ssrc/config/site.tspath isn't resolvable there). One exception on purpose: the partner-facing half of a package recommendation stays English. It goes to a dive shop abroad, for whom your shop-facing language is meaningless. See_shared/package-registration-email.ts.The registration PDF — translated, but it needs a font. jsPDF's built-in helvetica is WinAnsi: it does not fail on CJK, it silently emits mangled bytes.
supabase/functions/_shared/pdf-cjk.ttf(Noto Sans TC) is embedded and used per-string. A shop with Japanese divers must replace that file with Noto Sans JP — the two are not supersets of each other.pdf-font-coverage.test.tsfails with the missing glyphs named if you forget.Two rules when you edit
pdf.ts:- Never call
doc.setFont("helvetica", …)right before drawing text that could be CJK. CallsetFontFor(doc, text, style), which picks the embedded face when helvetica cannot encodetext. - The gate has to see the text.
payloadNeedsCjkFontwalks only the registration payload; catalog labels andidentity.taglinenever travel in it, socatalogNeedsCjkFont(inpdf-fonts.ts) covers those, invoking interpolating catalog values with a placeholder to reach their literal text. Without it a zh-TW deployment printing an all-Latin registration registers no font and mangles every label on the page.
- Never call
CSV exports — every builder leads with a UTF-8 BOM (
CSV_BOM). Excel opens a BOM-less UTF-8 CSV as the machine's ANSI codepage, which turns Chinese diver names, event titles and notes into mojibake. Covers the dive-log export, the database backup and the accounting sheets.
What the tests actually hold
pdf-render.test.tsdrives the real jsPDF renderer (through thenpm:jspdf@4.2.1→jspdfalias invitest.config.ts) and records the font every draw used. It fails if any string helvetica cannot encode reaches the page under helvetica — which is the only way to catch a straydoc.setFont("helvetica", …), since that mistake throws nothing.pdf-font-coverage.test.tsdeclares which languages the vendored font can print (SERVED) and checks that against all three catalogs, not just the configured one. Noto Sans TC servesenandzh-TW; it lacks seven Japanese shinjitai and every Hangul syllable. Settinglocale.languageto a language outsideSERVEDfails the build.outbound-copy.test.tsscans the source of every module that composes an email, a PDF page or a file inside an export, and fails on a bare prose literal. ItsALLOWEDlist is the record of what stays English on purpose — currently only the partner-facing halves of the package recommendation and the trusted-partner introduction.
Glyph rendering in the app is not testable here and does not need to be: the SPA embeds no font, so a CJK string is drawn with the viewer's own system fonts. What can break there is layout, not encoding — CJK offers no space to wrap on — and that needs a real browser, which neither repo has in CI.
- Legal text — not translated, and not in code. Terms of Use and waivers are shop-authored in the admin UI (Manage → Terms of Use / Waivers), so you write them in your own language. The starter template is English-only on purpose: a machine translation of a legal document is not the same document, and shipping three "equivalent" versions invites the question of which one a diver actually agreed to.