feat(i18n): a site whose languages sit in folders ships a root 404 page in the default language #320

Closed
opened 2026-10-20 14:18:54 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Summary

A site whose languages sit in folders (defaultContentLanguageInSubdir = true) ships a root /404.html, showing the default language's 404 page, so a visitor who hits an unprefixed address that does not exist (/leistungen/ on cloonar.com) sees the site's own page, in the site's default language, with a 404 status — not the host's generic page.

Current behavior

Reproduced on 2026-10-20 with Hugo 0.167.0 extended (the version harness init pins) on a fresh scaffold site with one page, after harness lang add en:

./de/404.html
./de/index.html
./de/leistungen/index.html
./en/404.html
./index.html            # Hugo's redirect page to /de/

Hugo writes one 404 page per language folder and none at the root. The hosts that answer a miss with the root /404.html — deckle-hosted (its Caddyfile tries /<first path segment>/404.html, then /404.html, with status 404, then its own page), Netlify, GitHub Pages, plain nginx or Caddy — therefore catch /en/… and /de/… misses with the site's page and every unprefixed miss with their own. On cloonar.com (2026-10-20), /404.html and /leistungen/ answer the host's generic page; /de/404.html is the site's. The German sitemap lists only /de/, /de/produkte/… and /de/datenschutz/: /leistungen/ is a URL of the site cloonar.com replaced, which is exactly the kind of address only a root 404 page can catch.

A one-language site builds /404.html at the root today and must keep doing so.

Desired behavior

  • On a multilingual site, the build also contains /404.html: the same document as /<default>/404.html (same head, header, texts and footer, in the default language, root-relative links intact), produced by the theme so that deckle-hosted's plain hugo --minify build — pinned Hugo, no network, no harness step — gets it too.
  • A root /404.html is a copy, never an alias page: a visitor stays on the address they typed with the 404 status, instead of being bounced to /<default>/404.html with a 200.
  • A one-language site's build output is unchanged byte for byte. When harness lang remove takes a site back to one language, the next build has no extra file: the root /404.html is then Hugo's own again.
  • The theme README and AGENTS.md state the fact; the skills that walk through the theme's layout files, a multilingual build's listing, and an old site's unprefixed URLs point at it.

Decisions

  • A root 404 copy, no redirects (option A of the original report; option B dropped). The maintainer decided on 2026-10-20 that the flip stays as ADR 0017 describes: harness lang add and the engine write no aliases, and no machine-owned redirect list is introduced. Reasons: B would reverse ADR 0017 (the flip writing aliases is what PR #193 removed), it only helps addresses that were real pages before the flip (cloonar.com's /leistungen/ was not), and migrate-site already covers an old site's URLs with hand-written /../x/ aliases. Record that decision where it is durable: ADR 0017's consequences gain a line saying that redirects for the flip were considered in #320 and declined, and that the root 404 copy is the answer to unprefixed misses.
  • The theme produces the file, from its 404 layout, with Hugo's resource pipeline. Verified on Hugo 0.167.0: resources.FromString "404.html" <document> | .Publish, executed while rendering the default language's 404 page, writes public/404.html at the root of a defaultContentLanguageInSubdir build, also under --minify. Rejected alternatives, verified not to work or not acceptable: the 404 kind takes no front matter so it cannot carry an alias; a content page with url: /../404.html lands at the root but is a regular page in every collection and does not use the 404 layout; Hugo has no setting that renders the 404 page at the root.
  • Gated on the multilingual default language only. The copy is written when hugo.IsMultilingual is true and the language being rendered is hugo.Sites.Default's — the owner of "which language is the default" the theme already uses (partials/l10n/source.html, baseof.html's x-default). The 404 layout calls it; it does not re-derive the default from hugo.toml or language weights. On a one-language site the gate is false and nothing changes.
  • Same document as the language's own 404 page. The copy must be the whole page as baseof.html renders it, not the main block alone. How the theme obtains the full document as a string is the implementer's call (the 404 layout rendering outside baseof's block "main" through the same partials, or baseof's shell becoming a partial both use); whichever it is, the theme README's layout-file contract for 404.html says it.
  • Docs travel with the change (CLAUDE.md): the theme README owns the theme fact, AGENTS.md "Addresses" owns the address-level fact, the skills point at them, and the hugo.toml scaffold comment and the internal/langcmd package comment (both say "nothing redirects the old unprefixed addresses") stay true and gain the sentence that the site's own 404 page answers them.

Key interfaces

  • themes/deckle/layouts/404.html and layouts/_default/baseof.html — the 404 page's document, and the root copy on a multilingual site (starting hint; the layout-file list is in the theme README "Layout files").
  • hugo.Sites.Default.Language.Lang — the default-language rule the theme already evaluates; call it.
  • Theme README "Languages" (the per-language render rules) and "Layout files" (layouts/404.html — its texts are i18n strings) — where the new fact and the changed contract line go.
  • AGENTS.md "Addresses", the No redirects paragraph — gains what an unprefixed miss gets on a multilingual site.
  • scaffold/site/hugo.toml header comment and the internal/langcmd package doc — one sentence each.
  • docs/adr/0017-the-engine-writes-no-aliases.md — consequences gain the #320 decision.
  • Skills: redo-theme (step 3 lists 404.html among the files whose contract a redone theme keeps — name the root copy), verify-site (the "look at the output's top level" paragraph for a multilingual build lists the root 404.html beside the per-language folders and the redirecting index.html), migrate-site (section 2 "Addresses" bullet and step 4's alias note: hand-written /../x/ aliases where a URL must keep working, the root 404 page for everything else).

Acceptance criteria

  • hugo --minify on a scaffold site with two languages (harness init, then harness lang add) writes public/404.html, and it is the same document as public/<default>/404.html (the German notfound_* strings on a German-default site, the full head, header and footer, root-relative links), with no meta refresh.
  • The same build on a one-language scaffold site produces output identical to before the change (diff the public/ tree against a build from main).
  • After harness lang remove takes a two-language site back to one, the next build's public/404.html is Hugo's own 404 page and no public/<code>/404.html remains.
  • The theme's own tests (internal/theme) cover the root copy on a multilingual fixture and its absence on a single-language one.
  • Theme README "Languages" and "Layout files", AGENTS.md "Addresses", the hugo.toml scaffold comment and the internal/langcmd package comment state the behaviour; scaffold/skills_test.go, internal/element/agentsdoc_test.go and docs/docs_test.go pass.
  • redo-theme, verify-site and migrate-site are revisited as the Key interfaces section says, pointing at the owning sections rather than restating them.
  • ADR 0017's consequences record the #320 decision (redirects for the flip declined; the root 404 copy answers unprefixed misses).
  • On a deckle-hosted preview or live site built from the change, an unprefixed address that does not exist answers the site's default-language 404 page with status 404.

How to verify

  • go build ./... && go test ./... (the web client's dist must exist for the harness binary; the theme tests need the pinned Hugo, which internal/hugobin downloads).
  • Scaffold a site, git init and commit, add a page, build with the pinned hugo --minify -d <tmp>, find <tmp> -name '*.html' | sort; then harness lang add en --label English, build again, list again, and diff <tmp>/404.html <tmp>/de/404.html.
  • harness check on the scaffold site before and after the flip.
  • On hosted: curl -s -o /dev/null -w '%{http_code}\n' https://<site>/does-not-exist/ and read the body; expect 404 and the site's own page.

Assumptions to check

  • That resources.FromString … .Publish keeps writing to the publish root, not the language folder, on the Hugo versions a site may pin above 0.167.0; verified on 0.167.0 only.
  • That the theme can obtain the full 404 document as a string without rendering the page twice in a way that duplicates side effects (the fonts' .Publish calls, .Store flags in baseof).
  • That the hosted Caddyfile's try_files /<segment>/404.html /404.html order is what the deckle-hosted snapshot of 2026-10-20 shows (it is in deploy/Caddyfile), so the root copy is served only for misses outside a language folder.

Out of scope

  • Redirects for the old unprefixed addresses (option B): dropped by the maintainer, see Decisions. harness lang add, harness lang remove, migrate-site's hand-written aliases and ADR 0017's rule are unchanged.
  • The host's 404 lookup order (deckle-hosted #152) and HTTP-level redirects on the host.
  • harness check and the editor's link targets: links are language-neutral page references the theme resolves at render time, and harness check has no URL-level check, so neither needs to know about 404 pages or redirects.
  • The 404 page's content, texts or styling.

Blocked by

None - can start immediately

Original report

Constraints and observations kept from the maintainer's report of 2026-10-20:

  • deckle-hosted builds a site with plain hugo --minify: the site's pinned Hugo, in a container without network, with no harness step. Whatever produces /404.html must come out of Hugo itself: the theme's templates, hugo.toml, or front matter the harness writes once.
  • Hugo aliases are HTML meta-refresh pages that a host serves with 200, not HTTP 301s. A root /404.html that is itself an alias to /<default>/404.html would send the 404 status first, then leave the visitor on /de/404.html with a 200.
  • Observed on cloonar.com (served by deckle-hosted, 2026-10-20): / is an alias page to /de/; /de/404.html and /en/404.html exist; /404.html does not; /leistungen/ and anything below it answer the host's generic 404 page.
  • The dropped option B, for the record: every page that had an unprefixed address before the flip keeps it as a redirect to its default-language address, written by harness lang add at the flip and by migrate-site for an old site's URLs. Its open questions (what the flip records, the reverse flip, renames, migrations) were not answered because the option was dropped.
> *This was generated by AI during triage.* ## Summary A site whose languages sit in folders (`defaultContentLanguageInSubdir = true`) ships a root `/404.html`, showing the default language's 404 page, so a visitor who hits an unprefixed address that does not exist (`/leistungen/` on cloonar.com) sees the site's own page, in the site's default language, with a 404 status — not the host's generic page. ## Current behavior Reproduced on 2026-10-20 with Hugo 0.167.0 extended (the version `harness init` pins) on a fresh scaffold site with one page, after `harness lang add en`: ``` ./de/404.html ./de/index.html ./de/leistungen/index.html ./en/404.html ./index.html # Hugo's redirect page to /de/ ``` Hugo writes one 404 page per language folder and none at the root. The hosts that answer a miss with the root `/404.html` — deckle-hosted (its Caddyfile tries `/<first path segment>/404.html`, then `/404.html`, with status 404, then its own page), Netlify, GitHub Pages, plain nginx or Caddy — therefore catch `/en/…` and `/de/…` misses with the site's page and every unprefixed miss with their own. On cloonar.com (2026-10-20), `/404.html` and `/leistungen/` answer the host's generic page; `/de/404.html` is the site's. The German sitemap lists only `/de/`, `/de/produkte/…` and `/de/datenschutz/`: `/leistungen/` is a URL of the site cloonar.com replaced, which is exactly the kind of address only a root 404 page can catch. A one-language site builds `/404.html` at the root today and must keep doing so. ## Desired behavior - On a multilingual site, the build also contains `/404.html`: the same document as `/<default>/404.html` (same head, header, texts and footer, in the default language, root-relative links intact), produced by the theme so that deckle-hosted's plain `hugo --minify` build — pinned Hugo, no network, no harness step — gets it too. - A root `/404.html` is a copy, never an alias page: a visitor stays on the address they typed with the 404 status, instead of being bounced to `/<default>/404.html` with a 200. - A one-language site's build output is unchanged byte for byte. When `harness lang remove` takes a site back to one language, the next build has no extra file: the root `/404.html` is then Hugo's own again. - The theme README and AGENTS.md state the fact; the skills that walk through the theme's layout files, a multilingual build's listing, and an old site's unprefixed URLs point at it. ## Decisions - **A root 404 copy, no redirects (option A of the original report; option B dropped).** The maintainer decided on 2026-10-20 that the flip stays as ADR 0017 describes: `harness lang add` and the engine write no `aliases`, and no machine-owned redirect list is introduced. Reasons: B would reverse ADR 0017 (the flip writing aliases is what PR #193 removed), it only helps addresses that were real pages before the flip (cloonar.com's `/leistungen/` was not), and `migrate-site` already covers an old site's URLs with hand-written `/../x/` aliases. Record that decision where it is durable: ADR 0017's consequences gain a line saying that redirects for the flip were considered in #320 and declined, and that the root 404 copy is the answer to unprefixed misses. - **The theme produces the file, from its 404 layout, with Hugo's resource pipeline.** Verified on Hugo 0.167.0: `resources.FromString "404.html" <document> | .Publish`, executed while rendering the default language's 404 page, writes `public/404.html` at the root of a `defaultContentLanguageInSubdir` build, also under `--minify`. Rejected alternatives, verified not to work or not acceptable: the 404 kind takes no front matter so it cannot carry an alias; a content page with `url: /../404.html` lands at the root but is a regular page in every collection and does not use the 404 layout; Hugo has no setting that renders the 404 page at the root. - **Gated on the multilingual default language only.** The copy is written when `hugo.IsMultilingual` is true and the language being rendered is `hugo.Sites.Default`'s — the owner of "which language is the default" the theme already uses (`partials/l10n/source.html`, `baseof.html`'s `x-default`). The 404 layout calls it; it does not re-derive the default from `hugo.toml` or language weights. On a one-language site the gate is false and nothing changes. - **Same document as the language's own 404 page.** The copy must be the whole page as `baseof.html` renders it, not the `main` block alone. How the theme obtains the full document as a string is the implementer's call (the 404 layout rendering outside baseof's `block "main"` through the same partials, or baseof's shell becoming a partial both use); whichever it is, the theme README's layout-file contract for `404.html` says it. - **Docs travel with the change** (CLAUDE.md): the theme README owns the theme fact, AGENTS.md "Addresses" owns the address-level fact, the skills point at them, and the `hugo.toml` scaffold comment and the `internal/langcmd` package comment (both say "nothing redirects the old unprefixed addresses") stay true and gain the sentence that the site's own 404 page answers them. ## Key interfaces - `themes/deckle/layouts/404.html` and `layouts/_default/baseof.html` — the 404 page's document, and the root copy on a multilingual site (starting hint; the layout-file list is in the theme README "Layout files"). - `hugo.Sites.Default.Language.Lang` — the default-language rule the theme already evaluates; call it. - Theme README "Languages" (the per-language render rules) and "Layout files" (`layouts/404.html — its texts are i18n strings`) — where the new fact and the changed contract line go. - AGENTS.md "Addresses", the **No redirects** paragraph — gains what an unprefixed miss gets on a multilingual site. - `scaffold/site/hugo.toml` header comment and the `internal/langcmd` package doc — one sentence each. - `docs/adr/0017-the-engine-writes-no-aliases.md` — consequences gain the #320 decision. - Skills: `redo-theme` (step 3 lists `404.html` among the files whose contract a redone theme keeps — name the root copy), `verify-site` (the "look at the output's top level" paragraph for a multilingual build lists the root `404.html` beside the per-language folders and the redirecting `index.html`), `migrate-site` (section 2 "Addresses" bullet and step 4's alias note: hand-written `/../x/` aliases where a URL must keep working, the root 404 page for everything else). ## Acceptance criteria - [ ] `hugo --minify` on a scaffold site with two languages (`harness init`, then `harness lang add`) writes `public/404.html`, and it is the same document as `public/<default>/404.html` (the German `notfound_*` strings on a German-default site, the full head, header and footer, root-relative links), with no meta refresh. - [ ] The same build on a one-language scaffold site produces output identical to before the change (diff the `public/` tree against a build from `main`). - [ ] After `harness lang remove` takes a two-language site back to one, the next build's `public/404.html` is Hugo's own 404 page and no `public/<code>/404.html` remains. - [ ] The theme's own tests (`internal/theme`) cover the root copy on a multilingual fixture and its absence on a single-language one. - [ ] Theme README "Languages" and "Layout files", AGENTS.md "Addresses", the `hugo.toml` scaffold comment and the `internal/langcmd` package comment state the behaviour; `scaffold/skills_test.go`, `internal/element/agentsdoc_test.go` and `docs/docs_test.go` pass. - [ ] `redo-theme`, `verify-site` and `migrate-site` are revisited as the Key interfaces section says, pointing at the owning sections rather than restating them. - [ ] ADR 0017's consequences record the #320 decision (redirects for the flip declined; the root 404 copy answers unprefixed misses). - [ ] On a deckle-hosted preview or live site built from the change, an unprefixed address that does not exist answers the site's default-language 404 page with status 404. ## How to verify - `go build ./... && go test ./...` (the web client's `dist` must exist for the harness binary; the theme tests need the pinned Hugo, which `internal/hugobin` downloads). - Scaffold a site, `git init` and commit, add a page, build with the pinned `hugo --minify -d <tmp>`, `find <tmp> -name '*.html' | sort`; then `harness lang add en --label English`, build again, list again, and `diff <tmp>/404.html <tmp>/de/404.html`. - `harness check` on the scaffold site before and after the flip. - On hosted: `curl -s -o /dev/null -w '%{http_code}\n' https://<site>/does-not-exist/` and read the body; expect 404 and the site's own page. ## Assumptions to check - That `resources.FromString … .Publish` keeps writing to the publish root, not the language folder, on the Hugo versions a site may pin above 0.167.0; verified on 0.167.0 only. - That the theme can obtain the full 404 document as a string without rendering the page twice in a way that duplicates side effects (the fonts' `.Publish` calls, `.Store` flags in baseof). - That the hosted Caddyfile's `try_files /<segment>/404.html /404.html` order is what the deckle-hosted snapshot of 2026-10-20 shows (it is in `deploy/Caddyfile`), so the root copy is served only for misses outside a language folder. ## Out of scope - Redirects for the old unprefixed addresses (option B): dropped by the maintainer, see Decisions. `harness lang add`, `harness lang remove`, `migrate-site`'s hand-written aliases and ADR 0017's rule are unchanged. - The host's 404 lookup order (deckle-hosted #152) and HTTP-level redirects on the host. - `harness check` and the editor's link targets: links are language-neutral page references the theme resolves at render time, and `harness check` has no URL-level check, so neither needs to know about 404 pages or redirects. - The 404 page's content, texts or styling. ## Blocked by None - can start immediately ## Original report Constraints and observations kept from the maintainer's report of 2026-10-20: - deckle-hosted builds a site with plain `hugo --minify`: the site's pinned Hugo, in a container without network, with no harness step. Whatever produces `/404.html` must come out of Hugo itself: the theme's templates, `hugo.toml`, or front matter the harness writes once. - Hugo aliases are HTML meta-refresh pages that a host serves with 200, not HTTP 301s. A root `/404.html` that is itself an alias to `/<default>/404.html` would send the 404 status first, then leave the visitor on `/de/404.html` with a 200. - Observed on cloonar.com (served by deckle-hosted, 2026-10-20): `/` is an alias page to `/de/`; `/de/404.html` and `/en/404.html` exist; `/404.html` does not; `/leistungen/` and anything below it answer the host's generic 404 page. - The dropped option B, for the record: every page that had an unprefixed address before the flip keeps it as a redirect to its default-language address, written by `harness lang add` at the flip and by `migrate-site` for an old site's URLs. Its open questions (what the flip records, the reverse flip, renames, migrations) were not answered because the option was dropped.
dominik.polakovics changed title from feat(i18n): a site whose languages sit in folders keeps its old addresses — a root 404 page, redirects, or both to feat(i18n): a site whose languages sit in folders ships a root 404 page in the default language 2026-10-20 15:44:06 +02:00
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#320
No description provided.