Multilingual sites: engine, content model and harness lang commands #190

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

Summary

Make the engine and the harness CLI multilingual: a site declares several languages, a translation is a file beside its default-language page (_index.de.md), connected translations follow their source page's element structure, addresses live under /<code>/, and harness lang add / harness lang remove manage the language list. This issue lands the whole content model and API so that the theme issue and the editor issue can build on it; after it, translations are usable by hand and through the API, and the existing editor keeps working for the default language.

Current behavior

The page identity on the wire, in the editor URL (/:module/:lang/*path) and in the stores is already (path, lang), but siteconfig requires exactly one language and pagetree.ContentRoot maps it to content/ itself; any other language is a 404. hugo.toml has no [languages] table. Permalinks have no language prefix. Badges, pending changes, publish-all and element/transfer are keyed or scoped by one language. Nothing knows about translations: no translation files, no connected mode, no change tracking. The full status quo is in the PRD below (sections 3 and 4 describe what changes).

Desired behavior

Everything in PRD sections 3 (configuration), 4 (content model), 5 (addresses), 6 (links: the engine side — language-neutral values, no rewriting for translation), 7 (operations: the commands, the server endpoints behind Translate page / Translate element / Disconnect / Delete translation, propagation, categories and module entries), 10 (publishing and checks) and 11 (Hugo version), plus the documentation in section 12 that belongs to the engine and CLI. In short:

  • harness.yaml languages becomes a list of {code, label} entries; the old string-list form keeps loading. The first entry is the default language.
  • harness lang add <code> [--label] [--json] and harness lang remove <code> [--purge] [--json] as specified, including the one-time flip of defaultContentLanguageInSubdir with aliases for every old address, the [languages.<code>] tables with their params block, the language's draft home page, and the Hugo ≥ 0.167 guard.
  • A translation is content/<dir>/_index.<code>.md; ContentRoot stays content/, the file name selects the language; every lang-scoped endpoint resolves a non-default language to the translation files; the tree of a non-default language is the default-language tree with translated titles and a per-node translation state (translated, missing, outdated, plus the existing enabled/badge state) so the editor can draw it.
  • Translated elements share their source's uid; the connected structure invariant is enforced on write and repaired on open; general-tab params and bodies are translatable, appearance-tab params synced; l10nSource hash per translated element; l10n: connected front matter; l10n and l10nSource reserved.
  • Propagation of structural operations from a source page into its connected translations in the same mutation, with the confirmation counts (delete, cut across pages) exposed in the existing plan/dry-run style; element operations refused with 409 on a connected translation.
  • New server endpoints (names are the implementer's call, shapes documented like the existing ones): translate page (mode connected/free/empty, with subpages), translate element, translate all, disconnect, delete translation; GET /api/site serves {code, label} entries; GET /api/tree gains the translation state per node; page responses carry the translation's l10n mode and each element's changed mark and synced params.
  • Permalinks under /<code>/ once the site has two languages, per-segment slug overrides from each ancestor's translation; aliases on slug change and on source rename.
  • Publishing is site-wide: the lang parameter is dropped from publish-all, pending and discard-all; pending rows carry lang; badges are keyed per file.
  • harness check findings of PRD section 10.
  • DefaultHugoVersion moves to the newest stable release ≥ 0.167.
  • AGENTS.md, README.md and the setup-site, migrate-site, verify-site, edit-content, add-element, add-page-type, create-feature skills updated as section 12 lists for the engine side (the theme and editor issues update what they change).

Decisions

Settled in the grilling session; implement, do not reopen. Reasons are in the PRD next to each.

  • Translation file beside the original, no content/<code>/ directory — one tree by construction, no migration of existing sites, categories and modules translate the same way.
  • Same uid in every language — connected mode is "same uid set, same order"; anchors resolve everywhere; no new reserved pointer param.
  • Structural edits propagate immediately — two files never disagree for long; the invariant is enforceable.
  • Translatable = general tab + body, synced = appearance tab — no new sidecar key; the existing tab already separates content from look.
  • l10nSource hash cleared by save — a save is the review.
  • Links stay language-neutral — resolution belongs to the theme at render time (theme issue); the engine never rewrites for translation.
  • /<code>/ for every language, root redirects, but only once a second language exists — single-language sites keep their addresses; the flip writes aliases once.
  • Strict fallback only, no fallback key.
  • harness lang add/remove are the only way to change languages; the editor has no settings screen.
  • One publish for all languages.
  • Hugo bump to the newest stable ≥ 0.167 instead of writing url on every translation.

Key interfaces

  • siteconfig.Config.Languages — from []string to a list of {Code, Label}; validateLanguages loses the "exactly one" rule, keeps the tag regex, accepts the legacy string list.
  • pagetree.ContentRoot, pagetree.Build, Node — language resolves to file names; a node gains the translation state; Tree.Lang stays.
  • pagetree/permalink.go — language prefix and per-segment slug overrides; reads defaultContentLanguageInSubdir.
  • content engine: the canonical hash of an element's translatable values; l10nSource and l10n as reserved names in element.ReservedParam* and the page-type loader's tool-managed keys.
  • elemops, pageops, server mutations — propagation into connected translations under the existing global mutation lock; the new endpoints; element/transfer across languages of the target page.
  • publish.go, git.go (badges), pendingChanges wire shape — per-file keys, no lang scope.
  • checkcmd — the new findings.
  • New package for the lang commands beside addcmd / removecmd; hugo.toml edits through the existing config-edit machinery used by feature bundles.
  • initcmd.DefaultHugoVersion.
  • Starting hints: internal/siteconfig, internal/pagetree, internal/pageops, internal/elemops, internal/server, internal/checkcmd, internal/addcmd (for the config-edit pattern), cmd/harness/main.go.

Acceptance criteria

  • A site with languages: [en] (legacy form) loads and behaves exactly as before: same addresses, same tree, same API answers.
  • harness lang add de --label Deutsch on such a site writes both config files as the PRD specifies, flips defaultContentLanguageInSubdir, writes the old address of every page into its aliases, creates content/_index.de.md as a draft, and hugo builds the site with /en/… and /de/ addresses and a redirecting root; running it again is a no-op with exit 0 (or exit 1 "already present", documented either way).
  • harness lang add refuses with exit 1 and nothing written when the effective Hugo version is below 0.167, naming the version.
  • harness lang remove de refuses while any *.de.md exists, listing them; --purge deletes them and the config entries; removing the default language exits 1.
  • GET /api/tree?lang=de returns the default-language tree with translated titles and a translation state per node; GET /api/page?lang=de on a translated page returns the translation; on an untranslated page it returns a documented "missing" answer the editor can render.
  • Translate page (connected) creates _index.de.md with l10n: connected, draft: true, every source element with the same uid, prefixed text, synced appearance params and l10nSource; subpages when asked; free and empty modes as specified.
  • Saving a source element copies its appearance params into every connected translated element and does not touch their translatable values; saving a translated element rewrites its l10nSource; the page response marks a translated element whose hash differs.
  • Reorder, move, delete, duplicate and cross-page cut on a source page propagate into each connected translation as specified; the delete and transfer plans report the affected translations; insert/move/delete/duplicate on a connected translation answer 409; a free translation accepts them.
  • A hand-written connected translation with a stray uid, a missing uid and a wrong order is repaired on open to the invariant; a hand-written file without l10n is treated as free and never written to by propagation.
  • Permalinks: /de/leistungen/webdesign/ for a translated child under a Services translation with slug: leistungen; changing the slug writes the old address into aliases; renaming the source writes aliases into translations without a slug.
  • Deleting a source page deletes its translations and the delete plan says so; moving a source page moves them.
  • Category and module-entry translations work through the same endpoints (a content/categories/<slug>/_index.de.md title; a news item translation listed by the module for lang=de).
  • Publish carries every language; the pending list has one row per file with lang; badges are per file.
  • harness check reports each finding of PRD section 10 on fixtures that trigger it, and passes on the scaffold site and on testdata/.
  • harness init scaffolds the new languages shape and the new default Hugo version; AGENTS.md, README.md and the listed skills describe the behaviour; go test ./... passes, including the AGENTS.md and skills drift tests.

How to verify

  • go test ./... with new table tests for siteconfig, pagetree (tree per language, permalinks), the hash, the invariant repair, propagation, the lang commands and checkcmd findings; fixtures under testdata/ gain a two-language site.
  • Scaffold a site with harness init, run harness lang add de, create translations through the API with curl, then harness check and hugo (the harness downloads the pinned version): the built public/ has /en/, /de/, a redirecting root and the alias pages.
  • Open the existing editor against the site in the default language: everything works as before.

Assumptions to check

  • Hugo ≥ 0.167 applies a section's slug to the pages beneath it (release notes say so; verify on the pinned version with a two-level fixture before relying on it for permalinks).
  • Hugo's index.html root redirect with defaultContentLanguageInSubdir = true is emitted for the default language on the pinned version.
  • Aliases written as /services/ on a page now served at /en/services/ produce a redirect page at /services/ (Hugo alias semantics with language subdirs).
  • StampUIDs repair is per file, so shared uids across translation files are not rewritten (confirm in elemops).
  • The hosted platform (deckle-hosted) builds with its own nix-shell Hugo; its pin is not changed here, so a site with two languages may fail to verify there until that repository bumps — note it in the PR.

Out of scope

  • The theme: i18n/, the language switch, hreflang, ce/href.html, the render hook (#191).
  • The editor: language switch, tree rendering, dialogs, forms, Languages view (#192).
  • Fallback to another language, reconnect, machine translation, a settings screen, translating the editor UI, the deckle-hosted Hugo pin.

Blocked by

None - can start immediately


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 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, which at the same time writes the old unprefixed URL of every page into that page's aliases so published links keep working as redirects.
  • 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), aliases, 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, aliases, backendLayout, backendLayoutNextLevel 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 writes the old address into that file's aliases. Renaming the source page (directory rename) keeps every translation's address when the translation has its own slug; translations without one get their old address written into their own aliases, as the source does today.
  • 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 and writes the old unprefixed address into aliases of every page; 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 and leaves the aliases in place (they now redirect to the unprefixed address, which is harmless). 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, plus the alias rule of section 5. 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 languageName; 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); 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) moves to the newest stable Hugo release at or above 0.167 at implementation time; 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 engine and the `harness` CLI multilingual: a site declares several languages, a translation is a file beside its default-language page (`_index.de.md`), connected translations follow their source page's element structure, addresses live under `/<code>/`, and `harness lang add` / `harness lang remove` manage the language list. This issue lands the whole content model and API so that the theme issue and the editor issue can build on it; after it, translations are usable by hand and through the API, and the existing editor keeps working for the default language. ## Current behavior The page identity on the wire, in the editor URL (`/:module/:lang/*path`) and in the stores is already (path, lang), but `siteconfig` requires exactly one language and `pagetree.ContentRoot` maps it to `content/` itself; any other language is a 404. `hugo.toml` has no `[languages]` table. Permalinks have no language prefix. Badges, pending changes, `publish-all` and `element/transfer` are keyed or scoped by one language. Nothing knows about translations: no translation files, no connected mode, no change tracking. The full status quo is in the PRD below (sections 3 and 4 describe what changes). ## Desired behavior Everything in PRD sections 3 (configuration), 4 (content model), 5 (addresses), 6 (links: the engine side — language-neutral values, no rewriting for translation), 7 (operations: the commands, the server endpoints behind Translate page / Translate element / Disconnect / Delete translation, propagation, categories and module entries), 10 (publishing and checks) and 11 (Hugo version), plus the documentation in section 12 that belongs to the engine and CLI. In short: - `harness.yaml` `languages` becomes a list of `{code, label}` entries; the old string-list form keeps loading. The first entry is the default language. - `harness lang add <code> [--label] [--json]` and `harness lang remove <code> [--purge] [--json]` as specified, including the one-time flip of `defaultContentLanguageInSubdir` with aliases for every old address, the `[languages.<code>]` tables with their params block, the language's draft home page, and the Hugo ≥ 0.167 guard. - A translation is `content/<dir>/_index.<code>.md`; `ContentRoot` stays `content/`, the file name selects the language; every lang-scoped endpoint resolves a non-default language to the translation files; the tree of a non-default language is the default-language tree with translated titles and a per-node translation state (`translated`, `missing`, `outdated`, plus the existing enabled/badge state) so the editor can draw it. - Translated elements share their source's `uid`; the connected structure invariant is enforced on write and repaired on open; general-tab params and bodies are translatable, appearance-tab params synced; `l10nSource` hash per translated element; `l10n: connected` front matter; `l10n` and `l10nSource` reserved. - Propagation of structural operations from a source page into its connected translations in the same mutation, with the confirmation counts (delete, cut across pages) exposed in the existing plan/dry-run style; element operations refused with 409 on a connected translation. - New server endpoints (names are the implementer's call, shapes documented like the existing ones): translate page (mode connected/free/empty, with subpages), translate element, translate all, disconnect, delete translation; `GET /api/site` serves `{code, label}` entries; `GET /api/tree` gains the translation state per node; page responses carry the translation's `l10n` mode and each element's `changed` mark and synced params. - Permalinks under `/<code>/` once the site has two languages, per-segment `slug` overrides from each ancestor's translation; aliases on slug change and on source rename. - Publishing is site-wide: the `lang` parameter is dropped from publish-all, pending and discard-all; pending rows carry `lang`; badges are keyed per file. - `harness check` findings of PRD section 10. - `DefaultHugoVersion` moves to the newest stable release ≥ 0.167. - AGENTS.md, README.md and the `setup-site`, `migrate-site`, `verify-site`, `edit-content`, `add-element`, `add-page-type`, `create-feature` skills updated as section 12 lists for the engine side (the theme and editor issues update what they change). ## Decisions Settled in the grilling session; implement, do not reopen. Reasons are in the PRD next to each. - Translation file beside the original, no `content/<code>/` directory — one tree by construction, no migration of existing sites, categories and modules translate the same way. - Same uid in every language — connected mode is "same uid set, same order"; anchors resolve everywhere; no new reserved pointer param. - Structural edits propagate immediately — two files never disagree for long; the invariant is enforceable. - Translatable = general tab + body, synced = appearance tab — no new sidecar key; the existing `tab` already separates content from look. - `l10nSource` hash cleared by save — a save is the review. - Links stay language-neutral — resolution belongs to the theme at render time (theme issue); the engine never rewrites for translation. - `/<code>/` for every language, root redirects, but only once a second language exists — single-language sites keep their addresses; the flip writes aliases once. - Strict fallback only, no `fallback` key. - `harness lang add/remove` are the only way to change languages; the editor has no settings screen. - One publish for all languages. - Hugo bump to the newest stable ≥ 0.167 instead of writing `url` on every translation. ## Key interfaces - `siteconfig.Config.Languages` — from `[]string` to a list of `{Code, Label}`; `validateLanguages` loses the "exactly one" rule, keeps the tag regex, accepts the legacy string list. - `pagetree.ContentRoot`, `pagetree.Build`, `Node` — language resolves to file names; a node gains the translation state; `Tree.Lang` stays. - `pagetree/permalink.go` — language prefix and per-segment slug overrides; reads `defaultContentLanguageInSubdir`. - `content` engine: the canonical hash of an element's translatable values; `l10nSource` and `l10n` as reserved names in `element.ReservedParam*` and the page-type loader's tool-managed keys. - `elemops`, `pageops`, `server` mutations — propagation into connected translations under the existing global mutation lock; the new endpoints; `element/transfer` across languages of the target page. - `publish.go`, `git.go` (badges), `pendingChanges` wire shape — per-file keys, no `lang` scope. - `checkcmd` — the new findings. - New package for the `lang` commands beside `addcmd` / `removecmd`; `hugo.toml` edits through the existing config-edit machinery used by feature bundles. - `initcmd.DefaultHugoVersion`. - Starting hints: `internal/siteconfig`, `internal/pagetree`, `internal/pageops`, `internal/elemops`, `internal/server`, `internal/checkcmd`, `internal/addcmd` (for the config-edit pattern), `cmd/harness/main.go`. ## Acceptance criteria - [ ] A site with `languages: [en]` (legacy form) loads and behaves exactly as before: same addresses, same tree, same API answers. - [ ] `harness lang add de --label Deutsch` on such a site writes both config files as the PRD specifies, flips `defaultContentLanguageInSubdir`, writes the old address of every page into its `aliases`, creates `content/_index.de.md` as a draft, and `hugo` builds the site with `/en/…` and `/de/` addresses and a redirecting root; running it again is a no-op with exit 0 (or exit 1 "already present", documented either way). - [ ] `harness lang add` refuses with exit 1 and nothing written when the effective Hugo version is below 0.167, naming the version. - [ ] `harness lang remove de` refuses while any `*.de.md` exists, listing them; `--purge` deletes them and the config entries; removing the default language exits 1. - [ ] `GET /api/tree?lang=de` returns the default-language tree with translated titles and a translation state per node; `GET /api/page?lang=de` on a translated page returns the translation; on an untranslated page it returns a documented "missing" answer the editor can render. - [ ] Translate page (connected) creates `_index.de.md` with `l10n: connected`, `draft: true`, every source element with the same uid, prefixed text, synced appearance params and `l10nSource`; subpages when asked; free and empty modes as specified. - [ ] Saving a source element copies its appearance params into every connected translated element and does not touch their translatable values; saving a translated element rewrites its `l10nSource`; the page response marks a translated element whose hash differs. - [ ] Reorder, move, delete, duplicate and cross-page cut on a source page propagate into each connected translation as specified; the delete and transfer plans report the affected translations; insert/move/delete/duplicate on a connected translation answer 409; a free translation accepts them. - [ ] A hand-written connected translation with a stray uid, a missing uid and a wrong order is repaired on open to the invariant; a hand-written file without `l10n` is treated as free and never written to by propagation. - [ ] Permalinks: `/de/leistungen/webdesign/` for a translated child under a Services translation with `slug: leistungen`; changing the slug writes the old address into `aliases`; renaming the source writes aliases into translations without a slug. - [ ] Deleting a source page deletes its translations and the delete plan says so; moving a source page moves them. - [ ] Category and module-entry translations work through the same endpoints (a `content/categories/<slug>/_index.de.md` title; a news item translation listed by the module for `lang=de`). - [ ] Publish carries every language; the pending list has one row per file with `lang`; badges are per file. - [ ] `harness check` reports each finding of PRD section 10 on fixtures that trigger it, and passes on the scaffold site and on `testdata/`. - [ ] `harness init` scaffolds the new `languages` shape and the new default Hugo version; AGENTS.md, README.md and the listed skills describe the behaviour; `go test ./...` passes, including the AGENTS.md and skills drift tests. ## How to verify - `go test ./...` with new table tests for `siteconfig`, `pagetree` (tree per language, permalinks), the hash, the invariant repair, propagation, the `lang` commands and `checkcmd` findings; fixtures under `testdata/` gain a two-language site. - Scaffold a site with `harness init`, run `harness lang add de`, create translations through the API with `curl`, then `harness check` and `hugo` (the harness downloads the pinned version): the built `public/` has `/en/`, `/de/`, a redirecting root and the alias pages. - Open the existing editor against the site in the default language: everything works as before. ## Assumptions to check - Hugo ≥ 0.167 applies a section's `slug` to the pages beneath it (release notes say so; verify on the pinned version with a two-level fixture before relying on it for permalinks). - Hugo's `index.html` root redirect with `defaultContentLanguageInSubdir = true` is emitted for the default language on the pinned version. - Aliases written as `/services/` on a page now served at `/en/services/` produce a redirect page at `/services/` (Hugo alias semantics with language subdirs). - `StampUIDs` repair is per file, so shared uids across translation files are not rewritten (confirm in `elemops`). - The hosted platform (`deckle-hosted`) builds with its own nix-shell Hugo; its pin is not changed here, so a site with two languages may fail to verify there until that repository bumps — note it in the PR. ## Out of scope - The theme: `i18n/`, the language switch, hreflang, `ce/href.html`, the render hook (#191). - The editor: language switch, tree rendering, dialogs, forms, Languages view (#192). - Fallback to another language, reconnect, machine translation, a settings screen, translating the editor UI, the deckle-hosted Hugo pin. ## Blocked by None - can start immediately --- ## 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 `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`, which at the same time writes the old unprefixed URL of every page into that page's `aliases` so published links keep working as redirects. - 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), `aliases`, `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`, `aliases`, `backendLayout`, `backendLayoutNextLevel` 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 writes the old address into that file's `aliases`. Renaming the source page (directory rename) keeps every translation's address when the translation has its own slug; translations without one get their old address written into their own `aliases`, as the source does today. - 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` and writes the old unprefixed address into `aliases` of every page; 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` and leaves the aliases in place (they now redirect to the unprefixed address, which is harmless). 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, plus the alias rule of section 5. 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 `languageName`; 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); 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) moves to the newest stable Hugo release at or above 0.167 at implementation time; 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#190
No description provided.