Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ prints advisory observations (which never fail the build) when a project's decla
automatic: the prerelease channel is withheld once a stable release (GitHub release or NuGet
version) catches up with the prerelease line, and returns with the next prerelease — never
delete the declaration to stop publishing it.
- A page's slug is its path from the docs root (ADR 0011): a topic folder stays a path segment
(`schemas/Custom-Rules.md` is published at `/docs/<id>/schemas/custom-rules/`), a flat corpus
keeps the single-segment slug it always had, and every relative link resolves against the page
that contains it. A project whose docs are reorganised records the old→new slug in
`docs.redirects`, which the build turns into redirects; a redirect never shadows a real page.
- Agent Skills a project publishes are declared in `skills:` (ADR 0010) at
`skills/<name>/SKILL.md`; the sync mirrors the whole directory to
`/skills/<id>/<name>/` and each project page renders the same install block
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,15 @@ docs (in-repo `docs/`, GitHub wikis via a shallow clone, or the README), then:
the conventional `index.md`/`Home.md`/`readme.md`, otherwise the repository
README when `readmeAsIndex: true`,
- converts GitHub alert blockquotes to Starlight asides,
- rewrites relative links and images so they resolve inside the portal,
- honours `_Sidebar.md` ordering when the source provides it.
- rewrites relative links and images so they resolve inside the portal, resolving
each link against the page that contains it (which is what GitHub does),
- honours `_Sidebar.md` ordering when the source provides it,
- derives each page's slug from its path relative to the docs root (ADR 0011), so
a topic folder stays a path segment — `schemas/Custom-Rules.md` is published at
`/docs/<project>/schemas/custom-rules/` — while a flat corpus keeps the
single-segment slug it always had. Each segment is reduced to lower-case
letters, digits and dashes, so any folder name publishes a URL that needs no
percent-encoding (`API v2 (beta)/` → `api-v2-beta/`).

### Documentation landing pages

Expand Down Expand Up @@ -299,6 +306,25 @@ Each documentation page shows its owning project, lifecycle status, staleness
(last review older than 365 days, configurable in
`src/lib/docs/staleness.ts`), and an edit link to the source repository.

### Moving a documentation page

A page's folder is part of its URL, so reorganising a docs tree changes published
URLs. Declare the move once in the project catalogue and the build writes a
meta-refresh redirect from the old slug to the new one:

```yaml
docs:
redirects:
custom-rules: schemas/custom-rules
```

Redirects never overwrite a real page, and a pair is skipped while the channel has
not published the new address — so while a `live` channel is pinned to a tag from
before the move, nothing happens; at promotion each redirect appears (ADR 0011).
`docsPage` deep links are the exception: the catalogue keeps them a single path
segment, so a page the catalogue deep-links either stays at the docs root or moves
together with its deep link.

### Live and prerelease documentation

A project whose default branch is ahead of its latest release can publish two
Expand Down
113 changes: 113 additions & 0 deletions docs/decisions/0011-documentation-folders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 0011 — Documentation folders keep their place in the URL

- **Status:** Accepted
- **Date:** 2026-03-11
- **Supersedes:** none

## Context

Documentation used to be one flat directory per project, so a page's slug was its
file name. That works for a dozen pages and fails at thirty: a project cannot
group its pages by topic without the grouping existing only in the sidebar, and a
reader cannot tell from an address that `custom-rules` is part of validation
rather than a top-level concern.

Letting a project nest its pages exposed two assumptions that were invisible while
every corpus was flat:

1. **Slugs ignored the folder.** `slugifyDocFile` folded the whole path into one
dash-joined slug, so `schemas/Custom-Rules.md` would publish at
`/docs/zodsharp/schemas-custom-rules/` — the URL says the tree has a folder and
then hides it.
2. **Links resolved from the docs root.** `rewriteDocMarkdown` matched a link by
its *file name* against a set of root-relative slugs. A page in `schemas/`
linking `../concepts/Core-Concepts.md` matched nothing, so the link degraded
into a GitHub blob URL computed from the root — pointing at a path that does not
exist.

## Decision

### 1. A page's folder is a path segment of its slug

