Skip to main content

i18n architecture (Sweep S13, #1418)

Status: Accepted (design decisions ratified 2026-06-17). Phase 1 (this ADR + plumbing + one pilot package) in progress.

Problem

Every user-facing string in the UI packages is hardcoded English (MessageInput"Type a message...", DataTable"No data available"). The @happyvertical/smrt-languages package exists — a server-side, async, DB-backed string registry with a 5-layer override chain (code → file config → app → tenant → runtime), locale fallback, and AI auto-translation — but no component imports it. S13 introduces an i18n layer for smrt-svelte and routes strings through it, package by package.

The core tension: resolveLanguageString is async and DB-backed, but Svelte component render is synchronous. The design has to bridge the two without putting await / loading states into every string.

Decisions (HITL)

  1. API surface — both $t() and <Trans>, equal first-class.
    • $t('key', vars) — a reactive accessor for the common case (plain strings, attributes like placeholder / aria-label / title).
    • <Trans key vars /> — a component for the same strings in element bodies, and the carrier for rich interpolation (links, <strong>, nested components) in later phases.
  2. Async→sync bridge — server snapshot → context. The server pre-resolves a per-locale dictionary of templates (every registered key, walked through the override/tenant/locale chain) via resolveLanguageString, and passes it into the smrt-svelte Provider. The Provider puts it on a Svelte context; $t / <Trans> read it synchronously on the client and interpolate variables locally. Tenant overrides and AI auto-translation stay entirely server-side; the client never does async string resolution.
  3. Phase 1 scope — this ADR + plumbing + one pilot package (smrt-svelte primitives). Extraction across the remaining ~85 components is phased.

Architecture

SERVER (request/SSR) CLIENT (sync render)
───────────────────── ────────────────────
defineMessages(...) ──registers──▶ LanguageRegistry (global)

buildI18nSnapshot({ locale, │ resolve each key's
tenantId, db }) ──────────────────────┘ template for `locale`
→ { locale, messages: { key: template } }

▼ passed as a prop
<Provider i18n={snapshot}> ──setI18nContext──▶ Svelte context

useI18n() / $t / <Trans> read it
t(key, vars) = renderTemplate(
messages[key] ?? default ?? key, vars)

Why ship templates, not rendered text

resolveLanguageString returns both the rendered text (variables already substituted) and the raw template. The snapshot ships templates keyed by message key. Variables are supplied at the call site at render time, so the client interpolates locally with the same renderTemplate the server uses — one interpolation contract, no double-rendering, and a value like a live count or a user name never has to round-trip to the server.

Interpolation contract

Single-brace placeholders: "Showing {count} of {total}". This matches @happyvertical/smrt-languages's renderTemplate (/\{([^{}]+)\}/g): Date → ISO string, array/object → JSON, null / undefined → empty string.

The client owns interpolation (src/i18n/render.ts)

The languages package root drags smrt-core, sql, ai, smrt-jobs, … — fine on the server, wrong for a browser bundle. And because the always-bundled client path (Provider/i18n) reaches the interpolation helper, importing the languages package there would force every smrt-svelte consumer (including downstream SvelteKit apps) to install that heavy tree — even though languages is only an optional peer needed for the server bridge.

So the client owns a tiny, dependency-free copy of renderTemplate (src/i18n/render.ts). The contract is small and stable; __tests__/render-parity.spec.ts pins the copy against the canonical languages implementation so the two never drift. Only the server subpath (/i18n/server) imports the languages root.

Key naming

<package>.<component>.<descriptor>, lowercase, snake_case within a segment. smrt-svelte primitives use the ui namespace.

  • ui.data_table.empty
  • ui.pagination.next
  • chat.message_input.placeholder

Keys are stable identifiers, not English text — renaming the English copy never changes a key. The package prefix keeps catalogs collision-free across the monorepo and makes a key's owner obvious.

Registration & client-side defaults

Each package ships a co-located catalog (src/i18n/strings.ts) that calls defineMessages({...}) (a thin smrt-svelte wrapper over defineLanguageString) for its English code defaults. Two consumers of that catalog:

  • Server: importing the catalog populates LanguageRegistry, so buildI18nSnapshot can resolve every key.
  • Client: defineMessages also records the English default in a small client-side map, so $t renders correctly without a server snapshot (standalone components, tests, Storybook). Resolution order in t(): snapshot templateregistered defaultthe key itself (loud, never-blank fallback).

useI18n() / $t / <Trans>

<script lang="ts">
import { useI18n, Trans } from '@happyvertical/smrt-ui/i18n';
const { t } = useI18n();
let { count, total }: { count: number; total: number } = $props();
</script>

<input placeholder={t('chat.message_input.placeholder')} />
<p><Trans key="ui.pagination.range" vars={{ count, total }} /></p>

useI18n() returns { locale, t }. Used outside a Provider, it degrades to registered defaults (no throw), so primitives stay usable in isolation.

Enforcement — hardcoded-string lint

scripts/check-hardcoded-strings.mjs (the established ratchet idiom) flags new prose string literals in .svelte markup — element text nodes and the user-facing attributes placeholder / title / alt / aria-label — in strict packages, report-only elsewhere. Conservative by construction (needs a letter and whitespace; ignores single tokens, bindings, {...} expressions) to avoid false positives. The pilot package is the first STRICT entry; packages flip to strict as their extraction completes (mirrors the design-token ratchets).

Phased rollout

  1. Phase 1 (this PR): ADR, /runtime subpath, smrt-svelte i18n core (context / useI18n / $t / <Trans> / defineMessages), buildI18nSnapshot server helper, Provider i18n prop, the lint rule (report-only + the pilot strict), and one pilot package extracted.
  2. Phase 2+: extract per package (high-traffic UI first: chat, content, users), flipping each to lint-strict as it lands.
  3. Locale switching & SSR loading in consumer apps (ergot/anytown): the consumer's load function builds the snapshot for the request locale and passes it to Provider. Documented as a consumer recipe; not framework code.

Alternatives considered

  • Build-time catalog compilation (Paraglide-style). Fastest runtime, fully static — but bypasses the languages package's runtime tenant/override/AI features entirely, making it dead weight. Rejected: the override chain and auto-translation are the reason the languages package exists.
  • Client async resolution (await per string). Keeps everything dynamic but forces loading UI into every label — awkward and a11y-noisy. Rejected.
  • Import a browser-safe /runtime subpath from languages on the client. Cleanest "single source of truth", but the always-bundled client path reaches it, so it would make the otherwise-optional languages package (and its heavy server dep tree) a hard requirement for every smrt-svelte consumer. Rejected: the client owns a tiny renderTemplate copy guarded by a parity test instead.