Multilingual sites: theme i18n, language switch, hreflang and render-time link resolution #191

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

Summary

Make the deckle theme multilingual: translated UI strings through i18n/, a header language switch, hreflang alternates, per-language site texts, and render-time resolution of language-neutral links through one ce/href.html partial and a markdown link render hook. After this, a site set up with harness lang add renders every language correctly from the translation files the engine issue introduces.

Current behavior

The theme's only language-aware line is <html lang="{{ site.Language.Lang }}">. Every UI string is hard-coded English (skip link, "Menu", nav and footer aria labels, 404 texts, the pager's Previous/Next, "All news →", the lightbox and consent labels, the "January 2, 2006" date format in the news bundle). There is no language switch and no hreflang. Link params are emitted verbatim (href="{{ .Get "href" }}" in button.html), markdown links render as written, so a /services/ link inside a German page would point at the default-language page even when a German translation exists. page params already resolve per language through site.GetPage.

Desired behavior

PRD sections 6 (links) and 9 (theme), plus the theme-side documentation of section 12:

  • i18n/<code>.toml in the theme (and in the news and categories bundles for their own strings) for every hard-coded string, used through T; shipped for en and de; a missing key falls back to the default language's string, never to empty. The date format is a translated string too.
  • A header language switch listing only the languages the current page exists in (.AllTranslations), labelled with each language's label (.Language.Label; the engine writes label, Hugo's replacement for the deprecated languageName), marking the current one; not rendered on a single-language site. Accessible like the existing nav: real links, an aria-label, no JS.
  • <link rel="alternate" hreflang="…"> for each translation of the page and x-default pointing at the default language's version, in baseof.html.
  • ce/href.html: takes a stored link value and returns the address to emit. Internal pretty URL (optional #c-<uid> fragment or bundle sub-path): look the page up by content path (site.GetPage with the stored value read as a path), never by comparing it with published URLs — the stored value is path-derived and ignores section slugs, so /services/web/ is published at /dienste/web/ once the Services page carries slug: dienste (Hugo ≥ 0.167); if the page has a translation in the current language, emit that translation's .RelPermalink (fragment and sub-path preserved); otherwise the default-language page's. External, mailto:, tel: and /media/… values are emitted as stored. Every shipped shortcode with a link param (button, card/cards, img/image, cta, text-media, news-list's all-news link, …) and the theme's own links go through it; layouts/_default/_markup/render-link.html applies the same rule to markdown links, including the stored new-tab form.
  • Page fields come from the default-language page (PRD section 4): a translation file holds only title, draft, slug, description, menuTitle and l10n, so every layout and partial reads type, backendLayout, backendLayoutNextLevel, weight, hidden, hideChildren, footer, date and every page-type field through the default-language page of the current page (.Translations / the default language's site), for the page itself and for menus, child lists and the footer. Without this a two-language site renders its translations with the wrong layout and menus.
  • Per-language site texts: the footer's about text and the default meta description read site.Params.description / site.Params.address, which Hugo merges per language from [languages.<code>.params]; the scaffold's hugo.toml comment explains that per-language values go there once the site has several languages (the engine issue's harness lang add writes the block).
  • The theme README's "Authoring a layout" contract documents ce/href.html, the render hook, i18n/, the language switch and hreflang; the redo-theme skill (showcase page rendered in each language, strings in i18n/), add-element (link params go through ce/href.html) and create-feature (a bundle ships its i18n/ strings) are updated; scaffold/skills_test.go and the AGENTS.md drift test keep passing.

Decisions

Settled in the grilling session; implement, do not reopen.

  • Links are resolved at render time, not rewritten in files — stored values stay language-neutral so renames and translations never race; page params already work this way.
  • An untranslated target resolves to the default-language page — strict mode: the page does not exist in the other language, and a working link beats a 404.
  • The switch lists only languages the page exists in — strict mode again; no dead links.
  • Hugo's T with fallback to the default language — a missing string must never blank out a label.

Key interfaces

  • layouts/partials/ce/href.html (new) — input: a stored link value; output: the address. Used by every shortcode with a link param, and by render-link.html.
  • layouts/_default/_markup/render-link.html (new) — the markdown link render hook; must keep the stored new-tab form's attributes (target="_blank" rel="noopener", optional title) intact.
  • layouts/_default/baseof.html — hreflang block; partials/header.html — the switch; partials/footer.html, 404.html, feature partials — strings through T.
  • themes/deckle/i18n/en.toml, de.toml; scaffold/features/news/i18n/, scaffold/features/categories/i18n/ for bundle strings.
  • scaffold/site/hugo.toml — comment for per-language params.
  • Starting hints: scaffold/themes/deckle/layouts/, scaffold/features/*/layouts/, scaffold/themes/deckle/README.md, scaffold/site/.claude/skills/redo-theme.

Acceptance criteria

  • On a two-language fixture site (built with the engine issue's harness lang add), hugo builds every language; the German pages show German UI strings and the German date format; the English pages are unchanged byte-for-byte except for the new switch and hreflang markup.
  • On a single-language site the switch is not rendered and the output is otherwise unchanged.
  • The header switch on a page with translations links to each translation's address and marks the current language; on a page without translations it is omitted or shows only the current language (documented either way).
  • Every page's <head> has one hreflang link per existing translation and an x-default.
  • A German translation of a page whose source sets backendLayout: two-col, weight: 5 and hidden: true renders with that layout, sorts by that weight and is hidden from the German menu; the translation file carries none of these keys.
  • A button with href="/services/web/" on a German page links to /de/dienste/web/ when services/_index.md carries slug: dienste and the German translation exists — the lookup is by content path, not by published URL.
  • A button with href="/services/" on a German page links to /de/leistungen/ when that translation exists and to /en/services/ when it does not; a markdown link [x](/services/#c-abc) behaves the same with the fragment kept; the stored new-tab form keeps its attributes; external, mailto:, tel: and /media/… values are emitted unchanged.
  • grep finds no hard-coded UI string left in the theme and bundle templates and scripts that i18n/ covers; the theme README lists the keys.
  • harness check and go test ./... pass (skills drift test, AGENTS.md drift test); the showcase page from the redo-theme skill still parses and renders in both languages.

How to verify

  • Build the two-language fixture with the pinned Hugo and inspect public/en/… and public/de/…: strings, switch, hreflang, resolved links (a small table of input value → emitted href per language, checked with grep on the output).
  • hugo --minify on the single-language scaffold site and diff against the output before this change: only the expected markup differs.
  • go test ./....

Assumptions to check

  • Hugo's T falls back to the default language's string when a key is missing for the current language on the pinned version (and what it does when the default language has no key either).
  • .AllTranslations includes the current page and excludes drafts in a production build; the switch must behave in hugo server with drafts too.
  • site.GetPage with a content path resolves to the current language's page and returns nil for an untranslated page, so ce/href.html can fall back through site.Sites / the default language site.

Out of scope

  • The engine, the harness lang commands, the content model (#190, landed in PR #193 — read the merged code for the API: every tree node carries address, the published URL in the tree's language).
  • The editor (#192).
  • Translating the editor UI; machine translation; fallback to another language.

Blocked by


PRD: multilingual sites, TYPO3-shaped

Mockup (private, shared on request): https://claude.ai/artifact/6RYbRG7y4yMyeGxYbFsh1n — sources in docs/mockups/multilingual/ (uncommitted at the time of writing; the editor issue commits them). 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:

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 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).
  • 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 " 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 , 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 ("" 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 " toggle, "Open ", 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.
## Summary Make the deckle theme multilingual: translated UI strings through `i18n/`, a header language switch, `hreflang` alternates, per-language site texts, and render-time resolution of language-neutral links through one `ce/href.html` partial and a markdown link render hook. After this, a site set up with `harness lang add` renders every language correctly from the translation files the engine issue introduces. ## Current behavior The theme's only language-aware line is `<html lang="{{ site.Language.Lang }}">`. Every UI string is hard-coded English (skip link, "Menu", nav and footer aria labels, 404 texts, the pager's Previous/Next, "All news →", the lightbox and consent labels, the `"January 2, 2006"` date format in the news bundle). There is no language switch and no `hreflang`. Link params are emitted verbatim (`href="{{ .Get "href" }}"` in `button.html`), markdown links render as written, so a `/services/` link inside a German page would point at the default-language page even when a German translation exists. `page` params already resolve per language through `site.GetPage`. ## Desired behavior PRD sections 6 (links) and 9 (theme), plus the theme-side documentation of section 12: - `i18n/<code>.toml` in the theme (and in the news and categories bundles for their own strings) for every hard-coded string, used through `T`; shipped for `en` and `de`; a missing key falls back to the default language's string, never to empty. The date format is a translated string too. - A header language switch listing only the languages the current page exists in (`.AllTranslations`), labelled with each language's `label` (`.Language.Label`; the engine writes `label`, Hugo's replacement for the deprecated `languageName`), marking the current one; not rendered on a single-language site. Accessible like the existing nav: real links, an `aria-label`, no JS. - `<link rel="alternate" hreflang="…">` for each translation of the page and `x-default` pointing at the default language's version, in `baseof.html`. - `ce/href.html`: takes a stored link value and returns the address to emit. Internal pretty URL (optional `#c-<uid>` fragment or bundle sub-path): look the page up **by content path** (`site.GetPage` with the stored value read as a path), never by comparing it with published URLs — the stored value is path-derived and ignores section slugs, so `/services/web/` is published at `/dienste/web/` once the Services page carries `slug: dienste` (Hugo ≥ 0.167); if the page has a translation in the current language, emit that translation's `.RelPermalink` (fragment and sub-path preserved); otherwise the default-language page's. External, `mailto:`, `tel:` and `/media/…` values are emitted as stored. Every shipped shortcode with a `link` param (`button`, `card`/`cards`, `img`/`image`, `cta`, `text-media`, `news-list`'s all-news link, …) and the theme's own links go through it; `layouts/_default/_markup/render-link.html` applies the same rule to markdown links, including the stored new-tab form. - **Page fields come from the default-language page** (PRD section 4): a translation file holds only `title`, `draft`, `slug`, `description`, `menuTitle` and `l10n`, so every layout and partial reads `type`, `backendLayout`, `backendLayoutNextLevel`, `weight`, `hidden`, `hideChildren`, `footer`, `date` and every page-type field through the default-language page of the current page (`.Translations` / the default language's site), for the page itself and for menus, child lists and the footer. Without this a two-language site renders its translations with the wrong layout and menus. - Per-language site texts: the footer's about text and the default meta description read `site.Params.description` / `site.Params.address`, which Hugo merges per language from `[languages.<code>.params]`; the scaffold's `hugo.toml` comment explains that per-language values go there once the site has several languages (the engine issue's `harness lang add` writes the block). - The theme README's "Authoring a layout" contract documents `ce/href.html`, the render hook, `i18n/`, the language switch and hreflang; the `redo-theme` skill (showcase page rendered in each language, strings in `i18n/`), `add-element` (link params go through `ce/href.html`) and `create-feature` (a bundle ships its `i18n/` strings) are updated; `scaffold/skills_test.go` and the AGENTS.md drift test keep passing. ## Decisions Settled in the grilling session; implement, do not reopen. - Links are resolved at render time, not rewritten in files — stored values stay language-neutral so renames and translations never race; `page` params already work this way. - An untranslated target resolves to the default-language page — strict mode: the page does not exist in the other language, and a working link beats a 404. - The switch lists only languages the page exists in — strict mode again; no dead links. - Hugo's `T` with fallback to the default language — a missing string must never blank out a label. ## Key interfaces - `layouts/partials/ce/href.html` (new) — input: a stored link value; output: the address. Used by every shortcode with a `link` param, and by `render-link.html`. - `layouts/_default/_markup/render-link.html` (new) — the markdown link render hook; must keep the stored new-tab form's attributes (`target="_blank" rel="noopener"`, optional `title`) intact. - `layouts/_default/baseof.html` — hreflang block; `partials/header.html` — the switch; `partials/footer.html`, `404.html`, feature partials — strings through `T`. - `themes/deckle/i18n/en.toml`, `de.toml`; `scaffold/features/news/i18n/`, `scaffold/features/categories/i18n/` for bundle strings. - `scaffold/site/hugo.toml` — comment for per-language params. - Starting hints: `scaffold/themes/deckle/layouts/`, `scaffold/features/*/layouts/`, `scaffold/themes/deckle/README.md`, `scaffold/site/.claude/skills/redo-theme`. ## Acceptance criteria - [ ] On a two-language fixture site (built with the engine issue's `harness lang add`), `hugo` builds every language; the German pages show German UI strings and the German date format; the English pages are unchanged byte-for-byte except for the new switch and hreflang markup. - [ ] On a single-language site the switch is not rendered and the output is otherwise unchanged. - [ ] The header switch on a page with translations links to each translation's address and marks the current language; on a page without translations it is omitted or shows only the current language (documented either way). - [ ] Every page's `<head>` has one `hreflang` link per existing translation and an `x-default`. - [ ] A German translation of a page whose source sets `backendLayout: two-col`, `weight: 5` and `hidden: true` renders with that layout, sorts by that weight and is hidden from the German menu; the translation file carries none of these keys. - [ ] A `button` with `href="/services/web/"` on a German page links to `/de/dienste/web/` when `services/_index.md` carries `slug: dienste` and the German translation exists — the lookup is by content path, not by published URL. - [ ] A `button` with `href="/services/"` on a German page links to `/de/leistungen/` when that translation exists and to `/en/services/` when it does not; a markdown link `[x](/services/#c-abc)` behaves the same with the fragment kept; the stored new-tab form keeps its attributes; external, `mailto:`, `tel:` and `/media/…` values are emitted unchanged. - [ ] `grep` finds no hard-coded UI string left in the theme and bundle templates and scripts that `i18n/` covers; the `theme README` lists the keys. - [ ] `harness check` and `go test ./...` pass (skills drift test, AGENTS.md drift test); the showcase page from the `redo-theme` skill still parses and renders in both languages. ## How to verify - Build the two-language fixture with the pinned Hugo and inspect `public/en/…` and `public/de/…`: strings, switch, hreflang, resolved links (a small table of input value → emitted href per language, checked with `grep` on the output). - `hugo --minify` on the single-language scaffold site and diff against the output before this change: only the expected markup differs. - `go test ./...`. ## Assumptions to check - Hugo's `T` falls back to the default language's string when a key is missing for the current language on the pinned version (and what it does when the default language has no key either). - `.AllTranslations` includes the current page and excludes drafts in a production build; the switch must behave in `hugo server` with drafts too. - `site.GetPage` with a content path resolves to the current language's page and returns nil for an untranslated page, so `ce/href.html` can fall back through `site.Sites` / the default language site. ## Out of scope - The engine, the `harness lang` commands, the content model (#190, landed in PR #193 — read the merged code for the API: every tree node carries `address`, the published URL in the tree's language). - The editor (#192). - Translating the editor UI; machine translation; fallback to another language. ## Blocked by - #190 --- ## PRD: multilingual sites, TYPO3-shaped Mockup (private, shared on request): https://claude.ai/artifact/6RYbRG7y4yMyeGxYbFsh1n — sources in `docs/mockups/multilingual/` (uncommitted at the time of writing; the editor issue commits them). 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.
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#191
No description provided.