`docSlug(path)` reduces each segment to lower-case ASCII letters, digits and inner
dashes and joins them with `/`, so `schemas/Custom-Rules.md` publishes at
`/docs/<id>/schemas/custom-rules/` and the mirror writes
`docs/<id>/schemas/custom-rules.md`. A folder is therefore supported without
restriction — `Topic Folder`, `Q&A` and `v2.guides` become `topic-folder`, `q-a`
and `v2-guides` — and only the file's own `.md`/`.mdx` extension is dropped.
`isUrlSafeDocSlug` is the contract every published slug must satisfy: non-empty
segments, no leading or trailing dash, nothing that would need percent-encoding. A
folder or file name that reduces to nothing (`___/Page.md`) fails the sync with a
`DocsValidationError` naming the source path, rather than publishing a page at an
address nobody can write down.

A flat corpus is the single-segment case of the same rule, which is why **no
existing URL moves**: `Getting-Started.md` is still `getting-started`, and a
project that never nests needs no changes.

### 2. Links resolve against the page that contains them

`DocLinkContext.pagePath` carries the page's doc-root-relative path, and a link is
resolved from that page's directory — the way GitHub and a browser resolve it.
A bare file name still resolves by name, so a flat corpus and a link written before
the folders keep working. Repository-path fallbacks resolve from the page's
*directory in the repository*, so `../../src/Foo` from a nested page still lands on
the right GitHub blob.

`<doclink:…>` placeholders now accept `/`, and `resolveDocRelativeLink` counts the
hops between two pages with `path.posix.relative` instead of assuming one `..`, so
`schemas/custom-rules` reaches `serialization/jsonschema-export` at `../../`.

### 3. A move is declared, and a redirect needs both ends

A page's folder is part of its URL, so reorganising a tree changes published URLs.
`docs.redirects` maps a former slug to its current one and the build writes a
meta-refresh redirect for each pair — but only when the pair is real in the build
at hand. `writeStaticRedirect` refuses to overwrite an existing file, so a redirect
never shadows a page that owns its URL; and `writeDocsPageRedirects` skips a pair
whose destination the channel has not published yet.

That second check is what makes a declaration timing-safe across a promotion. A
project that nests its pages changes every slug at once, while the `live` channel
keeps reading the last release tag until the next release is promoted: the old
slug is still a real page, so nothing is written. For a page that only exists in
the newer tree, the new address is unpublished too, so forwarding the reader would
be forwarding into a 404 — the post-build link crawl fails the build on exactly
that, which is how this was found. Both halves activate per page, at promotion.

### 4. `docsPage` stays a single segment

The catalogue's deep-link slug keeps its `^[a-z0-9-]+$` shape and resolves against
the default channel. A deep link must therefore keep working across two trees that
can differ (a tag and a branch), so a page the catalogue deep-links either stays at
the docs root or moves together with its deep link. Widening the schema would only
move the failure to the moment a channel's tree diverges.

## Consequences

- The published URL mirrors the source tree, so a page's address says where it
lives; moving a page between folders is a URL change and needs a `docs.redirects`
entry.
- Sibling folders are reached with `../../`; the previous assumption of a single
`..` is gone, and the recursion through `starlight-links-validator` is what
catches a mistake in the built site.
- Documentation written for a flat corpus is unaffected in both slug and link
terms, so this landed without touching another project's catalogue.
- ZodSharp's own move is the first user of `docs.redirects`: 30 entries that stay
inert until the release that carries the folders is promoted to `live`.

## Alternatives considered

- **Fold the folder into the slug** (`schemas-custom-rules`). One-line change, but
the URL then advertises a hierarchy it does not express, and `/` is what a
reader expects to see. Rejected.
- **Keep resolving links from the docs root.** Requires every cross-folder link to
be written root-relative, which breaks on GitHub and in any editor that resolves
relative paths. Rejected in favour of resolving from the page.
- **Redirect every nested page from its flattened slug automatically.** Would have
avoided the `docs.redirects` map, but it also invents URLs for pages that never
had them (`/docs/<id>/setup/` for a page that was always `guides/setup`) and
cannot express a rename. Rejected in favour of an explicit map.
- **Allow `/` in `docsPage`.** Rejected: see decision 4 — a deep link that exists
in one channel's tree and not the other fails the build either way, and the
schema is the cheapest place to make that impossible.
29 changes: 18 additions & 11 deletions src/config/docs-redirects-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ import type { AstroIntegration } from 'astro';
import { fileURLToPath } from 'node:url';

