Content
Checks
Structural verification that fails the build, not the visitor. The link audit resolves every in-prose link against the site's own address space; a broken one is a named error at prerender and on page open in dev.
A broken internal link is a bug you want to hear about at build time, from you. Not at runtime, from a visitor. Site checks run over the whole corpus as data, surface as named findings, and can fail the build. The link audit ships built in; you can add your own.
When to use
Turn on links() for any site with cross-references, which is every docs site. It is the safety net that lets you rename and reorganize fearlessly: move a page and every stale link to it fails loudly.
The link audit
Pass checks to site(). links() resolves every markdown link in every body against the site’s own address space:
export const docs = site({ outline: docs, checks: [links()] });A link to a page that does not exist, or an #anchor that no heading emits, is a finding. This site runs it: the cross-links you are clicking through this section were verified by exactly this check. When one of these pages linked to a sibling that had not been written yet, the build refused until it existed.
Where it fires:
- At prerender: a broken link fails the build with the page, the link text, and the target.
- In dev: the same finding surfaces when you open the page, so you catch it as you write.
Because the audit knows the whole address space (including dimension coordinates and declared redirects), it distinguishes a genuinely dead link from one that resolves through an alias.
Findings as data
docs.check() runs every check over the corpus and returns the findings as plain data, never throwing, so you can assert on them in a test or a CI step, independent of a prerender crawl:
// a vitest guard: the docs have no broken links
import { docs } from '$lib/site.server';
test('no broken links', async () => {
const findings = await docs.check({ base: '/docs' });
expect(findings).toEqual([]);
});This matters for a dynamic site where the prerender crawler never runs: the check is your only structural gate, and it is available as data.
Custom checks
A check is a named object with a page hook, a site hook, or both, each returning findings. page runs per slug (in load, so dev surfaces it on open; an error-severity finding throws); site runs once over the corpus via docs.check():
// a house rule: every page fills its summary
import type { Check, Finding } from 'ogygia/content';
export function requireSummary(): Check {
return {
name: 'require-summary',
async page(slug, { outline, ctx }): Promise<Finding[]> {
const resolved = await outline.resolve(slug, ctx);
if (!resolved) return [];
const entry = await resolved.collection.get(resolved.record.entryId, ctx);
if (!entry || typeof entry.data.summary === 'string') return [];
return [{
check: 'require-summary',
severity: 'error',
slug,
message: `'${slug}' has no summary — the docs index and llms.txt read it.`
}];
}
};
}import { links } from 'ogygia/content';
import { requireSummary } from '$lib/checks';
export const docs = site({ outline: docs, checks: [links(), requireSummary()] });Severity is the dial: 'error' throws from load (fails the build at prerender, blocks the page in dev); 'warn' surfaces in docs.check() findings without ever blocking a render.
Checks vs. structural build errors
Two different layers, deliberately. Structural invariants (an orphan entry, a slug collision, an inconsistent NN- sequence) are always-on outline build errors; they cannot be configured off because a site with them is not well-formed. Checks are content policy: pluggable, per-site, severity-dialed. links() is policy: a deliberately-dead link during a migration is your call to allow, tolerate as warn, or forbid.
Do / don’t
- Do run
links()from day one. The cost is zero and it turns every reorganization into a safe operation. - Do assert
docs.check()in CI for a dynamic site. It is the structural gate the prerender crawler would otherwise be. - Don’t treat a finding as noise. A broken link or a missing anchor is a real defect the reader would hit; fix the link or the target.
- Don’t wait for production to discover a dead cross-reference. The whole point is that the build, and your dev server, tell you first.
Next chapter: the router makes navigation feel like an app: one request, no reload, no waterfall.