editor: i18n runtime, English catalog and Accept-Language resolution #225
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#225
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-Languageheader, 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/srcis a hard-coded English literal: roughly 600 to 750 across ~60 files (JSX text,title/placeholder/aria-labelattributes, template literals inpageactions.ts,elementcrud.ts,undoinfo.tsand the components). 51 sites build plurals by appendings. Dates usetoLocaleString()with no locale (SourceValue.tsx,undoinfo.ts, the datetime widget); sorting useslocaleComparewith an undefined locale (elementcrud.ts). Thelangattribute on<html>is fixed. There is no i18n dependency and no concept of a UI language; the existingweb/src/l10n.tsxis the content-language axis and must not be confused with this.The harness serves the SPA and
GET /api/site(siteResponseininternal/server/server.go: title and content languages) without any notion of the request's language.Server errors arrive with
codeandargs(#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 inweb/src/l10n.tsx, under a module name that cannot be mistaken for content localization (e.g.web/src/i18n/). Plural forms arekey.one/key.otherentries selected withIntl.PluralRules; placeholders are{name}; dates go throughIntl.DateTimeFormat, relative times and list joins through the matching Intl APIs, sorting throughlocaleComparewith 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 aserver.*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/sitereturns the request'sAccept-Languagelist 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'snavigator.languages, then English. The<html lang>attribute anddocument.titlefollow 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 bycodewithargs, falling back to the Englisherror/msgstring 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 inundoinfo.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.
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 aserver.*entry.title,placeholder,aria-label,altattributes 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.Docs and skills (CLAUDE.md requires these in the same change):
Accept-Language, which catalogs ship, the fallback chain, and theacceptLanguagefield ofGET /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.goandinternal/element/agentsdoc_test.gokeep passing (new quoted headings must exist).Decisions
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.@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.t()regression is caught mechanically instead.[Translate to <label>:]content marker stays English; only the SPA's explanation of it is translated.SchemaErrorBanner) and Hugo output stay English.Key interfaces
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).siteResponseininternal/server/server.goandweb/src/api/site.ts— the newacceptLanguagefield.ApiErrorandserverError()inweb/src/api/client.ts; the field-error consumers inweb/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 inTreePane,PageScreen,FormScreen,MediaModule,FileBrowser,ElementForm.web/src/App.tsxbuilds the shell and provides contexts;web/src/test/holds the vitest suites and setup.Acceptance criteria
web/srccomponents outside the allowlist; the leak-check test enforces it.Accept-Language: deand no German catalog, the editor renders English; with a fixture catalog the smoke test shows the switch works end to end.GET /api/sitereturnsacceptLanguageas an ordered list of tags,[]when the header is absent.key.one/key.other; non === 1 ? "" : "s"remains.<html lang>anddocument.titlefollow the chosen language.setup-siteandverify-siteupdated as described; the scaffold drift tests pass.How to verify
make test(runs the web tests andgo test ./...);make build.harness serveontestdata/siteand load the editor with the browser set to German: English UI, no console errors,html[lang]isen.<button>Save</button>to a component: the leak check fails and names the file.Assumptions to check
ContentElements.tsx's count helper,StatusPill.tsx) and make sure they go throught()and the scan's allowlist is not hiding them.GET /api/siteis 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.ApiErrorchange).Out of scope
Blocked by