Skip to content

feat(docs): publish a page's topic folder as a URL segment - #52

Merged
kieronlanning merged 2 commits into
mainfrom
feat/docs-topic-folders
Nov 3, 2026
Merged

kieronlanning merged 2 commits into
mainfrom
feat/docs-topic-folders

Conversation

@kieronlanning

@kieronlanning kieronlanning commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Why

Documentation could only be flat. A page's slug was its file name, so a project with thirty pages could not group them by topic — the grouping existed only in the sidebar — and a reader could not tell from an address that custom-rules belongs to validation.

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

Assumption Effect
slugifyDocFile folded the whole path into one dash-joined slug schemas/Custom-Rules.md published at /docs/<id>/schemas-custom-rules/ — the URL advertises a folder, then hides it
rewriteDocMarkdown matched links by file name against root-relative slugs a page in schemas/ linking ../concepts/Core-Concepts.md matched nothing and degraded into a GitHub blob URL resolved from the wrong directory

What

A page's folder is a path segment of its slug (docSlug). schemas/Custom-Rules.md → /docs/<id>/schemas/custom-rules/. A flat corpus is the single-segment case of the same rule, so no existing URL moves and no other project needs to change.

Every segment is URL-safe by construction. Each is reduced to lower-case ASCII letters, digits and inner dashes, so a folder may be named anything — Topic Folder → topic-folder, Q&A → q-a, v2.guides → v2-guides — and nothing published needs percent-encoding. isUrlSafeDocSlug is the contract, and buildPage fails the sync with a DocsValidationError naming the source path when a folder or file name reduces to nothing (___/Page.md), instead of publishing an address nobody can write down.

Links resolve against the page that contains them (DocLinkContext.pagePath), as they do on GitHub: siblings by file name, anything else by its path from that page. A bare file name still resolves by name, so a flat corpus is unchanged, and repository-path fallbacks resolve from the page's directory in the repo, so ../../src/Foo still lands on the right blob. resolveDocRelativeLink counts hops with posix.relative (schemas/custom-rules → serialization/jsonschema-export is ../../), and <doclink:…> now accepts / — previously a nested placeholder was emitted verbatim and never resolved.

A move is declared (docs.redirects). Reorganising a tree changes published URLs, so the catalogue maps a former slug to its current one and the build writes a meta-refresh redirect per pair. writeStaticRedirect never overwrites a page, which makes the declaration timing-safe: while a live channel reads a tag from before the move, the old slug is a real page and nothing is written; at promotion it takes effect.

docsPage keeps its single-segment shape. A deep link is validated against the default channel and must survive two trees that can differ (tag vs branch), so a page the catalogue deep-links stays at the docs root or moves with its deep link. Widening the schema would only move the failure to the moment a channel's tree diverges.

Consuming change

purview-dev/zodsharp reorganises its wiki into seven topic folders in a companion PR (to follow, once this is merged): 30 entries in zodsharp.yml's docs.redirects keep every former flat URL alive, and they stay inert until the release that carries the folders is promoted to live.

Verification

  • bun test → 326 pass, 0 fail (new: docSlug folder/encoding cases, isUrlSafeDocSlug, in-folder link rewriting and hop counts, moved-page redirects including the unpublished-destination case)
  • bun run typecheck → 0 errors · bun run lint → 0 warnings · bun run format:check → clean
  • The whole CI gate was run locally against live data (bun run ci:build: data sync → typecheck → assets → projects → astro build → discovery → link crawl → generated check → dist tests), reproducing the CI channel split docs zodsharp: live=2.4.2 (27 pages), prerelease=3.0.0-prerelease.2 (33 pages): "All internal links are valid.", Redirects: 0 moved documentation page(s) (all 30 pairs correctly dormant while live is still the released tag), test:dist 41 pass / 0 fail
  • An end-to-end replay of zodsharp's corpus through the real helpers (33 pages, 216 internal links) reported no link degrading to a GitHub blob URL, every href resolving from the published page URL, every #fragment present on its target, and every slug URL-safe

Fixed in review

The first CI run failed its link crawl on six redirects this branch wrote
(/docs/zodsharp/trimming-and-aot/ → quality/trimming-and-aot/). Those six pages
do not exist in the 2.4.2 tree at all, so their former slug was free while their
new address was not yet published — a forward into a 404. writeDocsPageRedirects
now requires both ends: the destination must be published and the source slug must
no longer be a page. Pairs activate per page at promotion. Commit 2c9d5a3.

ADR: docs/decisions/0011-documentation-folders.md

A documentation corpus could only be flat: the slug was the file name, so a project
with thirty pages could not group them by topic without the grouping existing
solely in the sidebar. Letting a project nest its pages exposed two assumptions
that were invisible while every corpus was flat:

- slugifyDocFile folded the whole path into one dash-joined slug, so
  schemas/Custom-Rules.md published at .../schemas-custom-rules/ - a URL that
  advertises a folder and then hides it;
- rewriteDocMarkdown matched a link by its file name against root-relative slugs,
  so a page in schemas/ linking ../concepts/Core-Concepts.md matched nothing and
  degraded into a GitHub blob URL resolved from the wrong directory.

