feat: build the documentation layer, from sources to theme - #4
Merged
Merged
Conversation
The playground carried its own .gitignore for .nuxt, .output, .data and .nitro, on the premise that gitignore-sync ships no nuxt stack. It does, and has since v0.1.0 — the stack was simply never declared here, because duxt is a layer and its nuxt.config.ts sits under playground/ rather than at the root. Detection finds it anyway (PATTERN_DEPTH looks one level down), so `sync --detect` has been proposing nuxt all along. Declare it, and the root patterns cover the playground: they are unanchored, so .nuxt, .output and .data match at any depth. The override's only remaining line, .cache, is a Nuxt 2 leftover — no such directory exists and Nuxt 3 caches under .nuxt/cache. Adding the stack also re-sorts `agents` into template order; no block content changes.
playground/ was a standalone Nuxt project that used Content directly. That was right for the spike and wrong as a base: it contained no layer, so nothing was installable. ZTL-UwU/shadcn-docs-nuxt shows the shape a published theme takes, and this adopts it. The root IS the layer now — nuxt.config.ts, content.config.ts and app/ sit here, package.json points at them with main plus a files allowlist, and nuxt is a peerDependency. www/ is the consuming site, the only workspace package, and it extends the layer by path exactly as a downstream repo extends it by name. The finding that forced the design: Content resolves a collection against the rootDir of the LAYER that declared it (collection.__rootDir = curr.cwd), not against the consumer. A relative source.cwd in this repo points into this repo. The layer therefore computes an absolute path at load time — join(process.cwd(), 'docs') — which works because c12 executes the config rather than reading it. www/docs/ renders through the layer's own collection, proving it. Also here, both forced by the split: - tsc stops at the meta scripts. It cannot typecheck a Nuxt config, whose module options exist only in generated types. `nuxt typecheck` is the tool for that, but vue-tsc 3.3.11 cannot run on TypeScript 7 (it requires typescript/lib/tsc, gone in 7), so the layer's own typecheck is unsolved rather than solved badly. - The lockfile is rebuilt: after the rename pnpm 12 kept writing symlinks to store paths it never materialised.
TypeScript 7 is the native port. Its exports map no longer exposes the compiler internals Volar builds on — lib/tsc.js is still in the package, just unreachable through require.resolve — so vue-tsc cannot run on it, and vue-tsc is the only thing that can typecheck a .vue file or a Nuxt config. 3.3.11 is the current release and declares typescript >=5.0.0, which is a promise it does not keep. Given the choice between a version number and a typecheck, take the typecheck: the whole repo moves to 6, rather than carrying two TypeScript versions to hide the conflict. Move to 7 when Volar does. That makes the layer checkable, so `check` now chains typecheck:app (nuxt typecheck, run through www/) beside the tsc pass over the meta scripts. tsc keeps the scripts; it cannot see module options that exist only in generated types.
Vue declares typescript as an optional peer with range `*`. With autoInstallPeers, any package that does not pin it can pull in TypeScript 7 — which vue-tsc cannot run on, taking down `pnpm typecheck:app` even though the repo's own devDependency says 6. An override closes that door for transitive packages too. gildstone carries the same one for the same reason, retested there against 7.0.2.
The layer computed the docs folder from process.cwd(), which is the directory the site runs in. Those are the same directory only when the Nuxt app sits at the repository root — the case for a JavaScript project, and not the case when the site lives in a subfolder while docs/ stays at the root where a reader on GitHub expects it. This repo is the second kind, so its own docs move up to docs/ and the resolution walks up to the nearest .git instead, falling back to the working directory when there is no repository. Provisional: the `sources` shorthand should make this explicit rather than inferred.
The two arrangements a consumer can pick — Markdown only, with the site in another repository, or a site beside docs/ in the same one — and the rule that follows from both: the layer resolves docs/ against the repository, never against the site.
The theme decision, made concrete: shadcn-vue through shadcn-nuxt on a clean Tailwind 4 base, not Docus or Nuxt UI. Components are copied into app/components/ui rather than imported, so a consumer overriding one is editing a file, not fighting a prop. The palette is the neutral shadcn set as CSS variables in duxt.css, with @nuxtjs/color-mode toggling the `dark` class shadcn expects. A consumer redefines a token in its own stylesheet; nothing needs forking. Two traps a layer has to work around, both the same shape as the docs cwd: - A relative path in the layer's nuxt.config resolves against the CONSUMER, so the css entry and componentDir go through a layer() helper that resolves against import.meta.url. - '@' belongs to whoever extends the layer. Imports inside the layer use a layer-owned '@duxt' alias instead, and components.json points the shadcn CLI at the same one so generated files get it right. tsconfig gains the matching path for the CLI's resolution step. baseUrl is deliberately absent — TypeScript 6 deprecates it.
The layout a docs site needs before anything else: a sticky header with the theme toggle, a sidebar built from queryCollectionNavigation, the page itself, and a table of contents from the page's own toc links. The catch-all page now 404s on a missing path instead of rendering an empty article, and sets title and description from frontmatter. Navigation renders one level of nesting. Deeper trees are a sign the docs need splitting rather than the navigation needing more levels — revisit if a real project disagrees.
Content already ships MDC, so a page calls a Vue component with
`::callout{type="tip"}` and needs no MDX and no extra module. What was
missing is components worth calling: a callout in four intents and a
stepped list.
The docs page documents the syntax by using it, so a broken MDC setup
breaks a visible page rather than passing silently.
Three separate paths in this repo resolved against the consumer instead of the layer before being fixed. Write the rule down once, so the fourth is expected rather than debugged.
Every route returned 500 with "Cannot read properties of null (reading 'ce')" — but only in a production build, never in dev, which is what made it look like a component bug rather than a resolution one. reka-ui is a dependency of the LAYER, so Nitro externalises it for SSR and the server ends up importing a different copy than the client bundle. Its provide/inject stops matching across the two and the first component that reads its context renders against a null instance. Dev never hits it because the module graph is shared there. Inlining reka-ui for SSR keeps one copy on both sides. dedupe alone is not enough — the copies are physically the same file, they are just reached through two module graphs. gildstone carries a dedupe for the neighbouring version of this bug. Also switches @nuxt/icon to a scanned client bundle, so icons ship with the page instead of being fetched per collection after hydration.
alert, badge, breadcrumb, card, dropdown-menu, navigation-menu, sheet, sidebar, tabs, tooltip and friends, added through the shadcn CLI rather than hand-written, so they stay the upstream files a consumer already knows how to override.
Everything a consumer would otherwise fork a component to change — title, navbar links (with dropdowns), icon links, the landing hero and its feature cards, the footer — is data in the layer's app.config.ts. Nuxt merges a consumer's own on top, so overriding one key does not mean copying the rest. The config carries a real interface rather than being inferred from the layer's literal. Without it, every optional key the layer happens not to use becomes a type error in a consumer that does use it.
The landing page is its own route now, full width, no sidebar — and its own file, so a consumer replaces it by dropping an index.vue rather than by configuring around it. It was a Markdown page picking a layout at runtime, which cost an SSR crash: setPageLayout() after an await runs without a component context. The docs view keeps the two sidebars: navigation left, table of contents right. The navigation is built on shadcn's Sidebar primitives inside their provider, so restyling SidebarMenuButton restyles the docs tree with it. The spike page moves off "/" to /spike, where it stops shadowing the landing page.
The blocks a docs page actually needs, each built on a shadcn component so a consumer restyles them the same way it restyles everything else: - Code fences render through an overridden ProsePre with a filename bar and a copy button; the highlighter is untouched. - ::package-managers writes one command and renders it for pnpm, npm, yarn and bun in a Tabs group, npm's install/add difference included. - ::callout is shadcn's Alert in four intents. - ::file-tree renders a nested Markdown list, or a tree prop, as folders and files.
Every heading rendered at near-identical weight with a rule under it, which reads as one flat list rather than a hierarchy. Headings now carry the hierarchy through size and spacing, links are underlined in a muted tone until hovered, and tables and code get borders instead of colour. Each heading gets a # anchor in the margin, visible on hover, so a section can be linked without hunting for its id.
The install steps use the package-manager tabs, the project layout uses the file tree, and the callouts are callouts — so a broken component breaks a visible page instead of passing silently. docs/index.md becomes getting-started.md: its path was "/", which the landing page now owns.
Two things the first pass got wrong, both visible on the page: - The callout's icon sat on top of its title. shadcn's Alert places the icon in its own grid column with a `>svg` selector, and @nuxt/icon renders a span by default — so the icon landed in the text column. svg mode fixes it for Alert, Button and Sidebar alike. - The package managers were a tab strip floating above a separate code card, two boxes for one command. Now it is one box: tabs with each manager's own icon in the code block's header, copy button on the right, command below. npx/yarn dlx/bunx are handled alongside the install/add difference.
The sidebar was pinned to the window edge with a full-height border, which pushes the docs apart on a wide screen and reads as an app shell rather than a document. All three columns now sit inside one centred container. The navbar splits in two rows: identity and global links above, the documentation's own sections below as tabs. The sidebar then shows one section instead of the whole tree, and a page carries its section name above the title. The tree itself loses its boxes — group headings with icons, plain links, and weight only on the active item. The table of contents gains the fixed link block nuxt.com carries beside it, configured under `aside`. sections, version and aside join the typed app config.
Two sections, five pages: getting started (introduction, installation, configuration) and structure (where docs live, components in Markdown). The tree exists so the layout has something to lay out — a sidebar with one page in it proves nothing, and the section tabs, the group headings and the table of contents all need depth before they can be judged.
A component block's YAML props were being rewritten into a plain list and its closing `::` indented, which turns the component back into literal text — the stray `::` visible on the rendered page, and the reason the file tree never received its data. docs/ is MDC, not plain Markdown. It is excluded in .oxfmtrc.json and in lint-staged, and the components page says so, since the next person will hit it too.
Content ships no highlighter until a theme is named, so every fence rendered as flat text. Two themes, github-light and github-dark, chosen by the site's own class rather than a media query. Shiki emits both colours as custom properties per token and leaves the choice to CSS, so the rules that read them ship alongside — without those the page looks exactly as unhighlighted as before.
The callout's icon kept landing on top of its title. shadcn's Alert puts it in a grid column through a `has-[>svg]` selector, which depends on the icon being a direct svg child and collapsed to a zero-width column twice. The icon column is declared in the component now; Alert stays the base for everything else. The package-manager tabs carry each manager's brand colour, so the active one is obvious at a glance instead of being four grey logos.
One lookup, used by both: a code block's header shows the icon for its filename or language, and the file tree shows one per entry — a nuxt.config.ts looks like Nuxt, a package.json like npm, a folder like a folder. vscode-icons has an icon per ecosystem where lucide has one generic file. The tree takes its structure as a prop. Parsing it back out of the rendered Markdown list is attempted first and kept as a fallback, but a declared tree is what the docs use, because it survives a formatter.
The sidebar showed the whole tree regardless of which section was open, so Structure's pages sat under Get started. It now shows the branch the current section owns, and the section row moved out of the global header into the docs layout — the landing page has no sections to show and was displaying an empty tab strip.
Two of my own mistakes stacked, and together they looked exactly like Shiki being switched off: - ProsePre passed the raw `code` prop to the block, which rendered it instead of the highlighted slot. The tokens existed and were thrown away. - The shiki classes then fell through to the wrapper div, while Content styles its tokens with `html pre.shiki code .<token>` — so even once the tokens rendered, every one of them kept its class and lost its colour. The classes bind to the <pre> now, and the block renders the slot whenever there is one. Content injects the colour rules itself, so the ones I had written are gone; only the transparent background stays, because the block draws its own surface.
One value per manager does not survive both themes: bun's cream is invisible on white and npm's red goes muddy on black. Each manager carries a light and a dark colour, set as custom properties on the icon, and a class picks the one the theme needs.
Sidebar entries, section tabs, table-of-contents links, package-manager tabs and the landing cards all looked static: the only feedback was a colour shift on the label, which is easy to miss and tells you nothing about the size of the target. Each now takes a surface on hover, and the active sidebar entry gets a tinted background rather than colour alone.
The section tabs marked the active one with an underline and grew a second border on hover, so pointing at one looked like selecting it. The active tab takes a filled pill now and hover is a quieter version of the same shape — one visual language, two states. The package-manager tabs used bg-background/60, which is invisible against the header's own muted surface. They take the accent, like every other hoverable thing in the theme.
A tinted panel with a coloured border and a coloured title shouted for attention on every page that used one, and four of them in a row read as an error log. The colour now sits on the icon and a thin left rule, the surface stays close to the page, and the title reads as text.
The component carries no backend by design, and the reference said so without saying what to do about it. The guide wraps it, sends the answer somewhere, and reads the path it was given on.
The preview block was a tab strip floating above a box; it is now one frame with the switch in its own header, the way a code block carries its filename — and the fence inside the source tab loses the second border it brought with it. The landing frame's address bar keeps a fixed width with the status icon leftmost, where a browser puts its padlock: sized to its text it grew and shrank on every navigation inside the frame, which reads as the chrome jittering rather than as a URL changing. A spinner there reports the first load; a route change inside the frame draws its own progress bar.
Four reference pages carried eighty entries between them, and a reader after one component had to scroll past twenty. Each is now a folder whose pages are built the same way: what the thing is, an example, then props, slots and events — the shape an API reference is expected to have. The folders group what was already grouped in prose: chrome, navigation and page components; config, paths and reading composables; the prose overrides beside the blocks that are called by name.
The example sat in a tinted, bordered box, and every block that already carries a card — a callout, a file tree, a code group — was drawn inside a second one. The frame is gone: what the reader sees under the tabs is exactly what the same Markdown renders in a page, which is the only claim this block makes.
A header of pills and then the panel — the same shape as the package-manager block and the code block, so three blocks that all switch between things stop switching in three different shapes. The source tab carries the language's own icon and name, and the fence's own header comes off underneath it: it said 'mdc' directly below a tab already saying 'mdc'. Its copy button moves up into the header, where the other cards keep theirs. Their headers now share a minimum height, so a file tree and a code block stacked on a page no longer sit at two different heights.
Only the header is tinted now. An example is supposed to look like it looks in a page, and a card background under it is a colour the same block never sits on anywhere else.
The example lost its background because it should look like it looks in a page; the fence keeps its own for the same reason — code is read against that surface everywhere else on the site.
Switching tabs should change what is in the box, not what the box is made of.
The icon scanner read code only, so an icon named in a page's frontmatter — which is where a documentation tree names most of its icons — was missing from the bundle, absent from the server-rendered HTML, and arrived over the network after hydration if at all. Half the new reference pages showed no icon at all.
A column listing the page the reader is already on navigates nothing.
The second repository and its four refs were there to exercise every branch of the resolver. They also meant every page of this site carried a version segment it no longer needs.
Chrome is UI jargon that collides twice over in this repo: with the browser of the same name, and with the shell that shell-highlight.ts and every bash fence mean. The section, its links and the prose around them now say layout, interface or frame — whichever of the three the sentence actually meant. The ADR that uses the word is left alone: an accepted record is append-only, and a wording change is still a change.
One source means no repository segment, so every link this file wrote under /duxt/ pointed at a page that no longer exists there. The slug goes with them: it named a prefix the resolver no longer emits.
An unquoted value with a colon ends the YAML mapping there: the keys before it survive, the keys after it are dropped. Six pages named a description like "The navbar: title, search" and lost the icon on the line below it, which looked like a broken icon bundle and was not.
Search, the version and locale switchers, the shortcut sheet and the two indicators were filed under the layout, which they are not: a reader operates them, and the page would stand without any of them. They are controls now. The section row moved the other way, into navigation — it names the parts of the documentation tree, which is navigating rather than framing.
A repository name reaches `git ls-remote` as an argument, so a value shaped like `--upload-pack=…` is read as an option and that option runs a command. `--end-of-options` marks the URL as a URL, and `repoUrl` refuses a leading dash before it gets that far — one guard at the parser, one at the source. The slug trim was `/^[-.]+|[-.]+$/`, an anchored `+` over a character class: it backtracks, so a name that is nothing but separators costs time quadratic in its length. Scanning from each end costs its length, once.
The dash check lived in `repoUrl`, one call away from the command, and CodeQL reads the argument as reachable regardless. An anchored allowlist at the call site says what a remote URL may look like instead, and every value that is not one is rejected before `git` sees it.
This was referenced Sep 10, 2026
TitusKirch
added a commit
that referenced
this pull request
Sep 11, 2026
Deploy #4 rendered 1,053 OG images in a 158-second prerender pass, almost all of them redrawing an image no commit had touched. nuxt-og-image can keep them and does nothing with that until a directory is named and that directory outlives the runner. `og-image-cache.ts` supplies the two halves the module's own key leaves out. The DIRECTORY is stamped with a digest of every rendering input that key cannot see — the fonts, the renderer options, the versions of satori and resvg themselves — and is emptied when one moves, so a font change can never serve a thousand images of the previous design. The CI KEY is namespaced by the renderer versions alone, because a command cannot read a site's renderer options without loading a `nuxt.config.ts` that would claim the Nuxt process beside it; being generous there costs one download of a directory the build then empties, where being wrong about the stamp would cost a thousand wrong images. The render budget is deliberately not an input: a timeout cannot change a pixel, and it is the lever the prerender-concurrency work has to be free to move without discarding everything rendered so far. Measured on three local Workers builds of one commit. Cold: 1,132 routes prerendered in 190s, 237 images rendered. Warm: the same 1,132 routes in 117s with nothing rendered at all — a 38% cut to the prerender phase, 281 images in the output both times, zero timeouts in either. A third build with one colour changed in the template re-rendered all 237, which is the invalidation that matters most and the one nothing else would have caught. It rests on what #45 measured from the other side: the 281 images come out byte-identical between builds, so satori renders reproducibly. `duxt-og-cache --report` counts what a build reused, rendered and timed out on, writes it into the build job's summary and raises a warning annotation when a render ran out of time. That count is a measurement, not a fix: a warm build times out less because it renders less, and the first cold build after any invalidation is exactly as exposed as before. Refs #43
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
Turns the repository into the layer it describes.
nuxt.config.ts,content.config.tsandapp/sit at the root,www/is the consuming sitebeside them, and
extends: ['@kirchdev/duxt']resolves againstpackage.json.What the layer now ships, in the order a reader meets it: a landing page built
from config alone — hero with a copyable install command, a live window
embedding a page of the site itself, and a feature grid whose cards link to the
pages that explain them; the documentation layout with its navbar, section row,
sidebar, table of contents and footer; the controls beside them — search across
every source, the version and locale switchers, the shortcut sheet; and the
page's own components, from provenance to "was this page helpful?".
Underneath: one
sourceslist generating a collection per repository and ref,git-native sourcing from Content v3 rather than rebuilt, URL prefixes and
redirects decided at build time, seven locales,
llms.txtand an MCP route overthe same collections, and a devtools panel for the parts that were hardest to
debug.
The documentation is the layer's own: every MDC block, component, composable and
endpoint has a page of its own, built the same way — what it is, an example, then
props, slots and events.
Type of change
Checklist
pnpm checkpasses in full: lint, format, both typechecks, 256 tests, policyparity, the
wwwbuild with its broken-link check, and axe-core over fiverendered pages.
Related issues