Skip to content

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:

ts
// 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:

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 backstop

en.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/config or React. This keeps a catalog portable to the Deno edge-function runtime (see "Not yet covered" below).

  • No as const on the catalog object — values must widen to string so 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 Messages type:

    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 ​

  1. Add it to src/i18n/messages/en.ts under the right namespace.
  2. Add the same key to zh-TW.ts and ja.ts (the build fails until you do).
  3. 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 ​

  1. Extend SupportedLanguage in src/config/site.ts.
  2. Extend the zod enum in src/config/site.schema.ts.
  3. Add src/i18n/messages/<tag>.ts (const xx: Messages = { … }) and register it in src/i18n/index.ts.
  4. Bump CONFIG_CONTRACT_VERSION only if you also changed the config shape — adding an enum member does not require a bump.

Here the deployment's fundive.config.ts reaches the app through the virtual:fundive-config module, typed as SiteConfig, so the literal is validated by zod (not the TypeScript literal check) and needs no as const. A deployment repo that imports the config file directly does add as const to 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=1 to build from fundive.config.ts alone.
  • A database with no shop_profile table 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's src/config/site.ts path 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.ts fails with the missing glyphs named if you forget.

    Two rules when you edit pdf.ts:

    1. Never call doc.setFont("helvetica", …) right before drawing text that could be CJK. Call setFontFor(doc, text, style), which picks the embedded face when helvetica cannot encode text.
    2. The gate has to see the text. payloadNeedsCjkFont walks only the registration payload; catalog labels and identity.tagline never travel in it, so catalogNeedsCjkFont (in pdf-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.
  • 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.ts drives the real jsPDF renderer (through the npm:jspdf@4.2.1 → jspdf alias in vitest.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 stray doc.setFont("helvetica", …), since that mistake throws nothing.
  • pdf-font-coverage.test.ts declares which languages the vendored font can print (SERVED) and checks that against all three catalogs, not just the configured one. Noto Sans TC serves en and zh-TW; it lacks seven Japanese shinjitai and every Hangul syllable. Setting locale.language to a language outside SERVED fails the build.
  • outbound-copy.test.ts scans 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. Its ALLOWED list 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.

Released under the AGPL-3.0-or-later license.