Multilingual sites: theme i18n, language switch, hreflang and render-time link resolution #191
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#191
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Make the deckle theme multilingual: translated UI strings through
i18n/, a header language switch,hreflangalternates, per-language site texts, and render-time resolution of language-neutral links through onece/href.htmlpartial and a markdown link render hook. After this, a site set up withharness lang addrenders 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 nohreflang. Link params are emitted verbatim (href="{{ .Get "href" }}"inbutton.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.pageparams already resolve per language throughsite.GetPage.Desired behavior
PRD sections 6 (links) and 9 (theme), plus the theme-side documentation of section 12:
i18n/<code>.tomlin the theme (and in the news and categories bundles for their own strings) for every hard-coded string, used throughT; shipped forenandde; a missing key falls back to the default language's string, never to empty. The date format is a translated string too..AllTranslations), labelled with each language'slabel(.Language.Label; the engine writeslabel, Hugo's replacement for the deprecatedlanguageName), marking the current one; not rendered on a single-language site. Accessible like the existing nav: real links, anaria-label, no JS.<link rel="alternate" hreflang="…">for each translation of the page andx-defaultpointing at the default language's version, inbaseof.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.GetPagewith 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 carriesslug: 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 alinkparam (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.htmlapplies the same rule to markdown links, including the stored new-tab form.title,draft,slug,description,menuTitleandl10n, so every layout and partial readstype,backendLayout,backendLayoutNextLevel,weight,hidden,hideChildren,footer,dateand 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.site.Params.description/site.Params.address, which Hugo merges per language from[languages.<code>.params]; the scaffold'shugo.tomlcomment explains that per-language values go there once the site has several languages (the engine issue'sharness lang addwrites the block).ce/href.html, the render hook,i18n/, the language switch and hreflang; theredo-themeskill (showcase page rendered in each language, strings ini18n/),add-element(link params go throughce/href.html) andcreate-feature(a bundle ships itsi18n/strings) are updated;scaffold/skills_test.goand the AGENTS.md drift test keep passing.Decisions
Settled in the grilling session; implement, do not reopen.
pageparams already work this way.Twith 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 alinkparam, and byrender-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", optionaltitle) intact.layouts/_default/baseof.html— hreflang block;partials/header.html— the switch;partials/footer.html,404.html, feature partials — strings throughT.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.scaffold/themes/deckle/layouts/,scaffold/features/*/layouts/,scaffold/themes/deckle/README.md,scaffold/site/.claude/skills/redo-theme.Acceptance criteria
harness lang add),hugobuilds 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.<head>has onehreflanglink per existing translation and anx-default.backendLayout: two-col,weight: 5andhidden: truerenders with that layout, sorts by that weight and is hidden from the German menu; the translation file carries none of these keys.buttonwithhref="/services/web/"on a German page links to/de/dienste/web/whenservices/_index.mdcarriesslug: diensteand the German translation exists — the lookup is by content path, not by published URL.buttonwithhref="/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.grepfinds no hard-coded UI string left in the theme and bundle templates and scripts thati18n/covers; thetheme READMElists the keys.harness checkandgo test ./...pass (skills drift test, AGENTS.md drift test); the showcase page from theredo-themeskill still parses and renders in both languages.How to verify
public/en/…andpublic/de/…: strings, switch, hreflang, resolved links (a small table of input value → emitted href per language, checked withgrepon the output).hugo --minifyon the single-language scaffold site and diff against the output before this change: only the expected markup differs.go test ./....Assumptions to check
Tfalls 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)..AllTranslationsincludes the current page and excludes drafts in a production build; the switch must behave inhugo serverwith drafts too.site.GetPagewith a content path resolves to the current language's page and returns nil for an untranslated page, soce/href.htmlcan fall back throughsite.Sites/ the default language site.Out of scope
harness langcommands, the content model (#190, landed in PR #193 — read the merged code for the API: every tree node carriesaddress, the published URL in the tree's 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
languagesinharness.yaml, the same asdefaultContentLanguageinhugo.toml.content/<path>/_index.<code>.md, beside the default-language_index.md.l10n: connected.uidas its source element.3. Configuration
harness.yaml:codeis the existing BCP-47-ish lowercase tag (siteconfig.languageRe),labelthe name shown in the editor and the theme's language switch;labeldefaults to the code. The formerlanguages: [en]string-list form keeps loading as a one-entry list with label = code, so no existing site breaks.hugo.tomlholds the matching Hugo config:defaultContentLanguage= the first code; a[languages.<code>]table per language withlabel(Hugo's replacement for the deprecatedlanguageName),weight, and, for every non-default language,titleand a[languages.<code>.params]block (description,address) scaffolded commented-out like the root[params]block.harness lang addwrites these;harness checkreports a mismatch between the two files (language set, order of the default,defaultContentLanguageInSubdir) as an error.defaultContentLanguageInSubdir:falsewhile the site has one language (URLs unchanged:/services/);truefrom the moment a second language exists. Hugo then serves every language under/<code>/and writes a rootindex.htmlthat redirects to the default language. This flip happens exactly once, inharness lang add. Nothing redirects the old unprefixed URLs: the engine writes noaliases(decided 2026-10-10 while landing #193; #194 records it), so published links to/services/stop working once the page lives at/en/services/.4. Content model
content/<dir>/_index.<code>.md(orindex.<code>.md/<name>.<code>.mdfor the legacy page shapes the engine still reads). Hugo links it to the default-language file by path. There is nocontent/<code>/directory and notranslationKey.harness checkreports a translation whose source is missing as an error; the editor never creates one.title,draft(Enabled is per language), optionalslug(section 5),description,menuTitle, andl10n: connectedfor a connected translation.type,weight,backendLayout,backendLayoutNextLevel,hidden,hideChildren,footer,childOrder-relevantdateand 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.l10njoinstype,draft,weight,backendLayout,backendLayoutNextLevel(and the reservedaliases, which the engine never writes) as a tool-managed front-matter key (rejected in page-typedefaults, not declarable as a field).uidas its source element. Uid uniqueness is therefore per language: the key of an element is (language, uid).StampUIDsrepair stays per file. Anchors#c-<uid>resolve in every language.tabisgeneral(the default) and the element body are per language — image, link, page and categories params included. Params withtab: appearanceare synced: every save of the source element copies them into each connected translated element, and the translation form shows them read-only. The reserveddisabledparam 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.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.l10nSourceis reserved likeuidanddisabled(a sidecar declaring it fails to load;harness sidecarnever emits it). Free translations carry nol10nSourceand no marks._index.de.mdwithoutl10nis a free translation; one withl10n: connectedis repaired on open as described. AGENTS.md "Editing content by hand" documents the file shape.5. Addresses
/<code>/;/redirects to the default language (Hugo's own redirect page).slugwhere one is set, under/<code>/.slugon a translation is optional; empty means "same segment as the source". Hugo ≥ 0.167 applies a section page'sslugto the pages beneath it, which is what makes/de/leistungen/webdesign/follow fromslug: leistungenon the Services translation. The engine's permalink computation (pagetree/permalink.go) mirrors this: language prefix, per-segment slug override from each ancestor's translation.aliasesand never edits a hand-writtenaliaseskey (#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 onslug; an untranslated sibling reserves its source segment), Translate refuses the mirror case, andharness checkreports addresses two pages share./<code>/; its title is translatable, its address is not.6. Links
/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 alinkparam. 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.pageparams already behave this way:site.GetPagewith the stored content path resolves to the current language's page. They stay as they are./api/link/resolveshow 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 toharness.yamllanguages; writes the[languages.<code>]table and its params block intohugo.toml(and the default language's table if the file had none); on the first non-default language flipsdefaultContentLanguageInSubdirtotrue(content is not touched: no aliases); creates the language's homecontent/_index.<code>.mdas 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--jsonfollow the feature commands' convention.harness lang remove <code> [--purge] [--json]— removes a non-default language: refuses while any*.<code>.mdexists undercontent/unless--purge, listing them; with--purgedeletes those files; removes the language from both config files; when one language remains flipsdefaultContentLanguageInSubdirback tofalse. 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>:],l10nSourceset, appearance params copied; free — a copy withoutl10nand withoutl10nSource, no prefix; empty — the file with the title only (l10n: connected, no elements, so every source element shows as untranslated). The translation is created withdraft: 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
l10nand everyl10nSource; 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>.mdgives 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
languagesorder, 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/sitealready serves the language list; the client starts reading it.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 followssite.Language.Lang.i18n/<code>.tomlper shipped language (enat least,deas 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'sTfunction; a missing translation falls back to the default language's string (Hugo's behaviour), never to an empty string..AllTranslations), labelled with the language'slabel(.Language.Label); hidden on a single-language site.<link rel="alternate" hreflang>for every translation of the page plusx-defaultfor the default language.ce/href.htmland the markdown link render hook of section 6; every shipped shortcode with alinkparam (button, card, img, cta…) and the theme's own links go through it. The theme README's "Authoring a layout" contract documents it.[languages.<code>] titleand[languages.<code>.params] description / address; the footer and meta description readsite.Paramswhich Hugo already merges per language.10. Publishing and checks
/api/publish-all,/api/publish/pendingand/api/publish/discard-alllose theirlangparameter; pending rows carrylang.harness checkadditionally 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 withharness.yaml(error);defaultContentLanguageInSubdirnot matching the language count (error); a shortcode declaringl10nSourceor a page type declaringl10n(load error, as for every reserved name).11. Hugo version
DefaultHugoVersion(initcmd) is 0.167.0 since #193; the pin is exact as today. Existing sites keep theirharness.yamlpin until the developer raises it;harness lang addrefuses 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/), theharness langcommands, theharness.yamllanguages shape, the reserved namesl10nandl10nSource, page types (tool-managedl10n), "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.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(l10nreserved),create-feature(bundles shipi18n/strings). The skills drift test (scaffold/skills_test.go) must keep passing.13. Out of scope