diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index ba381c7..071afec 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -11,3 +11,6 @@ paths: .github/workflows/cd-staging.yml: ignore: - '^unexpected key "cache-mode" for "workflow" section\.' + .github/workflows/docs-pages.yml: + ignore: + - '^unexpected key "cache-mode" for "workflow" section\.' diff --git a/.github/actions/docs/action.yml b/.github/actions/docs/action.yml new file mode 100644 index 0000000..b4f87fb --- /dev/null +++ b/.github/actions/docs/action.yml @@ -0,0 +1,39 @@ +name: Build documentation +description: Build and validate the rendered documentation site, then retain review evidence. + +inputs: + install-dotnet: + description: Install the SDK pinned by global.json when the caller has not already done so. + required: false + default: "true" + +runs: + using: composite + steps: + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + if: ${{ inputs.install-dotnet == 'true' }} + env: + # Keep the pinned SDK isolated from runner-installed workload manifests. + DOTNET_INSTALL_DIR: ${{ runner.temp }}/ogb-dotnet + with: + global-json-file: global.json + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + package-manager-cache: false + + - name: Build and validate documentation + shell: pwsh + run: ./scripts/check-docs.ps1 + + - name: Upload rendered documentation and diagnostics + if: ${{ always() && !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ci-docs-${{ github.run_attempt }} + path: | + artifacts/docs/site/ + artifacts/docs/*.log + if-no-files-found: warn + retention-days: 7 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6fb7186..80f9cd1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -108,6 +108,29 @@ updates: cooldown: default-days: 3 + # DocFX is isolated from opt-in developer tools. + - package-ecosystem: "nuget" + directory: "/docs" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "America/Indiana/Indianapolis" + open-pull-requests-limit: 2 + cooldown: + default-days: 3 + + - package-ecosystem: "github-actions" + directory: "/.github/actions/docs" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:15" + timezone: "America/Indiana/Indianapolis" + open-pull-requests-limit: 3 + cooldown: + default-days: 3 + # API Dockerfile base images. - package-ecosystem: "docker" directory: "/src/OpenGameBuilder.Api" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 793fa61..8123611 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: mode: content artifact-name: ci-documentation-validation + - name: Build and validate documentation site + uses: ./.github/actions/docs + windows: name: Windows solution needs: select-checks @@ -91,6 +94,11 @@ jobs: with: artifact-name: ci-linux-validation + - name: Build and validate documentation site + uses: ./.github/actions/docs + with: + install-dotnet: "false" + - name: Build API image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..1d5003f --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,62 @@ +name: Publish documentation + +cache-mode: none + +# Publication is an explicit maintainer action, never a side effect of a PR. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: documentation-pages + cancel-in-progress: false + +jobs: + build: + name: Validate protected documentation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Require protected main + env: + SOURCE_REF: ${{ github.ref }} + SOURCE_PROTECTED: ${{ github.ref_protected }} + run: | + if [[ "$SOURCE_REF" != refs/heads/main || "$SOURCE_PROTECTED" != true ]]; then + echo 'Documentation publication requires a dispatch from protected main.' >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Validate source content + uses: ./.github/actions/validate + with: + mode: content + artifact-name: pages-content-validation + - name: Build and check the documentation site + uses: ./.github/actions/docs + - name: Package the validated site + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 + with: + path: artifacts/docs/site + + deploy: + name: Publish validated documentation + needs: build + if: ${{ github.ref == 'refs/heads/main' && github.ref_protected }} + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Publish to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/docfx.json b/docfx.json new file mode 100644 index 0000000..3e26d3b --- /dev/null +++ b/docfx.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "rules": { + "InvalidHref": "error", + "InvalidFileLink": "info" + }, + "build": { + "content": [ + { + "files": ["*.md", "docs/**/*.md", "toc.yml"], + "exclude": ["AGENTS.md", "docs/foundation-checklist.md"] + } + ], + "output": "artifacts/docs/site", + "fileMetadataFiles": ["artifacts/docs/source-metadata.json"], + "template": ["default", "modern"], + "globalMetadata": { + "_appName": "OpenGameBuilder", + "_appTitle": "OpenGameBuilder documentation", + "_enableSearch": true, + "_appLogoPath": "", + "_gitUrlPattern": "github" + } + } +} diff --git a/docs/.config/dotnet-tools.json b/docs/.config/dotnet-tools.json new file mode 100644 index 0000000..f12983d --- /dev/null +++ b/docs/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.80.1", + "commands": ["docfx"], + "rollForward": false + } + } +} diff --git a/docs/README.md b/docs/README.md index 36c7af2..5893f1b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,10 @@ application. The [public roadmap](https://github.com/orgs/OpenGameBuilder/projec tracks project work. There is no game runtime or editor yet; the first engine milestone's behavior and acceptance criteria remain to be agreed. +These guides also build as a searchable site. See +[documentation maintenance](setup/documentation.md) for the build, PR preview, +and publication procedure. + ## Working on the application | Task | Read | diff --git a/docs/foundation-checklist.md b/docs/foundation-checklist.md index 9fdf265..03ce37a 100644 --- a/docs/foundation-checklist.md +++ b/docs/foundation-checklist.md @@ -540,11 +540,11 @@ Sources: [Playwright servers](https://playwright.dev/docs/test-webserver), ### 17. Publish searchable documentation and checked examples -- [ ] Build a DocFX site with its modern template from the existing Markdown. +- [x] Build a DocFX site with its modern template from the existing Markdown. Keep one source copy per document, generated HTML untracked, and concise navigation for setup, contribution, architecture, testing, and operations. Exclude this temporary plan from site content and navigation. -- [ ] Add search and edit links, a PR site build, and checks of rendered local +- [x] Add search and edit links, a PR site build, and checks of rendered local links/anchors. Use appropriate failing diagnostic severities for broken content. Keep external-link maintenance separate as described in step 14. - [ ] Configure publication from validated protected content when authorized, @@ -561,6 +561,24 @@ generation do not block the first engine slice. Sources: [DocFX template](https://dotnet.github.io/docfx/docs/template.html), [.NET reference](https://dotnet.github.io/docfx/docs/dotnet-api-docs.html). +**Result (2026-09-22, local acceptance):** DocFX 2.80.1 builds 27 existing-source +pages with the modern template, task navigation, search, and original-source edit +links. The temporary plan and agent instructions are excluded; generated output +stays under ignored `artifacts/docs/site`. Repository-file links stay relative in +Markdown and resolve to the built source revision in the site. `check-docs.ps1` passed with zero build +warnings, all rendered local links/anchors, search and edit-link inventory, and +four regression groups rejecting deliberate content defects. Both selected PR +lanes run the gate and retain review artifacts. `check.ps1 full -Serial` also +passed, including the 72 .NET tests, content and CI-policy regressions, frontend +packaging, and five shell suites. + +The manual protected-`main` Pages workflow and guarded `github-pages` environment +are configured, but the workflow has not been dispatched. Actual hosted acceptance +remains pending. The first hosted run of the new PR checks is also unverified. The engine-example and +filtered-reference item remains open because steps 7–8 have no implemented slice +to compile or reuse. [Documentation maintenance](setup/documentation.md) records +the permanent build, publication, evidence, and example-adoption procedures. + ### 18. Clarify branches and make release notes useful - [ ] Document protected `main`, short-lived branches, draft PRs, squash merges, diff --git a/docs/quality/content-checks.md b/docs/quality/content-checks.md index 38db31e..651cbb3 100644 --- a/docs/quality/content-checks.md +++ b/docs/quality/content-checks.md @@ -72,7 +72,8 @@ content prerequisites before opting into the hook. See GitHub supports [`cache-mode: none`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#cache-mode), but actionlint 1.7.12 does not recognize that key. The [actionlint configuration](../../.github/actionlint.yaml) suppresses only that -specific root-key diagnostic in the three protected-source deployment workflows. +specific root-key diagnostic in the protected-source deployment workflows, +including manual documentation publication. The [cache-policy check](../../scripts/workflow-cache-policy.mjs) independently requires `none` and rejects job overrides that permit caching. Other workflow errors still fail, and the regression suite covers missing/changed cache policy. diff --git a/docs/quality/testing.md b/docs/quality/testing.md index c7ad51a..4351ceb 100644 --- a/docs/quality/testing.md +++ b/docs/quality/testing.md @@ -89,10 +89,10 @@ lane. An empty range, any other path, patch-branch push, or manual run selects full validation. A failed comparison fails selection and the required gate; it cannot silently skip validation. The selector logs the paths and decision. -| Selection | Required validation | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Documentation | Ubuntu runs `check.ps1 content`: first-party formatting, lint, workflow/shell checks, and all local Markdown links/anchors. Checking all documents catches backlinks broken by deletions or renames. No solution build or container runs. | -| Full | Windows runs `check.ps1 quick -Serial` for locked restore, C# format, Release build, and tests. Ubuntu runs `check.ps1 full`, then builds and checks the API container. | +| Selection | Required validation | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Documentation | Ubuntu runs `check.ps1 content` and `check-docs.ps1`: source formatting, lint, workflow/shell checks, local Markdown links, and the DocFX site with rendered link/anchor and search checks. No application solution build or container runs. | +| Full | Windows runs `check.ps1 quick -Serial` for locked restore, C# format, Release build, and tests. Ubuntu runs `check.ps1 full` and `check-docs.ps1`, then builds and checks the API container. | Both platforms restore and build the entire solution, including the AppHost, and run both test projects. Only the shipped API and Web Client have committed @@ -126,6 +126,26 @@ These tests exercise real temporary Git histories and lane-result combinations; they do not prove GitHub scheduling or merge enforcement. The hosted acceptance procedure is in [GitHub setup](../setup/github.md#ci-merge-gate). +### Documentation site validation + +Run `pwsh ./scripts/check-docs.ps1` after installing the content prerequisites. +It restores the pinned DocFX tool, builds the existing guides with warnings as +errors, checks rendered links and anchors offline, and verifies search entries +and edit links against the original Markdown. Regression fixtures require an +unresolved document, missing rendered page, renamed anchor, missing stylesheet, +missing search entry, and accidentally included temporary plan to fail. +Additional fixtures check relative page links and repository-source links at two +different commits, reject omitted documentation and assets, and preserve the +source Markdown. See the [link resolution rules](../setup/documentation.md#build-and-check). + +Both selected Ubuntu lanes run this command through the documentation action; +the required `build-test` check cannot pass when it fails. The `ci-docs` artifact +retains the rendered site and available logs for seven days. The command does +not start an application or browser. See [documentation maintenance](../setup/documentation.md) +for preview, publication, and the separate hosted acceptance procedure. It is +separate from `check.ps1 full`, so application deployment validation does not +acquire an unrelated site build. + ### Recorded platform CI acceptance The [clean hosted run](https://github.com/OpenGameBuilder/opengamebuilder/actions/runs/35748017596) diff --git a/docs/setup/documentation.md b/docs/setup/documentation.md new file mode 100644 index 0000000..5c6755d --- /dev/null +++ b/docs/setup/documentation.md @@ -0,0 +1,122 @@ +# Documentation site + +The DocFX site renders the existing root and `docs/` Markdown with the modern +template, search, and links to the original files at the built commit. Maintain those +files directly; there is no separate wiki or copied set of guides. The temporary +foundation plan is excluded from the site and navigation. + +## Build and check + +Use the SDK from `global.json`, PowerShell 7, and Node.js 22. Install the +[content prerequisites](../quality/content-checks.md), then run: + +```pwsh +pwsh ./scripts/check-docs.ps1 +``` + +The command restores the exact DocFX tool from `docs/.config/dotnet-tools.json`, +builds with warnings as errors, checks rendered local links and anchors offline, +and runs deliberate-defect checks. Output and logs are untracked under +`artifacts/docs/`; no application, browser, or server starts during validation. +The separate manifest does not restore Husky or install Git hooks. + +To preview the checked output, explicitly start DocFX's local server: + +```pwsh +Set-Location docs +dotnet tool run docfx -- serve ../artifacts/docs/site +``` + +Documentation and repository-source links stay relative in Markdown. During the +site build, links to code, scripts, workflows, and repository directories are +rewritten to the exact source revision because those files are not site pages. +Links between site pages stay relative, including when hosted under a different +base path. Source links and each page's edit link use the build's Git commit, +so branch previews and releases do not silently point to newer `main` content. +Local previews include working-tree edits, but their source links point to `HEAD`. +Commit newly added pages and linked repository files before building, and push +that commit before sharing its source links. The build rejects source paths that +do not exist in the selected commit, including generated or untracked files. + +DocFX reports repository-file links omitted from the site as informational +`InvalidFileLink` diagnostics. The resolver verifies these targets before rewriting +them, and the rendered-link gate still rejects missing pages, anchors, and assets. +Other build warnings remain errors. + +Remote availability remains part of the separate [external-link maintenance +report](../quality/content-checks.md), not the PR gate. + +## Pull requests + +Both the documentation lane and the Linux full lane build and check the site. +A failed site check fails the existing required `build-test` gate. Each retains +the generated site and available diagnostic logs for seven days. Download the +site artifact to review the rendered change before publication. + +## Recorded local acceptance + +On 2026-09-22, `check-docs.ps1` passed on Windows 11 (build 26200), with SDK +10.0.401, Node.js 22.23.2, DocFX 2.80.1, and the pinned Lychee 0.24.2. All 27 pages +built with zero warnings; rendered local links and anchors, search entries, +original-source edit links, and four deliberate-defect regression groups passed. +The tests rejected a broken DocFX source link, missing rendered page, renamed +anchor, missing stylesheet, missing search entry, and accidental plan publication. +Fixtures for two source commits verified that page links remain relative while +repository links follow the selected commit without changing the Markdown. + +The full local repository check also passed: locked restore, format, a Release +build with zero warnings, all 72 .NET tests, content and CI policy regressions, +frontend packaging, and five shell suites. The new hosted PR checks, browser +interaction with documentation search, and Pages deployment remain unverified. +No service or browser was started for these local checks. + +## Publication and hosted acceptance + +Publication requires explicit maintainer authorization. The manual +[publication workflow](../../.github/workflows/docs-pages.yml) +accepts only protected `main`, checks out the dispatch's exact commit, reruns the +content and site gates, and deploys that run's artifact through `github-pages`. +Only the deployment job receives Pages write and OIDC permissions. Pull requests +cannot publish and no push automatically publishes. + +Before the first authorized publication: + +1. Merge the reviewed implementation through the required PR checks. +2. Select **GitHub Actions** as the repository's Pages source. Restrict the + `github-pages` environment to `main` and require a maintainer review. Disable + administrator bypass where available; do not weaken branch or environment + protections to run the workflow. +3. Dispatch **Publish documentation** from `main` and approve the deployment + after reviewing its checked artifact. Do not add arbitrary ref inputs or + reuse an artifact from an untrusted PR workflow. +4. At the URL returned by deployment, open Setup, Contribute, Architecture, + Testing, and Operations. Reload a nested page, check its CSS and script loads, + search for `rollback`, follow a result and a heading link, and confirm its edit + link opens the correct Markdown at the deployed commit. Check the browser + console for errors. +5. Record the source SHA, workflow URL, site URL, browser/version, date, and actual + results here. A green build alone does not satisfy hosted acceptance. + +An initial check on 2026-09-22 found protected `main` and no configured Pages +site (HTTP 404). Later that day, Pages was configured to publish through GitHub +Actions at with HTTPS +enforced. The `github-pages` environment is restricted to `main`, requires review +by `ostomachion`, permits that reviewer to approve their own deployment, and does +not allow administrator bypass. No publication workflow has been dispatched. +Hosted navigation, assets, search, and links remain unverified. +This follows GitHub's [custom Pages workflow requirements](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages). + +## Checked examples and API reference + +There is no engine implementation or executable engine example yet. Its behavior +and acceptance criteria must be selected and implemented first. With that first +slice, add the example to the solution's Release build and tests, execute it in +CI, and include its checked source in the tutorial using DocFX code snippets +instead of copying a second implementation. Add a filtered public .NET reference +when the engine exposes useful APIs; exclude internal and host-only surfaces. +The [HTTP API guide](../backend/api.md) and development-only OpenAPI/Scalar remain +the documentation for the HTTP host. + +The site uses DocFX's [modern template](https://dotnet.github.io/docfx/docs/template.html), +[strict build diagnostics](https://dotnet.github.io/docfx/reference/docfx-cli-reference/docfx-build.html), +and [source snippet support](https://dotnet.github.io/docfx/docs/markdown.html). diff --git a/docs/setup/github.md b/docs/setup/github.md index 3a2c7e4..522ac58 100644 --- a/docs/setup/github.md +++ b/docs/setup/github.md @@ -15,7 +15,8 @@ tests in its place. CI runs on every pull request, without workflow-level path filters, on pushes to `patch/v*`, and by manual dispatch. `build-test` aggregates the selection job -and its selected lanes. Documentation-only PRs run the shared content check; +and its selected lanes. Documentation-only PRs run the shared content check and +the DocFX site build with rendered link, anchor, search, and edit-link checks; application, workflow, tooling, mixed, and unrecognized changes require both Windows solution checks and Linux full validation. Pushes and manual runs always require both platforms. See [selection and lane coverage](../quality/testing.md#pr-lanes-and-the-required-gate) @@ -25,7 +26,7 @@ The [shared validation action](../../.github/actions/validate/action.yml) uses the repository's `content`, `quick`, or `full` command. Both OS lanes run locked restore, formatting verification, a Release solution build (including AppHost), and all tests. Linux additionally checks content, frontend packaging, smoke-package -installation/syntax, and the shell suites, then builds and verifies the API +installation/syntax, the documentation site, and the shell suites, then builds and verifies the API container without pushing it. The aggregate requires explicit `success` for every selected lane and `skipped` for unselected lanes; failures, cancellations, missing results, and unexpected skips cannot pass it. Keep only the stable @@ -54,8 +55,11 @@ into a separate directory and runs the shared checks there. This keeps executabl pipeline definitions separate from the selected application's files. Third-party Actions in workflows and in the local composite validation action are pinned to reviewed commit SHAs with adjacent release comments. Separate -Dependabot entries cover `.github/workflows` and `.github/actions/validate`, so -both sets continue to receive reviewable version-update PRs. +Dependabot entries cover `.github/workflows`, `.github/actions/validate`, and +`.github/actions/docs`, so their pins continue to receive reviewable update PRs. +The DocFX tool manifest under `docs/.config` has its own NuGet update entry. +See [documentation publication](documentation.md#publication-and-hosted-acceptance) +for the manual protected-`main` Pages workflow and its outstanding hosted acceptance. The shared PowerShell command checks native exit codes while retaining output. The API image check uses Bash with `-e -o pipefail`, so `tee` cannot hide failure. diff --git a/index.md b/index.md new file mode 100644 index 0000000..1515a48 --- /dev/null +++ b/index.md @@ -0,0 +1,14 @@ +# OpenGameBuilder documentation + +Build and contribute to the independent MyGameBuilder reimplementation. +The application currently provides an API and web shell; the game runtime and +editor are still to come. Read the [project overview](README.md) for current scope. + +- [Set up the supported Windows environment](docs/setup/development.md). +- [Find the contribution and support routes](CONTRIBUTING.md). +- [Understand the project boundaries](docs/README.md#repository-map). +- [Run checks and interpret their evidence](docs/quality/testing.md). +- [Deploy, operate, and recover the application](docs/setup/hosting.md). + +Use the search box to find procedures across the guides, or use the navigation +to browse by task. Each guide links to its original Markdown for edits. diff --git a/package-lock.json b/package-lock.json index 888578e..955577b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "opengamebuilder-tooling", "devDependencies": { "markdownlint-cli2": "0.23.3", + "parse5": "8.0.1", "prettier": "3.9.8" }, "engines": { @@ -1212,6 +1213,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", diff --git a/package.json b/package.json index cf14100..7f60ee0 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "markdownlint-cli2": "0.23.3", + "parse5": "8.0.1", "prettier": "3.9.8" } } diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..565bea9 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,141 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { sourceRepository } from "./docs-links.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const siteRoot = path.join(root, "artifacts/docs/site"); + +function filesUnder(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(directory, entry.name); + return entry.isDirectory() ? filesUnder(file) : [file]; + }); +} + +function lycheePath() { + const platform = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; + const spec = JSON.parse( + readFileSync(path.join(root, "scripts/content-tools.json"), "utf8"), + ).tools.lychee.platforms[platform]; + const executable = path.join( + root, + "artifacts/content-tools", + platform, + "lychee", + `lychee${process.platform === "win32" ? ".exe" : ""}`, + ); + if ( + !spec || + !existsSync(executable) || + createHash("sha256").update(readFileSync(executable)).digest("hex") !== + spec.executableSha256 + ) { + throw new Error( + "Install the pinned link checker: pwsh ./scripts/install-content-tools.ps1", + ); + } + return executable; +} + +export function checkRenderedLinks(directory) { + const html = filesUnder(directory).filter((file) => file.endsWith(".html")); + if (html.length === 0) throw new Error("No rendered HTML pages to check."); + const result = spawnSync( + lycheePath(), + [ + "--config", + path.join(root, ".lychee.toml"), + "--root-dir", + directory, + "--index-files", + "index.html", + ...html, + ], + { encoding: "utf8", shell: false }, + ); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error(`Rendered links failed:\n${result.stdout}${result.stderr}`); + return result.stdout + result.stderr; +} + +export function checkSiteInventory(directory) { + const files = filesUnder(directory); + if ( + files.some((file) => path.basename(file).startsWith("foundation-checklist")) + ) { + throw new Error("The temporary foundation plan must not be published."); + } + const readJson = (file) => + JSON.parse(readFileSync(path.join(directory, file), "utf8")); + const search = readJson("index.json"); + const manifest = readJson("manifest.json"); + const build = readJson("build-info.json"); + if ( + build.sourceRepository !== sourceRepository || + !/^[0-9a-f]{40}$/.test(build.sourceRevision) + ) { + throw new Error("Missing immutable documentation source revision."); + } + const pages = manifest.files.filter((file) => file.type === "Conceptual"); + if (!pages.length) throw new Error("The site has no conceptual pages."); + for (const page of pages) { + const output = page.output[".html"].relative_path; + const entry = search[output]; + if (!entry?.summary || !entry.title || entry.href !== output) { + throw new Error(`Search is missing the rendered page ${output}.`); + } + const html = readFileSync(path.join(directory, output), "utf8"); + const source = page.source_relative_path; + const edit = `${sourceRepository}/blob/${build.sourceRevision}/${source}`; + if ( + !html.includes(`href="${edit}/#`) && + !html.includes(`href="${edit}#`) && + !html.includes(`href="${edit}"`) + ) { + throw new Error( + `Edit link does not point to the original source: ${source}.`, + ); + } + if (!html.includes('id="search-query"')) + throw new Error(`Search UI missing from ${output}.`); + } + for (const entry of Object.values(search)) { + if ( + !pages.some((page) => page.output[".html"].relative_path === entry.href) + ) { + throw new Error( + `Search points outside the rendered pages: ${entry.href}.`, + ); + } + } + for (const entry of readJson("toc.json").items) { + if (!existsSync(path.join(directory, entry.href))) + throw new Error(`Missing navigation page: ${entry.href}.`); + } + for (const file of [ + "index.html", + "public/docfx.min.js", + "public/docfx.min.css", + ]) { + if (!existsSync(path.join(directory, file))) + throw new Error(`Missing site asset: ${file}.`); + } + return `Search and original-source edit links checked for ${pages.length} pages.`; +} + +if ( + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url +) { + try { + console.log(checkSiteInventory(siteRoot)); + console.log(checkRenderedLinks(siteRoot)); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/check-docs.ps1 b/scripts/check-docs.ps1 new file mode 100644 index 0000000..018c9ab --- /dev/null +++ b/scripts/check-docs.ps1 @@ -0,0 +1,51 @@ +#Requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false +$repoRoot = Split-Path $PSScriptRoot -Parent +Push-Location $repoRoot +try { + New-Item -ItemType Directory -Force artifacts/docs | Out-Null + function Invoke-DocsCheck { + param([string] $Name, [string] $Command, [string[]] $Arguments) + & $Command @Arguments 2>&1 | Tee-Object -FilePath "$repoRoot/artifacts/docs/$Name.log" + if ($LASTEXITCODE -ne 0) { + throw "$Name failed (exit $LASTEXITCODE). See artifacts/docs/$Name.log." + } + } + + Invoke-DocsCheck 'prerequisites' 'pwsh' @('-NoProfile', '-File', 'scripts/doctor.ps1', '-Scope', 'quick') + if ((& node --version) -notmatch '^v22\.') { throw 'Documentation checks require Node.js 22 on PATH.' } + & "$PSScriptRoot/install-content-tools.ps1" -Verify + + # Clear only this fixed generated directory so removed pages cannot survive. + $sitePath = [IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts/docs/site')) + $artifactRoot = [IO.Path]::GetFullPath((Join-Path $repoRoot 'artifacts')) + [IO.Path]::DirectorySeparatorChar + if (-not $sitePath.StartsWith($artifactRoot, [StringComparison]::Ordinal)) { + throw 'Documentation output must stay inside repository artifacts.' + } + if (Test-Path -LiteralPath $sitePath) { Remove-Item -LiteralPath $sitePath -Recurse -Force } + + Invoke-DocsCheck 'source-metadata' 'node' @('scripts/docs-links.mjs', 'prepare') + + Push-Location docs + try { + Invoke-DocsCheck 'tool-restore' 'dotnet' @('tool', 'restore', '--configfile', '../NuGet.Config') + Invoke-DocsCheck 'build' 'dotnet' @( + 'tool', 'run', 'docfx', '--', 'build', '../docfx.json', + '--warningsAsErrors', '--log', '../artifacts/docs/diagnostics.log' + ) + } + finally { Pop-Location } + Invoke-DocsCheck 'source-links' 'node' @('scripts/docs-links.mjs', 'resolve') + Invoke-DocsCheck 'rendered-site' 'node' @('scripts/check-docs.mjs') + Invoke-DocsCheck 'regressions' 'node' @('--test', 'tests/docs-site/check.test.mjs') + Write-Host 'PASS documentation build, rendered links, search inventory, and regression checks' +} +catch { + Write-Error $_ -ErrorAction Continue + exit 1 +} +finally { Pop-Location } diff --git a/scripts/docs-links.mjs b/scripts/docs-links.mjs new file mode 100644 index 0000000..58c3880 --- /dev/null +++ b/scripts/docs-links.mjs @@ -0,0 +1,229 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parse } from "parse5"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const sourceRepository = + "https://github.com/OpenGameBuilder/opengamebuilder"; + +function documentationFiles(directory = root) { + const markdown = (folder) => + readdirSync(folder, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(folder, entry.name); + return entry.isDirectory() + ? markdown(file) + : entry.name.endsWith(".md") + ? [file] + : []; + }); + return [ + ...readdirSync(directory) + .filter((name) => name.endsWith(".md") && name !== "AGENTS.md") + .map((name) => path.join(directory, name)), + ...markdown(path.join(directory, "docs")).filter( + (file) => path.basename(file) !== "foundation-checklist.md", + ), + ]; +} + +export function sourceRevision(repositoryRoot = root) { + const result = spawnSync("git", ["rev-parse", "HEAD"], { + cwd: repositoryRoot, + encoding: "utf8", + shell: false, + }); + if (result.error) throw result.error; + const revision = result.stdout.trim(); + if (result.status !== 0 || !/^[0-9a-f]{40}$/.test(revision)) { + throw new Error( + `Cannot resolve the documentation source commit: ${result.stderr}`, + ); + } + return revision; +} + +function repositoryTree(repositoryRoot, revision) { + const result = spawnSync( + "git", + ["ls-tree", "--full-tree", "-r", "-t", "-z", revision], + { + cwd: repositoryRoot, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + shell: false, + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `Cannot read documentation source revision ${revision}: ${result.stderr}`, + ); + } + return new Map( + result.stdout + .split("\0") + .filter(Boolean) + .map((entry) => { + const separator = entry.indexOf("\t"); + const [mode, type] = entry.slice(0, separator).split(" "); + if (separator < 0 || !mode || !type) + throw new Error(`Invalid Git tree entry at ${revision}.`); + return [entry.slice(separator + 1), type]; + }), + ); +} + +export function sourceMetadata(repositoryRoot, revision) { + const tree = repositoryTree(repositoryRoot, revision); + return { + docurl: Object.fromEntries( + documentationFiles(repositoryRoot).map((file) => { + const relative = path + .relative(repositoryRoot, file) + .replaceAll(path.sep, "/"); + if (tree.get(relative) !== "blob") { + throw new Error( + `Documentation source is absent from source revision: ${relative}.`, + ); + } + return [ + relative, + `${sourceRepository}/blob/${revision}/${relative + .split("/") + .map(encodeURIComponent) + .join("/")}`, + ]; + }), + ), + }; +} + +function inside(directory, candidate) { + const relative = path.relative(directory, candidate); + return ( + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +// Page links stay local. Only links to existing repository files/directories +// omitted from the site become source URLs, pinned to this build's commit. +export function resolveRepositoryLinks(directory, repositoryRoot, revision) { + if (!/^[0-9a-f]{40}$/.test(revision)) + throw new Error("Source revision must be a full commit SHA."); + const tree = repositoryTree(repositoryRoot, revision); + const manifest = JSON.parse( + readFileSync(path.join(directory, "manifest.json"), "utf8"), + ); + let count = 0; + for (const page of manifest.files.filter( + (item) => item.type === "Conceptual", + )) { + const output = path.join(directory, page.output[".html"].relative_path); + const source = path.join(repositoryRoot, page.source_relative_path); + const html = readFileSync(output, "utf8"); + const edits = []; + function visit(node) { + for (const attribute of node.attrs ?? []) { + if (!["href", "src"].includes(attribute.name)) continue; + const href = attribute.value; + if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href)) continue; + const url = new URL(href, "https://site.invalid/"); + const relative = decodeURIComponent(href.split(/[?#]/, 1)[0]); + const renderedTarget = path.resolve(path.dirname(output), relative); + if (inside(directory, renderedTarget) && existsSync(renderedTarget)) + continue; + const target = path.resolve(path.dirname(source), relative); + if (!inside(repositoryRoot, target) || !existsSync(target)) { + throw new Error( + `${page.source_relative_path}: missing local link ${href}`, + ); + } + const repositoryPath = path + .relative(repositoryRoot, target) + .replaceAll(path.sep, "/"); + const objectType = tree.get(repositoryPath); + if (!objectType) { + throw new Error( + `${page.source_relative_path}: local link is absent from source revision ${href}`, + ); + } + if (repositoryPath.endsWith(".md") && repositoryPath !== "AGENTS.md") { + throw new Error( + `${page.source_relative_path}: documentation target was not rendered: ${href}`, + ); + } + // Images and scripts must ship as site resources; a GitHub file page is + // not an asset replacement. Missing generated resources must also fail. + if (node.tagName !== "a" || attribute.name !== "href") { + throw new Error( + `${page.source_relative_path}: missing site asset ${href}`, + ); + } + if (!["blob", "tree"].includes(objectType)) { + throw new Error( + `${page.source_relative_path}: unsupported Git object for local link ${href}`, + ); + } + const kind = objectType === "tree" ? "tree" : "blob"; + const encodedPath = repositoryPath + .split("/") + .map(encodeURIComponent) + .join("/"); + const destination = `${sourceRepository}/${kind}/${revision}/${encodedPath}${url.search}${url.hash}`; + const location = node.sourceCodeLocation.attrs[attribute.name]; + edits.push({ + ...location, + value: `href="${destination.replaceAll("&", "&")}"`, + }); + count++; + } + for (const child of node.childNodes ?? []) visit(child); + } + visit(parse(html, { sourceCodeLocationInfo: true })); + let rewritten = html; + for (const edit of edits.sort((a, b) => b.startOffset - a.startOffset)) { + rewritten = + rewritten.slice(0, edit.startOffset) + + edit.value + + rewritten.slice(edit.endOffset); + } + if (edits.length) writeFileSync(output, rewritten); + } + writeFileSync( + path.join(directory, "build-info.json"), + JSON.stringify({ sourceRepository, sourceRevision: revision }, null, 2) + + "\n", + ); + return `Resolved ${count} repository links against ${revision}; documentation links remain relative.`; +} + +if ( + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url +) { + try { + const revision = sourceRevision(); + if (process.argv[2] === "prepare") { + writeFileSync( + path.join(root, "artifacts/docs/source-metadata.json"), + JSON.stringify(sourceMetadata(root, revision)), + ); + } else if (process.argv[2] === "resolve") { + console.log( + resolveRepositoryLinks( + path.join(root, "artifacts/docs/site"), + root, + revision, + ), + ); + } else + throw new Error("Usage: node scripts/docs-links.mjs prepare|resolve"); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/workflow-cache-policy.mjs b/scripts/workflow-cache-policy.mjs index f6c77d8..2c3663a 100644 --- a/scripts/workflow-cache-policy.mjs +++ b/scripts/workflow-cache-policy.mjs @@ -1,7 +1,9 @@ // Compensate only for actionlint's documented cache-mode schema gap. export function checkDeploymentCachePolicy(file, workflow) { if ( - !/^\.github\/workflows\/(_deploy|cd-production|cd-staging)\.yml$/.test(file) + !/^\.github\/workflows\/(_deploy|cd-production|cd-staging|docs-pages)\.yml$/.test( + file, + ) ) return; if (workflow["cache-mode"] !== "none") { diff --git a/tests/ci-policy/check.test.mjs b/tests/ci-policy/check.test.mjs index e10028c..4166bb1 100644 --- a/tests/ci-policy/check.test.mjs +++ b/tests/ci-policy/check.test.mjs @@ -420,3 +420,69 @@ test("CI workflow keeps selection, lanes, and aggregate gate wired to the policy assert.equal(dotnet.env.DOTNET_INSTALL_DIR, "${{ runner.temp }}/ogb-dotnet"); assert.equal(action.inputs.mode.default, "full"); }); + +test("rendered documentation blocks both selected lanes and retains evidence", () => { + const workflow = parseYaml( + readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8"), + ); + for (const job of ["documentation", "linux"]) { + const lane = workflow.jobs[job]; + const validation = lane.steps.findIndex( + (step) => step.uses === "./.github/actions/validate", + ); + const docs = lane.steps.findIndex( + (step) => step.uses === "./.github/actions/docs", + ); + assert.ok(validation >= 0 && docs > validation, job); + assert.equal(lane.steps[docs]["continue-on-error"], undefined, job); + } + const docsLane = workflow.jobs.documentation.steps.find( + (step) => step.uses === "./.github/actions/docs", + ); + assert.equal(docsLane.with, undefined); + const linuxLane = workflow.jobs.linux.steps.find( + (step) => step.uses === "./.github/actions/docs", + ); + assert.equal(linuxLane.with["install-dotnet"], "false"); + + const action = parseYaml( + readFileSync(path.join(root, ".github/actions/docs/action.yml"), "utf8"), + ); + assert.equal(action.inputs["install-dotnet"].default, "true"); + const dotnet = action.runs.steps.find((step) => + step.uses?.startsWith("actions/setup-dotnet@"), + ); + assert.equal(dotnet.if, "${{ inputs.install-dotnet == 'true' }}"); + assert.equal(dotnet.env.DOTNET_INSTALL_DIR, "${{ runner.temp }}/ogb-dotnet"); + assert.equal(dotnet.with["global-json-file"], "global.json"); + const node = action.runs.steps.find((step) => + step.uses?.startsWith("actions/setup-node@"), + ); + assert.equal(node.with["node-version"], "22"); + assert.equal( + action.runs.steps.some( + (step) => + step.run?.includes("npm ci") || + step.run?.includes("install-content-tools.ps1"), + ), + false, + ); + const check = action.runs.steps.find( + (step) => step.run === "./scripts/check-docs.ps1", + ); + assert.equal(check.shell, "pwsh"); + assert.equal(check["continue-on-error"], undefined); + const upload = action.runs.steps.find((step) => + step.uses?.startsWith("actions/upload-artifact@"), + ); + assert.equal(upload.if, "${{ always() && !cancelled() }}"); + assert.match(upload.with.path, /artifacts\/docs\/site\//); + assert.match(upload.with.path, /artifacts\/docs\/\*\.log/); + assert.equal(upload.with["retention-days"], 7); + assert.deepEqual(workflow.jobs["build-test"].needs, [ + "select-checks", + "documentation", + "linux", + "windows", + ]); +}); diff --git a/tests/content-checks/check.test.mjs b/tests/content-checks/check.test.mjs index 0a1af73..4631f09 100644 --- a/tests/content-checks/check.test.mjs +++ b/tests/content-checks/check.test.mjs @@ -32,7 +32,7 @@ test("Prettier respects formatter ownership and generated/vendor boundaries", as }); test("deployment cache exception still rejects missing, writable, and overridden cache modes", () => { - for (const file of ["_deploy", "cd-production", "cd-staging"]) { + for (const file of ["_deploy", "cd-production", "cd-staging", "docs-pages"]) { const name = `.github/workflows/${file}.yml`; for (const mode of [undefined, "full", "read-only", true, "None"]) { assert.throws( diff --git a/tests/docs-site/check.test.mjs b/tests/docs-site/check.test.mjs new file mode 100644 index 0000000..80e3219 --- /dev/null +++ b/tests/docs-site/check.test.mjs @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + checkRenderedLinks, + checkSiteInventory, + siteRoot, +} from "../../scripts/check-docs.mjs"; +import { + resolveRepositoryLinks, + sourceMetadata, + sourceRepository, +} from "../../scripts/docs-links.mjs"; + +const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +function fixture(action) { + const directory = mkdtempSync(path.join(root, "artifacts/docs/fixture-")); + const write = (file, content) => { + const target = path.join(directory, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + }; + try { + action(directory, write); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function git(directory, ...arguments_) { + const result = spawnSync("git", arguments_, { + cwd: directory, + encoding: "utf8", + shell: false, + }); + if (result.error) throw result.error; + assert.equal(result.status, 0, result.stdout + result.stderr); + return result.stdout.trim(); +} + +test("rendered checks reject missing pages, anchors, and assets while staying offline", () => { + fixture((directory, write) => { + write( + "index.html", + 'GuideRemote', + ); + write("page.html", '

Guide

'); + write("style.css", "body { color: black; }"); + assert.doesNotThrow(() => checkRenderedLinks(directory)); + write("page.html", '

Guide

'); + assert.throws(() => checkRenderedLinks(directory), /section/); + write("page.html", '

Guide

'); + rmSync(path.join(directory, "style.css")); + assert.throws(() => checkRenderedLinks(directory), /style\.css/); + write("style.css", "body { color: black; }"); + rmSync(path.join(directory, "page.html")); + assert.throws(() => checkRenderedLinks(directory), /page\.html/); + }); +}); + +test("site inventory rejects a missing search entry and accidental plan publication", () => { + fixture((directory, write) => { + write("manifest.json", readFileSync(path.join(siteRoot, "manifest.json"))); + write( + "build-info.json", + readFileSync(path.join(siteRoot, "build-info.json")), + ); + write("index.json", "{}"); + assert.throws(() => checkSiteInventory(directory), /Search is missing/); + write("docs/foundation-checklist.html", "temporary plan"); + assert.throws(() => checkSiteInventory(directory), /must not be published/); + }); +}); + +test("site gate rejects an unresolved source link after DocFX's repository-link diagnostic", () => { + fixture((directory, write) => { + write("index.md", "# Broken guide\n\n[Missing document](missing.md)\n"); + write( + "docfx.json", + JSON.stringify({ + rules: { InvalidFileLink: "info" }, + build: { + content: ["*.md"], + template: ["default", "modern"], + output: "site", + }, + }), + ); + git(directory, "init", "--quiet"); + git(directory, "config", "user.name", "Documentation tests"); + git(directory, "config", "user.email", "docs@example.invalid"); + git(directory, "add", "."); + git(directory, "commit", "--quiet", "-m", "Broken documentation fixture"); + const revision = git(directory, "rev-parse", "HEAD"); + const result = spawnSync( + "dotnet", + [ + "tool", + "run", + "docfx", + "--", + "build", + path.join(directory, "docfx.json"), + "--warningsAsErrors", + ], + { cwd: path.join(root, "docs"), encoding: "utf8", shell: false }, + ); + if (result.error) throw result.error; + const diagnostic = result.stdout + result.stderr; + assert.equal(result.status, 0, diagnostic); + assert.match(diagnostic, /missing\.md/); + assert.match(diagnostic, /InvalidFileLink|InvalidHref/); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /missing local link missing\.md/, + ); + }); +}); + +test("relative links retain page context and resolve repository source against each build's commit", () => { + fixture((directory, write) => { + write("docs/page.md", "# Guide\n"); + write("docs/omitted.md", "# Omitted guide\n"); + write("source/example.cs", "// checked source\n"); + git(directory, "init", "--quiet"); + git(directory, "config", "user.name", "Documentation tests"); + git(directory, "config", "user.email", "docs@example.invalid"); + git(directory, "add", "."); + git(directory, "commit", "--quiet", "-m", "Initial documentation fixture"); + + const revisions = [git(directory, "rev-parse", "HEAD")]; + write("source/example.cs", "// second checked source\n"); + git(directory, "add", "source/example.cs"); + git(directory, "commit", "--quiet", "-m", "Update source fixture"); + revisions.push(git(directory, "rev-parse", "HEAD")); + assert.notEqual(revisions[0], revisions[1]); + + for (const revision of revisions) { + write("site/docs/guide.html", '

