Repository navigation
feat(docs): publish a page's topic folder as a URL segment - #52
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-rulesbelongs to validation.Letting a project nest its pages exposed two assumptions that were invisible while every corpus was flat:
slugifyDocFilefolded the whole path into one dash-joined slugschemas/Custom-Rules.mdpublished at/docs/<id>/schemas-custom-rules/— the URL advertises a folder, then hides itrewriteDocMarkdownmatched links by file name against root-relative slugsschemas/linking../concepts/Core-Concepts.mdmatched nothing and degraded into a GitHub blob URL resolved from the wrong directoryWhat
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.isUrlSafeDocSlugis the contract, andbuildPagefails the sync with aDocsValidationErrornaming 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/Foostill lands on the right blob.resolveDocRelativeLinkcounts hops withposix.relative(schemas/custom-rules→serialization/jsonschema-exportis../../), 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.writeStaticRedirectnever overwrites a page, which makes the declaration timing-safe: while alivechannel reads a tag from before the move, the old slug is a real page and nothing is written; at promotion it takes effect.docsPagekeeps 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/zodsharpreorganises its wiki into seven topic folders in a companion PR (to follow, once this is merged): 30 entries inzodsharp.yml'sdocs.redirectskeep every former flat URL alive, and they stay inert until the release that carries the folders is promoted tolive.Verification
bun test→ 326 pass, 0 fail (new:docSlugfolder/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→ cleanbun run ci:build: data sync → typecheck → assets → projects → astro build → discovery → link crawl → generated check → dist tests), reproducing the CI channel splitdocs 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 whileliveis still the released tag),test:dist41 pass / 0 fail#fragmentpresent on its target, and every slug URL-safeFixed 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 pagesdo not exist in the
2.4.2tree at all, so their former slug was free while theirnew address was not yet published — a forward into a 404.
writeDocsPageRedirectsnow 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