Multilingual editor (1/3): language switch, tree translation state, untranslated page, Translate page dialog #192

Closed
opened 2026-10-10 16:23:09 +02:00 by dominik.polakovics · 1 comment

Summary

First of three editor slices of the multilingual work. Give the editor its language axis: the language list from GET /api/site, a language switch in every module docheader with a status dot per language, translation state in the page tree, the untranslated-page screen, the Translate page dialog, and the guard that hides structural controls on a connected translation. The visual reference is round 2 of the mockup in docs/mockups/multilingual/ (artifact: https://claude.ai/artifact/6RYbRG7y4yMyeGxYbFsh1n), reviewed and accepted by the maintainer on 2026-10-10 and committed in #198.

The engine side landed in #193 (API documented in README "Translations on the wire"; every endpoint below exists), the theme in #196. The remaining editor screens are split into #199 (editing inside a translation) and #200 (page properties, page menu, pending changes, paste confirmation, Languages view, module lists). The PRD at the bottom is the contract for all three.

Current behavior

The editor carries the language in its URL (/:module/:lang/*path, web/src/routing.ts), in the stores (PageId = {path, lang}) and in every API call, but it takes the one language from the tree response (ModuleContext.treeLang, web/src/modules.tsx) and never reads GET /api/site's language list. There is no switch, no translation state in the tree, and a non-default language's URL shows the page as if it were the default language's. GET /api/tree?lang= and GET /api/badges?lang= already take the language (web/src/api/tree.ts).

Desired behavior

Every rule is phrased in terms of the default language (languages[0] of GET /api/site), never "English". Mockup: the EN · DE · FR switch, the tree in DE, Web design in DE, Translate page….

Language list. GET /api/site answers {title, languages: [{code, label}]} with at least one entry, the first being the default. The client reads it once at start (stores/modules.ts / ModuleContext) and exposes languages, defaultLang, and isDefault(lang). treeLang stops being the source of truth for the language; the URL segment is, validated against the list (an unknown code in the URL repairs to the default language, routerepair.ts).

Language switch (components/Docheader.tsx, every module): a segmented control labelled "Language", one segment per language in languages order showing the code, with a status dot for the current page: default (grey) · translated (green) · changed since translated (amber, tree translation: "outdated") · unpublished changes (blue, the page's badge in that language) · not translated (hollow). Tooltip: the label and the state. Switching calls buildRoute with the new language and the same module and page: the URL's language segment changes, the page stays selected, the tree reloads for that language, the form's unsaved-changes guard runs first. Modules keep the language on switch as they do today. On a module screen without a selected page the segments show no dots. A one-language site shows the switch with one segment (or hides it; pick one and test it).

Per-language state of the current page in the other languages comes from one-shot fetchTree(lang) and fetchBadgesBody(lang) per configured language, cached in a store and refreshed on the same signal that refreshes the current tree (mutations, git sync, the tree poll's change detection); the tree node's translation ("translated" | "missing" | "outdated") and l10n ("connected" | "free") plus the badge state give the dot. Keep it simple: a site has a handful of languages. If this proves too heavy in practice, a per-page endpoint is an engine follow-up, not this issue.

Page tree in a non-default language (components/TreePane.tsx, TreeList.tsx, stores/pageTree.ts): the server already sends translated titles (title), enabled from the translation and address. Untranslated pages (translation: "missing") show their default-language title in italics with the default language's code as a small tag, muted, and stay selectable. A page disabled in this language uses the existing struck-through style. A legend at the bottom of the tree explains the dots (translated · changed since · unpublished · not translated). "+ New page" is disabled outside the default language with the hint "New pages are created in first; a translation cannot exist without its source"; drag reorder and move in the tree are disabled outside the default language too (the server answers 400 for create, create-root, move and reorder). Inline rename stays enabled: it renames the title of the current language's file. The tree header shows the language badge.

Untranslated page screen (GET /api/page of a page without a translation answers 200 {page, missing: true, type, title, file, source: {path, lang}}; components/PageScreen.tsx): instead of the grid, two cards. Left: "“

## Summary First of three editor slices of the multilingual work. Give the editor its language axis: the language list from `GET /api/site`, a **language switch** in every module docheader with a status dot per language, **translation state in the page tree**, the **untranslated-page screen**, the **Translate page dialog**, and the guard that hides structural controls on a connected translation. The visual reference is round 2 of the mockup in `docs/mockups/multilingual/` (artifact: https://claude.ai/artifact/6RYbRG7y4yMyeGxYbFsh1n), reviewed and accepted by the maintainer on 2026-10-10 and committed in #198. The engine side landed in #193 (API documented in README "Translations on the wire"; every endpoint below exists), the theme in #196. The remaining editor screens are split into #199 (editing inside a translation) and #200 (page properties, page menu, pending changes, paste confirmation, Languages view, module lists). The PRD at the bottom is the contract for all three. ## Current behavior The editor carries the language in its URL (`/:module/:lang/*path`, `web/src/routing.ts`), in the stores (`PageId = {path, lang}`) and in every API call, but it takes the one language from the tree response (`ModuleContext.treeLang`, `web/src/modules.tsx`) and never reads `GET /api/site`'s language list. There is no switch, no translation state in the tree, and a non-default language's URL shows the page as if it were the default language's. `GET /api/tree?lang=` and `GET /api/badges?lang=` already take the language (`web/src/api/tree.ts`). ## Desired behavior Every rule is phrased in terms of **the default language** (`languages[0]` of `GET /api/site`), never "English". Mockup: the EN · DE · FR switch, the tree in DE, Web design in DE, Translate page…. **Language list.** `GET /api/site` answers `{title, languages: [{code, label}]}` with at least one entry, the first being the default. The client reads it once at start (`stores/modules.ts` / `ModuleContext`) and exposes `languages`, `defaultLang`, and `isDefault(lang)`. `treeLang` stops being the source of truth for the language; the URL segment is, validated against the list (an unknown code in the URL repairs to the default language, `routerepair.ts`). **Language switch** (`components/Docheader.tsx`, every module): a segmented control labelled "Language", one segment per language in `languages` order showing the code, with a **status dot** for the current page: default (grey) · translated (green) · changed since translated (amber, tree `translation: "outdated"`) · unpublished changes (blue, the page's badge in that language) · not translated (hollow). Tooltip: the label and the state. Switching calls `buildRoute` with the new language and the same module and page: the URL's language segment changes, the page stays selected, the tree reloads for that language, the form's unsaved-changes guard runs first. Modules keep the language on switch as they do today. On a module screen without a selected page the segments show no dots. A one-language site shows the switch with one segment (or hides it; pick one and test it). Per-language state of the current page in the *other* languages comes from one-shot `fetchTree(lang)` and `fetchBadgesBody(lang)` per configured language, cached in a store and refreshed on the same signal that refreshes the current tree (mutations, git sync, the tree poll's change detection); the tree node's `translation` ("translated" | "missing" | "outdated") and `l10n` ("connected" | "free") plus the badge state give the dot. Keep it simple: a site has a handful of languages. If this proves too heavy in practice, a per-page endpoint is an engine follow-up, not this issue. **Page tree** in a non-default language (`components/TreePane.tsx`, `TreeList.tsx`, `stores/pageTree.ts`): the server already sends translated titles (`title`), `enabled` from the translation and `address`. Untranslated pages (`translation: "missing"`) show their default-language title in italics with the default language's code as a small tag, muted, and stay selectable. A page disabled in this language uses the existing struck-through style. A **legend** at the bottom of the tree explains the dots (translated · <default label> changed since · unpublished · not translated). "+ New page" is disabled outside the default language with the hint "New pages are created in <default label> first; a translation cannot exist without its source"; drag reorder and move in the tree are disabled outside the default language too (the server answers 400 for create, create-root, move and reorder). Inline rename stays enabled: it renames the title of the current language's file. The tree header shows the language badge. **Untranslated page screen** (`GET /api/page` of a page without a translation answers 200 `{page, missing: true, type, title, file, source: {path, lang}}`; `components/PageScreen.tsx`): instead of the grid, two cards. Left: "“<title>” is not translated to <label>", the strict explanation (no address under `/<code>/`, not in that language's menu, links to it go to the default-language page, there is no fallback), **Translate page…**, **Open in <default label>** (switches the language), and a key/value list: the default-language file, the file that would be created (`file`), the address after translating (the parent's `address` in this language plus the source segment, "until you set one"). Right: "What the <default label> page holds": the source page's elements as dimmed placeholder rows (label, uid, summary), fetched with `GET /api/page/elements?path=&lang=<default>`. The docheader shows the language badge and a "Not translated" badge; Undo, Discard and View are disabled; the Content/Properties tabs are hidden. **Translate page dialog** (from the untranslated-page screen; in #200 also from the page menu and module lists — build it as a reusable component taking `{path, title, lang}`): header "Translate “<title>” to <label>, from <default label>"; an intro naming the file that is created beside the default-language file and the address; four options as radio cards with explanations, **Connected translation** selected by default, **Free copy**, **Start empty**, **Machine translation** disabled ("Not part of the first version"); the subpages row "Also create the N subpages that have no <label> translation yet, the same way" with a switch defaulting on, shown only when `GET /api/page/translate-plan?path=&lang=` answers `subpages > 0`; the note "Created disabled: the <label> page is not built until you enable it under Properties › Access. <default label> is unaffected." Create = `POST /api/page/translate {page, lang, mode, subpages}`; on success navigate to the page in that language (its Columns view), toast "Created the <label> translation of <title> (and N subpages)". Errors (409 exists, 422 source does not parse, the plan's refusals) are shown in the dialog. **Connected-translation guard** (minimal, so nothing between the slices offers what the server refuses): when the page answer carries `translation.mode: "connected"`, hide insert rows, drag handles and drop zones, and reduce the card menu to Edit and Enable/Disable. Placeholders, badges and banners are #199. **Default language**: every existing behaviour unchanged; existing web tests pass unmodified apart from the language-list fixture (`GET /api/site` in the test API mock). **AGENTS.md and skills** (CLAUDE.md rule): `scaffold/site/AGENTS.md` "Editor modules" describes the URL as `/<id>/<lang>/<page …>`; extend that sentence so it says the language segment is chosen by the docheader's language switch, that the tree then shows that language's titles and translation state, and that new pages are created in the default language. No skill walks through editor screens: confirm with `grep -ril "translat\|language" scaffold/site/.claude/skills/` and leave them untouched unless a step describes the editor's language handling. The drift tests (`internal/element/agentsdoc_test.go`, `scaffold/skills_test.go`) must keep passing. ## Decisions Settled in the grilling session and the mockup review; implement, do not reopen. - The language lives in the URL segment that already exists; reload, shared link and back button keep working. - New pages exist in the default language first; a translation cannot exist without its source. - Every non-default language is strict: an untranslated page has no address, no menu entry, and links to it go to the default-language page. No fallback. - Translate defaults to connected with the `[Translate to <label>:]` prefix (the server adds it) and creates the translation disabled. - The mockup's Source Sans tokens are not adopted; `web/src/styles.css` tokens and the existing `Docheader`, `TreePane`, `PageScreen`, `Dialog` are extended, not duplicated. - Three languages are the mockup's choice; the count is unlimited and the UI must not assume two. ## Key interfaces - `web/src/routing.ts` — unchanged scheme; `routerepair.ts` — unknown language code repairs to the default. - `web/src/stores/modules.ts`, `modules.tsx` (`ModuleContext`) — the language list from `GET /api/site`, `defaultLang`; a new per-language state store (trees and badges per language). - `web/src/api/tree.ts` — read `translation`, `l10n`, `address`, `enabled` per node; `api/pageedit.ts` — the `missing` page answer and the `translation` block; a new `api/translate.ts` (or in `pagemanage.ts`) — `translate-plan`, `translate`. - `components/Docheader.tsx`, `TreePane.tsx`, `TreeList.tsx`, `PageScreen.tsx`, `PageContent.tsx`, `LayoutGrid.tsx` (the guard), new `LanguageSwitch`, `UntranslatedPage`, `TranslateDialog`. - Mockup behaviour to mirror: `docs/mockups/multilingual/m/m.js` — `LangSwitch`, `L10nTree`, `UntranslatedScreen`, `openTranslateDialog`, `pageTarget`. ## Acceptance criteria - [ ] On `testdata/multilingual-site` (en, de) the docheader of every module shows the switch with correct dots for the selected page; switching rewrites the URL's language segment, keeps the page, and the tree shows translated titles, italic untranslated pages with the `EN` tag, and the legend; "+ New page" and tree reorder are disabled outside `en` with the hint. - [ ] A URL with an unknown language code repairs to the default language. - [ ] Opening an untranslated page in `de` shows the untranslated-page screen with the file that would be created and the source elements; Translate page… shows the subpages row only when the plan says so, creates the translation through `POST /api/page/translate` with the chosen mode and the subpages flag, and lands in that page's `de` view; the server's 409/422 messages show in the dialog. - [ ] A connected translation shows no insert rows, drag handles or structural menu items. - [ ] In the default language every existing editor behaviour is unchanged; existing web tests pass unmodified apart from the `GET /api/site` fixture. - [ ] AGENTS.md "Editor modules" mentions the language switch and default-language creation; `web` tests cover the language store, the switch's dot derivation, the tree's untranslated rendering, the translate dialog's request shapes and the untranslated-page screen; `go test ./...` and the skills drift test pass. ## How to verify - `make build`, `harness serve` on `testdata/multilingual-site`, walk the switch, the tree, Web design-style untranslated pages and the Translate dialog against the mockup; check the URL at each step. - `cd web && npm test`; `go test ./...`. ## Assumptions to check - Field names of the tree node (`translation`, `l10n`, `address`), the missing-page answer and `translate` / `translate-plan`: read `internal/server/` (`server.go`, `translation_ops.go`) and README "Translations on the wire", not this brief. - `GET /api/badges?lang=` is keyed per file; confirm the dot for "unpublished changes" reads the translation file's badge, not the source's. ## Out of scope - Editing inside a translation (Columns view with placeholders and banners, Translate element, the element form with source values, free-translation grid): #199. - Page properties, page menu, pending changes, paste confirmation, Languages view, module lists: #200. - The engine and CLI (#190, landed in #193) and the theme (#191, landed in #196); reconnect; machine translation; a settings screen; translating the editor UI. --- ## PRD: multilingual sites, TYPO3-shaped Mockup (private, shared on request): https://claude.ai/artifact/6RYbRG7y4yMyeGxYbFsh1n — sources in `docs/mockups/multilingual/` (committed in #198; round 2, reviewed and accepted on 2026-10-10). This PRD was settled in a grilling session on 2026-10-10 and is the contract for the three issues that implement it: engine and CLI, theme, editor. ### 1. Goal A deckle site can have several languages. The editor translates pages and their elements the way TYPO3 does: one page tree, a translation is a record beside the original page, and a translated page either follows the structure of its original (connected) or is a free copy. Every rule below is phrased in terms of **the default language**, never a specific language: the default language is whatever the site configures first. ### 2. Terms - **Default language** — the first entry of `languages` in `harness.yaml`, the same as `defaultContentLanguage` in `hugo.toml`. - **Translation** — a page's file for one non-default language, `content/<path>/_index.<code>.md`, beside the default-language `_index.md`. - **Source page / source element** — the default-language page or element a translation corresponds to. - **Connected translation** — a translation whose element structure follows its source page: front matter `l10n: connected`. - **Free translation** — a translation without that key. Edited independently; nothing tracks its source. - **Translated element** — a shortcode in a translation file carrying the same `uid` as its source element. ### 3. Configuration `harness.yaml`: ```yaml languages: - code: en # first entry = default language label: English - code: de label: Deutsch ``` - `code` is the existing BCP-47-ish lowercase tag (`siteconfig.languageRe`), `label` the name shown in the editor and the theme's language switch; `label` defaults to the code. The former `languages: [en]` string-list form keeps loading as a one-entry list with label = code, so no existing site breaks. - No fallback key. Untranslated pages are strict (section 5). A fallback key is added only together with a content layout that can honour it. - `hugo.toml` holds the matching Hugo config: `defaultContentLanguage` = the first code; a `[languages.<code>]` table per language with `label` (Hugo's replacement for the deprecated `languageName`), `weight`, and, for every non-default language, `title` and a `[languages.<code>.params]` block (`description`, `address`) scaffolded commented-out like the root `[params]` block. `harness lang add` writes these; `harness check` reports a mismatch between the two files (language set, order of the default, `defaultContentLanguageInSubdir`) as an error. - `defaultContentLanguageInSubdir`: `false` while the site has one language (URLs unchanged: `/services/`); `true` from the moment a second language exists. Hugo then serves every language under `/<code>/` and writes a root `index.html` that redirects to the default language. This flip happens exactly once, in `harness lang add`. Nothing redirects the old unprefixed URLs: the engine writes no `aliases` (decided 2026-10-10 while landing #193; #194 records it), so published links to `/services/` stop working once the page lives at `/en/services/`. - The editor's UI language stays English. "Language" in this PRD always means content language. ### 4. Content model - A translation is `content/<dir>/_index.<code>.md` (or `index.<code>.md` / `<name>.<code>.md` for the legacy page shapes the engine still reads). Hugo links it to the default-language file by path. There is no `content/<code>/` directory and no `translationKey`. - A translation cannot exist without its source file. `harness check` reports a translation whose source is missing as an error; the editor never creates one. - **Front matter of a translation** holds its own `title`, `draft` (Enabled is per language), optional `slug` (section 5), `description`, `menuTitle`, and `l10n: connected` for a connected translation. `type`, `weight`, `backendLayout`, `backendLayoutNextLevel`, `hidden`, `hideChildren`, `footer`, `childOrder`-relevant `date` and every other page-type field are read from the source file; the engine does not write them into translations and the theme reads them from the default-language page. `l10n` joins `type`, `draft`, `weight`, `backendLayout`, `backendLayoutNextLevel` (and the reserved `aliases`, which the engine never writes) as a tool-managed front-matter key (rejected in page-type `defaults`, not declarable as a field). - **Element identity.** A translated element carries the **same `uid`** as its source element. Uid uniqueness is therefore per language: the key of an element is (language, uid). `StampUIDs` repair stays per file. Anchors `#c-<uid>` resolve in every language. - **Connected structure invariant.** A connected translation holds, in every slot and at every nesting depth (items collections included), exactly the uids of its source page in the same order, each with the same element name. The engine enforces it on every write and the server repairs it on open the way it repairs uids: a uid missing in the translation is an **untranslated element** (allowed, rendered as absent); a uid in the translation that the source lacks is removed on open; order follows the source. - **Translatable vs synced params** (connected mode). Params whose sidecar `tab` is `general` (the default) and the element body are per language — image, link, page and categories params included. Params with `tab: appearance` are synced: every save of the source element copies them into each connected translated element, and the translation form shows them read-only. The reserved `disabled` param is per language. An items collection keeps the source structure (one translated item per source item, by uid); the item's own fields are translatable. - **Change tracking.** A translated element in a connected translation carries the reserved shortcode param `l10nSource="<hash>"`: a hash over the canonical form of the source element's translatable values (its general-tab params and body, in sidecar order, after the engine's standard value spelling). Creating a translated element and saving one both write the current hash. On open, a hash that differs from the source's current value marks the element "changed in <default language> since translated"; a missing param counts as unknown and shows no mark. `l10nSource` is reserved like `uid` and `disabled` (a sidecar declaring it fails to load; `harness sidecar` never emits it). Free translations carry no `l10nSource` and no marks. - **Hand editing.** Every rule above is tolerated by the engine: a hand-written `_index.de.md` without `l10n` is a free translation; one with `l10n: connected` is repaired on open as described. AGENTS.md "Editing content by hand" documents the file shape. ### 5. Addresses - One language: addresses are as today. Two or more: every language lives under `/<code>/`; `/` redirects to the default language (Hugo's own redirect page). - A translation's address is its source's path with each segment replaced by the translation's `slug` where one is set, under `/<code>/`. `slug` on a translation is optional; empty means "same segment as the source". Hugo ≥ 0.167 applies a section page's `slug` to the pages beneath it, which is what makes `/de/leistungen/webdesign/` follow from `slug: leistungen` on the Services translation. The engine's permalink computation (`pagetree/permalink.go`) mirrors this: language prefix, per-segment slug override from each ancestor's translation. - Changing a translation's slug moves its address and the addresses of every translation below it; nothing records the old ones. Renaming the source page (directory rename) keeps every translation's address when the translation has its own slug; translations without one follow the new segment. The engine writes no `aliases` and never edits a hand-written `aliases` key (#194). Two pages must not share an address in a language — Hugo builds one over the other silently — so the editor refuses a translation slug that is already a sibling's address in that language (a 422 field error on `slug`; an untranslated sibling reserves its source segment), Translate refuses the mirror case, and `harness check` reports addresses two pages share. - A language's home is `/<code>/`; its title is translatable, its address is not. - Untranslated page: no address in that language, not in that language's menu, not built. Internal links to it resolve to the default-language page (section 6). ### 6. Links - Stored link values stay language-neutral: `/services/` means "the Services page in the current language". The theme resolves them at render time: a markdown link render hook (`layouts/_default/_markup/render-link.html`) and one partial, `ce/href.html`, that every shortcode uses for a `link` param. Resolution: an internal pretty URL (with optional `#c-<uid>` fragment or bundle sub-path) is looked up as a page path; if that page has a translation in the current language, the translation's address is emitted; otherwise the default-language page's address (strict: the untranslated page does not exist in this language, so the link goes to the default language). External, `mailto:`, `tel:` and media-library links are emitted as stored. - `page` params already behave this way: `site.GetPage` with the stored content path resolves to the current language's page. They stay as they are. - The engine's link rewriting on rename, move and delete keeps working on the language-neutral values; it never rewrites links for the purpose of translation. The editor's link picker and `/api/link/resolve` show the target in the current language when it exists, else the default-language one with a note. ### 7. Operations **`harness lang add <code> [--label <label>] [--json]`** — adds a language: validates the code and that it is not present; appends it to `harness.yaml` `languages`; writes the `[languages.<code>]` table and its params block into `hugo.toml` (and the default language's table if the file had none); on the first non-default language flips `defaultContentLanguageInSubdir` to `true` (content is not touched: no aliases); creates the language's home `content/_index.<code>.md` as a draft with the site title; refuses (exit 1, nothing written) when the pinned or default Hugo version is below 0.167, naming the version; reports what it changed. Exit codes and `--json` follow the feature commands' convention. **`harness lang remove <code> [--purge] [--json]`** — removes a non-default language: refuses while any `*.<code>.md` exists under `content/` unless `--purge`, listing them; with `--purge` deletes those files; removes the language from both config files; when one language remains flips `defaultContentLanguageInSubdir` back to `false`. The default language cannot be removed (exit 1). **Translate page** (editor, from the page menu of a source page or from an untranslated page in a non-default language): creates the translation. Modes: *connected* (default) — `l10n: connected`, every element of the source copied with the same uid, translatable text params and bodies prefixed with `[Translate to <label>:] `, `l10nSource` set, appearance params copied; *free* — a copy without `l10n` and without `l10nSource`, no prefix; *empty* — the file with the title only (`l10n: connected`, no elements, so every source element shows as untranslated). The translation is created with `draft: true`. "Also create the N subpages" (default on) applies the same mode to every descendant that has no translation yet. The dialog is in the editor issue; the server endpoint that does the work is in the engine issue. **Translate element** (connected translation, per untranslated element): creates the translated element from the source with the prefix rule above and opens its form. "Translate all" does it for every untranslated element of the page. **Propagation** (connected translations, engine): every structural operation on a source page is applied to each of its connected translations in the same mutation: insert — nothing (the new uid is untranslated); move within the page and across slots — mirrored; reorder — mirrored; delete — the translated element is deleted too, and the confirmation names how many translations are affected; duplicate — the copy's new uid is untranslated everywhere; enable/disable — not propagated; cut to another page (`element/transfer`) — each translated element moves with it into that language's translation of the target page, and where the target has no translation in a language the translated element is dropped, which the confirmation names; save of the source element — appearance params copied into each translated element. Insert, move, delete and duplicate are refused on a connected translation (the server answers 409 with the reason; the editor hides the controls). A free translation accepts every element operation like a source page. **Disconnect** (page menu of a connected translation): removes `l10n` and every `l10nSource`; the page becomes free. No reconnect in v1. **Delete translation** (page menu of a translation): deletes only that file. Deleting a source page deletes its translations with it (they cannot exist alone) and the confirmation says so. Moving a source page moves its translations with it (same directory). **Rename** (source page): as today — links rewritten, no alias recorded. Renaming a translation changes its title; its slug is edited in Properties. **Categories**: a category's translation `content/categories/<slug>/_index.<code>.md` gives it a title in that language under the same slug; stored category values stay slugs. Created from the Categories module with the language switch. Renaming or deleting a category applies to all its files. **Module entries** (news items and any module page type): translated like every page. A module lists the selected language; "New" creates in the default language only; an entry's menu offers "Translate to …". The list's status column shows the translation state in a non-default language. ### 8. Editor - **Language switch** in the Page module docheader (and every module docheader): one segment per configured language, in `languages` order, each with a status dot for the current page (default · translated · changed since translated · unpublished changes · not translated), plus a view-mode toggle Columns / Languages. The language is the existing `/:module/:lang/` URL segment; switching keeps the page. `GET /api/site` already serves the language list; the client starts reading it. - **Page tree** in a non-default language shows translated titles; untranslated pages show the default-language title in italics with a code tag and are still selectable. A legend explains the dots. "+ New page" is disabled with a hint outside the default language. - **Columns view** of a connected translation: the backend layout as today, each translated element a card with a "↔ #uid" link badge, untranslated source elements as placeholder rows with a Translate button, the "changed in <default language>" badge on marked elements, no insert rows, a note per slot that structure is edited on the source page. Banners: N elements changed in the default language (with "Review first"); translation disabled; N of M elements untranslated (with "Translate all"). A free translation shows the normal insert rows and a "free" badge. In the default language the view is unchanged. - **Languages view**: for each slot of the layout a row of columns, one per language: the default language as source, each translation's cards (dense) or "Not translated — Translate page…", "Open" per column. - **Untranslated page screen**: what visitors see (strict), Translate page…, Open in <default language>, the file that would be created, the source page's element list. - **Translate dialog**: the three modes with explanations, the subpages switch, the note that the translation is created disabled; "machine translation" is shown disabled as a future option. - **Element form** in a translation: General tab fields with the source value under each field ("<default language>" box with Copy; amber "changed since this translation" when the element is marked), Appearance tab read-only with a "synced from #uid" note (connected) or editable (free), Access with the per-language Enabled switch and the uid. Docheader: language badge, "Hide/Show <default language>" toggle, "Open <default language>", the "changed" badge. Saving writes the hash. - **Page properties** in a translation: title, address segment (placeholder = source segment, hint shows the resulting address), menu title, description, each with the source value; Appearance read-only (follows the source); Access with the per-language Enabled, the address and the file path. - **Pending changes** list every translation as its own row with a language badge; tree badges are per file. Publish and Discard all act on every language. Page Undo and Discard… stay per file. - Inline rename in the tree renames the title of the file of the current language. - The mockup under `docs/mockups/multilingual/` is the visual reference; the editor's own tokens (`web/src/styles.css`) apply, not the mockup's. ### 9. Theme - `<html lang>` already follows `site.Language.Lang`. - `i18n/<code>.toml` per shipped language (`en` at least, `de` as the second reference) for every string the theme and the feature bundles hard-code today: skip link, "Menu", nav and footer aria labels, 404 texts, pager Previous/Next, "All news →", lightbox and consent labels, and the date format. Strings go through Hugo's `T` function; a missing translation falls back to the default language's string (Hugo's behaviour), never to an empty string. - **Language switch** in the header, listing only the languages the current page exists in (`.AllTranslations`), labelled with the language's `label` (`.Language.Label`); hidden on a single-language site. - `<link rel="alternate" hreflang>` for every translation of the page plus `x-default` for the default language. - `ce/href.html` and the markdown link render hook of section 6; every shipped shortcode with a `link` param (button, card, img, cta…) and the theme's own links go through it. The theme README's "Authoring a layout" contract documents it. - Per-language site texts: `[languages.<code>] title` and `[languages.<code>.params] description / address`; the footer and meta description read `site.Params` which Hugo already merges per language. - Menus need no change: the header and footer derive from the current language's page collection. ### 10. Publishing and checks - One publish for the whole site. `/api/publish-all`, `/api/publish/pending` and `/api/publish/discard-all` lose their `lang` parameter; pending rows carry `lang`. - `harness check` additionally reports: a translation without a source (error); a connected translation whose structure violates the invariant (warning — the server repairs it on open); two pages published at one address in one language (error); a `[languages]` table disagreeing with `harness.yaml` (error); `defaultContentLanguageInSubdir` not matching the language count (error); a shortcode declaring `l10nSource` or a page type declaring `l10n` (load error, as for every reserved name). - Every element save in a translation validates params against the sidecar exactly as in the default language. ### 11. Hugo version - `DefaultHugoVersion` (initcmd) is **0.167.0** since #193; the pin is exact as today. Existing sites keep their `harness.yaml` pin until the developer raises it; `harness lang add` refuses to add a second language while the effective version is below 0.167 and says why (section-slug inheritance). The deckle-hosted nix-shell Hugo is a sibling change in that repository and is not part of these issues. ### 12. Documentation and skills (per CLAUDE.md, in the same change as the code) - `scaffold/site/AGENTS.md`: Layout (translation files, `i18n/`), the `harness lang` commands, the `harness.yaml` languages shape, the reserved names `l10n` and `l10nSource`, page types (tool-managed `l10n`), "Editing content by hand" (translation file shape, connected invariant, prefix, hash), the check findings. The drift test (`internal/element/agentsdoc_test.go`) must keep passing. - `scaffold/themes/deckle/README.md`: `ce/href.html`, the render hook, `i18n/`, the language switch, hreflang. - Skills: `setup-site` (languages at setup, `harness lang add`), `migrate-site` (translations of an existing Hugo multilingual site: only the suffix layout is supported, directory layouts must be converted), `verify-site` (the new check findings), `edit-content` (hand-written translations), `add-element` (the href partial for link params, reserved params), `redo-theme` (i18n, switch, hreflang, showcase page per language), `add-page-type` (`l10n` reserved), `create-feature` (bundles ship `i18n/` strings). The skills drift test (`scaffold/skills_test.go`) must keep passing. - README.md: the command list and the multilingual section. ### 13. Out of scope - Fallback to another language for untranslated pages (needs the directory layout). - Reconnecting a free translation. - Machine translation. - A settings screen in the editor; the hosted platform's own language UI (deckle-hosted). - Translating the editor UI itself. - The deckle-hosted Hugo pin.
dominik.polakovics changed title from Multilingual sites: editor language switch, translation screens and Translate dialog to Multilingual editor (1/3): language switch, tree translation state, untranslated page, Translate page dialog 2026-10-10 23:49:36 +02:00
Author
Owner

Rewritten on 2026-10-10 after the mockup review: round 2 of the mockup (PR #198) was reviewed and accepted, the engine (#193) and theme (#196) have landed, and the editor work is split into three landable slices. This issue is slice 1 (language switch, tree state, untranslated page, Translate page dialog) and is ready for an AFK agent; #199 and #200 follow, each blocked by the previous one. The PRD stays in this issue's body as the contract for all three.

Rewritten on 2026-10-10 after the mockup review: round 2 of the mockup (PR #198) was reviewed and accepted, the engine (#193) and theme (#196) have landed, and the editor work is split into three landable slices. This issue is slice 1 (language switch, tree state, untranslated page, Translate page dialog) and is ready for an AFK agent; #199 and #200 follow, each blocked by the previous one. The PRD stays in this issue's body as the contract for all three.
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#192
No description provided.