What the build checks
The silent failures this layer had, turned into messages.
Every bug this layer actually had was quiet: a collection Content dropped because its name was not a JavaScript identifier, a page that 404ed because a link had gone stale, a navigation that came back empty. None of the three said anything — the build was green and the site was wrong. So the checks run after the build, all reading the same thing: the parse cache Content leaves behind.
| Check | Severity |
|---|---|
| A collection with no pages in it | error |
| A folder colliding with a URL segment | error |
A missing title or description | warning |
| A broken internal link or anchor | warning |
| What a generated section could not read | warning |
| What each language carries, and what has stood still | note |
Why severity is not uniform
An error costs a page that nobody can reach and nothing on the site would ever explain — an empty collection is a 404 on every page of one version. A warning costs quality: the page still renders, and a remote source can go stale between one release and the next without that being this build's fault. Failing the build on somebody else's repository having moved on would make a green build depend on a third party.
A note costs nothing at all: it is a state of the site rather than a defect in it. An untranslated page is not a mistake, and reporting it as a warning would train everyone to read past the warnings.
Generated sections
A generated section is checked like any other collection, in its own words. What reaches this report is one of three things:
- the artefact was not there at that ref, so the section was not built;
- the type read nothing out of it, so the section claims URLs and serves none;
- the type carried on past something — an unresolvable
$refin an OpenAPI document, a changelog heading that reads like a release but does not parse as one and therefore became part of the release above it.
All three are warnings, and that is not a compromise. Everything a local artefact can get wrong — a path that is not there, a file the type cannot read at all, an option it does not know — already fails the build while the config is loading, long before this report exists: the site's own configuration is a mistake the build must not carry. What is left here is either a remote artefact, which may legitimately have gone stale between releases, or a page that rendered with something missing from it.
An artefact is parsed while the config is loading, so a warning about a dropped
$ref was printed before the dev server had finished starting and had scrolled
away by the time anyone looked — while every other finding this layer produces
waited in one report. They come through the report now, and through the
Checks panel with it.
The translation report
One line per language, in every build:
[duxt] translations
[duxt] de: 118/121 pages, 3 behind the original
[duxt] "docs/guides/deploying.md" has no de translation.
[duxt] "docs/de/concepts/sources.md" has not moved since the original changed.
The second figure needs history: true on the source — the dates come from git,
and a source that has not asked for its history has none. Without it the report
counts coverage and says nothing about staleness, rather than guessing from the
absence.
It exists because of a specific failure. OpenCode translated its documentation into seventeen languages with an agent in CI, switched the workflow off, and nothing anywhere said the translations had stopped moving. The layer already knows, per page, which languages carry it and when each file last changed; a report is what turns that into something anybody sees.
The same report, as a command
The findings live in two places that are hard to hand to anybody: a build log
that has scrolled away, and devtools panels that exist only inside a running dev
server. duxt-report is the third — the same data as Markdown on stdout. It
ships with the package, so extending the layer is all it takes to have it.
pnpm exec duxt-report # Markdown
pnpm exec duxt-report --json # the same data, unrendered
It reads the site's app.config.ts, the artefacts beside it and the pages out
of Content's parse cache — no server, so it runs in CI and in a pipe. It
exits 1 on an error and 0 on warnings, which is the same severity rule the
build follows.
Three panels have no equivalent here, and deliberately: the search index, the path debugger and the cache listing are questions about a running process, and answering them from the outside would be guessing.
The pages come from Content's cache, so a page written since the last build or dev run is not in it. Run either one first — the report says so plainly when it finds no cache at all, rather than reporting every collection as empty.
What the checks know that you do not
- A folder collision is only a problem while a prefix is active. A docs
folder called
v2is fine on an unversioned site and unreachable on a versioned one, because the prefix wins. The build knows which, and the message names the file and offers the fix. - A link is resolved the way the theme resolves it. An absolute path written in a page is relative to that page's own source, so it is tried under the source's prefix first and bare second. A checker doing only the bare form would report every correct link on a multi-repo site.
- A link is resolved in its own language. Every language of one source serves identical content paths — the locale sits in front of the URL, not in the content tree — and anchors are derived from heading text, so a German heading has a German anchor. A checker resolving by path alone would check a German link against English headings and report every correct anchor on a translated site. A link to a page a language does not carry falls back to the original, which is what the site does too.
- External URLs are not checked. Verifying one would put the network in the build, and an unreachable third-party host is not a reason your documentation fails to ship.
Write links as absolute documentation paths
/concepts/sources, not ../2.concepts/2.sources.md. The prefix a page is
served under is decided at build time, so writing paths without one is what lets
the same Markdown serve /guides/deploying on one site and
/acme/v2.0.0/guides/deploying on another. It is also the only form the link
checker sees.