feat: localise the documentation and clear the layer of duxt's own content - #7
Merged
Merged
Conversation
A source gains a `locales` list, and so does a ref, resolved the way `status` already is. A string is a folder inside the source's path; an object moves that language to its own folder, repository or ref — which is what React and Vue do outside their tooling, with de.react.dev and the vuejs-translations org. The default locale stays the tree in `path` itself, without a folder, so adding the key moves no URL a site already serves. The locale is deliberately NOT part of the content prefix: i18n puts it in front of the path anyway, so original and translation live under identical content paths in separate collections — which leaves every path comparison in the theme untouched. `expandSources` is exported because content.config.ts and the manifest must agree entry for entry, and a name computed twice is a name that drifts once.
A translated tree is almost never complete — two pages of forty is the normal state of every project that has tried this — so a page missing from a language is answered with the nearest language that has it rather than a 404. Starlight is the only comparable generator that says this out loud, and it is right: the gap belongs to the writer, and hiding the page punishes the reader for it. The chain follows the rule the layer already applies twice, in the locale files and in resolveDuxtText: the locale, its base language, a sibling region, vue-i18n's own fallbackLocale, then the untranslated original. Reading fallbackLocale rather than carrying a second list is what keeps the interface and the pages agreeing on where they fall back to. The reader is told which language they are being shown, and the page carries noindex with a canonical pointing at the language that actually holds it — otherwise seven locales index the same English page.
A partly translated documentation has two obvious sidebars and both are wrong. Built from the translation, it hides every page that has none — pages the fallback serves perfectly well, so the reader is left with no way to them at all. Built from the original, a German reader looks at an English table of contents. So the structure comes from the original, which is complete by definition, and each entry takes the translated title where one exists. Two things this fixes in the process, both older than translations: The active collection was kept in a module-level ref so the handler could stay one stable function. On the server that variable is shared by every request in flight, and the navigation rendered for one request could be read by another — a hydration mismatch that took the page down with it. A closure is not the answer either: Nuxt compares handlers by reference, and several components asking for one key with a handler each is NUXT_E3004, after which the first handler wins and the rest are ignored. `useState` is per request AND reachable from a stable handler, which is what both halves need. `prefix` could resolve to undefined while the manifest was absent, which matched no source and emptied the query chain — a 404 on a page that exists.
content.config.ts is loaded by c12 in Content's own pass and has no access to the Nuxt config, so it resolves the default locale from sourceOptions.defaultLocale alone. If that disagrees with i18n.defaultLocale the collections are named for one language while the theme queries another, and the result is not an error but an empty page. The value is checked rather than injected: injecting it would fix the manifest and leave content.config.ts computing the other answer, which is the same bug one layer deeper. A translation the site does not serve is a warning, not an error — it builds and is never read.
Both pages said translated content was out of reach, which was true when they were written and is the opposite of what the layer now does. They describe the locales key, the folder-per-language layout and the fallback chain, and they name what a translation costs to build — roughly two seconds and 0.6 MB of database per language times version times repository, measured linear to at least 200 collections, because a consumer decides that matrix. ADR 0007 records what was read before the shape was chosen: four comparable generators put translations in a folder per language and only one has a documented fallback, while the largest projects leave their tooling entirely and run a translation repository of their own.
Four language folders rather than six: docs/pt serves both Portuguese locales and en-US reads the original through fallbackLocale, which is the rule the locale files already follow — the language carries the text, the region only its deviations. The development site serves them, deliberately incomplete: thirteen of a hundred and eleven pages, so both halves of the feature are exercised on the site the layer is built against — the translated page, and the fallback banner on every page that has none.
Eight guides in four languages. The checklists translate too — a checklist a reader cannot read is a checklist nobody ticks.
The sources reference gained the locales section it was missing — the feature shipped without the page that documents its fields.
The MDC components, the components, the composables and the endpoints, plus the credits, the decision records and the shared partials, in de, es, fr and pt. Carries the sections the English pages gained since the first batch — `refs` and the landing keys — and closes the German quotation marks that had stayed straight.
SQLite's default journal mode locks the whole file while a writer holds it, so a reader gets SQLITE_BUSY at once — `readOnly` does not help, it is the writer that excludes us. Content writes its parse cache from a `Promise.all` over the collections and the build's own hooks read it; with a handful of files the write is over before anyone reads, and at six hundred it is not. A busy timeout turns that race into a wait.
Every panel is now a renderer over data it is handed, with the queries, the file reads and the route rules left in the half beside it. Five things ride on that seam: - the filter box sits in a bar of its own between the heading and its table, and says how many rows it kept; - a structured config value unfolds instead of being cut off at 96 characters, so a translated label can be read rather than merely counted; - the version matrix holds one axis fixed at a time — versions of a language, languages of a version — and names its columns, which a locale-only site had left as bare asterisks; - every shipped redirect is listed, with the ones `redirectFrom` generated marked as the layer's own and the rest as another module's, from the list the module now records beside them; - the config panel no longer throws on a key that is a string in the defaults and an object in the site, which is every site that translates its headline.
A page can now embed a panel with `::devtools-panel{tab="pages"}`, and what it embeds is the
panel's own renderer run over a fixture site — `pnpm previews` writes one HTML document per tab
into `www/public/devtools/`, and `pnpm check:previews` fails the gate when they no longer match
what the panels draw. The two obvious alternatives both rot: a screenshot goes stale the day a
column is added, and the real route exists in no build, so a published page would frame a 404.
The frame is an iframe, because a panel carries a stylesheet written against no documentation
theme; the reader's theme goes in and the document's height comes back, both best-effort.
One page per tab — the question it answers, the panel itself embedded as it renders, its columns, and the traps it exists to catch — under Reference, in all five languages. The endpoint page keeps the route and hands the reader over, rather than summarising ten panels in a sentence.
`pnpm previews` wrote `www/public/devtools/*.html`, while the pages that embed them — `docs/4.reference/8.devtools/` — ship inside the package. On this repo's own site that resolved; on any other site rendering those pages it was ten broken frames. The generated documents move into the layer's own `public/`, which Nuxt serves for every layer, and `public` joins the `files` allowlist so they reach the tarball.
Original and translation live under IDENTICAL content paths in different collections — the locale sits in front of the URL, not in the content tree — so `sourceForPath`, which matches on the prefix alone, cannot separate them and answered with whichever collection sorted first. The devtools path debugger stripped a `/de/` segment, announced that it had, and then reported `docs`. `sourcesForRoute` decides prefix and language together, walking the same `localeChain` the theme walks, and both `useDuxtCollection` and the panel now call it — so the debugger cannot disagree with the site it debugs. The panel also prints the fallback chain and the language a candidate carries, neither of which anything showed before.
`definePartials` read `_partials/**/*.md` with `cwd` at the source's own folder,
so it saw `docs/_partials/` and nothing else. A German page including
`:partial{name="install"}` therefore rendered the English block in the middle of
German prose, with nothing on the page saying it had happened, and the
translated files under `docs/de/_partials/` were read by nothing at all.
Partials now carry the language dimension the pages carry: one collection per
language, named the way the page collections are — `duxt_partials` for the
original, `duxt_partials_de` beside it — and the component walks the same
fallback chain the page around it walked. A site that declares no `locales` gets
the one collection it always got, under the name it always had.
The link checker held one map from path to page, and every language of a source serves identical content paths — so the map kept whichever language was parsed last. Anchors are derived from heading TEXT, so a German heading has a German anchor, and the checker would have reported every correct anchor on a translated site as broken. A link is now resolved in the linking page's own collection first and in the untranslated original second, which is where the site itself falls back when a language does not carry the page.
The layer knew, per page, which locales carry it and when each file last changed — `git-meta` already reads that — and said neither out loud. OpenCode is the cautionary case: seventeen languages, an agent that kept them in sync, the workflow switched off, and nothing anywhere saying the translations had stopped moving. One line per language in every build, plus the files behind the figure. A note rather than a warning, because an untranslated page is a state of the site and not a defect in it, and reporting it as a warning would train everyone to read past the warnings. The staleness half needs `history: true` on the source and is skipped rather than guessed without it. The devtools Checks panel shows the same report.
On a document served from the site's own origin, `allow-scripts` together with `allow-same-origin` hands the frame back everything the sandbox attribute was meant to withhold. Nothing in the framed page needs it: `postMessage` crosses origins by design, and the theme and height handshake is all the page does. What is framed is generated HTML nobody reviews per release, so the sandbox is worth keeping teeth in.
`tag(value, kind)` interpolated the kind into the class list, so the commonest
call — `tag('default')`, with no kind — produced `class="tag "`. Appended
instead of interpolated.
A `nuxt build` the kernel or a Ctrl-C left behind keeps `contents.sqlite` open, and every following build then fails with `database is locked` — a message naming neither the process nor the file. The busy timeout in `content-cache.ts` covers the layer's own reader; Content's cache writer has no such patience, and a build cannot ask it for any. Write-ahead logging lets a reader and one writer coexist, which is the actual shape of a build. Measured on this repo's site while translating: 237 lock errors went to zero. The mode lives in the file header rather than in a connection, so setting it from the earliest module holds for Content too.
…lite lock Three changes reach the reader: partials now carry a language dimension, the build prints a coverage report per language, and a link is checked against its own language's anchors. The deployment guide gains the `database is locked` note, now that WAL removes the everyday case but not an orphaned process. ADR 0007 recorded the untranslated partials collection as deliberate. It was, while nothing translated a partial; the consequence is rewritten to say what replaced it and why. Mirrored into de, es, fr and pt.
The row takes the reader to duxt's repository, its tracker and its community, and its fourth entry linked `/getting-started` on the site the reader was already on. Absolute, so `useDuxtLink` passes it through instead of prefixing a locale onto it. The layer's own aside still ships empty.
`duxt.logo` takes a wordmark and its dark-mode twin; unset, the header and footer keep the icon-and-title pair they had. The layer therefore stays unbranded by default, which is the only correct behaviour for a theme: a site extending duxt must show its own name, never this one. The choice sits in DuxtBrand rather than in the three templates that draw it — the header twice, the footer once — because a branch repeated three times is a branch that drifts. The light/dark swap is CSS, not `useColorMode()`: the composable resolves on the client, so a scripted choice renders the light mark into the server HTML and flips it after hydration.
One SVG does the whole job — sharp at every size, with its own `prefers-color-scheme` rule inside so the mark lightens against a dark tab strip. The PNG exists only because iOS ignores SVG icons. The favicon is the bare `d` rather than the bracketed mark. At 16 px the brackets squeeze the bowl shut and the letter turns into a smudge; the full `[d]` only holds from roughly 32 px up, which is where the apple-touch icon keeps it.
Set through `duxt.logo` exactly the way a downstream site would set theirs, which is also what keeps the option honest: the development site takes the same path a stranger takes, so a change that breaks it breaks here first.
The mark is the package name with its first letter bracketed, because that is how the package is written where it is used: an array with one entry. It is typeset in IBM Plex Mono SemiBold and converted to outlines, so no asset depends on the font being installed anywhere. The section records the licence position rather than leaving it to be rediscovered: OFL clause 5 exempts documents created with the font, no font file is redistributed, and the reserved name appears in none of the assets. It also records the artwork as an AI-assisted placeholder with no replacement date, the way glimpse does for its own.
Nothing else can see this rule. `build:app` and `check:a11y` both run over `www/`, which overrides every leaking key — the one site in this repo that could render the layer's defaults is the one that never does. So it is checked over the assets themselves, the way `contrast.test.ts` checks the palette. The regex halves catch URLs and product names. The inventory is what enforces the rule: "The framework underneath" and `v0.0.0` name no product and hold no URL, yet both were leaks, so every shipped string is listed with a line saying why the layer draws it. Adding one costs that sentence.
The configuration reference documented `title`, `version`, the landing copy and the six feature cards as defaults, in five languages. Two comments named strings that no longer exist. The handoff that carried this work goes with it.
Nuxt serves every layer's `public/`, so all ten devtools preview documents are served from every downstream domain — titled "duxt — Sources" and indexable. They are fixtures for one reference page, not pages of anybody's site. defu concatenates the list across layers, so a consumer's own `disallow` is added to this rather than replacing it.
`detectBrowserLanguage.fallbackLocale` is nested, and defu merges nested objects
key by key — so a consumer setting `i18n: { defaultLocale: 'de-DE' }` does not
displace the layer's `en-GB` here. Narrow `duxt.locales` to exclude it and the
site stops serving that language while still redirecting root visitors to it.
Nothing throws, and the page is empty: the same silent failure the check beside
this one already covers for `defaultLocale`.
Three of the five routes the accessibility gate checked carried a `/duxt/` prefix and a `/workflows/v0.7.0` from a configuration `www` no longer has, so they 404'd — and a 404 renders the error view, which passes. The gate reported five pages while checking the same error page three times. Pointed at pages the site serves, it immediately found what it had been unable to see: `DuxtNavigation` rendered a `<nav aria-label="Documentation">` on every instance, and the mobile sheet renders a second copy of the tree while nested folders recurse into the component. Several landmarks shared one name — an axe `landmark-unique` failure and a screen-reader landmark list with the same entry repeated. The landmark is now opt-in through `label`, which only the sidebar sets. The route list also gains a page with an iframe, so `frame-title` has something to judge.
`render: duxt` cannot be detected — a docs tree pulled into another repo's site carries no dependency to read — so without it write-docs assumes the portable default and writes relative .md links and body H1s into a tree that uses neither. `locales` names the four translated trees beside the English root, so they are maintained and swept rather than left to drift.
Three gaps, one theme. The override guide says how to change the theme; nothing said how it is built — that components are copied rather than imported (and what that costs), why the palette departs from shadcn's default, and that dark mode switches on a class, which is what catches anyone overriding a colour. That is the new concepts page. Nothing at all covered identity. `title`, `logo` and the favicon are what makes a site read as its own rather than as the layer's, and `logo` was missing from the configuration reference despite existing in the type and being used by www. duxt's own mark moves out of the README into conventions, where the colour values, the licence reasoning and why the icon's brackets are redrawn have room — the README keeps the shape of the answer and links to it, so there is one copy.
The row stuck at `top-14`, one pixel short of the header's h-14 plus its own border-b, so it parked over that border and the two traded places by a pixel as the browser rounded the scroll offset. Both bars painted at `bg-background/80` over 4px of blur, which is not enough over the dark palette: the scrolling text stayed legible straight through them. Headings cleared 80px, the header alone. An anchor jump therefore landed the heading behind the section row; 112px clears the whole sticky stack.
The hook's script task listed only js/ts, so every SFC — nearly everything this repo changes — was staged unchecked. oxlint and oxfmt both read an SFC, so the extension joins the existing task rather than getting one of its own.
Why the layer owns its components rather than importing them from a library or a ready-made documentation theme — a choice between real alternatives that binds every part of the interface, with reasoning that leaves no trace in what shipped. ADR-0007 was missing from the decision log; it goes in with this one. The log is the index, not the record, so filling a gap in it is not an edit to an accepted decision.
Content names a directory node after the directory, so `99.adr/` arrived as "Adr" while the `index.md` it leads to says "Architecture decisions". The sidebar grouped eight records under a slug and the breadcrumb read "Adr › …". It only shows where the two differ, which is why it went unnoticed — `4.reference/` is "Reference" either way. An abbreviated or hyphenated folder is where it bites, and that is any consumer's tree, not just this one. The index is identified by carrying the folder's own path, which nothing else in the tree can hold. Applied before the translation overlay and again after, so a locale that translates the index title carries it up too.
`sections` is the consumer's reading order, and a tree holds folders it does not list — `99.adr/` is the standing example, an appendix by construction. The fallback handed back the entire tree, so the sidebar on such a page listed every section and every page under all of them at once, while the page beside it showed one branch. That reads as the sidebar breaking rather than as a page sitting outside the reading order, and it gets worse the larger the tree is. It now narrows to the branch the route is in, longest match first, and only reaches for the whole tree when the route matches no top-level node at all — a site with no sections configured, where the whole tree is the branch.
The ADRs sit in their own folder outside the reading order, so nothing led to them but the landing page. Conventions is where a reader asking why a rule exists already is.
It cannot sit under Conventions: Content builds the tree from file paths, and `99.adr/` is a top-level folder by contract — flat, fixed prefix. Nesting it would mean moving the records, which the append-only log does not allow. Last in the row, so the reading order still ends at the appendix.
The layer's stylesheet is linked TWICE: Vite resolves its absolute path along two routes — `/_nuxt/@fs/<abs>` and `/_nuxt/<abs>` — and deduplicates neither, so a 313 kB copy lands after this file. At equal specificity the layer's neutral `--primary` therefore won on source order, and which value painted first came down to which response arrived first: buttons and badges flashed grey before turning branded, and differently on every reload. `:root:root` outranks `:root`, so the order stops mattering. The doubling itself is the layer's to fix; until it is, this is what keeps a consumer's own colour from being a race.
A page states its icon in frontmatter and most do — the sidebar then shows ten pages with ten symbols that actually distinguish them. Some pages cannot: an ADR's frontmatter is fixed at `title`, `description`, `status` and `date`, so a decision log rendered eight bare rows beside sections that all have a column of icons. `pageIcon` on a section stands in for its pages, `duxt.pageIcon` for the whole tree, and a page's own frontmatter still wins. Unset at every level means no icon, which stays the right default — one symbol repeated down a whole sidebar distinguishes nothing. The precedence is a pure function so it can be tested; a nested section wins over the one above it, and the resolution is not tangled into the components.
The layer's reference gains the key at both levels; www sets it on the ADR section, which is the case that produced it.
The gavel heads the section and says what the log is. Repeating it on every row inside said nothing the heading had not; a record is one written decision, so it reads as a document.
Three modules were wired by hand — robots, sitemap and og-image — and the two that would have completed the picture were missing: schema.org, and the utils that derive og and Twitter tags from a page's title and description. `@nuxtjs/seo` is an ALIAS, not a wrapper: its own documentation says it contains no logic of its own. What it buys is the loading ORDER, which mattered here — sitemap wires itself into Content's collections and says so out loud when it is loaded second, and it was. It also completes the shared devtools panel, which every one of these modules feeds and which lists the ones that are missing. The three settings turned off are the ones that would fight the layer: a lowercased canonical, an invented fallback title, and a merge with site config that would overwrite what the duxt module already resolved.
What the modules derive is gone from the pages: the og and Twitter pairs that nuxt-seo-utils reads off title and description, and the JSON-LD that was written by hand before nuxt-schema-org kept one graph per document. What they cannot know is stated instead. `og:locale` is not derived from i18n, and Open Graph spells it with an underscore; the alternates are the other locales the site serves. The `WebSite` node says what the site is, once, for every page under it. A 404 asks not to be indexed — an indexed error page competes with the page the reader was looking for — while still allowing follow, because the suggestions on it are real. The landing page declares itself a website rather than an article, and finally renders an OG card: it is the page most likely to be shared and was the only one coming back as a bare URL. The canonical stays ours. The module points it at the page being rendered, which is right everywhere except here: an old version has to point at the current one, or a search engine keeps serving v0.7.0 to someone who wanted today's docs.
Every rule here breaks silently. A canonical pointing at the wrong version, a missing hreflang, an error page that forgot to say noindex, an OG card with no image — none of them throws, none fails a build, and all of them are only visible to a crawler weeks later. So they are asserted over the BUILT HTML rather than over the source: the canonical in particular is written twice, once by nuxt-seo-utils and once by the page, and only the rendered output says which one survived unhead's deduplication. Joins the check chain, so CI runs it without a workflow change.
The heading is "Navigation and links", so `#navigation` resolved to nothing — the build's own link check reported it. The sentence reads the same without it; the section is two screens up.
A link inside a menu is a link nobody opens the menu for, and this one is a page of the site while the five links beside it lead away from it. As a navbar entry it also renders server-side, which the dropdown's contents do not.
…race Vite resolves this stylesheet's absolute path along two routes at once — `/_nuxt/@fs/<abs>` and `/_nuxt/<abs>` — and deduplicates neither, so a second 313 kB copy of it lands AFTER the consumer's own `css` entry. At equal specificity the layer's neutral tokens then won on source order, and which value painted first came down to which response arrived first: a consumer's buttons and badges flashed neutral before turning branded, differently on every reload. Unlayered CSS beats layered CSS whatever the source order, so the palette moves into a cascade layer and the order stops deciding. The layer is ANONYMOUS: a named one can be reordered by a consumer writing `@layer` itself, which would hand the race straight back. Only the tokens go in. Everything further down styles the layer's own components, where an override by name is meant to need the specificity it takes. `www` drops the `:root:root` it needed while the race was live — it is the proof the fix works, not a leftover.
The accessible name read "the {tab} panel of the devtools tab", which is both
redundant and inaccurate: the tab it points at is registered as `duxt`, so the
sentence describes a thing by a name it does not carry. Naming the panel is
enough — that is what the iframe holds.
`new DatabaseSync` on a missing path WRITES an empty 4 kB database. Content then found a file where it expected none, never created its schema, and every query afterwards died with `no such table: _development_cache`. Invisible on any machine that has built once, because the file is there by then. It only bites a fresh checkout — which is to say CI, where it did, and every new contributor. Skipping costs the first build its WAL and nothing else: that build has no cache to read anyway, and the second one finds the file and switches it. The test asserts on the filesystem rather than the return value, because the bug was never about what this reports — only about what it left behind.
The devtools previews listen for a `postMessage` and act on it without asking who sent it. Reported ten times by CodeQL, once per generated page — but there is one source: `server/devtools/shell.ts` writes the script into all of them, which is why the fix belongs there and not in the suggested diffs. Editing the generated files would have been undone by the next `pnpm previews`. Origin AND source, because either alone leaves a hole: the origin still admits a sibling frame on the same site, and `event.source` alone admits a parent on another origin. These pages are served from `public/`, so the page framing one is always same-origin. The height message goes back addressed rather than broadcast for the same reason — `'*'` hands it to whatever ends up framing the page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three strands, which grew out of one another.
Translations become collections of their own. A source gains a locale
dimension rather than the content path gaining a locale segment, so a page
missing in one language falls back to the nearest one that has it instead of
404ing. The sidebar is built from the default language's tree and wears the
translated titles, so a partly translated site still lists every page. Recorded
as ADR-0007.
The layer stops shipping duxt's own content. A stranger extending
@kirchdev/duxtinherited a site advertising somebody else's project: thelanding headline, six feature cards, a "Resources" dropdown of duxt's tech
stack, the site name and a
v0.0.0badge. All of it moved towww/app/app.config.ts; the layer keeps only chrome, and a test now enforcesthat. The rule was already written in
app/utils/duxt-config.ts— this appliesit to what had been missed.
SEO comes from the Nuxt SEO bundle rather than three modules wired by hand,
which also brings the two that were missing: schema.org and the utils that
derive the og and Twitter tags. Recorded as ADR-0009. The version-aware
canonical stays hand-written, because an old version has to point at the current
one.
Along the way: devtools panels rendered into the docs, a
pageIcona section canlend to pages that carry none, a palette moved into a cascade layer so a
consumer's override cannot lose a race, and documentation for theming and
branding across all five locale trees.
Type of change
Checklist
Important
The full
pnpm checkhas not been run on the final commits.lint,format,typecheckand all 1660 tests pass;build:app,check:previews,check:a11yandcheck:seowere not run, because anuxt devserver held thewww/.dataSQLite and two writers corrupt it. The last two commits — thecascade-layer fix in particular — are therefore verified by unit test and
reasoning, not by a build. CI will run the full chain.
Note
Merge with a merge commit, not a squash. Squashing collapses the 69
individual
feat:/fix:commits into this PR's own title, and release-pleasewould then cut nothing.
Related issues
None.