Pages now keep their folder as a path segment (docSlug), and a link resolves
against the page that contains it (DocLinkContext.pagePath), with a bare file name
still resolving by name so a flat corpus keeps every URL it had. Every segment is
reduced to lower-case letters, digits and inner dashes, so a folder may be named
anything (Q&A, v2.guides, Topic Folder) and still publish a URL that needs no
percent-encoding; isUrlSafeDocSlug rejects a path that reduces to nothing rather
than publishing an address nobody can write down.

Reorganising a tree changes published URLs, so docs.redirects maps a former slug
to its current one and the build writes a meta-refresh redirect for each pair. A
redirect never shadows a real page, which makes the declaration timing-safe: while
a live channel reads a tag from before the move the old slug is a real page and
nothing is written; at promotion it takes effect.

docsPage keeps its single-segment shape. A deep link resolves against the default
channel and must survive two trees that can differ, so a page the catalogue
deep-links stays at the docs root or moves together with its deep link.

Verified locally: bun test (325 pass), typecheck, oxlint, oxfmt --check. A full
just validate (astro build, discovery, link crawl) needs the fetched release and
docs caches, so CI runs it.

ADR: docs/decisions/0011-documentation-folders.md
The CI link crawl failed with six broken links, all of them redirects this branch
wrote: /docs/zodsharp/trimming-and-aot/ -> /docs/zodsharp/quality/trimming-and-aot/.

The cause is the promotion lag the declaration was supposed to be safe against. The
sync published zodsharp's live channel from the 2.4.2 tag (27 pages, the old flat
tree) and its prerelease channel from main (33 pages, the folders), so six pages -
trimmming/AOT, HttpClient, OpenAPI, Blazor, Microsoft.Extensions.Validation and
Localization - do not exist in the live tree at all. Their former slug is therefore
not a page (so writeStaticRedirect happily writes), but neither is their new
address (so the redirect forwards into a 404).

Only the source half of "a redirect needs both ends" was implemented: a real page
wins at the old slug, but nothing checked that the destination exists.
writeDocsPageRedirects now skips a pair whose destination the build did not
publish, which makes activation per page and automatic at promotion - the 24 pairs
whose old slug is still live stay dormant because their source is published, and
the six stay dormant because their destination is not.

Reproduced CI locally by running the whole gate (bun run ci:build) against live
data: docs zodsharp live=2.4.2 (27 pages), prerelease=3.0.0-prerelease.2 (33 pages),
redirects written 0, "All internal links are valid.", test:dist 41 pass. The six
broken links are gone.

Also updated the ADR decision, README section and the zodsharp catalogue comment to
describe both halves of the check, and added a test for the unpublished-destination
case.
kieronlanning added a commit to purview-dev/zodsharp that referenced this pull request Nov 3, 2026
docs/wiki was one flat directory of 33 pages, so a reader found a page by
remembering its file name and the only hierarchy was the sidebar. It is now one
folder per topic area:

  concepts/       Core Concepts, Composition and Transforms
  schemas/        one page per schema family, plus the rule catalogue
  generator/      Source Generator, DataAnnotations, Diagnostics
  serialization/  JSON Schema export and import, System.Text.Json, Newtonsoft
  hosting/        Microsoft.Extensions.Validation, OpenAPI, Blazor, HttpClient,
                  dependency injection, localization
  quality/        guarantees, performance, trimming/AOT, cross-platform interop
  development/    Contributing, Release Flow

Every internal link (216) now resolves from the page that contains it - siblings
by file name, cross-folder through ../<folder>/ - and the sidebar lists each page
by its path from the wiki root, which is what the docs sync slugifies for page
order. Pages publish at /docs/zodsharp/<folder>/<page>/, so the address mirrors
the tree, and folder and file names stay letters/digits/dashes so no URL needs
percent-encoding.

Three pages stay at the root: Getting-Started.md is the site's landing page
(docs.rootPage), and Fluent-Schema-API.md and AspNetCore-Integration.md are the
two pages the site catalogue deep-links through docsPage, whose slug must be a
single path segment and must exist in both documentation channels.

Home.md and _Sidebar.md carry the same seven sections in the same order, and
Getting-Started.md - the page the site serves as the landing page - groups what
follows by the goal it serves. Contributing.md gains the tree, the URL rules and
a three-step recipe for adding a page; AGENTS.md records the constraint for the
next agent.

DocumentationWikiTests grew guards for the new shape: every page in a topic
folder, folders limited to the documented seven, the root pages unchanged, file
and folder names URL-safe, links resolved from the containing page, and no
page missing from the sidebar or the index.

Depends on purview-dev/purview-dev.github.io#52 (nested slugs, page-relative
links and docs.redirects), which also keeps every former flat URL alive.

Verified: dotnet test src/tests/Repository.UnitTests (9/9 DocumentationWikiTests),
csharpier check.
@kieronlanning
kieronlanning merged commit d1eff27 into main Nov 3, 2026
1 check passed
@kieronlanning
kieronlanning deleted the feat/docs-topic-folders branch November 3, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant