From 5e232a2c9290d7ff07e8641af1de633e9b2fcc27 Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya Date: Sun, 2 Aug 2026 07:15:18 -0700 Subject: [PATCH] feat(docs): assemble a versioned documentation site, and rehearse the tag path before there is a tag (p-docs-s3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Actions now assembles a published tree — dev/ from main, vX.Y.Z/ from each release tag, a latest/ copy of the highest version, a root redirect and a versions.json — onto a docs-published branch the hosting app serves prebuilt. NOTHING ADDED HERE CREATES A TAG OR A RELEASE. The workflow reacts to a tag the maintainer has already pushed: its triggers are a push to main, a push of an existing tag, and a manual dry run. (release.yml's own release step predates this and is not touched.) latest/ follows the highest semver rather than the most recent tag, so tagging a patch on an old line after a newer minor exists does not drag latest backwards. Assembly is idempotent and replaces one version directory at a time, recomputing the derived files from whatever is on the branch — a version an individual run knows nothing about survives it, which is the property that makes rebuilding dev safe once releases exist. The whole pipeline works with dev/ alone, which is today's state and is a supported one rather than a half-built one: latest is null, the root redirect points at dev/, and the dropdown hides itself rather than offering a choice of one. The workflow_dispatch dry run rehearses the tag path before any real tag exists. It cannot damage the live tree two ways over. The assembler is handed the scratch directory AS ITS ROOT, so it writes a complete tree of its own in there and the real dev/, latest/, versions.json and redirect are not addressable from inside it — containment by construction rather than a list of paths to avoid. Then a step asks git what actually changed and fails on anything outside the prefix, because a dry run that quietly republished dev/ would otherwise look exactly like a passing one. The dropdown preserves the reader's current page where the target version has it and falls back to that version's landing page where it does not. It reads each version's page list out of versions.json rather than probing the server: a HEAD request would make correctness depend on how the host answers for a missing file, and a host with an SPA-style 200 fallback would make every switch look successful while landing the reader on a not-found page that renders like a real one. It takes LanguageSelect's header slot, which Starlight renders unconditionally and a single-language site leaves empty, and is not registered at all for the offline reader. One defect fixed on the way, found by building with a base and reading the output rather than by reasoning about it: Astro applies `base` to the links IT generates, but a link written in the guide's Markdown is content and passes through untouched. Under a version segment the sidebar and nav worked while every in-content cross-page link 404ed — the failure a spot check from the front page cannot see. The adapter now applies the same prefix from the same environment variable the Astro config reads, check-web-build.mjs fails a build where any root-absolute reference is missing it, and PR CI builds under /dev/ rather than / so that gate is not decorative. Closes #347 --- .github/workflows/docs-publish.yml | 190 +++++++++++ .github/workflows/docs-site.yml | 21 +- CHANGELOG.md | 42 +++ docs-site/README.md | 30 +- docs-site/astro.config.mjs | 15 + docs-site/package.json | 2 + docs-site/publish/amplify.yml | 26 ++ docs-site/scripts/adapt.mjs | 11 +- docs-site/scripts/assemble.mjs | 258 +++++++++++++++ docs-site/scripts/build-web.mjs | 64 ++++ docs-site/scripts/check-web-build.mjs | 70 +++++ docs-site/src/components/VersionSelect.astro | 104 +++++++ docs-site/test/assemble.test.mjs | 311 +++++++++++++++++++ docs/README.md | 5 +- docs/contributing/docs-site-publishing.md | 153 +++++++++ 15 files changed, 1286 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/docs-publish.yml create mode 100644 docs-site/publish/amplify.yml create mode 100644 docs-site/scripts/assemble.mjs create mode 100644 docs-site/scripts/build-web.mjs create mode 100644 docs-site/scripts/check-web-build.mjs create mode 100644 docs-site/src/components/VersionSelect.astro create mode 100644 docs-site/test/assemble.test.mjs create mode 100644 docs/contributing/docs-site-publishing.md diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 00000000..916f7431 --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,190 @@ +# Assembles the published documentation tree onto the `docs-published` branch +# (ADR-0009 / docs-s3). Linux-only and Node-only, like docs-site.yml; nothing +# here touches the C++/CMake matrix. +# +# THIS WORKFLOW NEVER CREATES A TAG OR A RELEASE. +# Read the triggers: a push to main, a push of a tag that ALREADY EXISTS, and a +# manual dry run. There is no `gh release create`, no `git tag`, no tag push, no +# release action. Only the maintainer publishes (docs/roadmap/README.md, release +# philosophy §4) and this only ever reacts. +# +# Hosting is the maintainer's: the app is connected to `docs-published` and +# serves it prebuilt. See docs/contributing/docs-site-publishing.md. +name: docs publish + +on: + push: + # No `paths` filter, deliberately. A push filter applies to tag pushes too, + # so filtering on docs paths would skip publishing a release whose tag + # happens to carry no documentation change — the one case that must never be + # skipped. Rebuilding `dev` on every push to main is the cheaper mistake: + # the assembler writes only changed bytes and the commit step exits early + # when the tree is identical. + branches: [main] + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'DRY RUN ONLY — version segment to rehearse, e.g. v0.1.0' + required: true + default: 'v0.1.0' + +permissions: + contents: write + +concurrency: + # One writer at a time. Two runs assembling the same branch would race on the + # push and the loser's version directory would vanish without a failure. + group: docs-publish + cancel-in-progress: false + +jobs: + assemble: + name: >- + ${{ github.event_name == 'workflow_dispatch' + && format('DRY RUN {0} — scratch prefix, publishes nothing', inputs.version) + || (startsWith(github.ref, 'refs/tags/') + && format('publish {0}', github.ref_name) + || 'publish dev') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Decide the segment and where it goes + id: plan + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # A dry run assembles a COMPLETE tree of its own under a scratch + # prefix — its own versions.json, latest/ and root redirect. It + # cannot reach the real ones because from inside the scratch root + # they are not addressable: containment by construction rather than + # by remembering to be careful. + segment='${{ inputs.version }}' + scratch='_dryrun' + base="/$scratch/$segment/" + else + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + segment="${GITHUB_REF_NAME}" + else + segment='dev' + fi + scratch='' + base="/$segment/" + fi + + if [[ ! "$segment" =~ ^(dev|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::'$segment' is neither 'dev' nor vMAJOR.MINOR.PATCH" + exit 1 + fi + + { + echo "segment=$segment" + echo "scratch=$scratch" + echo "base=$base" + } >> "$GITHUB_OUTPUT" + + { + if [[ -n "$scratch" ]]; then + echo "### DRY RUN — rehearsing \`$segment\`" + echo "" + echo "Writes **only** under \`$scratch/\`. The live \`dev/\`, \`latest/\`," + echo "\`versions.json\` and root redirect are untouched, and a step below" + echo "proves that against git rather than trusting it." + else + echo "### Publishing \`$segment\`" + fi + echo "" + echo "- base: \`$base\`" + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/setup-node@v4 + with: + node-version-file: docs-site/.nvmrc + cache: npm + cache-dependency-path: docs-site/package-lock.json + + - name: Install + working-directory: docs-site + run: npm ci + + - name: Licence gate + working-directory: docs-site + run: npm run licenses + + # Ends in check-web-build.mjs, which fails if any root-absolute reference + # is missing the segment prefix — the failure that leaves the sidebar + # working and every in-content link dead. + - name: Build the site for this segment + working-directory: docs-site + run: npm run build:web -- --base=${{ steps.plan.outputs.base }} + + # A second checkout so the publishing branch never shares a working tree + # with the source. actions/checkout persists the token, which is what lets + # the push at the end authenticate. + - uses: actions/checkout@v4 + with: + path: published + + - name: Prepare the publishing branch + working-directory: published + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + if git ls-remote --exit-code --heads origin docs-published > /dev/null 2>&1; then + git fetch --depth 1 origin docs-published + git checkout -B docs-published origin/docs-published + else + # First run: start the branch with no history of the source tree. + echo "docs-published does not exist yet — creating it" + git checkout --orphan docs-published + git rm -rq --cached . || true + find . -mindepth 1 -maxdepth 1 -not -name .git -exec rm -rf {} + + fi + + - name: Assemble + run: | + set -euo pipefail + node docs-site/scripts/assemble.mjs \ + --root=published \ + --segment='${{ steps.plan.outputs.segment }}' \ + --build=docs-site/dist \ + --scratch='${{ steps.plan.outputs.scratch }}' | tee assemble.log + { + echo '' + echo '```' + cat assemble.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Verify the dry run stayed inside its scratch prefix + if: github.event_name == 'workflow_dispatch' + working-directory: published + run: | + set -euo pipefail + # Independent of the assembler's own containment: ask git what actually + # changed. A dry run that quietly republished dev/ would otherwise look + # exactly like a pass. + outside=$(git status --porcelain -- . ':(exclude)_dryrun' || true) + if [[ -n "$outside" ]]; then + echo '::error::the dry run modified paths outside _dryrun/:' + echo "$outside" + exit 1 + fi + echo 'dry run touched only _dryrun/ — verified against git, not assumed' + + - name: Commit and push + working-directory: published + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then + echo 'nothing to publish — the assembled tree is byte-identical' + exit 0 + fi + kind='publish' + if [[ -n '${{ steps.plan.outputs.scratch }}' ]]; then kind='dry run'; fi + git commit -m "docs: $kind ${{ steps.plan.outputs.segment }} (${GITHUB_SHA:0:7})" + git push origin HEAD:docs-published diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml index 7024aedd..d95be814 100644 --- a/.github/workflows/docs-site.yml +++ b/.github/workflows/docs-site.yml @@ -48,22 +48,17 @@ jobs: - name: Licence gate run: npm run licenses - - name: Theme tokens (from theme.cpp) - run: npm run theme - - - name: Adapt docs/user-guide - run: npm run adapt - - # Every F1-reachable page must exist on the site too, so a page can never - # be reachable in-app but missing here. - - name: F1 coverage - run: npm run check - - name: Script tests run: npm test - - name: Build - run: npx astro build + # Built under a version segment rather than at the root, because that is + # how it is actually published and because a root build cannot exercise + # the base at all: Astro prefixes the links IT generates, so a missing + # prefix on the links written in the guide's Markdown is invisible until + # something is served from `/dev/`. build:web runs theme -> adapt -> F1 + # coverage -> astro build -> the base check, in that order. + - name: Build (under a version segment, as published) + run: npm run build:web -- --base=/dev/ - uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c466548a..3810cb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Current version on `main`: **0.0.1**. ### Added +- **The documentation site publishes itself, versioned** + ([#347](https://github.com/Robomous/RoadMaker/issues/347), docs-s3 — + [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). GitHub + Actions assembles a published tree — `dev/` from `main`, `vX.Y.Z/` from each + release tag, a `latest/` copy of the highest version, a root redirect and a + `versions.json` — onto a `docs-published` branch that the hosting app serves + prebuilt. Runbook: + [Publishing the documentation site](docs/contributing/docs-site-publishing.md). + + **Nothing added here creates a tag or a release.** The workflow reacts to a tag + the maintainer has already pushed; publishing stays their decision + ([release philosophy](docs/roadmap/README.md#release-philosophy)). + + `latest/` follows the **highest semver, not the most recent tag**, so patching + an old line after a newer minor exists does not drag `latest` backwards. + Assembly is idempotent and replaces one version directory at a time, + recomputing the derived files from whatever is on the branch — a version an + individual run knows nothing about survives it. + + The whole pipeline works with `dev/` alone, which is today's state: `latest` is + `null`, the root redirect points at `dev/`, and the version dropdown hides + itself rather than offering a choice of one. + + A `workflow_dispatch` **dry run** rehearses the tag-driven path into a scratch + prefix before any real tag exists. It cannot damage the live tree two ways + over: the assembler is handed the scratch directory as its root, so the real + `dev/`, `latest/`, `versions.json` and redirect are not addressable from + inside it; and a following step asks git what changed and fails if anything + outside the prefix did. + + The version dropdown preserves the reader's current page where the target + version has it and falls back to that version's landing page where it does + not. It reads each version's page list out of `versions.json` rather than + probing the server, because a host that answers a missing file with a 200 + fallback would make a broken switch look like a working one. + + One defect fixed on the way: Astro applies its `base` to the links it + generates, but a link written in the guide's Markdown is content and passed + through untouched — so under a version segment the sidebar and nav worked + while every in-content cross-page link 404ed. The adapter now applies the same + prefix, and `check-web-build.mjs` fails a build where any root-absolute + reference is missing it. - **The manual ships with the app, and reference pages bridge into it** ([#346](https://github.com/Robomous/RoadMaker/issues/346), docs-s2 — [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). Every diff --git a/docs-site/README.md b/docs-site/README.md index c23c1083..5a8da1ec 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -35,6 +35,7 @@ Tiers ([ADR-0009](../docs/decisions/0009-documentation-site-tiered-docs.md)): npm ci npm run build # theme -> adapt -> F1 coverage -> astro build npm run build:local # the offline reader that ships in a release +npm run build:web -- --base=/dev/ # the published site, for one version npm run dev # same as build, then a dev server npm run licenses # licence gate over the installed tree npm test # script tests (node:test) @@ -47,7 +48,7 @@ target. | Build | Output | Search | Links | |---|---|---|---| -| `build` (web) | directory URLs | Pagefind | root-absolute | +| `build:web` | directory URLs, under `--base` | Pagefind | root-absolute, segment-prefixed | | `build:local` | `format: 'file'` | **off** | fully relative | `build:local` produces the copy bundled in every release, which a reader opens @@ -73,6 +74,33 @@ A maintained relative-links integration was considered and rejected: every npm package here is a permanent obligation under the licence gate, and this transform is string work over a directory of HTML. +## Versioned publishing + +`build:web` takes a `--base=//`, because each published version is a +path segment (`/dev/`, `/v0.1.0/`). The base reaches **two** consumers through +one environment variable, and that is not incidental: Astro prefixes the links +*it* generates — sidebar, nav, assets — but a link written in the guide's +Markdown is content and passes through untouched, so `adapt.mjs` has to apply +the same prefix. Getting that wrong leaves the sidebar working perfectly and +every in-content link dead, which is why `check-web-build.mjs` gates it. + +`scripts/assemble.mjs` builds the published tree — version directories, a +`latest/` copy of the **highest semver** (not the most recent tag), a root +redirect, and the `versions.json` the header dropdown reads. It is idempotent +and non-destructive: it replaces one segment and recomputes the derived files +from whatever is on disk, so a version it knows nothing about survives. + +`versions.json` carries each version's **page list**, so the dropdown preserves +the reader's current page without probing the server — a host that answers a +missing file with a 200 fallback would otherwise make a broken switch look fine. + +The whole thing works with `dev/` alone, which is today's state: `latest` is +`null`, the root redirect points at `dev/`, and the dropdown hides itself rather +than offering a choice of one. + +Maintainer runbook, including the dry run: +[Publishing the documentation site](../docs/contributing/docs-site-publishing.md). + ## The reference → guide bridge A reference page may end with a section under the exact heading `## Full guide` diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 782e48c6..2c28a3fc 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -15,7 +15,14 @@ import starlight from '@astrojs/starlight'; // turns the root-absolute refs Astro emits into relative ones. const local = process.env.RM_DOCS_TARGET === 'local'; +// Published under a version segment (`/dev/`, `/v0.1.0/`, or a dry-run scratch +// prefix) — docs-s3. Always a leading and trailing slash so it composes by +// concatenation; `/` for a site served from the domain root, and for the local +// reader, which has no server and no site root at all. +const base = local ? '/' : (process.env.RM_DOCS_BASE ?? '/'); + export default defineConfig({ + base, // Astro's default image service is `sharp`, whose prebuilt libvips binaries // are LGPL-3.0-or-later. Qt is this project's ONLY sanctioned LGPL dependency // (docs/standards/dependencies.md), so the passthrough service is used and @@ -36,6 +43,14 @@ export default defineConfig({ // Never ship a search box that does nothing: switching Pagefind off also // removes the header UI that would query it. pagefind: !local, + // The version dropdown takes LanguageSelect's slot: the header renders it + // unconditionally and a single-language site leaves it empty, so it is a + // header position already shaped for choosing a variant of the site. + // + // Not registered at all for the local reader. Guarding inside the + // component would still ship its hoisted + + +`; +} + +/** Copy a directory over the top of a destination, replacing it wholesale. */ +function replaceDir(src, dest) { + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { recursive: true }); +} + +/** Write only when the bytes differ, so an unchanged run reports nothing changed. */ +function writeIfChanged(path, content) { + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false; + writeFileSync(path, content); + return true; +} + +/** + * Place `buildDir` at `segment` inside `publishRoot` and recompute the derived + * files. + * + * `scratch` is the dry run: everything, including that run's own versions.json, + * latest/ and root redirect, is written inside `publishRoot//`. That is + * a containment property rather than a promise — the real dev/, latest/, + * versions.json and root redirect are not addressable from inside the scratch + * root at all, so a dry run cannot reach them even if this code is wrong. + */ +export function assemble({ publishRoot, buildDir, segment, scratch = '', rootPath = '/' }) { + if (!SEGMENT_RE.test(segment)) { + throw new Error( + `refusing to publish segment '${segment}': expected 'dev' or 'vMAJOR.MINOR.PATCH'`, + ); + } + if (buildDir && !existsSync(join(buildDir, 'index.html'))) { + throw new Error(`'${buildDir}' has no index.html — that is not a built site`); + } + + const root = scratch ? join(publishRoot, scratch) : publishRoot; + const effectiveRootPath = scratch ? `${rootPath}${scratch}/`.replace(/\/+/g, '/') : rootPath; + mkdirSync(root, { recursive: true }); + + const changed = []; + + if (buildDir) { + replaceDir(buildDir, join(root, segment)); + changed.push(`${segment}/`); + } + + // latest/ is a COPY, not a link: the host serves files, and a symlink in a git + // tree is a file containing a path. + const manifest = buildVersionsManifest(root, effectiveRootPath); + if (manifest.latest) { + replaceDir(join(root, manifest.latest), join(root, 'latest')); + changed.push(`latest/ (= ${manifest.latest})`); + } + + if (manifest.default) { + if (writeIfChanged(join(root, 'index.html'), buildRootRedirect(manifest.default))) { + changed.push(`index.html -> ${manifest.default}/`); + } + } + + if (writeIfChanged(join(root, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`)) { + changed.push('versions.json'); + } + + const amplify = join(dirname(fileURLToPath(import.meta.url)), '..', 'publish', 'amplify.yml'); + if (existsSync(amplify)) { + if (writeIfChanged(join(root, 'amplify.yml'), readFileSync(amplify, 'utf8'))) { + changed.push('amplify.yml'); + } + } + + return { root, manifest, changed }; +} + +// ------------------------------------------------------------------ as a script + +function arg(name, fallback = '') { + const prefix = `--${name}=`; + const found = process.argv.find((a) => a.startsWith(prefix)); + return found ? found.slice(prefix.length) : fallback; +} + +const here = fileURLToPath(import.meta.url); +if (process.argv[1] && join(process.argv[1]) === here) { + const publishRoot = arg('root'); + const segment = arg('segment'); + if (!publishRoot || !segment) { + console.error( + 'usage: assemble.mjs --root= --segment=dev|vX.Y.Z [--build=] [--scratch=]', + ); + process.exit(1); + } + try { + const { manifest, changed } = assemble({ + publishRoot, + buildDir: arg('build'), + segment, + scratch: arg('scratch'), + }); + console.log(`assemble: ${changed.length > 0 ? changed.join(', ') : 'nothing changed'}`); + console.log( + `assemble: ${manifest.versions.length} version(s), latest=${manifest.latest ?? '(none yet)'}, root -> ${manifest.default}/`, + ); + } catch (error) { + console.error(`assemble: ${error.message}`); + process.exit(1); + } +} diff --git a/docs-site/scripts/build-web.mjs b/docs-site/scripts/build-web.mjs new file mode 100644 index 00000000..39067c5c --- /dev/null +++ b/docs-site/scripts/build-web.mjs @@ -0,0 +1,64 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// `npm run build:web -- --base=/dev/` — the published site, for one version +// segment (ADR-0009 / docs-s3). Search on, directory URLs, and every link +// prefixed with the segment. +// +// The base has to reach BOTH the Astro config (which prefixes the links Astro +// generates) and the adapter (which prefixes the links written in the guide's +// Markdown — content Astro passes through untouched). Passing it through one +// environment variable is what keeps those two from disagreeing. +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); + +const raw = + process.argv.find((a) => a.startsWith('--base='))?.slice('--base='.length) ?? + process.env.RM_DOCS_BASE ?? + '/'; +// Normalise to leading + trailing slash so the two consumers compose it by +// plain concatenation and cannot differ over a missing separator. +const base = `/${raw.replace(/^\/+|\/+$/g, '')}/`.replace('//', '/'); + +const env = { ...process.env, RM_DOCS_TARGET: 'web', RM_DOCS_BASE: base }; + +function step(label, args) { + console.log(`\nbuild:web (${base}) — ${label}`); + const result = spawnSync(process.execPath, args, { + cwd: root, + env, + stdio: 'inherit', + shell: false, + }); + if (result.error) { + console.error(`build:web: ${label} could not start: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) { + console.error(`build:web: ${label} failed (exit ${result.status})`); + process.exit(result.status ?? 1); + } +} + +step('theme tokens', [join(here, 'theme-css.mjs')]); +step('adapt docs/user-guide', [join(here, 'adapt.mjs')]); +step('F1 coverage', [join(here, 'check-f1-coverage.mjs')]); +step('astro build', [join(root, 'node_modules', 'astro', 'astro.js'), 'build']); +step('verify the base', [join(here, 'check-web-build.mjs')]); + +console.log(`\nbuild:web: dist/ is ready to publish under ${base}`); diff --git a/docs-site/scripts/check-web-build.mjs b/docs-site/scripts/check-web-build.mjs new file mode 100644 index 00000000..a925f1fe --- /dev/null +++ b/docs-site/scripts/check-web-build.mjs @@ -0,0 +1,70 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The gate on a versioned web build (docs-s3): every root-absolute reference +// must carry the version segment. +// +// This exists because the failure it catches is close to invisible. Astro +// prefixes the links IT generates, so the sidebar and the nav are correct and +// the site looks fine; only the links written in the guide's own Markdown are +// left bare, and those 404 the moment a reader follows one from inside a page. +// A build under `/dev/` that emits `/reference/junction` is broken in exactly +// the places a spot check does not look. +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { htmlFiles } from './relativize.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distDir = process.argv[2] ?? join(here, '..', 'dist'); +const base = process.env.RM_DOCS_BASE ?? '/'; + +if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { + console.error(`check-web-build: no build at ${distDir}`); + process.exit(1); +} + +const pages = htmlFiles(distDir); +if (pages.length === 0) { + console.error('check-web-build: the build contains no HTML pages at all'); + process.exit(1); +} + +const failures = []; +let checked = 0; + +for (const pageRel of pages) { + const html = readFileSync(join(distDir, pageRel), 'utf8'); + for (const match of html.matchAll(/\b(href|src|srcset)="([^"]*)"/g)) { + const [, attr, value] = match; + const urls = attr === 'srcset' ? value.split(',').map((p) => p.trim().split(/\s+/)[0]) : [value]; + for (const url of urls) { + // `//host/…` is protocol-relative, i.e. genuinely off-site. + if (!url.startsWith('/') || url.startsWith('//')) continue; + checked += 1; + if (!url.startsWith(base)) { + if (failures.length < 20) failures.push(`${pageRel}: ${attr}="${url}" is missing the ${base} prefix`); + else if (failures.length === 20) failures.push('…and more'); + } + } + } +} + +if (failures.length > 0) { + console.error(`check-web-build: root-absolute references outside ${base}`); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log(`check-web-build: ${pages.length} pages, ${checked} absolute references, all under ${base}`); diff --git a/docs-site/src/components/VersionSelect.astro b/docs-site/src/components/VersionSelect.astro new file mode 100644 index 00000000..51d9b272 --- /dev/null +++ b/docs-site/src/components/VersionSelect.astro @@ -0,0 +1,104 @@ +--- +// The header's version dropdown (ADR-0009 / docs-s3). +// +// It overrides Starlight's LanguageSelect, which the site's own Header renders +// unconditionally but which stays empty for a single-language site — so this is +// the one header slot already shaped for "pick a variant of this site", and +// using it needs no layout surgery. Select.astro is Starlight's own control, so +// the dropdown matches the theme selector beside it. +// +// The manifest lives at the PUBLISH ROOT, one level above this version's base, +// and both paths are known at build time — the component never has to guess +// where it is. +import Select from '@astrojs/starlight/components/Select.astro'; + +const base = import.meta.env.BASE_URL; +// `/dev/` -> `/`; `/_dryrun/v9.9.9/` -> `/_dryrun/`. A build served from the +// domain root has no version segment and therefore no sibling versions. +const root = base.replace(/[^/]+\/$/, ''); +const versioned = base !== '/'; +--- + +{ + versioned && ( + +