From 211b24c318f6d4a1650edd30b58228255f5901cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:09:14 +0100 Subject: [PATCH 1/4] docs: publish the guides, the READMEs and the API to GitHub Pages with DocFX .github/workflows/docs.yml builds docs/*.md, the root README, the package READMEs, CHANGELOG.md and the API pages of the five run-time packages with DocFX 2.81.0 (pinned in .config/dotnet-tools.json, configured in site/), and deploys them on a push to main only. A pull request touching a page builds it with --warningsAsErrors and publishes nothing. The sources are not copied or edited. A link to a repository file that is not a page (design/, CONTRIBUTING.md) is a build resource, so DocFX checks it exists, and site/template rewrites it to a GitHub address at the built commit. The README's one directory link, spikes/, is now an absolute address on main. Spike: DocFX lists the members of C# 14 extension blocks as extension methods, on their static class and on the type they extend; the workflow fails if Option or Result stops listing them. site/** joins the prose lane in tools/CiLanes, ProseReaderTests counts "site" as prose, and DocsSiteTests holds the menu to the README's tables and the API pages to the run-time packages. Decision 115. Co-Authored-By: Claude Opus 5.5 --- .config/dotnet-tools.json | 7 + .github/workflows/docs.yml | 139 ++++++++++++++++++ CHANGELOG.md | 1 + CONTRIBUTING.md | 41 ++++++ README.md | 6 +- ...-the-docs-site-is-docfx-built-from-main.md | 66 +++++++++ design/adr/README.md | 1 + site/docfx.json | 40 +++++ site/index.md | 3 + site/template/conceptual.extension.js | 28 ++++ site/toc.yml | 56 +++++++ .../SelectionTests.cs | 4 + .../DocsSiteTests.cs | 116 +++++++++++++++ .../ProseReaderTests.cs | 14 +- tools/CiLanes/Program.cs | 5 +- 15 files changed, 518 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 design/adr/0115-the-docs-site-is-docfx-built-from-main.md create mode 100644 site/docfx.json create mode 100644 site/index.md create mode 100644 site/template/conceptual.extension.js create mode 100644 site/toc.yml create mode 100644 test/CodoMetis.TypeKit.Conventions.Tests/DocsSiteTests.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4d3e20e4..94e23d85 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,6 +8,13 @@ "dotnet-CycloneDX" ], "rollForward": false + }, + "docfx": { + "version": "2.81.0", + "commands": [ + "docfx" + ], + "rollForward": false } } } diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..14e4ab11 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,139 @@ +# Builds the docs site from the guides in docs/, the root README, the package READMEs, the changelog and the API of +# the run-time packages, and publishes it to GitHub Pages from main (#115, design/adr/0115-the-docs-site-is-docfx-built-from-main.md): +# +# https://caffeinatedcoder.github.io/CodoMetis.TypeKit/ +# +# Only main is published, because main is the released state: topics merge into release/, which merges into +# main when it is tagged, so the site always describes the version `dotnet add package` installs. A release branch +# is never published. A second line under its own path (/2.x/, /3.x/) waits until two lines are supported at once; +# CONTRIBUTING.md ("The docs site") says how one would be added. +# +# A pull request into main or a release branch that touches what a page is built from builds the site and publishes +# nothing. DocFX runs with warnings as errors, so a link to a missing page, a missing heading or a missing file of the +# repository fails the pull request rather than the deployment. +# +# The pages are the Markdown files as they are: nothing copies or edits them, so ReadmeSampleTests and +# OracleCatalogueTests hold the text readers see. A link to a file of the repository that is not a page (design/, +# CONTRIBUTING.md) becomes a GitHub address at the built commit in site/template, at build time. +# +# Publishing needs Settings > Pages > Source set to "GitHub Actions", which no workflow can set. +# +# Actions are pinned to commit SHAs, as in dotnet.yml. + +name: Docs + +# Everything a page is built from: the Markdown, the XML docs in src/, the site's config, and the SDK and tool pins. +# Keep the two lists the same. +on: + push: + branches: [ "main" ] + paths: + - "*.md" + - "LICENSE" + - "docs/**" + - "design/**" + - "src/**" + - "site/**" + - "global.json" + - "Directory.Build.props" + - "Directory.Packages.props" + - ".config/dotnet-tools.json" + - ".github/workflows/docs.yml" + pull_request: + branches: [ "main", "release/**" ] + paths: + - "*.md" + - "LICENSE" + - "docs/**" + - "design/**" + - "src/**" + - "site/**" + - "global.json" + - "Directory.Build.props" + - "Directory.Packages.props" + - ".config/dotnet-tools.json" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: bash + +# A pull request's run is superseded by its next push. Publishing runs queue instead, so a deployment is never +# cancelled halfway and the last one to finish publishes the latest main. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('docs-{0}', github.ref) || 'docs-publish' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + build: + name: Build the site + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + global-json-file: global.json + + # The API pages are read from published assemblies, which carry the XML docs and every dependency DocFX needs + # to resolve a signature. The packages are the ones site/docfx.json names, so the two cannot disagree. + - name: Publish the run-time packages + run: | + for assembly in $(jq -r '.metadata[].src[].files[]' site/docfx.json); do + dotnet publish "src/${assembly%.dll}" -c Release -o site/obj/pub + done + + - name: Build the site, warnings as errors + run: | + dotnet tool restore + dotnet docfx site/docfx.json --warningsAsErrors --metadata _commit="$GITHUB_SHA" + + # The members of C# 14 extension blocks are listed as extension methods, on their static class and on the type + # they extend: measured with DocFX 2.81.0 (decision 115). A DocFX that stops listing them fails here, not on + # a reader who finds Option's page without its combinators. + - name: The extension-block members are listed + run: | + grep -qF ' - CodoMetis.TypeKit.Option`1.CodoMetis.TypeKit.Option.Select``2(' site/obj/api/CodoMetis.TypeKit.Option-1.yml + grep -qF ' - CodoMetis.TypeKit.Result`2.CodoMetis.TypeKit.Result.Select``3(' site/obj/api/CodoMetis.TypeKit.Result-2.yml + + # The files a link of a page names to be checked, which the site links on GitHub instead (site/template). + - name: Leave the repository's own files out + run: rm -rf site/obj/_site/_repository + + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + with: + path: site/obj/_site + + publish: + name: Publish + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + + steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d33373ac..9066c9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ section is the GitHub release's notes. - `TraverseAsync` on `Option` and `Patch`: an asynchronous check of an optional input or a patch field, with a token form; nothing is called without a value ([Collect many results](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/result.md#collect-many-results)). - `Fault.Cause`: `Fault.Of(reason, detail, exception)` keeps the exception underneath for your log; equality and `ToString()` leave it out, and `ToHttpResult()` hands it to the host as `IFaultFeature.Cause` and `FaultProblem.Cause`, never into the problem ([What failed underneath](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-failed-underneath)). - Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)). +- Docs: the guides, the package READMEs, the changelog and the API of every run-time package as one site with a menu and search, built from the latest release ([caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/)). - Analyzers: a validated value object without `Create` gets a code fix, **Add Create**, that writes a `Create` accepting every value, ready for your rules ([Add Create](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#add-create)). ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ecac8de..cfa05d7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -195,6 +195,47 @@ The packages share one version and are released together, only through `workflow_dispatch` with `dry_run` (the default) rehearses everything but the push. +## The docs site + +The guides in `docs/`, the root README, the package READMEs, the changelog and the API pages of the +run-time packages are published to GitHub Pages, at +[caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/), by +`.github/workflows/docs.yml` with DocFX +([decision 115](design/adr/0115-the-docs-site-is-docfx-built-from-main.md)). + +- **Only `main` is published**, on every push to it, so the site always shows the latest release: the + release's PR into `main` publishes it. A release branch is never published. +- **The pages are the files as they are.** Nothing copies or edits them for the site. The menu is + `site/toc.yml`: a new guide goes into the README's Documentation table and into the menu's Guides, in + the same order, and `DocsSiteTests` fails until both list it. A new run-time package goes into + `site/docfx.json`'s `metadata`, or into `DocsSiteTests.WithoutApiPages` with the reason it has no API + pages. +- **Links are checked on every pull request** that touches what a page is built from. DocFX runs with + `--warningsAsErrors`, so a link to a missing page, heading or file fails the PR. A link to a file that + is not a page (`design/`, this file) becomes a GitHub address at the built commit, at build time + (`site/template/conceptual.extension.js`). +- **Pages needs one setting**, made once by a maintainer: Settings > Pages > Source, "GitHub Actions". + +To build the site locally (`jq` reads the packages from `site/docfx.json`) and serve it at +http://localhost:8080: + +```bash +for assembly in $(jq -r '.metadata[].src[].files[]' site/docfx.json); do dotnet publish "src/${assembly%.dll}" -c Release -o site/obj/pub; done +dotnet tool restore +dotnet docfx site/docfx.json --warningsAsErrors --metadata _commit="$(git rev-parse HEAD)" --serve +``` + +**A second line**, such as `3.x` beside `2.x`, waits until two lines are supported at once. GitHub Pages +serves one artifact per repository, so each deployment then carries every line: + +1. Build each line from its own branch into its own folder (`/2.x/`, `/3.x/`), with `main`'s + `site/` config, as a matrix in the build job; DocFX's `_appTitle` names the line. +2. Assemble the folders into one artifact with a root `index.html` that redirects to the default line, + the latest release, and deploy that. +3. Start the workflow from both branches. A push to the older line's branch cannot run a workflow that + branch lacks, so run on `workflow_run` of the build workflow there, and check that line out by name, + never by the triggering run's SHA. CodoMetis.ValueRanges' `docs.yml` does this for its two lines. + ## AI-assisted development A substantial portion of this codebase was written with AI assistance, using diff --git a/README.md b/README.md index 14d6850e..277bb0a6 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ under [Packages](#packages), are the reference for each package: what it adds, h exception message and analyzer help link points into them. Every C# block in the guides and the READMEs is compiled, and the outputs it states are tested. +You can also read all of it as one website, with a menu, search and the API of every run-time +package: [caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/). +It shows the latest release. + | Page | Covers | |---|---| | [Value objects](docs/value-objects.md) | Declare one, pick a struct or a class, validate input, design its errors, pick the right factory, write your own members, generic code | @@ -381,7 +385,7 @@ one. [CHANGELOG.md](CHANGELOG.md) lists what each version changed. The design notes, the decision records and the roadmap are under [design/](design/plan.md). The measurements that decided the design are in -[spikes/](spikes/). +[spikes/](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/tree/main/spikes). ## Building diff --git a/design/adr/0115-the-docs-site-is-docfx-built-from-main.md b/design/adr/0115-the-docs-site-is-docfx-built-from-main.md new file mode 100644 index 00000000..3bc4f0be --- /dev/null +++ b/design/adr/0115-the-docs-site-is-docfx-built-from-main.md @@ -0,0 +1,66 @@ +# 0115. The docs site is DocFX, built from main, from the sources as they are + +Date: 2026-10-29 +Status: accepted + +Context: [#115](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/115); CodoMetis.ValueRanges chose DocFX +in its spike ([CaffeinatedCoder/CodoMetis.ValueRanges#417](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/417)); +[decision 113](0113-ci-runs-the-lanes-a-change-can-affect.md) holds the CI lanes this adds a path to. + +The guides in `docs/`, the root README and the package READMEs could only be read as Markdown on GitHub: no +navigation beyond the README's Documentation table, no search across them, and the XML docs of every public +member shown only by an IDE. + +**The site is DocFX, publishing to GitHub Pages from `main` alone.** `.github/workflows/docs.yml` builds +`docs/*.md`, `README.md`, `src/*/README.md`, `CHANGELOG.md` and the API pages of the run-time packages +(`CodoMetis.TypeKit`, `.EntityFrameworkCore`, `.AspNetCore`, `.Serilog`, `.Testing`) and deploys them on a push to +`main`, which is the released state: topics merge into `release/`, which merges into `main` when it is +tagged. A release branch is never published, so the site always describes the version `dotnet add package` +installs. The Analyzers' surface is their rules and the Generators' is what they generate into a consumer, so +their READMEs are their reference and they get no API pages. DocFX is pinned in `.config/dotnet-tools.json` +(2.81.0) and configured in `site/docfx.json`, inside the repository because DocFX refuses a content root outside +its base directory. A second line (`/2.x/`, `/3.x/`) waits until two are supported at once; CONTRIBUTING.md says +how one would be added. + +**The pages are the files as they are.** Nothing copies or edits a page for the site, so `ReadmeSampleTests` and +`OracleCatalogueTests` hold the text a reader sees. A link between two pages resolves as written, since every +page is a site page. A link to a file of the repository that is not a page (`design/`, `CONTRIBUTING.md`, +`AGENTS.md`, `SECURITY.md`, `LICENSE`) becomes a GitHub address at the built commit, rewritten by +`site/template/conceptual.extension.js` at build time. Those files are the build's resources, under `_repository/`, +so DocFX checks that each exists; the workflow then deletes them from the output. A link to a directory names no +file DocFX can check, so the README's one link to `spikes/` became an absolute address on `main`, which +`DocumentationLinkTests` checks. + +**A pull request that touches a page checks its links.** On a pull request into `main` or a release branch whose +paths a page is built from (`*.md`, `docs/`, `design/`, `src/`, `site/`, the SDK and tool pins), the workflow runs +the whole build with `--warningsAsErrors` and publishes nothing. Measured on 2026-10-29: a seeded link to a missing +page (`InvalidFileLink`), to a missing heading of a page (`InvalidBookmark`) and to a missing design record +(`InvalidFileLink`, through the resources) each fail the build; without them it reports no warning. An anchor into +a file that is not a page is left to `DocumentationLinkTests`, which reads every anchor as GitHub derives it. + +**`site/**` is prose** in `tools/CiLanes`: the docs workflow and the conventions tests read it, and nothing is +built from it, so a change to it alone runs the prose suite. `ProseReaderTests` counts a literal naming `site` as +prose, so no test outside `test/Prose.slnf` reads it unseen. The workflow itself is a workflow, which decision 113 +already classes as anything else. `DocsSiteTests` holds the menu (`site/toc.yml`) to the README's Documentation +table, in its order, and to every shipping package's README, and the API pages to every run-time package. + +**The spike: DocFX lists the members of C# 14 extension blocks.** `src/` holds 35 `extension(...)` blocks, which +carry most of the `Option` and `Result` extensions, `AsText` and the `IQueryable` helpers, and ValueRanges had none. +Measured with DocFX 2.81.0 on the published assemblies: + +- Every instance member of a block is listed on its static class as the extension method the compiler lowers it + to, with its XML docs: `Option.FirstOrNone(this IEnumerable source)`, `StringTextExtensions.AsText(this + string? text)`: 37 extension methods on `Option` and 52 on `Result`. +- The type a block extends lists them under "Extension Methods": `Option`'s page shows `Select`, `SelectMany`, + `Where`, `Zip`, `ToResult` and `Result.Traverse`. A member whose constraints the open type does not meet is + left out there, as for any extension method: `OrDefault()` (`where T : struct`) and `OrNull()` + (`where T : class`) are on `Option`'s page only. +- The signature shown is the lowered one, `this in Option a`, not the `extension(...)` block it is declared in. +- The one static member of a block, `GuidValueExtensions.New()`, is listed as `New()` with its constraints, + on `GuidValueExtensions` only, not as `OrderId.New()` on the value objects it serves. + +So the API pages ship. The workflow fails if `Option`'s or `Result`'s page stops listing its +extension-block `Select`, so a DocFX update that loses them fails there, not on a reader. + +**Needed once from the maintainer:** Settings > Pages > Source set to "GitHub Actions". No workflow can set it, +and until it is set, the first deployment from `main` fails at `configure-pages`. diff --git a/design/adr/README.md b/design/adr/README.md index 26a5f3bf..737e0380 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -121,3 +121,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0112](0112-the-prober-gives-way-to-valueranges-oracle-kit.md) | The prober, its corpus, its seeds, the CI replay and the release gate are retired for CodoMetis.ValueRanges' oracle kit under `test/oracles/`: claims quoted verbatim and held by named tests, every public member claimed or waived, oracles as ordinary test classes each with a seed CI checks applies, candidates, and a hand-written axes file; `tools/Oracles seeds` builds one project and runs one class per seed | 2026-10-28 | accepted | | [0113](0113-ci-runs-the-lanes-a-change-can-affect.md) | CI runs the lanes a change can affect: each changed path selects Linux, Windows and the pack by its class in `tools/CiLanes` (Windows for the analyzers, the generators, tests and tools; the pack for the packages), a path no class names selects all three, a prose-only change runs only the test projects that read prose (`test/Prose.slnf`), and only a pull request into `main`, a push to `main`, the nightly and the release run macOS; a gate job, `CI`, over every job is the required check; a convention test refuses non-portable file names; `release.yml` calls the build workflow before it verifies | 2026-10-29 | accepted | | [0114](0114-a-value-object-s-temporary-number-is-its-wrapped-type-s.md) | A value object over a number gets the temporary values a property of its wrapped type gets, from a factory in its converter's mapping hints, which EF consults after a generator on the property and after HiLo: a default `ulong` key no longer throws at `Add`, and a `char` key configured on add gets a temporary `char` on SQL Server; under `--nativeaot` the factory is lost and a default `ulong` key still throws | 2026-10-29 | accepted | +| [0115](0115-the-docs-site-is-docfx-built-from-main.md) | The docs site is DocFX, published to GitHub Pages from `main` alone: the guides, the READMEs, the changelog and the API pages of the run-time packages, from the files as they are; a link to a file that is not a page becomes a GitHub address at the built commit; a pull request touching a page builds it with warnings as errors; `site/**` is prose; DocFX 2.81.0 lists the members of C# 14 extension blocks as extension methods | 2026-10-29 | accepted | diff --git a/site/docfx.json b/site/docfx.json new file mode 100644 index 00000000..037bc1dc --- /dev/null +++ b/site/docfx.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "src": [ + { + "src": "obj/pub", + "files": [ + "CodoMetis.TypeKit.dll", + "CodoMetis.TypeKit.EntityFrameworkCore.dll", + "CodoMetis.TypeKit.AspNetCore.dll", + "CodoMetis.TypeKit.Serilog.dll", + "CodoMetis.TypeKit.Testing.dll" + ] + } + ], + "dest": "obj/api" + } + ], + "build": { + "content": [ + { "files": [ "index.md", "toc.yml" ] }, + { "src": "obj/api", "dest": "api", "files": [ "**.yml" ] }, + { "src": "..", "files": [ "README.md", "CHANGELOG.md", "docs/*.md", "src/*/README.md" ] } + ], + "resource": [ + { "src": "..", "dest": "_repository", "files": [ "design/**", "CONTRIBUTING.md", "SECURITY.md", "AGENTS.md", "LICENSE" ] } + ], + "output": "obj/_site", + "template": [ "default", "modern", "template" ], + "globalMetadata": { + "_appTitle": "CodoMetis.TypeKit", + "_appName": "CodoMetis.TypeKit", + "_appFooter": "MIT licensed. GitHub", + "_enableSearch": true, + "_disableContribution": true, + "_repository": "https://github.com/CaffeinatedCoder/CodoMetis.TypeKit" + } + } +} diff --git a/site/index.md b/site/index.md new file mode 100644 index 00000000..a9560a15 --- /dev/null +++ b/site/index.md @@ -0,0 +1,3 @@ +--- +redirect_url: README.html +--- diff --git a/site/template/conceptual.extension.js b/site/template/conceptual.extension.js new file mode 100644 index 00000000..21f604c4 --- /dev/null +++ b/site/template/conceptual.extension.js @@ -0,0 +1,28 @@ +// A link from a page to a file of the repository that is not a page of the site (a design note, a decision record, +// CONTRIBUTING.md, spikes/) becomes a GitHub address at the commit the site is built from (#115). The sources are +// never edited for the site. +// +// DocFX hands this script each page's HTML with its links still rooted at site/ (`~/../design/plan.md`), before it +// resolves and checks them. A link to a page is left as it is, so DocFX checks it and its anchor and fails the build +// with --warningsAsErrors when either is broken; a link rewritten here is checked by DocumentationLinkTests instead. +// The patterns are the content globs of site/docfx.json: change the two together. +var pages = [/^README\.md$/, /^CHANGELOG\.md$/, /^docs\/[^\/]+\.md$/, /^src\/[^\/]+\/README\.md$/]; + +exports.preTransform = function (model) { + return model; +}; + +exports.postTransform = function (model) { + var commit = model._commit; + if (!commit) { + throw new Error('site/template: no commit to link the repository at. Pass --metadata _commit=, as CONTRIBUTING.md shows.'); + } + + model.conceptual = model.conceptual.replace(/href="~\/\.\.\/([^"#]*)(#[^"]*)?"/g, function (link, path, anchor) { + if (pages.some(function (page) { return page.test(path); })) return link; + + var kind = path === '' || path.charAt(path.length - 1) === '/' ? 'tree' : 'blob'; + return 'href="' + model._repository + '/' + kind + '/' + commit + '/' + path + (anchor || '') + '"'; + }); + return model; +}; diff --git a/site/toc.yml b/site/toc.yml new file mode 100644 index 00000000..b3f9a3f8 --- /dev/null +++ b/site/toc.yml @@ -0,0 +1,56 @@ +- name: Home + href: ../README.md +- name: Guides + items: + - name: Value objects + href: ../docs/value-objects.md + - name: Closed sets + href: ../docs/closed-sets.md + - name: Option + href: ../docs/option.md + - name: Result + href: ../docs/result.md + - name: Written text + href: ../docs/text.md + - name: Type constraints + href: ../docs/type-constraints.md + - name: Merge patches + href: ../docs/merge-patches.md + - name: JSON + href: ../docs/json.md + - name: EF Core + href: ../docs/ef-core.md + - name: ASP.NET Core + href: ../docs/aspnet-core.md + - name: Native AOT + href: ../docs/native-aot.md + - name: Logging and secrets + href: ../docs/logging-and-secrets.md + - name: Testing + href: ../docs/testing.md + - name: Migrating from 2.0 to 2.1 + href: ../docs/migrating-to-2-1.md + - name: Migrating from 1.5 to 2.0 + href: ../docs/migrating-to-2.md + - name: How it compares + href: ../docs/comparison.md +- name: Reference + items: + - name: CodoMetis.TypeKit + href: ../src/CodoMetis.TypeKit/README.md + - name: CodoMetis.TypeKit.Analyzers + href: ../src/CodoMetis.TypeKit.Analyzers/README.md + - name: CodoMetis.TypeKit.Generators + href: ../src/CodoMetis.TypeKit.Generators/README.md + - name: CodoMetis.TypeKit.EntityFrameworkCore + href: ../src/CodoMetis.TypeKit.EntityFrameworkCore/README.md + - name: CodoMetis.TypeKit.AspNetCore + href: ../src/CodoMetis.TypeKit.AspNetCore/README.md + - name: CodoMetis.TypeKit.Serilog + href: ../src/CodoMetis.TypeKit.Serilog/README.md + - name: CodoMetis.TypeKit.Testing + href: ../src/CodoMetis.TypeKit.Testing/README.md +- name: API + href: obj/api/ +- name: Changelog + href: ../CHANGELOG.md diff --git a/test/CodoMetis.TypeKit.CiLanes.Tests/SelectionTests.cs b/test/CodoMetis.TypeKit.CiLanes.Tests/SelectionTests.cs index d82fa50b..8ecf0cad 100644 --- a/test/CodoMetis.TypeKit.CiLanes.Tests/SelectionTests.cs +++ b/test/CodoMetis.TypeKit.CiLanes.Tests/SelectionTests.cs @@ -34,6 +34,9 @@ public SelectionTests() { "README.md", "prose", "linux" }, { "test/oracles/README.md", "prose", "linux" }, { ".github/ISSUE_TEMPLATE/bug.yml", "prose", "linux" }, + { "site/docfx.json", "prose", "linux" }, + { "site/toc.yml", "prose", "linux" }, + { "site/template/conceptual.extension.js", "prose", "linux" }, { "CHANGELOG.md", "changelog", "linux, pack" }, { "src/CodoMetis.TypeKit.Analyzers/CodeFixes/AddCreateCodeFix.cs", "source text", "linux, windows, pack" }, { "src/CodoMetis.TypeKit.Analyzers/AnalyzerReleases.Unshipped.md", "source text", "linux, windows, pack" }, @@ -56,6 +59,7 @@ public SelectionTests() { "Directory.Packages.props", "unlisted", "linux, windows, pack" }, { ".config/dotnet-tools.json", "unlisted", "linux, windows, pack" }, { ".github/workflows/dotnet.yml", "unlisted", "linux, windows, pack" }, + { ".github/workflows/docs.yml", "unlisted", "linux, windows, pack" }, { "benchmarks/CodoMetis.TypeKit.Benchmarks/Program.cs", "unlisted", "linux, windows, pack" }, { "a-file-nobody-listed", "unlisted", "linux, windows, pack" }, }; diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/DocsSiteTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/DocsSiteTests.cs new file mode 100644 index 00000000..cb250242 --- /dev/null +++ b/test/CodoMetis.TypeKit.Conventions.Tests/DocsSiteTests.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace CodoMetis.TypeKit.Conventions.Tests; + +/// +/// The docs site's menu and API pages follow the repository, so a new guide, package README or run-time package +/// reaches the site without anyone remembering it (design/adr/0115). DocFX builds every page whether the menu lists +/// it or not, so a page the menu forgets is reached only by search, and its build passes. +/// +/// +/// The menu is site/toc.yml, read line by line in the one shape it has: top-level entries, the sections among +/// them holding items, each entry a name and an href. +/// +public sealed partial class DocsSiteTests +{ + private const string Site = "site"; + + /// The packages with no API pages, and why: their READMEs are their reference. + private static readonly Dictionary WithoutApiPages = new(StringComparer.Ordinal) + { + ["CodoMetis.TypeKit.Analyzers"] = "its public surface is its rules, which its README lists", + ["CodoMetis.TypeKit.Generators"] = "its aspects are internal, and its surface is what they generate into a consumer", + }; + + private static readonly Lazy> Menu = new(() => ReadMenu(File.ReadAllLines(Path.Combine(Repository.Root, Site, "toc.yml")))); + + [Fact] + public void The_menu_lists_the_guides_in_the_order_of_the_README() + { + var guides = Section("Guides").Select(entry => entry.Href); + var listed = ReadmeSamples.DocumentationLinks(File.ReadAllText(Path.Combine(Repository.Root, ReadmeSamples.RootReadme))); + + guides.ShouldBe(listed.Select(page => $"../{page}"), + "site/toc.yml's Guides are the pages of the README's Documentation table, in its order."); + } + + [Fact] + public void The_menu_lists_every_package_README_as_reference() + { + var reference = Section("Reference").Select(entry => entry.Href); + + reference.ShouldBe(Repository.ShippingProjects().Select(package => $"../src/{package}/README.md"), ignoreOrder: true, + "site/toc.yml's Reference lists every shipping package's README, and nothing else."); + } + + [Fact] + public void The_menu_starts_at_the_README_and_ends_with_the_API_and_the_changelog() + { + Menu.Value.Select(entry => entry.Name).ShouldBe(["Home", "Guides", "Reference", "API", "Changelog"]); + Menu.Value[0].Href.ShouldBe("../README.md"); + Menu.Value[^1].Href.ShouldBe("../CHANGELOG.md"); + } + + [Fact] + public void The_API_pages_are_the_run_time_packages() + { + using var config = JsonDocument.Parse(File.ReadAllText(Path.Combine(Repository.Root, Site, "docfx.json"))); + var assemblies = config.RootElement.GetProperty("metadata").EnumerateArray() + .SelectMany(metadata => metadata.GetProperty("src").EnumerateArray()) + .SelectMany(source => source.GetProperty("files").EnumerateArray()) + .Select(file => file.GetString()!); + + WithoutApiPages.Keys.ShouldBeSubsetOf(Repository.ShippingProjects(), "An exemption names a package that no longer exists."); + assemblies.ShouldBe(Repository.ShippingProjects().Where(package => !WithoutApiPages.ContainsKey(package)).Select(package => $"{package}.dll"), ignoreOrder: true, + "site/docfx.json's metadata reads every run-time package's assembly. A package without API pages goes into WithoutApiPages with the reason."); + } + + [Fact] + public void The_menu_is_read() + { + Menu.Value.Count.ShouldBeGreaterThanOrEqualTo(5); + Section("Guides").Count.ShouldBeGreaterThanOrEqualTo(10); + } + + private static IReadOnlyList Section(string name) => + Menu.Value.SingleOrDefault(entry => entry.Name == name)?.Items ?? throw new InvalidOperationException($"site/toc.yml has no section '{name}'."); + + private static IReadOnlyList ReadMenu(string[] lines) + { + var menu = new List(); + foreach (var line in lines) + { + if (line.Trim().Length == 0 || line.Trim() == "items:") continue; + + var match = Entry().Match(line); + if (!match.Success) throw new InvalidOperationException($"site/toc.yml has a line this test cannot read: '{line}'."); + + var nested = match.Groups["indent"].Length > 2; + var value = match.Groups["value"].Value; + if (match.Groups["key"].Value == "name") + { + var entry = new MenuEntry(value); + if (nested) menu[^1].Items.Add(entry); + else menu.Add(entry); + } + else + { + var owner = nested ? menu[^1].Items[^1] : menu[^1]; + owner.Href = value; + } + } + return menu; + } + + private sealed record MenuEntry(string Name) + { + /// Empty for a section, which has instead. + public string Href { get; set; } = ""; + + public List Items { get; } = []; + } + + [GeneratedRegex(@"^(?[ -]*)(?name|href): (?.+)$")] + private static partial Regex Entry(); +} diff --git a/test/CodoMetis.TypeKit.Conventions.Tests/ProseReaderTests.cs b/test/CodoMetis.TypeKit.Conventions.Tests/ProseReaderTests.cs index d6146c78..97f85eef 100644 --- a/test/CodoMetis.TypeKit.Conventions.Tests/ProseReaderTests.cs +++ b/test/CodoMetis.TypeKit.Conventions.Tests/ProseReaderTests.cs @@ -5,14 +5,14 @@ namespace CodoMetis.TypeKit.Conventions.Tests; /// /// A prose-only change builds and runs only the test projects of test/Prose.slnf (design/adr/0113). So no test -/// project outside it may read prose: a file under docs/, design/ or .github/, or a Markdown file -/// outside src/. One that did would go untested by the very change that breaks it, silently. +/// project outside it may read prose: a file under docs/, design/, site/ or .github/, or a +/// Markdown file outside src/. One that did would go untested by the very change that breaks it, silently. /// /// /// Read from the sources, by the string literals that name such a path: "docs", "design", -/// ".github", a literal starting with one of them and a slash, and one ending in .md. A package's README is -/// not prose (the selection classes it with its package), so "README.md" passes in a file that also names -/// "src", as Path.Combine(root, "src", package, "README.md") does, and a literal holding src/ passes. +/// "site", ".github", a literal starting with one of them and a slash, and one ending in .md. A +/// package's README is not prose (the selection classes it with its package), so "README.md" passes in a file +/// that also names "src", as Path.Combine(root, "src", package, "README.md") does, and a literal holding src/ passes. /// A literal that names such a path without reading it is waived below, with the reason. /// public sealed partial class ProseReaderTests @@ -79,6 +79,7 @@ public void No_test_project_outside_the_prose_suite_reads_prose() [InlineData("""File.ReadAllText(Path.Combine(root, "docs", "json.md"));""", true)] [InlineData("""var page = "design/adr/README.md";""", true)] [InlineData("""Path.Combine(root, ".github", "workflows", "dotnet.yml")""", true)] + [InlineData("""Path.Combine(root, "site", "toc.yml")""", true)] [InlineData("""Path.Combine(root, "CHANGELOG.md")""", true)] [InlineData("""Path.Combine(root, "README.md")""", true)] [InlineData("""Path.Combine(root, "src", "CodoMetis.TypeKit", "README.md")""", false)] @@ -95,9 +96,10 @@ private static IEnumerable Prose(string source) foreach (var literal in literals) { - var directory = literal is "docs" or "design" or ".github" + var directory = literal is "docs" or "design" or "site" or ".github" || literal.StartsWith("docs/", StringComparison.Ordinal) || literal.StartsWith("design/", StringComparison.Ordinal) + || literal.StartsWith("site/", StringComparison.Ordinal) || literal.StartsWith(".github/", StringComparison.Ordinal); var markdown = literal.EndsWith(".md", StringComparison.OrdinalIgnoreCase) && !literal.Contains("src/", StringComparison.Ordinal) diff --git a/tools/CiLanes/Program.cs b/tools/CiLanes/Program.cs index 5db52692..28cc15d7 100644 --- a/tools/CiLanes/Program.cs +++ b/tools/CiLanes/Program.cs @@ -97,8 +97,9 @@ static partial class Table { public sealed record Class(string Name, Lane Lanes, string[] Patterns, string Why); - // Read by the test projects of test/Prose.slnf alone, which ProseReaderTests holds; built into nothing. - public static readonly Class Prose = new("prose", Lane.Linux, ["docs/**", "design/**", ".github/ISSUE_TEMPLATE/**", "**/*.md"], + // Read by the test projects of test/Prose.slnf alone, which ProseReaderTests holds; built into nothing. The docs + // site's config (site/) is read by the docs workflow and the conventions tests (decision 115). + public static readonly Class Prose = new("prose", Lane.Linux, ["docs/**", "design/**", "site/**", ".github/ISSUE_TEMPLATE/**", "**/*.md"], "read by the prose suite, built into nothing"); // The first class whose pattern matches a path decides its lanes. `**` matches any number of directories, From 8dfe0a955c6a40445b2d30461c46c40d67b0395f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:09:57 +0100 Subject: [PATCH 2/4] test: seed a broken link, to show the docs build fails the pull request Reverted by the next commit. Co-Authored-By: Claude Opus 5.5 --- docs/json.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/json.md b/docs/json.md index 44e9c24b..70d05c4f 100644 --- a/docs/json.md +++ b/docs/json.md @@ -393,3 +393,5 @@ an enum the context doesn't list, so list the enum as well, as `NamedShipmentJso What a context must list for the OpenAPI document is a different question. [Native AOT](native-aot.md#json-contexts) answers it. + +Seed for #115: [a missing page](no-such-page.md). From 546ac9cde02565aaab585ce2f6cb68c232509e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:14:13 +0100 Subject: [PATCH 3/4] test: remove the seeded broken link The Docs check failed on it with InvalidFileLink (run 40879451445). Co-Authored-By: Claude Opus 5.5 --- docs/json.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/json.md b/docs/json.md index 70d05c4f..44e9c24b 100644 --- a/docs/json.md +++ b/docs/json.md @@ -393,5 +393,3 @@ an enum the context doesn't list, so list the enum as well, as `NamedShipmentJso What a context must list for the OpenAPI document is a different question. [Native AOT](native-aot.md#json-contexts) answers it. - -Seed for #115: [a missing page](no-such-page.md). From cc526b3b55eb6e4584c94d7470a64b4063bec824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:14:13 +0100 Subject: [PATCH 4/4] docs: GitHub Pages is configured to deploy from Actions Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 2 +- CONTRIBUTING.md | 3 ++- design/adr/0115-the-docs-site-is-docfx-built-from-main.md | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 14e4ab11..0c1eb64a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -16,7 +16,7 @@ # OracleCatalogueTests hold the text readers see. A link to a file of the repository that is not a page (design/, # CONTRIBUTING.md) becomes a GitHub address at the built commit in site/template, at build time. # -# Publishing needs Settings > Pages > Source set to "GitHub Actions", which no workflow can set. +# Pages is configured to deploy from GitHub Actions (Settings > Pages > Source), the one setting no workflow can make. # # Actions are pinned to commit SHAs, as in dotnet.yml. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfa05d7c..4943c93b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -214,7 +214,8 @@ run-time packages are published to GitHub Pages, at `--warningsAsErrors`, so a link to a missing page, heading or file fails the PR. A link to a file that is not a page (`design/`, this file) becomes a GitHub address at the built commit, at build time (`site/template/conceptual.extension.js`). -- **Pages needs one setting**, made once by a maintainer: Settings > Pages > Source, "GitHub Actions". +- **Pages is configured** to deploy from GitHub Actions (Settings > Pages > Source), the one setting a + workflow cannot make. To build the site locally (`jq` reads the packages from `site/docfx.json`) and serve it at http://localhost:8080: diff --git a/design/adr/0115-the-docs-site-is-docfx-built-from-main.md b/design/adr/0115-the-docs-site-is-docfx-built-from-main.md index 3bc4f0be..fdee3e8c 100644 --- a/design/adr/0115-the-docs-site-is-docfx-built-from-main.md +++ b/design/adr/0115-the-docs-site-is-docfx-built-from-main.md @@ -62,5 +62,6 @@ Measured with DocFX 2.81.0 on the published assemblies: So the API pages ship. The workflow fails if `Option`'s or `Result`'s page stops listing its extension-block `Select`, so a DocFX update that loses them fails there, not on a reader. -**Needed once from the maintainer:** Settings > Pages > Source set to "GitHub Actions". No workflow can set it, -and until it is set, the first deployment from `main` fails at `configure-pages`. +**Pages is configured** to deploy from GitHub Actions (Settings > Pages > Source), the one setting no workflow can +make: on 2026-10-29 the API reports `build_type: workflow` at `https://caffeinatedcoder.github.io/CodoMetis.TypeKit/`. +The first deployment follows the 2.3.0 release's merge into `main`.