import { readDocsManifest } from '../src/lib/docs/aggregate';
import { writeRetiredChannelRedirects, writeRetiredPageRedirects } from '../src/lib/docs/redirects';
import {
writeDocsPageRedirects,
writeRetiredChannelRedirects,
writeRetiredPageRedirects,
} from '../src/lib/docs/redirects';
import { loadProjects } from '../src/lib/manifest/load';

/**
* Redirects for retired documentation channels and moved pages (ADR 0009,
* ADR 0010).
* Redirects for retired documentation channels, moved documentation pages and the
* site's own moved pages (ADR 0009, ADR 0010).
*
* A project's `prerelease` channel is withheld automatically once a stable
* release catches up with it, and `/skills/` moved to the project pages, so
* neither set of URLs may 404. purview.dev is served from GitHub Pages, which
* only serves files, so each retired URL is written as a meta-refresh page
* pointing at its new home.
* release catches up with it, a project may declare the slugs a reorganisation
* moved (`docs.redirects`), and `/skills/` moved to the project pages, so none of
* those URLs may 404. purview.dev is served from GitHub Pages, which only serves
* files, so each retired URL is written as a meta-refresh page pointing at its new
* home.
*
* Runs at `astro:build:done` beside the discovery integration. It deliberately
* writes outside Astro's route list, so the redirects never enter the sitemap,
Expand All @@ -28,11 +33,13 @@ export default function docsRedirectsIntegration(): AstroIntegration {
hooks: {
'astro:build:done': ({ dir, logger }) => {
const dist = fileURLToPath(dir);
const retired = writeRetiredChannelRedirects(dist, loadProjects(), readDocsManifest());
const moved = writeRetiredPageRedirects(dist);
if (retired.length + moved.length > 0) {
const projects = loadProjects();
const retired = writeRetiredChannelRedirects(dist, projects, readDocsManifest());
const moved = writeDocsPageRedirects(dist, projects);
const relocated = writeRetiredPageRedirects(dist);
if (retired.length + moved.length + relocated.length > 0) {
logger.info(
`Redirects: ${retired.length} retired documentation URL(s) and ${moved.length} moved page(s) now point at their new home.`,
`Redirects: ${retired.length} retired documentation URL(s), ${moved.length} moved documentation page(s) and ${relocated.length} moved site page(s) now point at their new home.`,
);
}
},
Expand Down
35 changes: 35 additions & 0 deletions src/src/data/projects/zodsharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,41 @@ docs:
ref: latest-stable
prerelease:
ref: default
# The documentation was reorganised into topic folders, which makes each page's folder part of its published URL.
# Every page that moved keeps its old flat URL working: the site writes a meta-refresh redirect for each pair once
# the default channel publishes that page's new address (and only while the old slug is no longer a page), so these
# entries stay dormant while `live` still reads a tag that predates the move and activate per page at promotion.
redirects:
core-concepts: concepts/core-concepts
composition-and-transforms: concepts/composition-and-transforms
string-validation: schemas/string-validation
number-validation: schemas/number-validation
object-validation: schemas/object-validation
arrays-and-other-schemas: schemas/arrays-and-other-schemas
unions-and-discriminated-unions: schemas/unions-and-discriminated-unions
validation-rules-reference: schemas/validation-rules-reference
custom-rules: schemas/custom-rules
value-objects-integration: schemas/value-objects-integration
compiled-validators-and-caching: schemas/compiled-validators-and-caching
source-generator: generator/source-generator
source-generator-dataannotations: generator/source-generator-dataannotations
source-generator-diagnostics: generator/source-generator-diagnostics
jsonschema-export: serialization/jsonschema-export
jsonschema-import: serialization/jsonschema-import
systemtextjson-integration: serialization/systemtextjson-integration
newtonsoftjson-integration: serialization/newtonsoftjson-integration
microsoft-extensions-validation: hosting/microsoft-extensions-validation
openapi-integration: hosting/openapi-integration
blazor-integration: hosting/blazor-integration
http-integration: hosting/http-integration
dependency-injection: hosting/dependency-injection
localization: hosting/localization
guarantees-and-limitations: quality/guarantees-and-limitations
performance: quality/performance
trimming-and-aot: quality/trimming-and-aot
cross-platform-interop: quality/cross-platform-interop
contributing: development/contributing
release-flow: development/release-flow
targetFrameworks:
- net8.0
- net9.0
Expand Down
Loading
Loading