Setup

'); + write( + "site/docs/page.html", + 'GuideCodeDirectoryRemote', + ); + write( + "site/manifest.json", + JSON.stringify({ + files: [ + { + type: "Conceptual", + source_relative_path: "docs/page.md", + output: { ".html": { relative_path: "docs/page.html" } }, + }, + ], + }), + ); + resolveRepositoryLinks(path.join(directory, "site"), directory, revision); + const html = readFileSync( + path.join(directory, "site/docs/page.html"), + "utf8", + ); + assert.ok(html.includes('href="guide.html#setup"')); + assert.ok( + html.includes( + `${sourceRepository}/blob/${revision}/source/example.cs?plain=1#L1`, + ), + ); + assert.ok( + html.includes( + `${sourceRepository}/tree/${revision}/source?view=1#files`, + ), + ); + assert.ok(html.includes('href="https://example.com"')); + assert.equal( + readFileSync(path.join(directory, "docs/page.md"), "utf8"), + "# Guide\n", + ); + + write("site/docs/page.html", 'Outside'); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /missing local link/, + ); + write("site/docs/page.html", 'Omitted'); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /documentation target was not rendered/, + ); + write("site/docs/page.html", ''); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /missing site asset/, + ); + } + + const revision = revisions.at(-1); + assert.equal( + sourceMetadata(directory, revision).docurl["docs/page.md"], + `${sourceRepository}/blob/${revision}/docs/page.md`, + ); + write("source/untracked.cs", "// absent from the selected commit\n"); + write("site/docs/page.html", 'Code'); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /absent from source revision/, + ); + write("site/docs/page.html", 'Git metadata'); + assert.throws( + () => + resolveRepositoryLinks( + path.join(directory, "site"), + directory, + revision, + ), + /absent from source revision/, + ); + + write("docs/untracked.md", "# Untracked guide\n"); + assert.throws( + () => sourceMetadata(directory, revision), + /Documentation source is absent from source revision: docs\/untracked\.md/, + ); + }); +}); diff --git a/toc.yml b/toc.yml new file mode 100644 index 0000000..55ac4bd --- /dev/null +++ b/toc.yml @@ -0,0 +1,44 @@ +- name: Home + href: index.md +- name: Setup + href: docs/setup/development.md +- name: Contribute + href: CONTRIBUTING.md + items: + - name: Support + href: SUPPORT.md + - name: Security + href: SECURITY.md + - name: Stewardship + href: docs/community/stewardship.md + - name: AI policy + href: AI_POLICY.md +- name: Architecture + href: docs/README.md + items: + - name: HTTP API + href: docs/backend/api.md +- name: Testing + href: docs/quality/testing.md + items: + - name: Content checks + href: docs/quality/content-checks.md + - name: Browser support + href: docs/frontend/browser-support.md + - name: Documentation site + href: docs/setup/documentation.md +- name: Operations + href: docs/setup/hosting.md + items: + - name: GitHub setup + href: docs/setup/github.md + - name: Host keys + href: docs/setup/deployment-host-key.md + - name: Releases + href: docs/release/README.md + - name: Versioning + href: docs/release/versioning.md + - name: Changelog + href: CHANGELOG.md + - name: AI tooling + href: docs/setup/ai-tooling.md