editor: i18n runtime, English catalog and Accept-Language resolution #225

Closed
opened 2026-10-12 14:22:15 +02:00 by dominik.polakovics · 0 comments

Summary

The editor SPA gets a translation runtime and every user-facing string moves out of the components into an English catalog, so the interface can be shipped in other languages (German follows in its own issue). The language comes from the request's Accept-Language header, which deckle-hosted will set per user and which browsers send on their own. No visible change for English users.

Current behavior

Every user-facing string in web/src is a hard-coded English literal: roughly 600 to 750 across ~60 files (JSX text, title/placeholder/aria-label attributes, template literals in pageactions.ts, elementcrud.ts, undoinfo.ts and the components). 51 sites build plurals by appending s. Dates use toLocaleString() with no locale (SourceValue.tsx, undoinfo.ts, the datetime widget); sorting uses localeCompare with an undefined locale (elementcrud.ts). The lang attribute on <html> is fixed. There is no i18n dependency and no concept of a UI language; the existing web/src/l10n.tsx is the content-language axis and must not be confused with this.

The harness serves the SPA and GET /api/site (siteResponse in internal/server/server.go: title and content languages) without any notion of the request's language.

Server errors arrive with code and args (#224) and are shown verbatim.

Desired behavior

Runtime. A hand-rolled t(key, params) over flat JSON catalogs, provided through a Solid context like the language-axis context in web/src/l10n.tsx, under a module name that cannot be mistaken for content localization (e.g. web/src/i18n/). Plural forms are key.one / key.other entries selected with Intl.PluralRules; placeholders are {name}; dates go through Intl.DateTimeFormat, relative times and list joins through the matching Intl APIs, sorting through localeCompare with the UI locale. Catalogs are bundled statically. No dependency is added.

Catalog. One flat JSON file per language under the i18n module, dotted keys grouped by screen or namespace (tree.noPages, dialog.unsaved.title), with a server.* namespace holding one entry per server error code from the registry #224 exports. The English catalog is complete after this issue.

Language resolution. GET /api/site returns the request's Accept-Language list as received (parsed into ordered tags; nothing is matched or translated in Go). The SPA picks the first tag with a catalog, then tries the browser's navigator.languages, then English. The <html lang> attribute and document.title follow the chosen language. The host-navigation contract (/api/host) is untouched.

Server errors. ApiError (web/src/api/client.ts) and the 422 field-error path translate by code with args, falling back to the English error/msg string when the catalog has no entry for a code, so an untranslated code still shows readable text.

Extraction. Every component, store and helper that produces user-visible text uses t(). Sentences assembled from pieces (Translate to ${label}…, "removed from N pages", relative times in undoinfo.ts) become single parameterized keys so word order can differ per language. Labels that come from data (site title, content-language labels, sidecar labels, host-nav entries) are never translated.

Tests.

  • Existing tests keep asserting English: the test setup forces the English catalog. No rewrite of the 62 test files that assert English strings.
  • A parity test asserts: every key used by t() exists in every catalog (English for now; the German issue adds de.json to the same check); no catalog key is unused; every code in the exported server registry has a server.* entry.
  • A static leak check: a test scans the component sources for JSX text nodes and the title, placeholder, aria-label, alt attributes holding a literal that starts with a capital letter and contains a space, with a short allowlist (product name, test ids). It fails on a newly added raw English string.
  • One smoke test renders the shell under a second catalog (a tiny fixture one, since German does not exist yet) and asserts the chosen language and a translated string.

Docs and skills (CLAUDE.md requires these in the same change):

  • README, next to "Host navigation": a subsection documenting that the editor's language follows Accept-Language, which catalogs ship, the fallback chain, and the acceptLanguage field of GET /api/site. The "Development" section: where the catalogs and the glossary live, how to add a string, what the parity and leak tests enforce.
  • scaffold/site/AGENTS.md, under "Languages": the editor's chrome follows the browser or host and is not configured in harness.yaml; sidecar labels are single-valued and shown as written, so write them in the language the site's editors work in.
  • scaffold/site/.claude/skills/setup-site/SKILL.md: one interview line asking which language the customer's editors work in (default: the default content language) so element, page-type and module labels are written in it.
  • scaffold/site/.claude/skills/verify-site/SKILL.md, "Smoke-test in the editor": the editor appears in the browser's language; check it in the customer's.
  • scaffold/skills_test.go and internal/element/agentsdoc_test.go keep passing (new quoted headings must exist).

Decisions

  • Client-only translation; the server only echoes Accept-Language — one runtime, one catalog, and the server's wire contract stays plain strings. Decided 2026-10-12 after weighing a server-side catalog.
  • Accept-Language, not a query parameter and not a harness.yaml key — ?lang= is already the content language; the header is sent on page load and every fetch by every browser, deckle-hosted can override it in its reverse proxy per user, and no new host contract is needed. A per-site pin may be added later if a standalone customer asks.
  • Hand-rolled runtime over @solid-primitives/i18n — the library replaces only the lookup; plurals, dates and lists are Intl either way, and owning the plural syntax matters for the parity test.
  • Tests stay in English plus a static leak check — rewriting 551 assertions buys little; the forgotten-t() regression is caught mechanically instead.
  • Site-authored labels (element, field, page-type, module, backend-layout, bundle labels) stay single-valued and untranslated. A per-language label map was rejected as a large schema change hosted does not need.
  • The [Translate to <label>:] content marker stays English; only the SPA's explanation of it is translated.
  • CLI output, sidecar load diagnostics (SchemaErrorBanner) and Hugo output stay English.

Key interfaces

  • New i18n module: the t() function, the Solid context and provider, the catalog JSON files, a glossary file for translators (register rule: impersonal phrasing, formal "Sie" where an address is unavoidable — recorded now so the German issue follows it).
  • siteResponse in internal/server/server.go and web/src/api/site.ts — the new acceptLanguage field.
  • ApiError and serverError() in web/src/api/client.ts; the field-error consumers in web/src/widgets/ — translate by code.
  • web/src/undoinfo.ts, pageactions.ts, elementcrud.ts, components/*.tsx, rte/RichTextField.tsx, widgets/controls.tsx — the extraction surface; heaviest in TreePane, PageScreen, FormScreen, MediaModule, FileBrowser, ElementForm.
  • The exported server-code registry JSON from #224 — read by the parity test.
  • Starting hints: web/src/App.tsx builds the shell and provides contexts; web/src/test/ holds the vitest suites and setup.

Acceptance criteria

  • No raw English prose remains in web/src components outside the allowlist; the leak-check test enforces it.
  • The English catalog is complete; the parity test passes (used keys ⊆ catalog, catalog ⊆ used keys, server codes covered).
  • With Accept-Language: de and no German catalog, the editor renders English; with a fixture catalog the smoke test shows the switch works end to end.
  • GET /api/site returns acceptLanguage as an ordered list of tags, [] when the header is absent.
  • Server errors with a known code render the catalog's string with args; unknown codes render the server's English message.
  • All plurals use key.one/key.other; no n === 1 ? "" : "s" remains.
  • <html lang> and document.title follow the chosen language.
  • Existing vitest and Go suites pass unchanged in their assertions; README, AGENTS.md, setup-site and verify-site updated as described; the scaffold drift tests pass.

How to verify

  • make test (runs the web tests and go test ./...); make build.
  • Run harness serve on testdata/site and load the editor with the browser set to German: English UI, no console errors, html[lang] is en.
  • Add a raw <button>Save</button> to a component: the leak check fails and names the file.
  • Remove a key from en.json: the parity test fails and names the key.

Assumptions to check

  • That every JSX text node is reachable by a simple source scan — check the few places that build text through helpers (ContentElements.tsx's count helper, StatusPill.tsx) and make sure they go through t() and the scan's allowlist is not hiding them.
  • That GET /api/site is fetched before the first render that needs a translated string; if not, the shell must render nothing user-visible until the language is known, to avoid a flash of English.
  • That no SPA code matches on server message text (a grep found none, but confirm after the ApiError change).

Out of scope

  • The German catalog, glossary entries beyond the register rule, and the layout pass (next issue).
  • Translating site-authored labels; the scaffold's English labels.
  • A harness.yaml setting for the editor language.
  • deckle-hosted's per-user language setting and header override (other repository).
  • CLI messages, sidecar diagnostics, Hugo output, the content placeholder marker.

Blocked by

## Summary The editor SPA gets a translation runtime and every user-facing string moves out of the components into an English catalog, so the interface can be shipped in other languages (German follows in its own issue). The language comes from the request's `Accept-Language` header, which deckle-hosted will set per user and which browsers send on their own. No visible change for English users. ## Current behavior Every user-facing string in `web/src` is a hard-coded English literal: roughly 600 to 750 across ~60 files (JSX text, `title`/`placeholder`/`aria-label` attributes, template literals in `pageactions.ts`, `elementcrud.ts`, `undoinfo.ts` and the components). 51 sites build plurals by appending `s`. Dates use `toLocaleString()` with no locale (`SourceValue.tsx`, `undoinfo.ts`, the datetime widget); sorting uses `localeCompare` with an undefined locale (`elementcrud.ts`). The `lang` attribute on `<html>` is fixed. There is no i18n dependency and no concept of a UI language; the existing `web/src/l10n.tsx` is the content-language axis and must not be confused with this. The harness serves the SPA and `GET /api/site` (`siteResponse` in `internal/server/server.go`: title and content languages) without any notion of the request's language. Server errors arrive with `code` and `args` (#224) and are shown verbatim. ## Desired behavior **Runtime.** A hand-rolled `t(key, params)` over flat JSON catalogs, provided through a Solid context like the language-axis context in `web/src/l10n.tsx`, under a module name that cannot be mistaken for content localization (e.g. `web/src/i18n/`). Plural forms are `key.one` / `key.other` entries selected with `Intl.PluralRules`; placeholders are `{name}`; dates go through `Intl.DateTimeFormat`, relative times and list joins through the matching Intl APIs, sorting through `localeCompare` with the UI locale. Catalogs are bundled statically. No dependency is added. **Catalog.** One flat JSON file per language under the i18n module, dotted keys grouped by screen or namespace (`tree.noPages`, `dialog.unsaved.title`), with a `server.*` namespace holding one entry per server error code from the registry #224 exports. The English catalog is complete after this issue. **Language resolution.** `GET /api/site` returns the request's `Accept-Language` list as received (parsed into ordered tags; nothing is matched or translated in Go). The SPA picks the first tag with a catalog, then tries the browser's `navigator.languages`, then English. The `<html lang>` attribute and `document.title` follow the chosen language. The host-navigation contract (`/api/host`) is untouched. **Server errors.** `ApiError` (`web/src/api/client.ts`) and the 422 field-error path translate by `code` with `args`, falling back to the English `error`/`msg` string when the catalog has no entry for a code, so an untranslated code still shows readable text. **Extraction.** Every component, store and helper that produces user-visible text uses `t()`. Sentences assembled from pieces (`Translate to ${label}…`, "removed from N pages", relative times in `undoinfo.ts`) become single parameterized keys so word order can differ per language. Labels that come from data (site title, content-language labels, sidecar labels, host-nav entries) are never translated. **Tests.** - Existing tests keep asserting English: the test setup forces the English catalog. No rewrite of the 62 test files that assert English strings. - A parity test asserts: every key used by `t()` exists in every catalog (English for now; the German issue adds de.json to the same check); no catalog key is unused; every code in the exported server registry has a `server.*` entry. - A static leak check: a test scans the component sources for JSX text nodes and the `title`, `placeholder`, `aria-label`, `alt` attributes holding a literal that starts with a capital letter and contains a space, with a short allowlist (product name, test ids). It fails on a newly added raw English string. - One smoke test renders the shell under a second catalog (a tiny fixture one, since German does not exist yet) and asserts the chosen language and a translated string. **Docs and skills** (CLAUDE.md requires these in the same change): - README, next to "Host navigation": a subsection documenting that the editor's language follows `Accept-Language`, which catalogs ship, the fallback chain, and the `acceptLanguage` field of `GET /api/site`. The "Development" section: where the catalogs and the glossary live, how to add a string, what the parity and leak tests enforce. - `scaffold/site/AGENTS.md`, under "Languages": the editor's chrome follows the browser or host and is not configured in harness.yaml; sidecar labels are single-valued and shown as written, so write them in the language the site's editors work in. - `scaffold/site/.claude/skills/setup-site/SKILL.md`: one interview line asking which language the customer's editors work in (default: the default content language) so element, page-type and module labels are written in it. - `scaffold/site/.claude/skills/verify-site/SKILL.md`, "Smoke-test in the editor": the editor appears in the browser's language; check it in the customer's. - `scaffold/skills_test.go` and `internal/element/agentsdoc_test.go` keep passing (new quoted headings must exist). ## Decisions - Client-only translation; the server only echoes `Accept-Language` — one runtime, one catalog, and the server's wire contract stays plain strings. Decided 2026-10-12 after weighing a server-side catalog. - `Accept-Language`, not a query parameter and not a harness.yaml key — `?lang=` is already the content language; the header is sent on page load and every fetch by every browser, deckle-hosted can override it in its reverse proxy per user, and no new host contract is needed. A per-site pin may be added later if a standalone customer asks. - Hand-rolled runtime over `@solid-primitives/i18n` — the library replaces only the lookup; plurals, dates and lists are Intl either way, and owning the plural syntax matters for the parity test. - Tests stay in English plus a static leak check — rewriting 551 assertions buys little; the forgotten-`t()` regression is caught mechanically instead. - Site-authored labels (element, field, page-type, module, backend-layout, bundle labels) stay single-valued and untranslated. A per-language label map was rejected as a large schema change hosted does not need. - The `[Translate to <label>:] ` content marker stays English; only the SPA's explanation of it is translated. - CLI output, sidecar load diagnostics (`SchemaErrorBanner`) and Hugo output stay English. ## Key interfaces - New i18n module: the `t()` function, the Solid context and provider, the catalog JSON files, a glossary file for translators (register rule: impersonal phrasing, formal "Sie" where an address is unavoidable — recorded now so the German issue follows it). - `siteResponse` in `internal/server/server.go` and `web/src/api/site.ts` — the new `acceptLanguage` field. - `ApiError` and `serverError()` in `web/src/api/client.ts`; the field-error consumers in `web/src/widgets/` — translate by code. - `web/src/undoinfo.ts`, `pageactions.ts`, `elementcrud.ts`, `components/*.tsx`, `rte/RichTextField.tsx`, `widgets/controls.tsx` — the extraction surface; heaviest in `TreePane`, `PageScreen`, `FormScreen`, `MediaModule`, `FileBrowser`, `ElementForm`. - The exported server-code registry JSON from #224 — read by the parity test. - Starting hints: `web/src/App.tsx` builds the shell and provides contexts; `web/src/test/` holds the vitest suites and setup. ## Acceptance criteria - [ ] No raw English prose remains in `web/src` components outside the allowlist; the leak-check test enforces it. - [ ] The English catalog is complete; the parity test passes (used keys ⊆ catalog, catalog ⊆ used keys, server codes covered). - [ ] With `Accept-Language: de` and no German catalog, the editor renders English; with a fixture catalog the smoke test shows the switch works end to end. - [ ] `GET /api/site` returns `acceptLanguage` as an ordered list of tags, `[]` when the header is absent. - [ ] Server errors with a known code render the catalog's string with args; unknown codes render the server's English message. - [ ] All plurals use `key.one`/`key.other`; no `n === 1 ? "" : "s"` remains. - [ ] `<html lang>` and `document.title` follow the chosen language. - [ ] Existing vitest and Go suites pass unchanged in their assertions; README, AGENTS.md, `setup-site` and `verify-site` updated as described; the scaffold drift tests pass. ## How to verify - `make test` (runs the web tests and `go test ./...`); `make build`. - Run `harness serve` on `testdata/site` and load the editor with the browser set to German: English UI, no console errors, `html[lang]` is `en`. - Add a raw `<button>Save</button>` to a component: the leak check fails and names the file. - Remove a key from en.json: the parity test fails and names the key. ## Assumptions to check - That every JSX text node is reachable by a simple source scan — check the few places that build text through helpers (`ContentElements.tsx`'s count helper, `StatusPill.tsx`) and make sure they go through `t()` and the scan's allowlist is not hiding them. - That `GET /api/site` is fetched before the first render that needs a translated string; if not, the shell must render nothing user-visible until the language is known, to avoid a flash of English. - That no SPA code matches on server message text (a grep found none, but confirm after the `ApiError` change). ## Out of scope - The German catalog, glossary entries beyond the register rule, and the layout pass (next issue). - Translating site-authored labels; the scaffold's English labels. - A harness.yaml setting for the editor language. - deckle-hosted's per-user language setting and header override (other repository). - CLI messages, sidecar diagnostics, Hugo output, the content placeholder marker. ## Blocked by - #224
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#225
No description provided.