Skip to content

feat: build the documentation layer, from sources to theme - #4

Merged
TitusKirch merged 158 commits into
devfrom
fix/declare-nuxt-stack
Sep 6, 2026
Merged

TitusKirch merged 158 commits into
devfrom
fix/declare-nuxt-stack

Conversation

@TitusKirch

Copy link
Copy Markdown
Member

Summary

Turns the repository into the layer it describes. nuxt.config.ts,
content.config.ts and app/ sit at the root, www/ is the consuming site
beside them, and extends: ['@kirchdev/duxt'] resolves against package.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 sources list 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.txt and an MCP route over
the 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

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Internal / chore

Checklist

  • The project's checks pass locally (lint / format / tests, as applicable)
  • Tests added or updated — or not applicable
  • Docs updated (README / inline help / comments) — or not applicable
  • Commits follow Conventional Commits

pnpm check passes in full: lint, format, both typechecks, 256 tests, policy
parity, the www build with its broken-link check, and axe-core over five
rendered pages.

Related issues

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.
Comment thread sources-git.ts Fixed
Comment thread sources-resolve.ts Fixed
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.
Comment thread sources-git.ts Fixed
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.
@TitusKirch
TitusKirch merged commit 83ade55 into dev Sep 6, 2026
6 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants