diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a027d36..f46bc000 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,66 @@ concurrency: cancel-in-progress: true jobs: + docs: + name: Public documentation + runs-on: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }} + steps: + - name: Check out full repository history + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'false' || 'true' }} + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install pinned documentation tools + run: | + ./scripts/vercel-install-docs.sh + echo "$PWD/.vercel-tools/bin" >> "$GITHUB_PATH" + + - name: Install browser test dependencies + run: | + npm ci --prefix frontend + node frontend/node_modules/@playwright/test/cli.js install --with-deps chromium webkit + + - name: Verify documentation sources and assets + run: | + bash scripts/docs-assets-sync.test.sh + npm --prefix frontend run screenshots:check + make docs-subpath-test + node --test scripts/docs/*.test.mjs + make docs-build + + - name: Verify Vercel upload boundary + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ -z "$VERCEL_TOKEN" ]; then + echo "VERCEL_TOKEN must be configured for trusted main-branch documentation checks" >&2 + exit 1 + fi + npm install --global vercel@58.4.4 + mkdir -p .superpowers + vercel deploy --dry --json --project docbank.ai --scope kenn-software --token "$VERCEL_TOKEN" > .superpowers/vercel-dry-run.json + node scripts/docs/assert-vercel-dry-run.mjs .superpowers/vercel-dry-run.json + + - name: Verify documentation in Chromium and WebKit + run: | + npm --prefix frontend run docs-site:check + npm --prefix frontend run docs-site:test + frontend: name: Web application runs-on: ${{ github.repository == 'kenn-io/docbank' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.base.repo.full_name == github.repository)) && 'kenn-linux-x64-public' || 'ubuntu-latest' }} diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 00000000..4e4e4489 --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,71 @@ +name: Deploy documentation + +on: + workflow_dispatch: + inputs: + source_sha: + description: Full eligible documentation source commit SHA + required: true + type: string + +permissions: + contents: read + +concurrency: + group: docbank-documentation-production + cancel-in-progress: false + +jobs: + validate: + name: Validate documentation source + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release.outputs.release_tag }} + source_sha: ${{ steps.release.outputs.source_sha }} + steps: + - name: Check out trusted release policy + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: main + + - name: Validate requested source against the release boundary + id: release + env: + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags + release_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main) + ./scripts/validate-docs-release.sh "$SOURCE_SHA" "$release_tag" + echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT" + + deploy: + name: Build and promote documentation + needs: validate + runs-on: ubuntu-latest + environment: production + steps: + - name: Check out the validated source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.validate.outputs.source_sha }} + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + + - name: Install pinned Vercel CLI + run: npm install --global vercel@58.4.4 + + - name: Verify, build, inspect, and promote + env: + DOCS_SOURCE: ${{ needs.validate.outputs.source_sha }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: ./scripts/deploy-docs.sh diff --git a/.gitignore b/.gitignore index 21ba218d..972248f8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,14 @@ internal/web/dist/* !internal/web/dist/.keep # docs build +**/__pycache__/ +/.cache/docs-assets/ +/site/ +/.vercel/ +/.vercel-tools/ docs/.venv/ docs/site/ docs/zensical-public-docs.* docs/.zensical-build.* .kata.local.toml +.env* diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 00000000..817eb397 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,50 @@ +# Vercel receives only the static documentation build inputs. Product source, +# screenshots, tests, internal plans, generated output, and local state remain +# outside the upload boundary. +/* + +!/vercel.json + +!/LICENSES +/LICENSES/* +!/LICENSES/Inter-OFL-1.1.txt +!/LICENSES/JetBrains-Mono-OFL-1.1.txt + +!/website + +!/docs +/docs/* +!/docs/*.md +/docs/README.md +!/docs/agents +!/docs/architecture +!/docs/usage +!/docs/overrides +/docs/overrides/* +!/docs/overrides/main.html +!/docs/stylesheets +/docs/stylesheets/* +!/docs/stylesheets/extra.css +!/docs/scripts +/docs/scripts/* +!/docs/scripts/check_built_site.py +!/docs/scripts/check_markdown_sources.py +!/docs/llms.txt +!/docs/pyproject.toml +!/docs/uv.lock +!/docs/zensical-docs.sh +!/docs/zensical.toml + +!/scripts +/scripts/* +!/scripts/docs +/scripts/docs/* +!/scripts/docs/build.mjs +!/scripts/docs/verify-site.mjs +!/scripts/docs-assets.ref +!/scripts/docs-assets.txt +!/scripts/install.ps1 +!/scripts/install.sh +!/scripts/sync-docs-assets.sh +!/scripts/vercel-build-docs.sh +!/scripts/vercel-install-docs.sh diff --git a/AGENTS.md b/AGENTS.md index 4aaa4003..b19d7481 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,30 @@ Instructions for autonomous coding agents working in this repository. `frontend/package-lock.json`. Run `make frontend-test` for type, kit-ui, unit, and production-build checks. Release CI builds the frontend once and embeds those exact assets in all six platform archives. -- Real web screenshots use the repository-owned Playwright harness in - `frontend/screenshots/`. Run `make frontend-screenshots`; captures must use +- Real documentation screenshots use the repository-owned Playwright harness in + `frontend/screenshots/`. Run `make docs-screenshots`; captures must use its temporary synthetic vault and real daemon, never a developer vault or mocked API data. Generated images stay under `.superpowers/screenshots/` - until they are visually inspected and attached to the relevant pull request. + until they are visually inspected and published as a complete reviewed set. + Publish the complete set as one orphan `docs-assets` commit, then pin that + exact commit in `scripts/docs-assets.ref`. Documentation builds consume the + pin and never generate screenshots or follow a mutable branch head. +- Run `make docs-serve` from the repository root to build, watch, and preview + the product page, authority guide, and operating documentation on one origin. + Run `make docs-build` after the final source or asset-pin edit. The strict + build stages only the explicit public allowlist into `site/`; internal plans, + scripts, and agent instructions must not enter the output. +- A software release makes a documentation source eligible; it does not publish + that source. The selected source is normally the post-tag documentation-only + follow-up. Deployment still requires maintainer authorization. From the + repository root, `make docs-deploy DOCS_SOURCE=$(git rev-parse HEAD)` uploads + an unpromoted production build, verifies it and the release boundary, and + then promotes it. The deploy path does not generate screenshots, build the + product, run Docker, or install frontend dependencies. Pull-request jobs + never receive Vercel credentials; the authenticated upload dry run is a + trusted `main`-push check, and the manual production workflow validates its + requested source without credentials before entering the protected + environment. ## Private Data Boundary diff --git a/LICENSES/Inter-OFL-1.1.txt b/LICENSES/Inter-OFL-1.1.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/LICENSES/Inter-OFL-1.1.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/LICENSES/JetBrains-Mono-OFL-1.1.txt b/LICENSES/JetBrains-Mono-OFL-1.1.txt new file mode 100644 index 00000000..23a3dca4 --- /dev/null +++ b/LICENSES/JetBrains-Mono-OFL-1.1.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Makefile b/Makefile index 2ed16392..9dc3ec76 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ DEFAULT_GOLANGCI_LINT_CACHE := $(shell git rev-parse --path-format=absolute --gi GOLANGCI_LINT_CACHE ?= $(DEFAULT_GOLANGCI_LINT_CACHE) export GOLANGCI_LINT_CACHE -.PHONY: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev frontend-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-build docs-serve docs-link docs-deploy help +.PHONY: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev docs-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-subpath-test docs-assets-test docs-assets-sync docs-build docs-serve docs-link docs-deploy help build: frontend CGO_ENABLED=1 go build -tags "$(BUILD_TAGS)" -ldflags="$(LDFLAGS)" -o docbank ./cmd/docbank @@ -60,7 +60,7 @@ frontend-test: frontend-dev: cd frontend && npm run dev -frontend-screenshots: +docs-screenshots: cd frontend && npm run screenshots fmt: @@ -93,14 +93,23 @@ install-hooks: docs-install: cd docs && uv sync --frozen +docs-subpath-test: + cd docs && uv run --project . --frozen --no-dev python scripts/check_zensical_subpath.py + bridge-contract: go test -tags fts5 ./document/bridge -run '^TestBridgeContractNormativeDocuments' -docs-build: bridge-contract - cd docs && ./zensical-docs.sh build +docs-assets-test: + bash scripts/docs-assets-sync.test.sh + +docs-assets-sync: + ./scripts/sync-docs-assets.sh + +docs-build: bridge-contract docs-subpath-test + node scripts/docs/build.mjs docs-serve: - cd docs && ./zensical-docs.sh serve + node scripts/docs/serve.mjs # Deploys use the operator's installed Vercel CLI; install it with # `npm install -g vercel` or from https://vercel.com/docs/cli. @@ -109,20 +118,19 @@ docs-link: echo "vercel CLI not found. Install: https://vercel.com/docs/cli" >&2; \ exit 1; \ fi - cd docs && vercel link - -docs-deploy: docs-build - @if ! command -v vercel >/dev/null 2>&1; then \ - echo "vercel CLI not found. Install: https://vercel.com/docs/cli" >&2; \ + vercel link + @test -f .vercel/project.json || { \ + echo "Vercel did not create .vercel/project.json at the repository root." >&2; \ exit 1; \ - fi - @if [ ! -f docs/.vercel/project.json ]; then \ - echo "docs are not linked to a Vercel project yet." >&2; \ - echo "Run: vercel login && make docs-link" >&2; \ + } + +docs-deploy: + @if [ -z "$(DOCS_SOURCE)" ]; then \ + echo "DOCS_SOURCE is required and must be a full source commit SHA." >&2; \ + echo "Run: make docs-deploy DOCS_SOURCE=$$(git rev-parse HEAD)" >&2; \ exit 1; \ fi - cp -R docs/.vercel docs/site/.vercel - vercel deploy docs/site --prod --yes + DOCS_SOURCE="$(DOCS_SOURCE)" ./scripts/deploy-docs.sh help: - @echo "Targets: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev frontend-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-build docs-serve docs-link docs-deploy" + @echo "Targets: build install clean test test-v release-scripts-test frontend frontend-test frontend-dev docs-screenshots fmt lint lint-ci tidy install-hooks docs-install docs-subpath-test docs-assets-test docs-assets-sync docs-build docs-serve docs-link docs-deploy" diff --git a/README.md b/README.md index 0d5bf72d..72325490 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ recoverable deletion, verified backup, and optional permanent audited history. The vault catalog stays under your control instead of inside a provider account. -![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/v0.12.0/web-vault-browser.png) +![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://docbank.ai/assets/generated/web-vault-browser.png) The standalone CLI, web application, TUI, scripts, and agents all use the same authenticated daemon contract. Go applications can instead embed independently diff --git a/docs/README.md b/docs/README.md index df1d4229..d725c050 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,24 +15,76 @@ plus internal design material. metadata, assets, and publishing boundary; all Python tools run through the locked `uv` environment -Every rendered directory route also publishes its exact Markdown source at -the sibling `.md` path: `/setup/` has `/setup.md`, and -`/usage/importing/` has `/usage/importing.md`. This gives agents and other -text-first clients a stable representation without scraping rendered HTML. -Section landing pages use a sibling source such as `usage.md`, not -`usage/index.md`, so relative links keep the same base at `/usage.md`. +Every rendered directory route also publishes its exact Markdown source at the +sibling `.md` path. The operating page `/docs/setup/` has `/docs/setup.md`, and +`/docs/usage/importing/` has `/docs/usage/importing.md`. The product page and +authority guide follow the same rule at `/index.md` and `/guide.md`. Agents and +other text-first clients can use these stable representations without scraping +rendered HTML. ## Building +Run documentation commands from the repository root: + +```bash +make docs-install # one-time: install the locked Zensical environment +make docs-serve # build and watch all three tiers on http://127.0.0.1:8000 +make docs-build # strict production build into site/ +``` + +`make docs-serve` serves the product page, authority guide, and operating +documentation from one origin. It rebuilds after changes beneath `website/` or +`docs/`, or after the pinned screenshot commit or manifest changes. A failed +rebuild leaves the last successful `site/` available for inspection. + +## Screenshots + +Screenshot generation is a separate reviewed workflow. It never runs during a +documentation build or deployment. + +1. Run `make docs-screenshots`. The harness starts a real daemon with a + temporary synthetic vault and writes the complete capture set beneath + `.superpowers/screenshots/`. +2. Inspect every generated image and its metadata. +3. Publish the complete reviewed set as one orphan `docs-assets` commit. +4. Put that exact commit in `scripts/docs-assets.ref` and run + `make docs-assets-sync`. +5. Run `make docs-build` after the final source or asset-pin edit. + +Never capture a developer vault, publish a partial set, or point the build at a +mutable branch head. + +## Deployment + +A software release makes a documentation source eligible; it does not publish +that source. The selected source is normally the documentation-only follow-up +after the tag and release notes exist. A maintainer must still authorize every +deployment. + +Link the Vercel project once from the repository root with `make docs-link`. +Deploy an exact eligible source from a clean checkout with: + ```bash -cd docs -uv sync --frozen # one-time: installs zensical into docs/.venv -./zensical-docs.sh serve # live-reload preview -./zensical-docs.sh build # strict production build into docs/site/ +make docs-deploy DOCS_SOURCE=$(git rev-parse HEAD) ``` -Or from the repository root: `make docs-install`, `make docs-serve`, -`make docs-build`. +The command checks that the source is on `origin/main`, descends from the latest +software release, and contains only approved documentation changes. It uploads +an unpromoted production build, waits for Vercel to verify it, repeats the +release check, and only then promotes the build. Deployment does not generate +screenshots, build the product, run Docker, or install frontend dependencies. + +The protected `Deploy documentation` workflow provides the same path for an +explicitly supplied source SHA. A credential-free job checks that SHA against +the release policy from `main` before the protected production job receives +the validated SHA. It has no automatic push, pull-request, tag, or release +trigger. + +Pull-request documentation checks never receive Vercel credentials. The +authenticated upload dry run runs only after a trusted push to `main` and +requires the repository `VERCEL_TOKEN` secret. Production deployment requires +the `VERCEL_TOKEN`, `VERCEL_ORG_ID`, and `VERCEL_PROJECT_ID` secrets on the +protected `production` environment. ## Documentation boundary diff --git a/docs/index.md b/docs/index.md index bac0580f..420452e4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ curl -fsSL https://docbank.ai/install.sh | sh Build agent workflows

-![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/v0.12.0/web-vault-browser.png) +![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://docbank.ai/assets/generated/web-vault-browser.png)

A real synthetic vault in the local web application. See the visual tour.

diff --git a/docs/llms.txt b/docs/llms.txt index a414da96..25d90f54 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -2,59 +2,63 @@ > Docbank is a self-sovereign document system for people and agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history. -Every documentation page is also published as raw Markdown at the same path with a `.md` suffix; the links below point at those Markdown routes. +Every public page is also published as raw Markdown at the route listed below. + +## Public Site +- [Docbank](https://docbank.ai/index.md): The system of record for documents your agents can use. +- [Authority lifecycle guide](https://docbank.ai/guide.md): Follow one document from exact source ingestion through governed understanding, bounded retrieval, and verified recovery. ## Overview -- [Your documents. Your agents. One system.](https://docbank.ai/index.md): Docbank is a self-sovereign document storage system for you and your agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history. +- [Your documents. Your agents. One system.](https://docbank.ai/docs/index.md): Docbank is a self-sovereign document storage system for you and your agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history. ## Changelog -- [Changelog](https://docbank.ai/changelog.md): Release history. +- [Changelog](https://docbank.ai/docs/changelog.md): Release history. ## Start Here -- [Setup](https://docbank.ai/setup.md): Install docbank on Linux, macOS, or Windows and create the vault. -- [Quickstart](https://docbank.ai/quickstart.md): A ten-minute tour of the docbank CLI. -- [Capabilities](https://docbank.ai/capabilities.md): What Docbank does for people, agents, applications, recovery, and physical storage. -- [Visual Tour](https://docbank.ai/tour.md): See Docbank's real web and terminal interfaces running against synthetic document vaults. +- [Setup](https://docbank.ai/docs/setup.md): Install docbank on Linux, macOS, or Windows and create the vault. +- [Quickstart](https://docbank.ai/docs/quickstart.md): A ten-minute tour of the docbank CLI. +- [Capabilities](https://docbank.ai/docs/capabilities.md): What Docbank does for people, agents, applications, recovery, and physical storage. +- [Visual Tour](https://docbank.ai/docs/tour.md): See Docbank's real web and terminal interfaces running against synthetic document vaults. ## Work with Documents -- [Importing Documents](https://docbank.ai/usage/importing.md): Bulk import semantics — recursion, idempotency, collision suffixing, and failure handling. -- [Organizing & Tagging](https://docbank.ai/usage/organizing.md): Browsing, moving, renaming, and tagging in the virtual tree. -- [Searching](https://docbank.ai/usage/searching.md): Ranked, prefix-matching search over document names and verified text content. -- [Web application](https://docbank.ai/usage/web.md): Upload, browse, search, and organize the local vault in a responsive, authenticated web interface. -- [Interactive terminal browser](https://docbank.ai/usage/tui.md): Browse documents, inspect authority, storage, backups, and permanent history, and safely move or restore recoverable trash from the TUI. +- [Importing Documents](https://docbank.ai/docs/usage/importing.md): Bulk import semantics — recursion, idempotency, collision suffixing, and failure handling. +- [Organizing & Tagging](https://docbank.ai/docs/usage/organizing.md): Browsing, moving, renaming, and tagging in the virtual tree. +- [Searching](https://docbank.ai/docs/usage/searching.md): Ranked, prefix-matching search over document names and verified text content. +- [Web application](https://docbank.ai/docs/usage/web.md): Upload, browse, search, and organize the local vault in a responsive, authenticated web interface. +- [Interactive terminal browser](https://docbank.ai/docs/usage/tui.md): Browse documents, inspect authority, storage, backups, and permanent history, and safely move or restore recoverable trash from the TUI. ## Protect & Operate -- [Vault Lifecycle](https://docbank.ai/usage/lifecycle.md): Operate a docbank vault safely from first import through maintenance, upgrades, snapshots, and recovery. -- [Permanent Audited History](https://docbank.ai/usage/audited-history.md): Permanently retain every version and recorded change beneath a reviewed directory scope. -- [Trash, GC, Repack & Verify](https://docbank.ai/usage/trash-and-gc.md): The explicit deletion and physical-reclamation lifecycle. -- [Multi-store Storage](https://docbank.ai/usage/storage.md): Keep document bytes local-first, add filesystem or S3-compatible stores, and move verified authority deliberately. -- [Backup & Restore](https://docbank.ai/usage/backup.md): Create incremental, verifiable snapshots in an immutable repository. -- [Troubleshooting](https://docbank.ai/troubleshooting.md): Diagnose docbank startup, daemon, import, integrity, update, and HTTP API failures without risking the vault. +- [Vault Lifecycle](https://docbank.ai/docs/usage/lifecycle.md): Operate a docbank vault safely from first import through maintenance, upgrades, snapshots, and recovery. +- [Permanent Audited History](https://docbank.ai/docs/usage/audited-history.md): Permanently retain every version and recorded change beneath a reviewed directory scope. +- [Trash, GC, Repack & Verify](https://docbank.ai/docs/usage/trash-and-gc.md): The explicit deletion and physical-reclamation lifecycle. +- [Multi-store Storage](https://docbank.ai/docs/usage/storage.md): Keep document bytes local-first, add filesystem or S3-compatible stores, and move verified authority deliberately. +- [Backup & Restore](https://docbank.ai/docs/usage/backup.md): Create incremental, verifiable snapshots in an immutable repository. +- [Troubleshooting](https://docbank.ai/docs/troubleshooting.md): Diagnose docbank startup, daemon, import, integrity, update, and HTTP API failures without risking the vault. ## Automate & Integrate -- [Docbank for Agents](https://docbank.ai/agents.md): Why agents use docbank, which interface to choose, and the safety model for document automation. -- [Agent Integration Guide](https://docbank.ai/agents/integration.md): Connect an agent to docbank safely using its OpenAPI contract, authenticated HTTP API, revisions, and dry-run maintenance operations. -- [Embed in Go](https://docbank.ai/embedding.md): Own one or more independently rooted Docbank vaults inside a Go application, with CGO or pure-Go SQLite. -- [Document Understanding in Go](https://docbank.ai/document-understanding.md): Normalize and prepare documents for search, use bounded Mistral OCR, and embed images and video through Voyage without opening a Docbank vault. +- [Docbank for Agents](https://docbank.ai/docs/agents.md): Why agents use docbank, which interface to choose, and the safety model for document automation. +- [Agent Integration Guide](https://docbank.ai/docs/agents/integration.md): Connect an agent to docbank safely using its OpenAPI contract, authenticated HTTP API, revisions, and dry-run maintenance operations. +- [Embed in Go](https://docbank.ai/docs/embedding.md): Own one or more independently rooted Docbank vaults inside a Go application, with CGO or pure-Go SQLite. +- [Document Understanding in Go](https://docbank.ai/docs/document-understanding.md): Normalize and prepare documents for search, use bounded Mistral OCR, and embed images and video through Voyage without opening a Docbank vault. ## Reference -- [CLI Reference](https://docbank.ai/cli-reference.md): Every docbank command, flag, output format, and error behavior. -- [Configuration](https://docbank.ai/configuration.md): Vault location, data layout, config.toml, and environment variables. -- [HTTP API](https://docbank.ai/architecture/http-api.md): The agent-first HTTP API — filesystem-shaped endpoints, revision preconditions, and the daemon's error contract. +- [CLI Reference](https://docbank.ai/docs/cli-reference.md): Every docbank command, flag, output format, and error behavior. +- [Configuration](https://docbank.ai/docs/configuration.md): Vault location, data layout, config.toml, and environment variables. +- [HTTP API](https://docbank.ai/docs/architecture/http-api.md): The agent-first HTTP API — filesystem-shaped endpoints, revision preconditions, and the daemon's error contract. ## How It Works -- [How Docbank Works](https://docbank.ai/architecture/overview.md): A guided model of vaults, document identity, immutable content, storage authority, deletion, and recovery. -- [Source Metadata](https://docbank.ai/architecture/source-metadata.md): How Docbank records bounded facts found inside original files. -- [Visual Previews](https://docbank.ai/architecture/visual-previews.md): How Docbank identifies and retains canonical visual derivatives. -- [Storage](https://docbank.ai/architecture/storage.md): The SQLite schema, blob store layout, durability discipline, and enforced invariants. -- [Loose & Packed Content](https://docbank.ai/architecture/packed-storage.md): The shared Kit packed-CAS layer and docbank's application-owned authority boundary. -- [Editing & Versions](https://docbank.ai/architecture/editing-and-versions.md): Stable content-version identity, retrieval, replacement, reversion, and retention over immutable content. -- [Ownership & Concurrency](https://docbank.ai/architecture/locking.md): How daemon and embedded owners coordinate concurrent access with SQLite, hierarchy locks, and owner-local operation gates. -- [Daemon & Process Model](https://docbank.ai/architecture/daemon.md): docbank daemon run — the single process that owns the vault, and how the CLI discovers, auto-starts, and stops it. -- [Backup & Recovery](https://docbank.ai/architecture/backup.md): Docbank's JSONL-native Kit snapshot and restore architecture. -- [Integrity & Trust](https://docbank.ai/architecture/integrity.md): What docbank defends against, which layer owns each integrity guarantee, and the trade-offs that were considered and deliberately not taken. -- [Audited History](https://docbank.ai/architecture/audited-history.md): The permanent, tamper-evident history model for protected directory scopes, content versions, backup, agents, and future interactive clients. +- [How Docbank Works](https://docbank.ai/docs/architecture/overview.md): A guided model of vaults, document identity, immutable content, storage authority, deletion, and recovery. +- [Source Metadata](https://docbank.ai/docs/architecture/source-metadata.md): How Docbank records bounded facts found inside original files. +- [Visual Previews](https://docbank.ai/docs/architecture/visual-previews.md): How Docbank identifies and retains canonical visual derivatives. +- [Storage](https://docbank.ai/docs/architecture/storage.md): The SQLite schema, blob store layout, durability discipline, and enforced invariants. +- [Loose & Packed Content](https://docbank.ai/docs/architecture/packed-storage.md): The shared Kit packed-CAS layer and docbank's application-owned authority boundary. +- [Editing & Versions](https://docbank.ai/docs/architecture/editing-and-versions.md): Stable content-version identity, retrieval, replacement, reversion, and retention over immutable content. +- [Ownership & Concurrency](https://docbank.ai/docs/architecture/locking.md): How daemon and embedded owners coordinate concurrent access with SQLite, hierarchy locks, and owner-local operation gates. +- [Daemon & Process Model](https://docbank.ai/docs/architecture/daemon.md): docbank daemon run — the single process that owns the vault, and how the CLI discovers, auto-starts, and stops it. +- [Backup & Recovery](https://docbank.ai/docs/architecture/backup.md): Docbank's JSONL-native Kit snapshot and restore architecture. +- [Integrity & Trust](https://docbank.ai/docs/architecture/integrity.md): What docbank defends against, which layer owns each integrity guarantee, and the trade-offs that were considered and deliberately not taken. +- [Audited History](https://docbank.ai/docs/architecture/audited-history.md): The permanent, tamper-evident history model for protected directory scopes, content versions, backup, agents, and future interactive clients. ## Project -- [Roadmap](https://docbank.ai/roadmap.md): What is implemented today and what each phase adds. -- [License](https://docbank.ai/license.md): Docbank is open-source software licensed under the Apache License, Version 2.0. +- [Roadmap](https://docbank.ai/docs/roadmap.md): What is implemented today and what each phase adds. +- [License](https://docbank.ai/docs/license.md): Docbank is open-source software licensed under the Apache License, Version 2.0. diff --git a/docs/scripts/check_built_site.py b/docs/scripts/check_built_site.py index 7d636919..524528ed 100644 --- a/docs/scripts/check_built_site.py +++ b/docs/scripts/check_built_site.py @@ -36,13 +36,26 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None def local_target( - site: pathlib.Path, page: pathlib.Path, raw: str + site: pathlib.Path, page: pathlib.Path, raw: str, url_prefix: str = "" ) -> tuple[pathlib.Path, str] | None: parsed = urllib.parse.urlsplit(raw) if parsed.scheme or parsed.netloc: return None if parsed.path: - target = site / parsed.path.lstrip("/") if parsed.path.startswith("/") else page.parent / parsed.path + if parsed.path.startswith("/"): + normalized_prefix = f"/{url_prefix.strip('/')}" if url_prefix else "" + if normalized_prefix: + if parsed.path == normalized_prefix: + local_path = "/" + elif parsed.path.startswith(f"{normalized_prefix}/"): + local_path = parsed.path[len(normalized_prefix) :] + else: + return None + else: + local_path = parsed.path + target = site / local_path.lstrip("/") + else: + target = page.parent / parsed.path if parsed.path.endswith("/") or target.suffix == "": target /= "index.html" else: @@ -63,6 +76,7 @@ def markdown_output(site: pathlib.Path, rel: pathlib.Path) -> pathlib.Path: def main() -> None: site = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "site").resolve() source = pathlib.Path(sys.argv[2]).resolve() if len(sys.argv) > 2 else None + url_prefix = sys.argv[3] if len(sys.argv) > 3 else "" errors: list[str] = [] llms_source = source / "llms.txt" if source is not None else None @@ -120,14 +134,8 @@ def main() -> None: f"og:{field}" ): errors.append(f"{rel}: Twitter {field} differs from Open Graph {field}") - if ( - rel == pathlib.Path("index.html") - and parser.metadata.get("og:title") - != "Your documents. Your agents. One system." - ): - errors.append("index.html: social title differs from the homepage message") for raw in parser.urls: - local = local_target(site, page, raw) + local = local_target(site, page, raw, url_prefix) if local is None: continue target, fragment = local diff --git a/docs/scripts/check_built_site_test.py b/docs/scripts/check_built_site_test.py new file mode 100644 index 00000000..0cda3d43 --- /dev/null +++ b/docs/scripts/check_built_site_test.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import pathlib +import tempfile +import unittest + +from check_built_site import local_target + + +class LocalTargetTest(unittest.TestCase): + def test_strips_the_published_subpath_from_root_relative_urls(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + site = pathlib.Path(temporary) / "docs" + page = site / "usage" / "example" / "index.html" + + target, fragment = local_target( + site, + page, + "/docs/assets/stylesheets/main.css#theme", + "/docs", + ) + + self.assertEqual(target, (site / "assets/stylesheets/main.css").resolve()) + self.assertEqual(fragment, "theme") + + def test_ignores_root_routes_owned_by_another_site_tier(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + site = pathlib.Path(temporary) / "docs" + page = site / "index.html" + + self.assertIsNone(local_target(site, page, "/guide/", "/docs")) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/scripts/check_markdown_sources.py b/docs/scripts/check_markdown_sources.py index 34782e8a..da2b5c88 100644 --- a/docs/scripts/check_markdown_sources.py +++ b/docs/scripts/check_markdown_sources.py @@ -59,7 +59,13 @@ def nav_paths(value: object) -> set[str]: return result -LLMS_BASE_URL = "https://docbank.ai/" +LLMS_BASE_URL = "https://docbank.ai/docs/" +LLMS_PUBLIC_SITE = [ + "## Public Site", + "- [Docbank](https://docbank.ai/index.md): The system of record for documents your agents can use.", + "- [Authority lifecycle guide](https://docbank.ai/guide.md): Follow one document from exact source ingestion through governed understanding, bounded retrieval, and verified recovery.", + "", +] def frontmatter_field(path: pathlib.Path, field: str) -> str: @@ -114,7 +120,7 @@ def check_llms_txt(config: dict, errors: list[str]) -> None: actual = [line.rstrip() for line in lines[first_section:]] while actual and not actual[-1]: actual.pop() - expected = expected_llms_sections(config["project"]["nav"]) + expected = LLMS_PUBLIC_SITE + expected_llms_sections(config["project"]["nav"]) while expected and not expected[-1]: expected.pop() if actual != expected: diff --git a/docs/scripts/check_zensical_subpath.py b/docs/scripts/check_zensical_subpath.py new file mode 100644 index 00000000..d64590e6 --- /dev/null +++ b/docs/scripts/check_zensical_subpath.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import html.parser +import pathlib +import shutil +import subprocess +import tempfile +import urllib.parse + + +DOCS_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +class ParsedPage(html.parser.HTMLParser): + def __init__(self) -> None: + super().__init__() + self.canonical = "" + self.metadata: dict[str, str] = {} + self.urls: list[str] = [] + + def handle_starttag( + self, tag: str, attrs: list[tuple[str, str | None]] + ) -> None: + values = {key: value or "" for key, value in attrs} + if tag == "link" and values.get("rel") == "canonical": + self.canonical = values.get("href", "") + if tag == "meta" and values.get("property") and values.get("content"): + self.metadata[values["property"]] = values["content"] + for key in ("href", "src"): + if values.get(key): + self.urls.append(values[key]) + + @classmethod + def from_file(cls, path: pathlib.Path) -> ParsedPage: + parsed = cls() + parsed.feed(path.read_text(encoding="utf-8")) + return parsed + + +def write_project(root: pathlib.Path) -> pathlib.Path: + source = root / "source" + output = root / "site" + (source / "usage").mkdir(parents=True) + (source / "stylesheets").mkdir() + shutil.copytree(DOCS_ROOT / "overrides", root / "overrides") + (source / "index.md").write_text( + "---\ntitle: Overview\ndescription: Subpath overview.\n---\n\n" + "# Overview\n\n[Example](usage/example.md)\n", + encoding="utf-8", + ) + (source / "usage" / "example.md").write_text( + "---\ntitle: Example\ndescription: Subpath example.\n---\n\n" + "# Example\n\n[Overview](../index.md)\n", + encoding="utf-8", + ) + shutil.copyfile( + DOCS_ROOT / "stylesheets" / "extra.css", + source / "stylesheets" / "extra.css", + ) + + config = root / "zensical.toml" + config.write_text( + "\n".join( + [ + "[project]", + 'site_name = "docbank subpath probe"', + 'site_url = "https://docbank.ai/docs/"', + 'site_description = "Executable Zensical subpath probe."', + 'docs_dir = "source"', + 'site_dir = "site"', + 'extra_css = ["stylesheets/extra.css"]', + "use_directory_urls = true", + 'nav = [{"Overview" = "index.md"}, {"Usage" = [{"Example" = "usage/example.md"}]}]', + "", + "[project.theme]", + 'variant = "modern"', + "font = false", + 'custom_dir = "overrides"', + "", + ] + ), + encoding="utf-8", + ) + return output + + +def assert_site(output: pathlib.Path) -> None: + expected = { + "index.html": "https://docbank.ai/docs/", + "usage/example/index.html": "https://docbank.ai/docs/usage/example/", + } + for relative, canonical in expected.items(): + page = output / relative + if not page.is_file(): + raise AssertionError(f"missing rendered page: {relative}") + parsed = ParsedPage.from_file(page) + if parsed.canonical != canonical: + raise AssertionError( + f"{relative}: canonical is {parsed.canonical!r}, expected {canonical!r}" + ) + if parsed.metadata.get("og:url") != canonical: + raise AssertionError( + f"{relative}: og:url is {parsed.metadata.get('og:url')!r}, " + f"expected {canonical!r}" + ) + for raw_url in parsed.urls: + resolved = urllib.parse.urljoin(canonical, raw_url) + parsed_url = urllib.parse.urlsplit(resolved) + if parsed_url.netloc == "docbank.ai" and not parsed_url.path.startswith( + "/docs/" + ): + raise AssertionError( + f"{relative}: local URL escapes /docs/: {raw_url!r}" + ) + +def main() -> None: + with tempfile.TemporaryDirectory( + prefix="zensical-subpath-", dir=DOCS_ROOT + ) as temporary: + root = pathlib.Path(temporary) + output = write_project(root) + subprocess.run( + [ + "uv", + "run", + "--project", + str(DOCS_ROOT), + "--frozen", + "--no-dev", + "zensical", + "build", + "--strict", + "--config-file", + str(root / "zensical.toml"), + ], + cwd=DOCS_ROOT, + check=True, + ) + assert_site(output) + print("Zensical /docs/ subpath behavior passed") + + +if __name__ == "__main__": + main() diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 8d5102fa..6ac19498 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,276 +1,120 @@ -/* docbank: an archive desk at night. Deep ink surfaces, paper-warm details. */ +@font-face { font-family: "Inter"; src: url("/fonts/Inter-Regular.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; } +@font-face { font-family: "Inter"; src: url("/fonts/Inter-Medium.woff2") format("woff2"); font-style: normal; font-weight: 500; font-display: swap; } +@font-face { font-family: "Inter"; src: url("/fonts/Inter-SemiBold.woff2") format("woff2"); font-style: normal; font-weight: 600; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2"); font-style: normal; font-weight: 400; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-SemiBold.woff2") format("woff2"); font-style: normal; font-weight: 600; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2"); font-style: normal; font-weight: 700; font-display: swap; } :root { - --md-text-font: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --md-code-font: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + --md-text-font: "Inter", system-ui, sans-serif; + --md-code-font: "JetBrains Mono", ui-monospace, monospace; } [data-md-color-scheme="slate"] { - --md-hue: 205; - --db-bg: #0a0f14; - --db-raised: #0e151c; - --db-panel: #121b23; - --db-panel-hover: #17232d; - --db-border: #263541; - --db-border-strong: #466170; - --db-text: #e8edf0; - --db-muted: #9aabb5; - --db-paper: #e5c991; - --db-accent: #79c9bc; - --db-accent-bright: #a1e1d6; - --md-default-bg-color: var(--db-bg); + --md-hue: 165; + --db-ink: #071411; + --db-panel: #0c1e1a; + --db-panel-raised: #102822; + --db-rule: #25473f; + --db-teal: #41d7bd; + --db-teal-strong: #78ead6; + --db-text: #edf8f4; + --db-muted: #a7c1ba; + --md-default-bg-color: var(--db-ink); --md-default-fg-color: var(--db-text); - --md-default-fg-color--light: #c2ccd1; - --md-default-fg-color--lighter: #85959f; - --md-primary-fg-color: var(--db-raised); - --md-primary-fg-color--light: var(--db-panel); - --md-primary-fg-color--dark: var(--db-bg); + --md-default-fg-color--light: var(--db-muted); + --md-default-fg-color--lighter: #76948c; + --md-primary-fg-color: var(--db-panel); + --md-primary-fg-color--light: var(--db-panel-raised); + --md-primary-fg-color--dark: var(--db-ink); --md-primary-bg-color: var(--db-text); - --md-accent-fg-color: var(--db-accent-bright); - --md-typeset-a-color: var(--db-accent); - --md-code-bg-color: #101820; - /* Mermaid inherits accent/code variables this palette doesn't define; - set the diagram colors explicitly. All pairs are WCAG-checked against - the dark surface: node text 12.7:1, edge labels 8.1:1, borders 7.8:1. */ - --md-mermaid-node-bg-color: #1a292c; - --md-mermaid-node-fg-color: var(--db-accent); + --md-accent-fg-color: var(--db-teal-strong); + --md-typeset-a-color: var(--db-teal); + --md-code-bg-color: var(--db-panel); + --md-mermaid-node-bg-color: var(--db-panel-raised); + --md-mermaid-node-fg-color: var(--db-teal); --md-mermaid-edge-color: var(--db-muted); - --md-mermaid-label-bg-color: var(--db-bg); + --md-mermaid-label-bg-color: var(--db-ink); --md-mermaid-label-fg-color: var(--db-text); } .md-header, .md-tabs { - background: color-mix(in srgb, var(--db-raised) 94%, transparent); - border-bottom: 1px solid var(--db-border); - backdrop-filter: blur(12px); -} - -.md-typeset { - font-size: 0.82rem; -} - -.md-typeset a { - color: var(--db-accent); - text-decoration-thickness: 0.06em; - text-underline-offset: 0.18em; + background: var(--db-panel); + border-bottom: 1px solid var(--db-rule); } +.md-typeset { font-size: 0.84rem; } +.md-typeset a { color: var(--db-teal); text-decoration-thickness: 0.06em; text-underline-offset: 0.18em; } .md-typeset a:hover, .md-nav__link:hover, -.md-nav__link--active { - color: var(--db-accent-bright); -} - -.md-nav__item .md-nav__link--active { - background: rgba(121, 201, 188, 0.07); - box-shadow: inset 2px 0 0 var(--db-accent); -} - +.md-nav__link--active { color: var(--db-teal-strong); } +.md-nav__item .md-nav__link--active { padding-left: 0.6rem; border-left: 2px solid var(--db-teal); background: var(--db-panel); } .md-nav__item--section > .md-nav__link, -.md-nav__title { - letter-spacing: 0.015em; -} +.md-nav__title { letter-spacing: 0.015em; } .md-typeset h1, .md-typeset h2, .md-typeset h3, -.md-typeset h4 { - color: #f4f6f7; - font-weight: 620; - letter-spacing: -0.018em; -} - -.md-typeset h1 { - max-width: 24ch; - margin-bottom: 0.65em; - font-size: clamp(1.4rem, 3.5vw, 2.3rem); - line-height: 1.1; -} - -.md-typeset h2 { - padding-bottom: 0.32em; - border-bottom: 1px solid var(--db-border); -} - -.md-typeset .eyebrow { - margin: 0 0 0.8rem; - color: var(--db-paper); - font-family: var(--md-code-font); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.16em; -} - -.md-typeset .hero-actions { - display: flex; - flex-wrap: wrap; - gap: 0.7rem; - margin: 1.4rem 0 2rem; -} - -.md-typeset .image-caption { - margin: -0.65rem 0 2rem; - color: var(--db-muted); - font-size: 0.72rem; - text-align: center; -} - -.md-typeset img { - border: 1px solid var(--db-border); - border-radius: 4px; - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28); -} - -.md-typeset .md-button { - border-color: var(--db-border-strong); - border-radius: 2px; -} - -.md-typeset .md-button--primary { - background: var(--db-accent); - border-color: var(--db-accent); - color: #07100f; -} - +.md-typeset h4 { color: var(--db-text); font-weight: 600; letter-spacing: -0.018em; } +.md-typeset h1 { max-width: 24ch; margin-bottom: 0.65em; font-size: clamp(1.5rem, 3.5vw, 2.4rem); line-height: 1.1; } +.md-typeset h2 { padding-bottom: 0.32em; border-bottom: 1px solid var(--db-rule); } +.md-typeset .eyebrow { margin: 0 0 0.8rem; color: var(--db-teal); font-family: var(--md-code-font); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; } +.md-typeset .hero-actions { display: flex; flex-wrap: wrap; gap: 0.7rem; margin: 1.4rem 0 2rem; } +.md-typeset .image-caption { margin: -0.65rem 0 2rem; color: var(--db-muted); font-size: 0.72rem; text-align: center; } +.md-typeset img { border: 1px solid var(--db-rule); border-radius: 4px; } + +.md-typeset .md-button { border-color: var(--db-rule); border-radius: 3px; } +.md-typeset .md-button:hover, +.md-typeset .md-button:focus { border-color: var(--db-teal); background: var(--db-panel-raised); } +.md-typeset .md-button--primary { border-color: var(--db-teal); color: var(--db-ink); background: var(--db-teal); } .md-typeset .md-button--primary:hover, -.md-typeset .md-button--primary:focus { - background: var(--db-accent-bright); - border-color: var(--db-accent-bright); - color: #07100f; -} +.md-typeset .md-button--primary:focus { border-color: var(--db-teal-strong); color: var(--db-ink); background: var(--db-teal-strong); } .md-typeset .signal-grid, -.md-typeset .feature-grid { - display: grid; - gap: 0.8rem; - margin: 1.4rem 0 2.2rem; -} - -.md-typeset .signal-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - +.md-typeset .feature-grid { display: grid; gap: 1px; margin: 1.4rem 0 2.2rem; padding: 1px; background: var(--db-rule); } +.md-typeset .signal-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .md-typeset .signal-grid section, -.md-typeset .feature-grid section { - min-width: 0; - padding: 1rem; - background: linear-gradient(145deg, var(--db-panel), #0f171e); - border: 1px solid var(--db-border); - border-radius: 2px; -} - -.md-typeset .signal-grid section { - display: flex; - flex-direction: column; - gap: 0.38rem; -} - -.md-typeset .signal-grid strong { - color: var(--db-paper); - font-family: var(--md-code-font); - font-size: 0.72rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.md-typeset .signal-grid span { - color: var(--db-muted); - font-size: 0.78rem; - line-height: 1.45; -} - -.md-typeset .feature-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.md-typeset .feature-grid section:hover { - background: var(--db-panel-hover); - border-color: var(--db-border-strong); -} - -.md-typeset .feature-grid h3 { - margin: 0 0 0.42rem; - color: var(--db-paper); - font-size: 1rem; -} - -.md-typeset .feature-grid p { - margin: 0; - color: var(--db-muted); -} +.md-typeset .feature-grid section { min-width: 0; padding: 1rem; background: var(--db-panel); } +.md-typeset .signal-grid section { display: flex; flex-direction: column; gap: 0.38rem; } +.md-typeset .signal-grid strong { color: var(--db-teal-strong); font-family: var(--md-code-font); font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; } +.md-typeset .signal-grid span, +.md-typeset .feature-grid p { color: var(--db-muted); } +.md-typeset .feature-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.md-typeset .feature-grid section:hover { background: var(--db-panel-raised); } +.md-typeset .feature-grid h3 { margin: 0 0 0.42rem; color: var(--db-teal-strong); font-size: 1rem; } +.md-typeset .feature-grid p { margin: 0; } .md-typeset .admonition, -.md-typeset details { - background: var(--db-panel); - border-color: var(--db-border-strong); - border-radius: 2px; -} - +.md-typeset details { border-color: var(--db-rule); border-radius: 3px; background: var(--db-panel); } .md-typeset .admonition-title, -.md-typeset summary { - background: rgba(229, 201, 145, 0.07); -} - -.md-typeset table:not([class]) { - border-collapse: collapse; - font-size: 0.76rem; -} - -.md-typeset table:not([class]) th { - background: #15212a; - color: var(--db-paper); - font-weight: 650; -} - +.md-typeset summary { background: var(--db-panel-raised); } +.md-typeset table:not([class]) { border-collapse: collapse; font-size: 0.76rem; } +.md-typeset table:not([class]) th { color: var(--db-teal-strong); background: var(--db-panel-raised); font-weight: 600; } .md-typeset table:not([class]) th, -.md-typeset table:not([class]) td { - padding: 0.58em 0.76em; - border-color: var(--db-border); - vertical-align: top; -} - -.md-typeset table:not([class]) tr:nth-child(even) { - background: rgba(255, 255, 255, 0.016); -} - -.md-typeset code:not(pre code) { - padding: 0.1em 0.3em; - background: var(--md-code-bg-color); - border: 1px solid var(--db-border); - border-radius: 2px; -} - +.md-typeset table:not([class]) td { padding: 0.58em 0.76em; border-color: var(--db-rule); vertical-align: top; } +.md-typeset table:not([class]) tr:nth-child(even) { background: var(--db-panel); } +.md-typeset code:not(pre code) { padding: 0.1em 0.3em; border: 1px solid var(--db-rule); border-radius: 2px; background: var(--md-code-bg-color); } .md-typeset .highlight pre, -.md-typeset .mermaid { - background: #0e161d; - border: 1px solid var(--db-border); - border-radius: 2px; -} - -.md-typeset .mermaid { - padding: 1rem; - overflow-x: auto; -} - -::selection { - background: #31564f; - color: #ffffff; -} +.md-typeset .mermaid { border: 1px solid var(--db-rule); border-radius: 3px; background: var(--db-panel); } +.md-typeset .mermaid { padding: 1rem; overflow-x: auto; } -:focus-visible { - outline: 2px solid var(--db-accent); - outline-offset: 2px; -} +::selection { color: var(--db-ink); background: var(--db-teal-strong); } +:focus-visible { outline: 2px solid var(--db-teal); outline-offset: 3px; } @media (max-width: 50rem) { .md-typeset .signal-grid, - .md-typeset .feature-grid { - grid-template-columns: 1fr; - } - - .md-typeset .hero-actions .md-button { - width: 100%; - text-align: center; + .md-typeset .feature-grid { grid-template-columns: 1fr; } + .md-typeset .hero-actions .md-button { width: 100%; text-align: center; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; } } diff --git a/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md b/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md new file mode 100644 index 00000000..3d1ca9dc --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-tiered-documentation-site-design.md @@ -0,0 +1,248 @@ +# Tiered Documentation Site Design + +## Objective + +Give Docbank one public site with three deliberate levels of depth: + +- `https://docbank.ai/` establishes the product identity and install path. +- `https://docbank.ai/guide/` explains the document authority lifecycle through a visual operator tour. +- `https://docbank.ai/docs/` contains precise operating and architecture documentation. + +The site describes Docbank as it will exist after the current document-processing, retrieval, interface, and Model Context Protocol pull-request stack lands. Implementation begins from `origin/main`, but this branch must not merge or deploy until the dependent stack through PR #239 lands. After the stack lands, the branch is updated from `main`; its capability wording, navigation, examples, screenshots, and tests are reconciled against the landed code rather than the earlier pull-request descriptions. Publication waits for the corresponding software release and post-tag documentation follow-up. + +## Audience and position + +The primary reader is a highly technical operator or builder responsible for durable AI document workflows. They evaluate systems through authority boundaries, failure behavior, data portability, deployment control, and recovery evidence. The site must explain those properties directly instead of borrowing generic AI-product language. + +The core position is: + +> The system of record for documents your agents can use. + +Docbank keeps original documents authoritative while governed processing produces searchable renditions and embeddings. Every derivative and retrieval result remains tied to exact source identity, explicit disclosure, and a recovery path controlled by the operator. + +The organizing idea is the **authority lifecycle**: + +1. Ingest exact source bytes without changing the source. +2. Assign stable document identity and immutable content-version identity. +3. Review provider disclosure, retained classes, estimates, and consent before processing. +4. Publish renditions and embedding sets as derivatives, never replacement authority. +5. Retrieve lexically, semantically, or in hybrid mode behind an exact source-version fence. +6. Serve bounded access through the CLI, web application, terminal UI, HTTP API, embedded Go API, and MCP server. +7. Verify, audit, back up, restore, and rebuild without converting a provider or index into authority. + +“AI-native” describes the resulting operating model, not a visual theme or an excuse for vague claims. Copy uses the actual Docbank nouns: source version, processing plan, consent, rendition, embedding set, source fence, receipt, audit, backup, and restore. + +## Public tiers + +### Product page: `/` + +The static product page answers four questions in order: + +1. What is Docbank? An authoritative document system for technical operators and agents. +2. What makes it different? Original authority remains distinct from derived machine understanding. +3. What can an operator do? Store, version, process, retrieve, expose, audit, and recover documents through one governed system. +4. What is the next action? Install the current release or read the guide. + +The page contains: + +- A compact header with Docbank, Guide, Docs, and GitHub. +- A hero with the core position, concrete supporting copy, install and guide actions, and platform/license facts. +- A document authority ledger showing Source, Authorization, Understanding, Retrieval, and Recovery states. +- Four proof sections: keep the source sovereign, govern machine understanding, constrain every answer, and restore the record. +- A real product capture from the pinned screenshot set. +- A section explaining local, self-hosted, and hosted processing choices without turning a provider list into the product identity. +- A section showing one authority across the CLI, web, TUI, HTTP, embedded Go API, and MCP. +- A direct comparison with cloud drives and storage appliances that preserves Docbank’s existing archive/system-of-record boundary. +- A final guide/install call to action and a restrained legal footer. + +The page does not use synthetic AI artwork, network-node illustrations, fabricated dashboards, vanity metrics, customer logos, or unprovable security language. + +### Operator guide: `/guide/` + +The guide is a seven-stop visual explanation of one document’s path through Docbank: + +1. **Ingest** — copy exact source bytes into a virtual tree while leaving the source untouched. +2. **Identify** — show stable node identity, immutable versions, checksums, moves, and replacement history. +3. **Authorize** — inspect a processing plan, provider flow, retained classes, estimate, fingerprint, and consent state before egress. +4. **Understand** — build and inspect retained sanitized Markdown, normalized evidence, embedding sets, independent coverage, and durable jobs. +5. **Retrieve** — run lexical, semantic, hybrid, or automatic retrieval over an exact source-version fence and inspect the evidence used. +6. **Serve** — use the same authority through human interfaces, automation APIs, embedding, and the bounded MCP server. +7. **Prove** — verify content, inspect audited history, create and verify a backup, restore without provider calls, and rebuild disposable indexes. + +Each stop uses an actual synthetic-vault capture or a compact native diagram derived from product contracts. Each stop links to the corresponding operating documentation. The guide avoids duplicating full command references. + +### Operating documentation: `/docs/` + +The existing Zensical content moves under `/docs/`. Its navigation continues to separate starting, document work, protection and operations, automation and integration, reference, architecture, and project status. + +The incoming stack’s document-processing, source-fenced retrieval, processing-configuration, derivative-architecture, and MCP pages remain operating documentation. Their final routes remove the ambiguous duplicate names introduced by the stack: + +- Existing `usage/searching.md` becomes **Catalog search** and continues to cover names, verified current plain text, tags, media types, directories, and modification times. +- Incoming `usage/search.md` moves to `usage/retrieval.md` as **Source-fenced retrieval** and covers lexical, semantic, hybrid, and automatic retrieval over processed derivatives. +- Existing root `configuration.md` becomes **Vault and daemon configuration**. +- Incoming `usage/configuration.md` moves to `usage/processing-configuration.md` as **Processing configuration**. + +The post-stack reconciliation performs those moves, updates links and `llms.txt`, and preserves the exact contracts of both workflows. + +## Visual language + +The site uses a flat teal-on-ink system: + +- Solid ink backgrounds and slightly raised solid panels. +- Thin, precise rules to establish hierarchy. +- One restrained teal accent for active states, links, and primary actions. +- Off-white primary text and quiet blue-green gray secondary text. +- Square or minimally rounded geometry. +- No gradients, glows, glass effects, translucent floating panels, decorative blobs, particle fields, or “AI network” imagery. +- No ornamental motion. Any transition must clarify interaction state and respect reduced-motion preferences. + +The authority ledger is the main graphic motif. It presents meaningful identities and states rather than decoration. + +### Typography + +Vendor pinned WOFF2 files and their license texts for the fonts required by the public site: + +- **JetBrains Mono** for the wordmark, headings, navigation, labels, code, identifiers, and lifecycle states. +- **Inter** for paragraphs, tables, captions, long-form documentation, and other continuous reading. + +Body text uses Inter 400 with generous line height. Inter 500 or 600 provides emphasis. The site does not use font weights below 400; visual lightness comes from spacing, scale, and color contrast rather than fragile hairline glyphs. JetBrains Mono uses 600 or 700 for display hierarchy and 400 for code. + +All text and interactive states must meet WCAG AA contrast. Focus states are visible and use the same teal system. + +Zensical sets `project.theme.font = false` so it does not load a second copy from Google Fonts. The built-site verifier rejects `fonts.googleapis.com` and `fonts.gstatic.com`; all font requests resolve to the vendored files. + +## Human- and machine-readable publishing contract + +Every substantive page has a stable HTML route and a Markdown peer: + +| Human route | Markdown route | +| --- | --- | +| `/` | `/index.md` | +| `/guide/` | `/guide.md` | +| `/docs/` | `/docs/index.md` | +| `/docs//` | `/docs/.md` | +| `/docs/
//` | `/docs/
/.md` | + +`https://docbank.ai/llms.txt` is the hand-maintained root index for all published Markdown pages across the three tiers. It uses canonical `docbank.ai` URLs. + +The production build fails when: + +- a substantive HTML page lacks its Markdown peer; +- a published Markdown page lacks its HTML route; +- `llms.txt` omits a published Markdown page or names a missing page; +- the copied Markdown differs from its source; +- a local link, anchor, image, canonical URL, or required social metadata is invalid; or +- private/internal material crosses the publishing boundary. + +The static product page and guide use dedicated text-first Markdown sources. They contain the same claims, examples, and links as their visual HTML pages without trying to reproduce visual layout in Markdown. + +## Screenshot generation and publication + +Screenshot generation is independent from documentation builds and deployment. + +`make docs-screenshots` replaces the existing `make frontend-screenshots` target; there is no compatibility alias. `AGENTS.md`, `frontend/screenshots/README.md`, the Makefile help text, and the `.superpowers` hook message all name the replacement command. It runs the repository-owned Playwright harness against a real temporary daemon and synthetic vault. It never reads a developer vault, uses mocked API data, or writes captures into tracked documentation sources. + +Expanding that harness is part of this project. The current branch emits only a subset of the historical `docs-assets` branch, and the stack head adds processing-plan, partial-coverage, and rendition captures. After the stack lands, the harness must emit every screenshot named by the new manifest before this branch can merge. Guide stops without a meaningful product screen—such as the cross-surface authority map and provider-free recovery flow—use tracked native diagrams instead of fabricated UI captures. + +Publishing replaces the complete root of the orphan `docs-assets` branch with the reviewed capture set. `scripts/docs-assets.txt` is a sorted newline-delimited manifest containing one root-level PNG filename per line; directory components, duplicate names, blank entries, and non-PNG extensions are invalid. `scripts/docs-assets.ref` records the full lowercase 40-character reviewed commit SHA. The first publication under this contract flattens the current ad-hoc feature/version directories and removes the branch README so the orphan branch contains exactly the manifested PNG files. + +Documentation builds fetch the exact pinned commit, validate every expected asset, reject unmanifested files, and fail closed on missing, duplicate, malformed, oversized, or unsupported files. They never fall back to the mutable branch head. The pinned Git commit supplies content identity; the local synced-generation record stores each PNG’s SHA-256 for cache verification. + +Documentation screenshots are PNG unless a specific reviewed vector diagram is maintained as tracked site source. Remote SVG screenshots are not accepted. The generated set is staged beside its destination and published atomically so a failed move cannot delete the previous usable set. + +The build materializes pinned assets into an ignored local cache before rendering. Published Markdown and root `README.md` use canonical `https://docbank.ai/assets/generated/.png` URLs so images render both on the site and on GitHub after the first production publication. The current `raw.githubusercontent.com/.../docs-assets/...` URLs are removed during migration. Local HTML may use root-relative `/assets/generated/.png` URLs. + +## Build architecture + +The repository root owns documentation commands: + +- `make docs-install` installs the locked Zensical environment. +- `make docs-serve` runs a small dependency-free Node watcher/server that builds the same three tiers, serves the generated `site/` root, rebuilds after changes to public website or documentation sources, and keeps `/`, `/guide/`, and `/docs/` on one local origin. +- `make docs-build` produces and verifies the complete static site. +- `make docs-screenshots` generates the separate capture set and is the only screenshot target. +- `make docs-deploy` performs the verified production deployment from the repository root. + +The static build follows an explicit public allowlist: + +1. Copy `website/` into a scratch site root for `/`, `/guide/`, fonts, favicon, and static scripts. +2. Copy installer scripts from their repository-owned sources. +3. Fetch and validate the pinned screenshot generation. +4. Stage only publishable Zensical sources into a scratch project. +5. Render Zensical under `/docs/`. +6. Publish every Markdown peer and root `llms.txt`. +7. Verify routes, links, metadata, assets, Markdown parity, and the public boundary. +8. Atomically replace the local `site/` output. + +Zensical remains locked through `docs/uv.lock`. Before the rest of the build migration, an executable spike builds a minimal page with Zensical 0.0.45 at `site_url = "https://docbank.ai/docs/"` and verifies its canonical URL, asset URLs, navigation, and Markdown peer under that subpath. A failed spike stops implementation for a design revision; the plan does not assume subpath behavior that has not been observed. + +Static website production code has no frontend framework or package-install step. Small browser behavior such as screenshot zoom and current-release download selection uses dependency-free JavaScript. Browser verification is a development/CI concern and may install the repository’s locked frontend test dependencies. + +The Vercel install script downloads the standalone `uv` 0.12.1 archive for the detected Linux x86-64 or ARM64 architecture, verifies the architecture-specific SHA-256 pinned in the script, and installs only `uv` and `uvx` into a project-local Vercel tools directory. The build path then uses the locked Zensical environment, syncs the pinned assets, and builds the static site. It does not install or run Go, npm dependencies, the product frontend, Docker, Playwright, screenshot generation, provider runtimes, or browser tests. + +Root `.vercelignore` acts as an upload allowlist. It includes only the static website, allowlisted documentation sources, fonts/favicon, installer sources, and the scripts/configuration required for the static build. It excludes repository source, caches, generated product assets, local state, credentials, internal documentation, plans, reports, and screenshot tooling. + +Vercel project ownership moves completely to the repository root. Root `vercel.json` defines the install command, build command, `site` output directory, and trailing-slash behavior. `make docs-link` runs `vercel link` from the root and checks root `.vercel/project.json`. Tracked `docs/vercel.json` is deleted, and no command reads, copies, or falls back to the old local `docs/.vercel` link. Existing operators run `make docs-link` once to create the new root link. + +## Release and deployment contract + +Candidate feature documentation must not become public before the corresponding software release. + +The successful software release makes a documentation source eligible but does not publish it immediately. The intended production source is the post-tag documentation-only follow-up after the final release changelog and wording corrections merge. Deployment verifies that the latest release tag is an ancestor of the selected source and that every commit between the tag and that source changes only the approved documentation and publishing surface. The selected source may be an exact commit on `main` if unrelated next-release work has already advanced the branch. + +The GitHub deployment workflow is explicitly dispatched with that eligible source after the post-tag follow-up. `make docs-deploy` uses the same release-aware validation and remains the recommended manual production command. The Vercel upload uses the root allowlist and does not send the full repository. + +Deployment and promotion are separate operations. The workflow verifies the intended latest release before build, creates a production-target deployment with automatic domain assignment disabled (`vercel deploy --prod --skip-domain`), waits for that deployment to become ready, and verifies the intended latest release again. Only then does it run `vercel promote `. A failed or stale check leaves an unpromoted deployment and cannot replace the current production aliases. + +The canonical host is `https://docbank.ai/`. `www.docbank.ai` redirects to the apex host. + +## Development workflow for maintainers and agents + +`AGENTS.md` and `docs/README.md` document the same workflow: + +- Use `make docs-serve` for content, navigation, and visual work. +- Use `make docs-screenshots` only when product UI captures need regeneration; `make frontend-screenshots` no longer exists. +- Inspect generated screenshots before publishing a complete `docs-assets` generation. +- Update the pinned commit and manifest in a separate reviewed change. +- Run `make docs-build` after the final documentation edit. +- Review substantive changes as rendered pages, not only source diffs. +- Do not publish candidate feature documentation before its release. +- Use `make docs-deploy` from the root only for an authorized production release or post-tag documentation follow-up. + +The guidance explicitly tells agents that docs builds consume screenshots but never generate them. + +While updating `docs/README.md`, remove its inaccurate statement that section landings use files such as `usage.md`; the current navigation has no section-landing Markdown files. + +## Verification + +Verification exercises owned behavior rather than restating configuration text: + +- Unit tests build a fixture site and prove that missing twins, stale `llms.txt` entries, broken links, leaked private paths, and undeclared assets fail. +- Asset-sync tests use a local synthetic Git repository and prove exact-commit pinning, manifest completeness, file-type/size validation, and fail-closed behavior without network fallbacks. +- Upload-boundary tests construct the actual Vercel input set and prove that required public files are present while representative Go, frontend, internal, local-environment, and screenshot-generation files are absent. +- The strict production build validates all output pages and Markdown peers. +- Browser verification uses the repository’s locked Playwright dependency, adds explicit Chromium and WebKit projects for the built static site, and installs those browsers only in the development/CI test lane. It loads the built landing page, guide, representative docs pages, navigation, theme, responsive layouts, screenshot lightbox, and download fallback. +- Accessibility checks cover headings, landmarks, keyboard navigation, visible focus, image alternatives, reduced motion, and color contrast. + +Tests do not assert that a parser returns values copied from the same configuration, inspect source strings as a proxy for behavior, or recreate the implementation logic inside the assertion. + +## Non-goals + +- Redesigning the Docbank product UI. +- Changing document-processing, retrieval, storage, audit, backup, or MCP contracts. +- Adding a JavaScript framework or general-purpose website build system. +- Publishing unreleased capability claims before the post-stack release. +- Tracking generated screenshots on the main branch. +- Running product builds or screenshot capture inside Vercel. +- Merging or deploying this branch before the dependent stack through PR #239 lands and the site is reconciled against its final code. + +## Acceptance criteria + +- The three tiers build into one verified static site at the documented routes. +- The homepage and guide express the authority lifecycle for technical operators using post-stack product behavior. +- The visual system uses flat teal-on-ink surfaces, JetBrains Mono display text, and readable Inter body text without AI-style decoration. +- Root `llms.txt` lists every published Markdown page, and every substantive HTML page has its documented Markdown peer. +- Documentation builds consume one pinned, complete screenshot generation and never run screenshot capture. +- `make docs-deploy` works from the repository root, enforces the release boundary, uploads only the narrow public input set, and promotes only after the final release check passes. +- Strict build, behavioral boundary tests, and browser verification pass. +- Maintainer and agent instructions describe the complete development, screenshot, review, and release workflow. +- The branch has been updated after the dependent stack lands, and its pages, screenshots, examples, and capability claims match the resulting code before merge. diff --git a/docs/tour.md b/docs/tour.md index b1bef17f..1a67e7a3 100644 --- a/docs/tour.md +++ b/docs/tour.md @@ -16,7 +16,7 @@ node's current path, stable ID, revision, immutable version, SHA-256 identity, tags, provenance, and permanent-audit status. The browser receives scoped, daemon-lifetime credentials rather than the daemon's master API key. -![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/v0.12.0/web-vault-browser.png) +![The Docbank web application browsing a synthetic vault and showing the selected document's stable authority.](https://docbank.ai/assets/generated/web-vault-browser.png) ## Organize independently of folders @@ -24,7 +24,7 @@ Tags form a shared vocabulary with stable UUIDs and revision-protected definitions. People can manage the catalog or a selected document's assignments in the web application; agents use the same bounded daemon API. -![The Docbank web application managing a synthetic vault's stable tag catalog.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/web-tag-catalog/web-tag-catalog.png) +![The Docbank web application managing a synthetic vault's stable tag catalog.](https://docbank.ai/assets/generated/web-tag-catalog.png) ## Verify permanent history @@ -33,7 +33,7 @@ changes. Independent verification replays the history, re-hashes protected content, and returns terminal scope heads that can be recorded outside the vault for later comparison. -![The Docbank web application showing independently verified permanent audit evidence for a synthetic vault.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/web-audit-evidence/web-audit-evidence.png) +![The Docbank web application showing independently verified permanent audit evidence for a synthetic vault.](https://docbank.ai/assets/generated/web-audit-evidence.png) ## See where physical authority lives @@ -43,7 +43,7 @@ The web view is deliberately read-only: it separates logical authority, physical inventory, store health, sole copies, and live-document impact without exposing deployment paths or credentials. -![The Docbank web application showing the primary and a secondary physical store for a synthetic vault.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/multi-store-storage/web-multi-store-storage.png) +![The Docbank web application showing the primary and a secondary physical store for a synthetic vault.](https://docbank.ai/assets/generated/web-multi-store-storage.png) ## Operate from a terminal @@ -52,7 +52,7 @@ document identity, permanent history, recoverable trash and restore, job status, and a read-only operational screen. Storage and backup results load independently so one unavailable repository does not hide live-vault status. -![The Docbank TUI showing physical storage inventory and two synthetic backup recovery points.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/tui-storage-backup/tui-storage-backup.png) +![The Docbank TUI showing physical storage inventory, two content stores, and two synthetic backup recovery points.](https://docbank.ai/assets/generated/tui-multi-store-storage.png) Continue with the [Quickstart](quickstart.md), or choose a task from [Capabilities](capabilities.md). diff --git a/docs/usage/audited-history.md b/docs/usage/audited-history.md index 968dfef9..f12686c4 100644 --- a/docs/usage/audited-history.md +++ b/docs/usage/audited-history.md @@ -29,7 +29,7 @@ including records outside the selected directory. Those unrelated documents do not become audit members and their content versions are not protected by the scope, but the enrollment-time metadata remains part of the evidence. -![The Docbank web application showing independently verified permanent audit evidence for a synthetic vault.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/web-audit-evidence/web-audit-evidence.png) +![The Docbank web application showing independently verified permanent audit evidence for a synthetic vault.](https://docbank.ai/assets/generated/web-audit-evidence.png) ## Review before enabling diff --git a/docs/usage/organizing.md b/docs/usage/organizing.md index aa729ad5..04dce106 100644 --- a/docs/usage/organizing.md +++ b/docs/usage/organizing.md @@ -80,7 +80,7 @@ Tags organize documents independently of their current paths. Each tag has a stable UUID; its name can change without breaking assignments or agent-held references. -![The Docbank web application managing a synthetic vault's stable tag catalog.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/web-tag-catalog/web-tag-catalog.png) +![The Docbank web application managing a synthetic vault's stable tag catalog.](https://docbank.ai/assets/generated/web-tag-catalog.png) ```bash docbank tag create taxes diff --git a/docs/usage/storage.md b/docs/usage/storage.md index 441e56ed..376f0018 100644 --- a/docs/usage/storage.md +++ b/docs/usage/storage.md @@ -16,7 +16,7 @@ records which verified stores may satisfy each retained SHA-256 identity, but it does not mirror arbitrary filesystem changes, manage bucket lifecycle rules, or replace a complete [backup](backup.md). -![The Docbank web application showing the primary and a secondary physical store for a synthetic vault.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/multi-store-storage/web-multi-store-storage.png) +![The Docbank web application showing the primary and a secondary physical store for a synthetic vault.](https://docbank.ai/assets/generated/web-multi-store-storage.png) The web application and TUI expose this inventory read-only. Registration, placement, repair, takeover, evacuation, and removal remain explicit CLI or diff --git a/docs/usage/tui.md b/docs/usage/tui.md index 9c83a58e..cc67bde1 100644 --- a/docs/usage/tui.md +++ b/docs/usage/tui.md @@ -18,7 +18,7 @@ outlive the background daemon's idle window, so each bounded interaction rediscovers or restarts a compatible daemon before issuing its request; leaving the terminal open does not pin an otherwise idle process. -![The Docbank TUI showing physical storage inventory and two synthetic backup recovery points.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/tui-storage-backup/tui-storage-backup.png) +![The Docbank TUI showing physical storage inventory, two content stores, and two synthetic backup recovery points.](https://docbank.ai/assets/generated/tui-multi-store-storage.png) The main view is a full-width document table. At ordinary terminal widths it shows each document's name, type, size, and UTC modification time; search results diff --git a/docs/usage/web.md b/docs/usage/web.md index 34d1600c..066a95b1 100644 --- a/docs/usage/web.md +++ b/docs/usage/web.md @@ -41,7 +41,7 @@ The browser is another client of the authenticated HTTP API. It does not open SQLite or the blob store, and it has no private route that the CLI or an agent cannot use. The daemon remains loopback-only. -![The Docbank web application showing a synthetic vault tree and the selected document's authority.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/v0.12.0/web-vault-browser.png) +![The Docbank web application showing a synthetic vault tree and the selected document's authority.](https://docbank.ai/assets/generated/web-vault-browser.png) *The root browser keeps the document table primary while the authority card shows the selected file's stable identity and verified content hash.* @@ -271,7 +271,7 @@ workflows are bound to the tag's stable UUID so a later rename does not silently change which definition was selected. Changing the selector reruns the current browse or search. -![The Docbank web application showing extracted-text search results in a synthetic vault.](https://raw.githubusercontent.com/kenn-io/docbank/docs-assets/screenshots/v0.12.0/web-search-results.png) +![The Docbank web application showing extracted-text search results in a synthetic vault.](https://docbank.ai/assets/generated/web-search-results.png) *Search results display complete virtual paths and keep the same authority inspection available from ordinary folder browsing.* diff --git a/docs/vercel.json b/docs/vercel.json deleted file mode 100644 index 9959e60c..00000000 --- a/docs/vercel.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "framework": null -} diff --git a/docs/zensical-docs.sh b/docs/zensical-docs.sh index 92bed6f8..a0e7f569 100755 --- a/docs/zensical-docs.sh +++ b/docs/zensical-docs.sh @@ -18,7 +18,11 @@ if ! command -v uv >/dev/null 2>&1; then fi uv_run=(uv run --project "$docs_root" --frozen --no-dev) -repo_root="$(git -C "$docs_root" rev-parse --show-toplevel)" +if [[ -n "${DOCBANK_REPO_ROOT:-}" ]]; then + repo_root="$(cd "$DOCBANK_REPO_ROOT" && pwd)" +else + repo_root="$(git -C "$docs_root" rev-parse --show-toplevel)" +fi if [[ "/$site_dir/" == *"/../"* ]]; then printf 'refusing docs site directory containing parent traversal: %s\n' "$site_dir" >&2 exit 2 @@ -159,7 +163,7 @@ case "$command_name" in mkdir -p "$(dirname "$markdown_dest")" cp "$markdown_source" "$markdown_dest" done < <(find "$tmp_docs" -type f -name '*.md' -print0) - "${uv_run[@]}" python "$docs_root/scripts/check_built_site.py" "$tmp_site" "$tmp_docs" + "${uv_run[@]}" python "$docs_root/scripts/check_built_site.py" "$tmp_site" "$tmp_docs" /docs printf '%s\n' "$site_marker_contents" > "$tmp_site/$site_marker" mkdir -p "$(dirname "$site_path")" if [[ -e "$site_path" || -L "$site_path" ]]; then diff --git a/docs/zensical.toml b/docs/zensical.toml index e745fdc5..07bb6e4d 100644 --- a/docs/zensical.toml +++ b/docs/zensical.toml @@ -1,6 +1,6 @@ [project] site_name = "docbank" -site_url = "https://www.docbank.ai/" +site_url = "https://docbank.ai/docs/" site_description = "Docbank is a self-sovereign document system for people and agents, with indexed retrieval, stable identity, verifiable content, incremental recovery, and audited history." site_author = "Kenn Software LLC" copyright = 'Copyright © 2026 Kenn Software LLC.' @@ -70,6 +70,7 @@ homepage = "/" [project.theme] variant = "modern" custom_dir = "overrides" +font = false features = [ "navigation.instant", "navigation.instant.progress", diff --git a/frontend/docs-site/playwright.config.ts b/frontend/docs-site/playwright.config.ts new file mode 100644 index 00000000..4b60e340 --- /dev/null +++ b/frontend/docs-site/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(here, "..", ".."); +const port = 41738; + +export default defineConfig({ + testDir: ".", + testMatch: /site\.spec\.ts/, + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + outputDir: path.join(repositoryRoot, ".superpowers", "docs-site-playwright"), + reporter: "line", + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + }, + webServer: { + command: "node scripts/docs/serve.mjs", + cwd: repositoryRoot, + env: { + DOCBANK_DOCS_PORT: String(port), + }, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + colorScheme: "dark", + }, + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + colorScheme: "dark", + }, + }, + ], +}); diff --git a/frontend/docs-site/site.spec.ts b/frontend/docs-site/site.spec.ts new file mode 100644 index 00000000..00fbe6f3 --- /dev/null +++ b/frontend/docs-site/site.spec.ts @@ -0,0 +1,176 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + + +const routes = ["/", "/guide/", "/docs/", "/docs/usage/searching/"]; + + +function channel(value: number): number { + const normalized = value / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; +} + + +function luminance([red, green, blue]: [number, number, number]): number { + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); +} + + +function ratio(foreground: [number, number, number], background: [number, number, number]): number { + const lighter = Math.max(luminance(foreground), luminance(background)); + const darker = Math.min(luminance(foreground), luminance(background)); + return (lighter + 0.05) / (darker + 0.05); +} + + +function parseOpaqueColor(value: string): [number, number, number] { + const match = value.match(/^rgba?\(\s*(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)(?:\D+([\d.]+))?\s*\)$/); + if (!match) throw new Error(`unsupported computed color: ${value}`); + if (match[4] !== undefined && Number(match[4]) !== 1) { + throw new Error(`contrast sample is not fully opaque: ${value}`); + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + + +async function computedColors(locator: Locator): Promise<{ foreground: string; background: string }> { + return locator.evaluate((element) => { + const foreground = getComputedStyle(element).color; + let current: Element | null = element; + while (current) { + const background = getComputedStyle(current).backgroundColor; + const alpha = background.match(/[\d.]+\)$/)?.[0].slice(0, -1); + if (!background.startsWith("rgba") || alpha === "1") return { foreground, background }; + current = current.parentElement; + } + throw new Error("no opaque background found"); + }); +} + + +async function expectContrast(locator: Locator, minimum: number): Promise { + const colors = await computedColors(locator); + expect(ratio(parseOpaqueColor(colors.foreground), parseOpaqueColor(colors.background))).toBeGreaterThanOrEqual(minimum); +} + + +test("publishes each tier with semantic page landmarks", async ({ page }) => { + for (const route of routes) { + await page.goto(route); + await expect(page.locator("main")).toBeVisible(); + await expect(page.getByRole("heading", { level: 1 })).toHaveCount(1); + await expect(page.getByRole("navigation").first()).toBeVisible(); + } +}); + + +test("supports keyboard navigation and returns focus after the image dialog", async ({ browserName, page }) => { + await page.goto("/"); + const skipLink = page.getByRole("link", { name: "Skip to content" }); + await skipLink.focus(); + await expect(skipLink).toBeFocused(); + if (browserName !== "webkit") { + await page.keyboard.press("Tab"); + await expect(page.locator(".wordmark")).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Guide", exact: true }), + ).toBeFocused(); + } + + const trigger = page.getByRole("link", { name: /synthetic technical document collection/i }); + await trigger.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + await expect(page.getByRole("button", { name: "Close image" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(trigger).toBeFocused(); +}); + + +test("shows the documented install command with a Windows alternative", async ({ page }) => { + await page.goto("/"); + await expect(page.locator("[data-install-command] code")).toHaveText("curl -fsSL https://docbank.ai/install.sh | sh"); + await expect(page.getByRole("link", { name: "Windows install" })).toBeVisible(); +}); + + +test("renders repository facts in the header when GitHub responds", async ({ page }) => { + await page.route("https://api.github.com/repos/kenn-io/docbank", (route) => + route.fulfill({ json: { stargazers_count: 1280, forks_count: 34 } }), + ); + await page.route("https://api.github.com/repos/kenn-io/docbank/releases/latest", (route) => + route.fulfill({ json: { tag_name: "v0.4.0" } }), + ); + await page.goto("/"); + await expect(page.locator('[data-fact="stars"]')).toHaveText(/1\.3k/); + await expect(page.locator('[data-fact="forks"]')).toHaveText(/34/); + await expect(page.locator('[data-fact="version"]')).toHaveText(/v0\.4\.0/); +}); + + +test("keeps a static header when the GitHub API is unavailable", async ({ page }) => { + await page.route("https://api.github.com/**", (route) => route.fulfill({ status: 503, body: "unavailable" })); + const settled = page.waitForResponse("https://api.github.com/repos/kenn-io/docbank"); + await page.goto("/"); + await settled; + await expect(page.getByRole("link", { name: /on GitHub/ })).toBeVisible(); + await expect(page.locator("[data-facts]")).toBeHidden(); +}); + + +test("keeps primary navigation usable at a narrow viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + const navigation = page.getByRole("navigation", { name: "Primary navigation" }); + await expect(navigation.getByRole("link", { name: "Guide" })).toBeVisible(); + await expect(navigation.getByRole("link", { name: "Docs" })).toBeVisible(); + await expect(navigation.getByRole("link", { name: /on GitHub/ })).toBeVisible(); + await expect(navigation.getByRole("link", { name: "Docbank Discord" })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); +}); + + +test("honors reduced motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect.poll(() => page.locator("html").evaluate((element) => getComputedStyle(element).scrollBehavior)).toBe("auto"); + const duration = await page.getByRole("link", { name: "See how it works" }).evaluate( + (element) => getComputedStyle(element).transitionDuration, + ); + expect(Number.parseFloat(duration)).toBeLessThanOrEqual(0.00001); +}); + + +test("gives every rendered image accessible text", async ({ page }) => { + for (const route of routes) { + await page.goto(route); + const images = page.locator("img"); + for (let index = 0; index < await images.count(); index += 1) { + expect((await images.nth(index).getAttribute("alt"))?.trim(), `${route} image ${index}`).toBeTruthy(); + } + } +}); + + +test("keeps representative text and controls above contrast thresholds", async ({ page }) => { + await page.goto("/"); + await expectContrast(page.getByRole("heading", { level: 1 }), 3); + await expectContrast(page.locator(".lede").first(), 4.5); + await expectContrast(page.getByRole("link", { name: "Guide" }).first(), 4.5); + await expectContrast(page.getByRole("link", { name: "See how it works" }), 4.5); + await expectContrast(page.locator("[data-install-command] code"), 4.5); + + const copy = page.getByRole("button", { name: "Copy" }); + await copy.focus(); + const focus = await copy.evaluate((element) => { + const style = getComputedStyle(element); + return { outline: style.outlineColor, background: style.backgroundColor }; + }); + expect(ratio(parseOpaqueColor(focus.outline), parseOpaqueColor(focus.background))).toBeGreaterThanOrEqual(3); + + await page.goto("/docs/"); + await expectContrast(page.locator(".md-typeset p").first(), 4.5); + await expectContrast(page.locator(".md-typeset a").first(), 4.5); +}); diff --git a/frontend/docs-site/tsconfig.json b/frontend/docs-site/tsconfig.json new file mode 100644 index 00000000..072124cb --- /dev/null +++ b/frontend/docs-site/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2023", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023", + "types": ["node", "@playwright/test"] + }, + "include": ["*.ts"] +} diff --git a/frontend/package.json b/frontend/package.json index 2887235c..b620f9ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,10 @@ "build": "vp build", "check": "svelte-check --tsconfig ./tsconfig.json", "check:kit-ui": "kit-ui-check src", + "docs-site:check": "tsc -p docs-site/tsconfig.json --noEmit", + "docs-site:test": "node node_modules/@playwright/test/cli.js test --config docs-site/playwright.config.ts", "screenshots": "node screenshots/run.mjs", - "screenshots:check": "tsc -p screenshots/tsconfig.json --noEmit", + "screenshots:check": "tsc -p screenshots/tsconfig.json --noEmit && node --test screenshots/publish.node-test.mjs", "test": "vp test run" }, "dependencies": { diff --git a/frontend/screenshots/README.md b/frontend/screenshots/README.md index b4d44499..a048b5b6 100644 --- a/frontend/screenshots/README.md +++ b/frontend/screenshots/README.md @@ -15,21 +15,30 @@ node node_modules/@playwright/test/cli.js install chromium Then run from the repository root: ```sh -make frontend-screenshots +make docs-screenshots ``` The command builds the current frontend and Docbank binary, creates and seeds an owner-private temporary vault, opens the daemon-issued browser session, captures the requested state, stops the daemon, and removes the vault. -Generated images are written beneath `.superpowers/screenshots/` for visual -inspection and PR attachment; the current case captures both move-to-trash and +Generated images are atomically published beneath `.superpowers/screenshots/` +for visual inspection and orphan-branch publication; the current set captures +both move-to-trash and restore confirmations, the tag-definition catalog, and a completed tag assignment, current vault browsing, extracted-text search, retained-version selection, packed-storage status, and independently verified permanent-audit -evidence. Generated images are intentionally not committed. +evidence. Generated images are intentionally not committed to the main branch. -Pass ordinary Playwright arguments after `--` to select a case: +The command must produce the complete set listed in `scripts/docs-assets.txt`. +Documentation builds consume a reviewed set and never run this harness. + +For focused harness development, invoke Playwright directly with a separate +temporary `DOCBANK_SCREENSHOT_DIR`; the root Make target intentionally rejects +partial generations: ```sh -npm --prefix frontend run screenshots -- --grep "trash confirmation" +cd frontend +DOCBANK_SCREENSHOT_DIR="$(mktemp -d)" node node_modules/@playwright/test/cli.js test \ + --config screenshots/playwright.config.ts --project chromium \ + --grep "trash confirmation" ``` diff --git a/frontend/screenshots/publish.mjs b/frontend/screenshots/publish.mjs new file mode 100644 index 00000000..bdb885c7 --- /dev/null +++ b/frontend/screenshots/publish.mjs @@ -0,0 +1,99 @@ +import { + access, + open, + readdir, + rename as renamePath, + rm, +} from "node:fs/promises"; +import path from "node:path"; + + +const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const screenshotName = /^[A-Za-z0-9][A-Za-z0-9._-]*\.png$/; + + +async function exists(target) { + try { + await access(target); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + + +function validateNames(names) { + if (!Array.isArray(names) || names.length === 0) { + throw new Error("screenshot manifest is empty"); + } + const sorted = [...names].sort(); + if (names.some((name, index) => name !== sorted[index])) { + throw new Error("screenshot manifest is not sorted"); + } + if (new Set(names).size !== names.length) { + throw new Error("screenshot manifest contains duplicate names"); + } + for (const name of names) { + if (!screenshotName.test(name) || path.basename(name) !== name) { + throw new Error(`invalid screenshot name: ${name}`); + } + } +} + + +async function validateStaging(staging, names) { + const entries = await readdir(staging, { withFileTypes: true }); + const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort(); + const unexpectedEntry = entries.find((entry) => !entry.isFile()); + if (unexpectedEntry) { + throw new Error(`unexpected screenshot entry: ${unexpectedEntry.name}`); + } + for (const name of names) { + if (!files.includes(name)) throw new Error(`missing screenshot: ${name}`); + } + for (const name of files) { + if (!names.includes(name)) throw new Error(`unexpected screenshot: ${name}`); + } + for (const name of names) { + const file = await open(path.join(staging, name), "r"); + try { + const header = Buffer.alloc(pngSignature.length); + const { bytesRead } = await file.read(header, 0, header.length, 0); + if (bytesRead !== pngSignature.length || !header.equals(pngSignature)) { + throw new Error(`invalid PNG signature: ${name}`); + } + } finally { + await file.close(); + } + } +} + + +export async function publishScreenshots({ + output, + staging, + names, + rename = async (from, to, realRename) => realRename(from, to), +}) { + validateNames(names); + if (path.dirname(output) !== path.dirname(staging)) { + throw new Error("screenshot staging directory must be beside output"); + } + await validateStaging(staging, names); + + const previous = path.join( + path.dirname(output), + `.${path.basename(output)}.previous`, + ); + await rm(previous, { recursive: true, force: true }); + const hadOutput = await exists(output); + if (hadOutput) await rename(output, previous, renamePath); + try { + await rename(staging, output, renamePath); + } catch (error) { + if (hadOutput) await rename(previous, output, renamePath); + throw error; + } + await rm(previous, { recursive: true, force: true }); +} diff --git a/frontend/screenshots/publish.node-test.mjs b/frontend/screenshots/publish.node-test.mjs new file mode 100644 index 00000000..338ba5a2 --- /dev/null +++ b/frontend/screenshots/publish.node-test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { publishScreenshots } from "./publish.mjs"; + +// This suite uses Node's built-in test runner and is intentionally named so +// Vitest does not discover it as part of the Svelte unit-test lane. + + +const pngBytes = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, +]); + + +async function workspace(t) { + const root = await mkdtemp(path.join(tmpdir(), "docbank-screenshot-publish-")); + t.after(() => rm(root, { recursive: true, force: true })); + return { + output: path.join(root, "screenshots"), + staging: path.join(root, ".screenshots.next"), + }; +} + + +test("publishes a complete staged set atomically", async (t) => { + const { output, staging } = await workspace(t); + await mkdir(output); + await writeFile(path.join(output, "old.png"), pngBytes); + await mkdir(staging); + await writeFile(path.join(staging, "one.png"), pngBytes); + + await publishScreenshots({ output, staging, names: ["one.png"] }); + + assert.deepEqual(await readdir(output), ["one.png"]); + assert.deepEqual(await readFile(path.join(output, "one.png")), pngBytes); +}); + + +test("keeps the previous set when the staged manifest is incomplete", async (t) => { + const { output, staging } = await workspace(t); + await mkdir(output); + await writeFile(path.join(output, "old.png"), pngBytes); + await mkdir(staging); + await writeFile(path.join(staging, "one.png"), pngBytes); + + await assert.rejects( + publishScreenshots({ + output, + staging, + names: ["one.png", "two.png"], + }), + /missing screenshot: two\.png/, + ); + + assert.deepEqual(await readdir(output), ["old.png"]); + assert.deepEqual(await readFile(path.join(output, "old.png")), pngBytes); +}); + + +test("restores the previous set when final rename fails", async (t) => { + const { output, staging } = await workspace(t); + await mkdir(output); + await writeFile(path.join(output, "old.png"), pngBytes); + await mkdir(staging); + await writeFile(path.join(staging, "one.png"), pngBytes); + let renameCalls = 0; + + await assert.rejects( + publishScreenshots({ + output, + staging, + names: ["one.png"], + async rename(from, to, realRename) { + renameCalls += 1; + if (renameCalls === 2) throw new Error("synthetic rename failure"); + await realRename(from, to); + }, + }), + /synthetic rename failure/, + ); + + assert.deepEqual(await readdir(output), ["old.png"]); + assert.deepEqual(await readFile(path.join(output, "old.png")), pngBytes); +}); + + +test("rejects unsafe manifest names before changing output", async (t) => { + const { output, staging } = await workspace(t); + await mkdir(output); + await writeFile(path.join(output, "old.png"), pngBytes); + await mkdir(staging); + + await assert.rejects( + publishScreenshots({ output, staging, names: ["nested/one.png"] }), + /invalid screenshot name/, + ); + + assert.deepEqual(await readdir(output), ["old.png"]); +}); diff --git a/frontend/screenshots/run.mjs b/frontend/screenshots/run.mjs index e2f80cd9..b5618d60 100644 --- a/frontend/screenshots/run.mjs +++ b/frontend/screenshots/run.mjs @@ -1,10 +1,17 @@ import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { publishScreenshots } from "./publish.mjs"; + const here = path.dirname(fileURLToPath(import.meta.url)); const frontendRoot = path.resolve(here, ".."); const repositoryRoot = path.resolve(frontendRoot, ".."); +const workspaceRoot = path.join(repositoryRoot, ".superpowers"); +const output = path.join(workspaceRoot, "screenshots"); +const staging = path.join(workspaceRoot, ".screenshots.next"); +const manifestPath = path.join(repositoryRoot, "scripts", "docs-assets.txt"); const build = spawnSync("make", ["build"], { cwd: repositoryRoot, @@ -13,6 +20,9 @@ const build = spawnSync("make", ["build"], { if (build.error) throw build.error; if (build.status !== 0) process.exit(build.status ?? 1); +await rm(staging, { recursive: true, force: true }); +await mkdir(staging, { recursive: true, mode: 0o700 }); + const playwrightCLI = path.join( frontendRoot, "node_modules", @@ -34,7 +44,19 @@ const result = spawnSync( { cwd: frontendRoot, stdio: "inherit", + env: { + ...process.env, + DOCBANK_SCREENSHOT_DIR: staging, + }, }, ); if (result.error) throw result.error; -process.exit(result.status ?? 1); +if (result.status !== 0) { + await rm(staging, { recursive: true, force: true }); + process.exit(result.status ?? 1); +} + +const names = (await readFile(manifestPath, "utf8")) + .split("\n") + .filter((line) => line !== ""); +await publishScreenshots({ output, staging, names }); diff --git a/frontend/screenshots/web-trash.screenshot.ts b/frontend/screenshots/web-trash.screenshot.ts index ea8a5c8e..659f6c5e 100644 --- a/frontend/screenshots/web-trash.screenshot.ts +++ b/frontend/screenshots/web-trash.screenshot.ts @@ -10,72 +10,25 @@ const execFileAsync = promisify(execFile); const here = path.dirname(fileURLToPath(import.meta.url)); const repositoryRoot = path.resolve(here, "..", ".."); const binary = path.join(repositoryRoot, "docbank"); -const screenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-trash-confirmation.png", -); -const restoreScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", +const screenshotDirectory = process.env.DOCBANK_SCREENSHOT_DIR; +if (!screenshotDirectory) throw new Error("DOCBANK_SCREENSHOT_DIR is required"); +const screenshotPathFor = (name: string): string => + path.join(screenshotDirectory, name); +const screenshotPath = screenshotPathFor("web-trash-confirmation.png"); +const restoreScreenshotPath = screenshotPathFor( "web-trash-restore-confirmation.png", ); -const tagAssignmentScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-tag-assignment.png", -); -const tagCatalogScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-tag-catalog.png", -); -const auditEvidenceScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-audit-evidence.png", -); -const storageScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-multi-store-storage.png", -); -const tuiStorageScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "tui-multi-store-storage.png", -); -const vaultBrowserScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-vault-browser.png", -); -const searchResultsScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-search-results.png", -); -const retainedVersionScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", +const tagAssignmentScreenshotPath = screenshotPathFor("web-tag-assignment.png"); +const tagCatalogScreenshotPath = screenshotPathFor("web-tag-catalog.png"); +const auditEvidenceScreenshotPath = screenshotPathFor("web-audit-evidence.png"); +const storageScreenshotPath = screenshotPathFor("web-multi-store-storage.png"); +const tuiStorageScreenshotPath = screenshotPathFor("tui-multi-store-storage.png"); +const vaultBrowserScreenshotPath = screenshotPathFor("web-vault-browser.png"); +const searchResultsScreenshotPath = screenshotPathFor("web-search-results.png"); +const retainedVersionScreenshotPath = screenshotPathFor( "web-retained-version-download.png", ); -const packedStorageScreenshotPath = path.join( - repositoryRoot, - ".superpowers", - "screenshots", - "web-storage-status.png", -); +const packedStorageScreenshotPath = screenshotPathFor("web-storage-status.png"); test.describe("Docbank web screenshots", () => { let workspace = ""; @@ -155,11 +108,13 @@ test.describe("Docbank web screenshots", () => { await rm(retainedVersionScreenshotPath, { force: true }); await rm(packedStorageScreenshotPath, { force: true }); const archive = path.join(workspace, "archive-store"); + const backup = path.join(workspace, "backup-repository"); await mkdir(vault, { recursive: true, mode: 0o700 }); await mkdir(archive, { recursive: true, mode: 0o700 }); await writeFile( path.join(vault, "config.toml"), - `[store_bindings.archive]\nkind = "filesystem"\npath = ${JSON.stringify(archive)}\npriority = 20\n`, + `[backup]\nrepo = ${JSON.stringify(backup)}\n\n` + + `[store_bindings.archive]\nkind = "filesystem"\npath = ${JSON.stringify(archive)}\npriority = 20\n`, { mode: 0o600 }, ); const reports = path.join(workspace, "synthetic", "Reports"); @@ -265,6 +220,15 @@ test.describe("Docbank web screenshots", () => { if (!extractionReady) { throw new Error("synthetic text extraction did not complete"); } + await runDocbank(["backup", "init"]); + await runDocbank([ + "backup", + "create", + "--tag", + "before-review", + "--jobs", + "1", + ]); const revisedReport = path.join( workspace, "synthetic", @@ -280,6 +244,14 @@ test.describe("Docbank web screenshots", () => { revisedReport, "/Reports/quarterly-tax-report.txt", ]); + await runDocbank([ + "backup", + "create", + "--tag", + "reviewed", + "--jobs", + "1", + ]); webURL = await runDocbank(["web", "--no-browser"]); const browserURL = new URL(webURL); const port = Number(browserURL.port); @@ -561,10 +533,10 @@ test.describe("Docbank web screenshots", () => { .poll(capture, { timeout: 15_000 }) .toContain("documents for you and your agents"); await tmux(["send-keys", "-t", session, "O"]); - const terminal = await expect + await expect .poll(capture, { timeout: 15_000 }) - .toContain("Vault operations") - .then(capture); + .toContain("2 recovery point(s)"); + const terminal = await capture(); await page.setViewportSize({ width: 1280, height: 760 }); await page.setContent(` diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 643426cb..156a00e6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ }, test: { environment: "jsdom", - exclude: ["node_modules/**"], + exclude: ["docs-site/**", "node_modules/**"], server: { deps: { inline: ["svelte"], diff --git a/prek.toml b/prek.toml index a5054aed..70aee9fc 100644 --- a/prek.toml +++ b/prek.toml @@ -15,5 +15,5 @@ always_run = true id = "forbid-superpowers-artifacts" name = "forbid-superpowers-artifacts" language = "fail" -entry = ".superpowers is temporary workspace output; publish inspected screenshots to docs-assets instead" +entry = ".superpowers is temporary workspace output; inspect make docs-screenshots output and publish the complete set to docs-assets instead" files = "^\\.superpowers/" diff --git a/scripts/deploy-docs.sh b/scripts/deploy-docs.sh new file mode 100755 index 00000000..2a81b101 --- /dev/null +++ b/scripts/deploy-docs.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) +source_sha=${DOCS_SOURCE:-} + +if [ -z "$source_sha" ]; then + printf 'DOCS_SOURCE is required; use a full source commit SHA\n' >&2 + exit 2 +fi +if ! command -v vercel >/dev/null 2>&1; then + printf 'vercel CLI not found; install Vercel CLI 58.4.4 or later\n' >&2 + exit 127 +fi + +cd "$repo_root" +if [ -n "$(git status --porcelain)" ]; then + printf 'refusing documentation deploy from a dirty worktree\n' >&2 + exit 1 +fi +head_sha=$(git rev-parse HEAD) +if [ "$head_sha" != "$source_sha" ]; then + printf 'DOCS_SOURCE must equal HEAD: expected %s, got %s\n' "$head_sha" "$source_sha" >&2 + exit 1 +fi +if [ ! -f .vercel/project.json ] && { [ -z "${VERCEL_ORG_ID:-}" ] || [ -z "${VERCEL_PROJECT_ID:-}" ]; }; then + printf 'documentation project is not linked; run make docs-link or provide Vercel project IDs\n' >&2 + exit 1 +fi + +git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags +expected_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main) +./scripts/validate-docs-release.sh "$source_sha" "$expected_tag" + +deployment_url=$(vercel deploy --prod --skip-domain --yes) +case "$deployment_url" in + https://*.vercel.app) ;; + *) + printf 'Vercel did not return a deployment URL: %s\n' "$deployment_url" >&2 + exit 1 + ;; +esac + +vercel inspect "$deployment_url" --wait --timeout 10m +./scripts/validate-docs-release.sh "$source_sha" "$expected_tag" +vercel promote "$deployment_url" --yes diff --git a/scripts/docs-assets-sync.test.sh b/scripts/docs-assets-sync.test.sh new file mode 100755 index 00000000..87f76c23 --- /dev/null +++ b/scripts/docs-assets-sync.test.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sync_script="$script_dir/sync-docs-assets.sh" +test_root="$(mktemp -d)" +trap 'rm -rf -- "$test_root"' EXIT INT TERM + +export GIT_CONFIG_GLOBAL="$test_root/gitconfig" +export GIT_CONFIG_NOSYSTEM=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_NAMESPACE + +manifest="$test_root/docs-assets.txt" +ref_file="$test_root/docs-assets.ref" +cache="$test_root/cache" +remote="$test_root/remote.git" +fixture="$test_root/fixture" +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$@"; } +else + checksum() { shasum -a 256 "$@"; } +fi + +fail() { + printf 'docs asset sync test failed: %s\n' "$1" >&2 + exit 1 +} + +write_png() { + local destination="$1" + mkdir -p "$(dirname "$destination")" + printf '\211PNG\r\n\032\n' > "$destination" + printf 'synthetic png payload\n' >> "$destination" +} + +create_remote() { + local mode="$1" + rm -rf -- "$remote" "$fixture" + git init --bare --quiet "$remote" + git init --quiet "$fixture" + git -C "$fixture" config user.name "Docbank Test" + git -C "$fixture" config user.email "docbank-test@example.invalid" + case "$mode" in + valid) + write_png "$fixture/one.png" + ;; + extra) + write_png "$fixture/one.png" + write_png "$fixture/extra.png" + ;; + malformed) + printf 'not a png\n' > "$fixture/one.png" + ;; + oversized) + write_png "$fixture/one.png" + dd if=/dev/zero bs=1048576 count=11 >> "$fixture/one.png" 2>/dev/null + ;; + nested) + write_png "$fixture/one.png" + write_png "$fixture/nested/two.png" + ;; + *) + fail "unknown fixture mode $mode" + ;; + esac + git -C "$fixture" add . + git -C "$fixture" commit --quiet -m "docs assets fixture" + fixture_commit="$(git -C "$fixture" rev-parse HEAD)" + git -C "$fixture" push --quiet "$remote" "HEAD:refs/heads/docs-assets-candidate" + printf '%s\n' "$fixture_commit" > "$ref_file" +} + +run_sync() { + DOCBANK_DOCS_ASSETS_REMOTE="$remote" \ + DOCBANK_DOCS_ASSETS_MANIFEST="$manifest" \ + DOCBANK_DOCS_ASSETS_REF="$ref_file" \ + DOCBANK_DOCS_ASSETS_CACHE="$cache" \ + "$sync_script" +} + +expect_failure() { + local label="$1" + if run_sync >"$test_root/stdout" 2>"$test_root/stderr"; then + fail "$label unexpectedly succeeded" + fi +} + +printf 'one.png\n' > "$manifest" +create_remote valid +run_sync +destination="$cache/$fixture_commit" +test -f "$destination/one.png" || fail "valid PNG was not cached" +test -f "$destination/.sha256" || fail "checksum record was not cached" +before_checksum="$(checksum "$destination/one.png")" + +printf 'docs-assets-candidate\n' > "$ref_file" +expect_failure "mutable ref" +printf '%s\n' "$fixture_commit" > "$ref_file" + +printf 'one.png\ntwo.png\n' > "$manifest" +expect_failure "missing manifest member" +test "$(checksum "$destination/one.png")" = "$before_checksum" || + fail "failed sync changed the previous cache" + +printf 'nested/one.png\n' > "$manifest" +expect_failure "nested manifest name" + +printf 'one.png\none.png\n' > "$manifest" +expect_failure "duplicate manifest name" + +printf 'one.png\n\n' > "$manifest" +expect_failure "blank manifest entry" + +printf 'one.jpg\n' > "$manifest" +expect_failure "unsupported manifest extension" + +printf 'one.png\n' > "$manifest" +create_remote extra +expect_failure "unmanifested PNG" + +create_remote malformed +expect_failure "invalid PNG signature" + +create_remote oversized +expect_failure "oversized PNG" + +create_remote nested +expect_failure "nested tree entry" + +printf 'docs asset sync tests passed\n' diff --git a/scripts/docs-assets.ref b/scripts/docs-assets.ref new file mode 100644 index 00000000..5ae01b3d --- /dev/null +++ b/scripts/docs-assets.ref @@ -0,0 +1 @@ +6491f8221930159600e542f7c0955aed2d1b55b9 diff --git a/scripts/docs-assets.txt b/scripts/docs-assets.txt new file mode 100644 index 00000000..42c23100 --- /dev/null +++ b/scripts/docs-assets.txt @@ -0,0 +1,11 @@ +tui-multi-store-storage.png +web-audit-evidence.png +web-multi-store-storage.png +web-retained-version-download.png +web-search-results.png +web-storage-status.png +web-tag-assignment.png +web-tag-catalog.png +web-trash-confirmation.png +web-trash-restore-confirmation.png +web-vault-browser.png diff --git a/scripts/docs/assert-vercel-dry-run.mjs b/scripts/docs/assert-vercel-dry-run.mjs new file mode 100644 index 00000000..665cb33e --- /dev/null +++ b/scripts/docs/assert-vercel-dry-run.mjs @@ -0,0 +1,141 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + + +const requiredUploads = [ + "vercel.json", + "website/index.html", + "docs/zensical.toml", + "docs/uv.lock", + "scripts/vercel-install-docs.sh", + "scripts/vercel-build-docs.sh", + "scripts/sync-docs-assets.sh", + "scripts/docs-assets.ref", + "scripts/docs-assets.txt", + "scripts/docs/build.mjs", + "scripts/docs/verify-site.mjs", + "scripts/install.sh", + "scripts/install.ps1", +]; + +const maximumUploadBytes = 10 * 1024 * 1024; + +const allowedUploads = new Set([ + ...requiredUploads, + "LICENSES/Inter-OFL-1.1.txt", + "LICENSES/JetBrains-Mono-OFL-1.1.txt", + "docs/llms.txt", + "docs/overrides/main.html", + "docs/pyproject.toml", + "docs/scripts/check_built_site.py", + "docs/scripts/check_markdown_sources.py", + "docs/stylesheets/extra.css", + "docs/zensical-docs.sh", + "website/favicon.svg", + "website/fonts/Inter-Medium.woff2", + "website/fonts/Inter-Regular.woff2", + "website/fonts/Inter-SemiBold.woff2", + "website/fonts/JetBrainsMono-Bold.woff2", + "website/fonts/JetBrainsMono-Regular.woff2", + "website/fonts/JetBrainsMono-SemiBold.woff2", + "website/guide.md", + "website/guide/index.html", + "website/index.html", + "website/index.md", + "website/scripts/site.js", + "website/styles/site.css", +]); + +const allowedUploadPatterns = [ + /^docs\/(?!README\.md$)[^/]+\.md$/, + /^docs\/(?:agents|architecture|usage)\/[^/]+\.md$/, + /^website\/assets\/[a-z0-9][a-z0-9-]*\.svg$/, +]; + + +function normalizeEntry(entry) { + const reported = typeof entry === "string" ? entry : entry?.path; + if (typeof reported !== "string" || reported.length === 0) { + throw new Error("Vercel dry-run entry is missing a path field"); + } + let normalized = reported.replaceAll("\\", "/"); + while (normalized.startsWith("./")) normalized = normalized.slice(2); + if (normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error(`Vercel dry-run entry is not repository-relative: ${reported}`); + } + return normalized; +} + + +function uploadSize(entry) { + if (typeof entry === "string") return 0; + if (!Number.isSafeInteger(entry?.size) || entry.size < 0) { + throw new Error(`Vercel dry-run entry has an invalid size: ${entry?.path ?? ""}`); + } + return entry.size; +} + + +function isAllowed(upload) { + return allowedUploads.has(upload) + || allowedUploadPatterns.some((pattern) => pattern.test(upload)); +} + + +export function assertUploadBoundary(entries) { + if (!Array.isArray(entries)) throw new Error("Vercel dry-run report must be a JSON array"); + const normalized = entries.map((entry) => ({ + path: normalizeEntry(entry), + size: uploadSize(entry), + })); + const uploads = new Set(normalized.map((entry) => entry.path)); + for (const required of requiredUploads) { + if (!uploads.has(required)) throw new Error(`missing required upload: ${required}`); + } + for (const upload of uploads) { + if (!isAllowed(upload)) throw new Error(`forbidden upload: ${upload}`); + } + const totalBytes = normalized.reduce((total, entry) => total + entry.size, 0); + if (totalBytes > maximumUploadBytes) { + throw new Error(`Vercel upload exceeds 10 MiB limit: ${totalBytes} bytes`); + } + return [...uploads].sort(); +} + + +export async function readDryRunReport(reportPath) { + let report; + try { + report = JSON.parse(await readFile(reportPath, "utf8")); + } catch (error) { + throw new Error(`invalid Vercel dry-run JSON: ${error.message}`, { cause: error }); + } + const entries = Array.isArray(report) ? report : report?.files; + if (!Array.isArray(entries)) { + throw new Error("Vercel dry-run report must be a JSON array or contain a files array"); + } + entries.forEach((entry) => { + normalizeEntry(entry); + uploadSize(entry); + }); + return entries; +} + + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + const reportPath = process.argv[2]; + if (!reportPath || process.argv.length !== 3) { + process.stderr.write("usage: node scripts/docs/assert-vercel-dry-run.mjs \n"); + process.exitCode = 2; + } else { + try { + const uploads = assertUploadBoundary(await readDryRunReport(reportPath)); + process.stdout.write(`validated ${uploads.length} Vercel upload files\n`); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } + } +} diff --git a/scripts/docs/assert-vercel-dry-run.test.mjs b/scripts/docs/assert-vercel-dry-run.test.mjs new file mode 100644 index 00000000..fa035442 --- /dev/null +++ b/scripts/docs/assert-vercel-dry-run.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { assertUploadBoundary, readDryRunReport } from "./assert-vercel-dry-run.mjs"; + + +const required = [ + "vercel.json", + "website/index.html", + "docs/zensical.toml", + "docs/uv.lock", + "scripts/vercel-install-docs.sh", + "scripts/vercel-build-docs.sh", + "scripts/sync-docs-assets.sh", + "scripts/docs-assets.ref", + "scripts/docs-assets.txt", + "scripts/docs/build.mjs", + "scripts/docs/verify-site.mjs", + "scripts/install.sh", + "scripts/install.ps1", +]; + + +test("accepts the complete static documentation build input", () => { + assert.doesNotThrow(() => assertUploadBoundary(required)); +}); + + +test("normalizes CLI path objects", () => { + const report = required.map((filePath, index) => ({ + path: index % 2 === 0 ? `./${filePath}` : filePath.replaceAll("/", "\\"), + size: 10, + })); + assert.doesNotThrow(() => assertUploadBoundary(report)); +}); + + +test("reads the Vercel CLI report envelope", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "docbank-vercel-report-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const report = path.join(directory, "report.json"); + const files = required.map((entry) => ({ path: entry, size: 10 })); + await writeFile(report, JSON.stringify({ fileCount: required.length, files })); + assert.deepEqual(await readDryRunReport(report), files); +}); + + +test("rejects a missing build input", () => { + assert.throws( + () => assertUploadBoundary(required.filter((filePath) => filePath !== "vercel.json")), + /missing required upload: vercel\.json/, + ); +}); + + +for (const forbidden of [ + "payload-1gb.bin", + "go.mod", + "deploy/private-data", + "cmd/docbank/main.go", + "internal/store/store.go", + "frontend/package.json", + ".superpowers/plan.md", + "docs/superpowers/specs/plan.md", + ".git/config", + ".env.local", + "docs/.env", + "AGENTS.md", + "docs/README.md", +]) { + test(`rejects forbidden upload ${forbidden}`, () => { + assert.throws(() => assertUploadBoundary([...required, forbidden]), /forbidden upload/); + }); +} + + +test("rejects an oversized upload within an approved source path", () => { + const report = required.map((filePath) => ({ path: filePath, size: 10 })); + report.push({ path: "docs/architecture/oversized.md", size: 10 * 1024 * 1024 }); + assert.throws(() => assertUploadBoundary(report), /upload exceeds 10 MiB limit/); +}); + + +test("rejects malformed reports instead of treating them as empty", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "docbank-vercel-report-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const report = path.join(directory, "report.json"); + await writeFile(report, '{"path":"website/index.html"}\n'); + await assert.rejects(() => readDryRunReport(report), /files array/); + await writeFile(report, '{"files":[{"size":10}]}\n'); + await assert.rejects(() => readDryRunReport(report), /path field/); + await writeFile(report, '{"files":[{"path":"vercel.json"}]}\n'); + await assert.rejects(() => readDryRunReport(report), /invalid size/); +}); diff --git a/scripts/docs/build.mjs b/scripts/docs/build.mjs new file mode 100644 index 00000000..0d2cb438 --- /dev/null +++ b/scripts/docs/build.mjs @@ -0,0 +1,220 @@ +import { execFile } from "node:child_process"; +import { + copyFile, + lstat, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { canonicalOrigin, verifySite } from "./verify-site.mjs"; + + +const execFileAsync = promisify(execFile); +const outputMarker = ".docbank-docs-output"; +const outputMarkerContents = "generated by docbank docs build\n"; +const websiteFiles = ["favicon.svg", "guide.md", "index.html", "index.md"]; +const websiteDirectories = ["assets", "fonts", "guide", "scripts", "styles"]; + + +async function exists(target) { + try { + await lstat(target); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + + +async function copyPublicPath(source, destination, relative) { + const metadata = await lstat(source); + if (metadata.isSymbolicLink()) { + throw new Error(`website publishing boundary contains symlink: ${relative}`); + } + if (relative.split("/").some((part) => part.startsWith("."))) { + throw new Error(`website publishing boundary contains dot path: ${relative}`); + } + if (metadata.isDirectory()) { + await mkdir(destination, { recursive: true }); + for (const entry of await readdir(source)) { + await copyPublicPath( + path.join(source, entry), + path.join(destination, entry), + `${relative}/${entry}`, + ); + } + return; + } + if (!metadata.isFile()) { + throw new Error(`unsupported website source: ${relative}`); + } + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); +} + + +async function copyWebsite(repoRoot, staging) { + const website = path.join(repoRoot, "website"); + for (const relative of websiteFiles) { + const source = path.join(website, relative); + if (!(await exists(source))) throw new Error(`missing website source: ${relative}`); + await copyPublicPath(source, path.join(staging, relative), relative); + } + for (const relative of websiteDirectories) { + const source = path.join(website, relative); + if (await exists(source)) { + await copyPublicPath(source, path.join(staging, relative), relative); + } + } +} + + +async function defaultBuildDocs({ repoRoot, output }) { + await execFileAsync(path.join(repoRoot, "docs", "zensical-docs.sh"), ["build"], { + cwd: path.join(repoRoot, "docs"), + env: { + ...process.env, + DOCBANK_DOCS_SITE_DIR: output, + }, + maxBuffer: 10 * 1024 * 1024, + }); +} + + +async function defaultSyncAssets({ repoRoot }) { + await execFileAsync(path.join(repoRoot, "scripts", "sync-docs-assets.sh"), [], { + cwd: repoRoot, + maxBuffer: 10 * 1024 * 1024, + }); +} + + +async function readManifest(repoRoot) { + const contents = await readFile(path.join(repoRoot, "scripts", "docs-assets.txt"), "utf8"); + const names = contents.split("\n").filter((line) => line !== ""); + if (names.length === 0) throw new Error("docs asset manifest is empty"); + return names; +} + + +async function copyGeneratedAssets(repoRoot, staging, names) { + const ref = (await readFile(path.join(repoRoot, "scripts", "docs-assets.ref"), "utf8")).trim(); + const source = path.join(repoRoot, ".cache", "docs-assets", ref); + const destination = path.join(staging, "assets", "generated"); + await mkdir(destination, { recursive: true }); + for (const name of names) { + await copyFile(path.join(source, name), path.join(destination, name)); + } +} + + +async function localizeAssetUrls(directory) { + const absolutePrefix = `="${canonicalOrigin}/assets/`; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + await localizeAssetUrls(target); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".html")) continue; + const contents = await readFile(target, "utf8"); + if (!contents.includes(absolutePrefix)) continue; + await writeFile(target, contents.replaceAll(absolutePrefix, '="/assets/')); + } +} + + +async function replaceOutput({ output, staging }) { + const previous = path.join(path.dirname(output), `.${path.basename(output)}.previous`); + await rm(previous, { recursive: true, force: true }); + const hadOutput = await exists(output); + if (hadOutput) await rename(output, previous); + try { + await rename(staging, output); + } catch (error) { + if (hadOutput) await rename(previous, output); + throw error; + } + await rm(previous, { recursive: true, force: true }); +} + + +export async function buildSite({ + repoRoot, + output = path.join(repoRoot, "site"), + buildDocs = defaultBuildDocs, + syncAssets = defaultSyncAssets, +}) { + const resolvedOutput = path.resolve(output); + if (await exists(resolvedOutput)) { + const marker = path.join(resolvedOutput, outputMarker); + if (!(await exists(marker)) || (await readFile(marker, "utf8")) !== outputMarkerContents) { + throw new Error(`refusing to replace unmarked site output: ${resolvedOutput}`); + } + } + + await mkdir(path.dirname(resolvedOutput), { recursive: true }); + const staging = path.join( + path.dirname(resolvedOutput), + `.${path.basename(resolvedOutput)}.next.${process.pid}`, + ); + await rm(staging, { recursive: true, force: true }); + await mkdir(staging); + try { + await copyWebsite(repoRoot, staging); + await copyFile(path.join(repoRoot, "scripts", "install.sh"), path.join(staging, "install.sh")); + await copyFile(path.join(repoRoot, "scripts", "install.ps1"), path.join(staging, "install.ps1")); + await syncAssets({ repoRoot }); + const assets = await readManifest(repoRoot); + await copyGeneratedAssets(repoRoot, staging, assets); + + const docsOutput = path.join(staging, "docs"); + await buildDocs({ repoRoot, output: docsOutput }); + await rm(path.join(docsOutput, "llms.txt"), { force: true }); + await rm(path.join(docsOutput, "install.sh"), { force: true }); + await rm(path.join(docsOutput, "install.ps1"), { force: true }); + await rm(path.join(docsOutput, outputMarker), { force: true }); + await localizeAssetUrls(docsOutput); + await copyFile(path.join(repoRoot, "docs", "llms.txt"), path.join(staging, "llms.txt")); + + await verifySite({ + site: staging, + sources: { + website: path.join(repoRoot, "website"), + docs: path.join(repoRoot, "docs"), + assets, + }, + }); + await writeFile(path.join(staging, outputMarker), outputMarkerContents); + await replaceOutput({ output: resolvedOutput, staging }); + return resolvedOutput; + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } +} + + +function outputArgument(argv) { + const index = argv.indexOf("--output"); + if (index === -1) return undefined; + if (!argv[index + 1]) throw new Error("--output requires a directory"); + return argv[index + 1]; +} + + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(here, "..", ".."); + const output = await buildSite({ repoRoot, output: outputArgument(process.argv.slice(2)) }); + process.stdout.write(`built documentation site at ${output}\n`); +} diff --git a/scripts/docs/build.test.mjs b/scripts/docs/build.test.mjs new file mode 100644 index 00000000..25ebdd65 --- /dev/null +++ b/scripts/docs/build.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { buildSite } from "./build.mjs"; + + +async function write(root, relative, contents) { + const destination = path.join(root, relative); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, contents); +} + + +function html(canonical, link = "") { + return `Docbank + + + + + + +

Docbank

${link}
`; +} + + +async function repositoryFixture(t) { + const repoRoot = await mkdtemp(path.join(tmpdir(), "docbank-site-build-")); + t.after(() => rm(repoRoot, { recursive: true, force: true })); + await write(repoRoot, "website/index.html", html("https://docbank.ai/", 'Guide')); + await write(repoRoot, "website/index.md", "# Product\n"); + await write(repoRoot, "website/guide/index.html", html("https://docbank.ai/guide/", 'Docs')); + await write(repoRoot, "website/guide.md", "# Guide\n"); + await write(repoRoot, "website/styles/site.css", "body { color: white; }\n"); + await write(repoRoot, "website/scripts/site.js", "export {};\n"); + await write(repoRoot, "website/favicon.svg", '\n'); + await write(repoRoot, "scripts/install.sh", "#!/bin/sh\n"); + await write(repoRoot, "scripts/install.ps1", "# installer\n"); + await write(repoRoot, "scripts/docs-assets.txt", "capture.png\n"); + await write(repoRoot, "scripts/docs-assets.ref", `${"a".repeat(40)}\n`); + await write(repoRoot, "docs/index.md", "# Docs\n"); + await write(repoRoot, "docs/setup.md", "# Setup\n"); + await write( + repoRoot, + "docs/llms.txt", + [ + "# Docbank", + "- [Product](https://docbank.ai/index.md)", + "- [Guide](https://docbank.ai/guide.md)", + "- [Docs](https://docbank.ai/docs/index.md)", + "- [Setup](https://docbank.ai/docs/setup.md)", + "", + ].join("\n"), + ); + return repoRoot; +} + + +async function syncAssetsFixture({ repoRoot }) { + await write(repoRoot, `.cache/docs-assets/${"a".repeat(40)}/capture.png`, "png fixture"); +} + + +async function buildDocsFixture({ repoRoot, output }) { + await write(output, "index.html", html("https://docbank.ai/docs/", 'Setup')); + await write( + output, + "setup/index.html", + html( + "https://docbank.ai/docs/setup/", + 'DocsCapture', + ), + ); + await write(output, "index.md", await readFile(path.join(repoRoot, "docs/index.md"))); + await write(output, "setup.md", await readFile(path.join(repoRoot, "docs/setup.md"))); + await write(output, "llms.txt", "redundant docs index\n"); + await write(output, "install.sh", "redundant installer\n"); +} + + +test("assembles all tiers and public assets into one site root", async (t) => { + const repoRoot = await repositoryFixture(t); + const output = path.join(repoRoot, "site"); + + await buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: syncAssetsFixture }); + + for (const relative of [ + "index.html", + "index.md", + "guide/index.html", + "guide.md", + "docs/index.html", + "docs/index.md", + "docs/setup/index.html", + "docs/setup.md", + "assets/generated/capture.png", + "install.sh", + "install.ps1", + "llms.txt", + ]) { + await access(path.join(output, relative)); + } + await assert.rejects(access(path.join(output, "docs/llms.txt"))); + await assert.rejects(access(path.join(output, "docs/install.sh"))); + + const setup = await readFile(path.join(output, "docs/setup/index.html"), "utf8"); + assert.match(setup, /src="\/assets\/generated\/capture\.png"/); + assert.doesNotMatch(setup, /docbank\.ai\/assets/); +}); + + +test("fails before assembling when the pinned assets are not synced", async (t) => { + const repoRoot = await repositoryFixture(t); + const output = path.join(repoRoot, "site"); + + await assert.rejects( + () => buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: async () => {} }), + { code: "ENOENT" }, + ); + await assert.rejects(access(output)); +}); + + +test("keeps the previous marked output when verification fails", async (t) => { + const repoRoot = await repositoryFixture(t); + const output = path.join(repoRoot, "site"); + await write(output, ".docbank-docs-output", "generated by docbank docs build\n"); + await write(output, "sentinel.txt", "previous output\n"); + await write(repoRoot, "website/index.html", html("https://docbank.ai/", 'Missing')); + + await assert.rejects( + () => buildSite({ repoRoot, output, buildDocs: buildDocsFixture, syncAssets: syncAssetsFixture }), + /broken local URL/, + ); + assert.equal(await readFile(path.join(output, "sentinel.txt"), "utf8"), "previous output\n"); +}); diff --git a/scripts/docs/serve.mjs b/scripts/docs/serve.mjs new file mode 100644 index 00000000..f0924fbd --- /dev/null +++ b/scripts/docs/serve.mjs @@ -0,0 +1,287 @@ +import { watch as watchPath } from "node:fs"; +import { lstat, open } from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { buildSite } from "./build.mjs"; + + +const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".md", "text/markdown; charset=utf-8"], + [".png", "image/png"], + [".ps1", "text/plain; charset=utf-8"], + [".sh", "text/x-shellscript; charset=utf-8"], + [".svg", "image/svg+xml"], + [".txt", "text/plain; charset=utf-8"], + [".woff2", "font/woff2"], + [".xml", "application/xml; charset=utf-8"], +]); + + +export function createRebuildQueue({ build, debounceMs = 100, onError = console.error }) { + let timer; + let running = false; + let pending = false; + let closed = false; + const waiters = new Set(); + + function notify() { + for (const waiter of waiters) waiter(); + } + + function schedule(delay = debounceMs) { + if (timer) clearTimeout(timer); + timer = setTimeout(run, delay); + } + + async function run() { + timer = undefined; + if (closed) { + notify(); + return; + } + running = true; + notify(); + try { + await build(); + } catch (error) { + onError(error); + } finally { + running = false; + if (pending && !closed) { + pending = false; + schedule(0); + } + notify(); + } + } + + return { + request() { + if (closed) return; + if (running) { + pending = true; + return; + } + schedule(); + }, + + idle({ waitForRunning = true } = {}) { + return new Promise((resolve) => { + const check = () => { + const settled = waitForRunning + ? !timer && !running && !pending + : running || (!timer && !pending); + if (!settled) return; + waiters.delete(check); + resolve(); + }; + waiters.add(check); + check(); + }); + }, + + close() { + closed = true; + pending = false; + if (timer) clearTimeout(timer); + timer = undefined; + notify(); + }, + }; +} + + +const generatedDocsEntry = /^(?:\.|site$|zensical-site\.|zensical-public-docs\.)/; + + +export function isGeneratedDocsPath(filename) { + if (!filename) return false; + const [first] = String(filename).split(/[\\/]/, 1); + return generatedDocsEntry.test(first); +} + + +function resolveRequest(siteRoot, rawUrl) { + const rawPath = rawUrl.split(/[?#]/, 1)[0]; + let decoded; + try { + decoded = decodeURIComponent(rawPath); + } catch { + return undefined; + } + if (!decoded.startsWith("/") || decoded.includes("\0") || decoded.includes("\\")) { + return undefined; + } + if (decoded.split("/").includes("..")) return undefined; + + const route = decoded.endsWith("/") ? `${decoded}index.html` : decoded; + const target = path.resolve(siteRoot, `.${route}`); + const prefix = `${path.resolve(siteRoot)}${path.sep}`; + if (!target.startsWith(prefix)) return undefined; + return target; +} + + +async function serveFile(siteRoot, request, response, logger) { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end(); + return; + } + + const target = resolveRequest(siteRoot, request.url ?? "/"); + if (!target) { + response.writeHead(404); + response.end("Not found\n"); + return; + } + + let metadata; + try { + metadata = await lstat(target); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + response.writeHead(404); + response.end("Not found\n"); + return; + } + throw error; + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + response.writeHead(404); + response.end("Not found\n"); + return; + } + + let handle; + try { + handle = await open(target, "r"); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + response.writeHead(404); + response.end("Not found\n"); + return; + } + throw error; + } + try { + const { size } = await handle.stat(); + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Length": size, + "Content-Type": contentTypes.get(path.extname(target)) ?? "application/octet-stream", + }); + } catch (error) { + await handle.close(); + throw error; + } + if (request.method === "HEAD") { + response.end(); + await handle.close(); + return; + } + const stream = handle.createReadStream(); + stream.on("error", (error) => { + logger.error(error); + response.destroy(error); + }); + stream.pipe(response); +} + + +function listen(server, host, port) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(); + }); + }); +} + + +export async function startDocsServer({ + repoRoot, + host = "127.0.0.1", + port = 8000, + build = ({ output }) => buildSite({ repoRoot, output }), + watch = true, + watchFactory = watchPath, + logger = console, +}) { + const siteRoot = path.join(repoRoot, "site"); + await build({ output: siteRoot }); + + const queue = createRebuildQueue({ + build: async () => { + try { + await build({ output: siteRoot }); + logger.log("documentation rebuilt"); + } catch (error) { + logger.error("documentation rebuild failed; serving the previous build"); + throw error; + } + }, + onError: (error) => logger.error(error), + }); + + const watchers = []; + if (watch) { + for (const [source, recursive, ignore] of [ + [path.join(repoRoot, "website"), true, () => false], + [path.join(repoRoot, "docs"), true, isGeneratedDocsPath], + [path.join(repoRoot, "scripts", "docs-assets.ref"), false, () => false], + [path.join(repoRoot, "scripts", "docs-assets.txt"), false, () => false], + ]) { + const watcher = watchFactory(source, { recursive }, (_event, filename) => { + if (!ignore(filename)) queue.request(); + }); + watcher.on?.("error", (error) => logger.error(error)); + watchers.push(watcher); + } + } + + const server = http.createServer((request, response) => { + serveFile(siteRoot, request, response, logger).catch((error) => { + logger.error(error); + if (!response.headersSent) response.writeHead(500); + response.end("Internal server error\n"); + }); + }); + await listen(server, host, port); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("documentation server has no TCP address"); + const url = `http://${host}:${address.port}/`; + + return { + url, + port: address.port, + close() { + queue.close(); + for (const watcher of watchers) watcher.close(); + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + }, + }; +} + + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(here, "..", ".."); + const configuredPort = process.env.DOCBANK_DOCS_PORT; + const port = configuredPort === undefined ? 8000 : Number(configuredPort); + if (!Number.isInteger(port) || port < 0 || port > 65_535) { + throw new Error("DOCBANK_DOCS_PORT must be an integer from 0 through 65535"); + } + const server = await startDocsServer({ repoRoot, port }); + process.stdout.write(`serving documentation at ${server.url}\n`); +} diff --git a/scripts/docs/serve.test.mjs b/scripts/docs/serve.test.mjs new file mode 100644 index 00000000..361f1ac8 --- /dev/null +++ b/scripts/docs/serve.test.mjs @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import http from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createRebuildQueue, startDocsServer } from "./serve.mjs"; + + +async function write(root, relative, contents) { + const destination = path.join(root, relative); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, contents); +} + + +async function fixture(t) { + const repoRoot = await mkdtemp(path.join(tmpdir(), "docbank-docs-server-")); + t.after(() => rm(repoRoot, { recursive: true, force: true })); + for (const [relative, contents] of Object.entries({ + "site/index.html": "

Product

", + "site/guide/index.html": "

Guide

", + "site/guide.md": "# Guide\n", + "site/docs/index.html": "

Docs

", + "site/docs/setup/index.html": "

Setup

", + "site/docs/setup.md": "# Setup\n", + "site/styles/site.css": "body { color: white; }\n", + "site/fonts/Inter-Regular.woff2": "font fixture", + "site/assets/generated/capture.png": "png fixture", + })) { + await write(repoRoot, relative, contents); + } + return repoRoot; +} + + +function rawRequest(port, requestPath) { + return new Promise((resolve, reject) => { + const request = http.get({ host: "127.0.0.1", port, path: requestPath }, (response) => { + response.resume(); + response.once("end", () => resolve(response.statusCode)); + }); + request.once("error", reject); + }); +} + + +test("serves every public tier and asset type from one origin", async (t) => { + const repoRoot = await fixture(t); + const server = await startDocsServer({ + repoRoot, + host: "127.0.0.1", + port: 0, + build: async () => {}, + watch: false, + }); + t.after(() => server.close()); + + for (const [route, contentType, body] of [ + ["/", "text/html; charset=utf-8", "Product"], + ["/guide/", "text/html; charset=utf-8", "Guide"], + ["/guide.md", "text/markdown; charset=utf-8", "# Guide"], + ["/docs/", "text/html; charset=utf-8", "Docs"], + ["/docs/setup/", "text/html; charset=utf-8", "Setup"], + ["/docs/setup.md", "text/markdown; charset=utf-8", "# Setup"], + ["/styles/site.css", "text/css; charset=utf-8", "body"], + ["/fonts/Inter-Regular.woff2", "font/woff2", "font fixture"], + ["/assets/generated/capture.png", "image/png", "png fixture"], + ]) { + const response = await fetch(new URL(route, server.url)); + assert.equal(response.status, 200, route); + assert.equal(response.headers.get("content-type"), contentType, route); + assert.match(await response.text(), new RegExp(body), route); + } +}); + + +test("rejects traversal outside the generated site", async (t) => { + const repoRoot = await fixture(t); + await write(repoRoot, "AGENTS.md", "private instructions\n"); + const server = await startDocsServer({ + repoRoot, + host: "127.0.0.1", + port: 0, + build: async () => {}, + watch: false, + }); + t.after(() => server.close()); + + assert.equal(await rawRequest(server.port, "/../AGENTS.md"), 404); + assert.equal(await rawRequest(server.port, "/%2e%2e/AGENTS.md"), 404); + assert.equal(await rawRequest(server.port, "/%00"), 404); +}); + + +test("keeps serving when a file disappears or cannot be opened after stat", async (t) => { + const repoRoot = await fixture(t); + const server = await startDocsServer({ + repoRoot, + host: "127.0.0.1", + port: 0, + build: async () => {}, + watch: false, + logger: { log() {}, error() {} }, + }); + t.after(() => server.close()); + + const unreadable = path.join(repoRoot, "site", "unreadable.txt"); + await writeFile(unreadable, "secret\n", { mode: 0o000 }); + if (process.getuid?.() !== 0) { + assert.equal(await rawRequest(server.port, "/unreadable.txt"), 500); + } + await rm(unreadable, { force: true }); + assert.equal(await rawRequest(server.port, "/unreadable.txt"), 404); + assert.equal(await rawRequest(server.port, "/"), 200); +}); + + +test("ignores the docs build's own temporary entries", async (t) => { + const repoRoot = await fixture(t); + const listeners = new Map(); + let builds = 0; + const server = await startDocsServer({ + repoRoot, + host: "127.0.0.1", + port: 0, + build: async () => { + builds += 1; + }, + watchFactory: (source, _options, listener) => { + listeners.set(path.basename(source), listener); + return { close() {} }; + }, + logger: { log() {}, error() {} }, + }); + t.after(() => server.close()); + assert.equal(builds, 1); + + const docs = listeners.get("docs"); + for (const generated of [ + "zensical-site.Ab12Cd/index.html", + "zensical-public-docs.Ab12Cd/tour.md", + ".zensical-build.Ab12Cd.toml", + "site/index.html", + ".venv/lib/python", + ]) { + docs("rename", generated); + } + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(builds, 1); + + docs("change", "tour.md"); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(builds, 2); +}); + + +test("coalesces file events into one queued rebuild", async () => { + let calls = 0; + let finishFirst; + const firstBuild = new Promise((resolve) => { + finishFirst = resolve; + }); + const errors = []; + const queue = createRebuildQueue({ + debounceMs: 5, + build: async () => { + calls += 1; + if (calls === 1) await firstBuild; + }, + onError: (error) => errors.push(error), + }); + + queue.request(); + await queue.idle({ waitForRunning: false }); + assert.equal(calls, 1); + + queue.request(); + queue.request(); + queue.request(); + finishFirst(); + await queue.idle(); + + assert.equal(calls, 2); + assert.deepEqual(errors, []); + queue.close(); +}); + + +test("keeps the queue usable after a failed rebuild", async () => { + let calls = 0; + const errors = []; + const queue = createRebuildQueue({ + debounceMs: 1, + build: async () => { + calls += 1; + if (calls === 1) throw new Error("fixture build failed"); + }, + onError: (error) => errors.push(error.message), + }); + + queue.request(); + await queue.idle(); + queue.request(); + await queue.idle(); + + assert.equal(calls, 2); + assert.deepEqual(errors, ["fixture build failed"]); + queue.close(); +}); diff --git a/scripts/docs/verify-site.mjs b/scripts/docs/verify-site.mjs new file mode 100644 index 00000000..63ad9112 --- /dev/null +++ b/scripts/docs/verify-site.mjs @@ -0,0 +1,278 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + + +export const canonicalOrigin = "https://docbank.ai"; +const forbiddenParts = new Set([ + ".git", + ".superpowers", + "internal", + "overrides", + "reports", + "superpowers", +]); +const requiredMetadata = [ + "description", + "og:type", + "og:title", + "og:description", + "og:url", + "og:site_name", + "twitter:card", + "twitter:title", + "twitter:description", +]; + + +async function filesUnder(root) { + const files = []; + async function visit(directory, prefix = "") { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + const absolute = path.join(directory, entry.name); + const metadata = await lstat(absolute); + if (metadata.isSymbolicLink()) { + throw new Error(`publishing boundary contains symlink: ${relative}`); + } + if (metadata.isDirectory()) await visit(absolute, relative); + else if (metadata.isFile()) files.push(relative); + } + } + await visit(root); + return files.sort(); +} + + +function attributes(raw) { + const result = new Map(); + const expression = /([A-Za-z_:][A-Za-z0-9_.:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; + for (const match of raw.matchAll(expression)) { + result.set(match[1].toLowerCase(), match[2] ?? match[3] ?? ""); + } + return result; +} + + +function parsePage(contents) { + const metadata = new Map(); + const urls = []; + const images = []; + const anchors = new Set(); + let canonical = ""; + for (const match of contents.matchAll(/<([A-Za-z0-9]+)\b([^>]*)>/g)) { + const tag = match[1].toLowerCase(); + const attrs = attributes(match[2]); + if (attrs.get("id")) anchors.add(attrs.get("id")); + if (tag === "a" && attrs.get("name")) anchors.add(attrs.get("name")); + if (tag === "link" && attrs.get("rel") === "canonical") { + canonical = attrs.get("href") ?? ""; + } + if (tag === "meta" && attrs.get("content")) { + const key = attrs.get("property") ?? attrs.get("name"); + if (key) metadata.set(key, attrs.get("content")); + } + for (const key of ["href", "src"]) { + if (attrs.get(key)) urls.push(attrs.get(key)); + } + if (tag === "img" && attrs.get("src")) images.push(attrs.get("src")); + } + return { + anchors, + canonical, + images, + metadata, + title: /]*)?>\s*[^<\s][^<]*<\/title>/i.test(contents), + urls, + }; +} + + +function routeForHtml(relative) { + if (relative === "index.html") return "/"; + return `/${relative.replace(/index\.html$/, "")}`; +} + + +function markdownPeer(relative) { + if (relative === "index.html") return "index.md"; + if (relative === "guide/index.html") return "guide.md"; + if (relative === "docs/index.html") return "docs/index.md"; + if (!relative.endsWith("/index.html")) return null; + return relative.replace(/\/index\.html$/, ".md"); +} + + +function htmlForMarkdown(relative) { + if (relative === "index.md") return "index.html"; + if (relative === "guide.md") return "guide/index.html"; + if (relative === "docs/index.md") return "docs/index.html"; + return relative.replace(/\.md$/, "/index.html"); +} + + +function targetForUrl(site, page, raw) { + if (/^(?:mailto|tel|data|javascript):/i.test(raw)) return null; + const base = `${canonicalOrigin}${routeForHtml(page)}`; + let resolved; + try { + resolved = new URL(raw, base); + } catch { + return { error: "invalid" }; + } + if (resolved.origin !== canonicalOrigin) return null; + let relative = decodeURIComponent(resolved.pathname).replace(/^\//, ""); + if (relative === "" || relative.endsWith("/")) relative += "index.html"; + else if (path.posix.extname(relative) === "") relative += "/index.html"; + return { + absolute: path.join(site, ...relative.split("/")), + fragment: decodeURIComponent(resolved.hash.replace(/^#/, "")), + relative, + }; +} + + +async function compareSource(site, relative, source, errors) { + try { + const [published, original] = await Promise.all([ + readFile(path.join(site, ...relative.split("/"))), + readFile(source), + ]); + if (!published.equals(original)) { + errors.push(`${relative}: published Markdown differs from source`); + } + } catch (error) { + if (error?.code === "ENOENT") { + errors.push(`${relative}: Markdown source is missing`); + return; + } + throw error; + } +} + + +export async function verifySite({ site, sources }) { + const errors = []; + const files = await filesUnder(site); + const fileSet = new Set(files); + for (const relative of files) { + const parts = relative.split("/"); + if (parts.some((part) => forbiddenParts.has(part))) { + errors.push(`publishing boundary leaked ${relative}`); + } + } + + const htmlFiles = files.filter((relative) => relative.endsWith(".html")); + if (htmlFiles.length === 0) errors.push("no HTML pages were built"); + const parsedPages = new Map(); + for (const relative of htmlFiles) { + const contents = await readFile(path.join(site, ...relative.split("/")), "utf8"); + const parsed = parsePage(contents); + parsedPages.set(relative, parsed); + if (!parsed.title) errors.push(`${relative}: missing title`); + for (const key of requiredMetadata) { + if (!parsed.metadata.get(key)) errors.push(`${relative}: missing ${key} metadata`); + } + if (!relative.endsWith("404.html")) { + const expectedCanonical = `${canonicalOrigin}${routeForHtml(relative)}`; + if (parsed.canonical !== expectedCanonical) { + errors.push(`${relative}: canonical is ${parsed.canonical || "missing"}; expected ${expectedCanonical}`); + } + if (parsed.metadata.get("og:url") !== expectedCanonical) { + errors.push(`${relative}: og:url does not match its route`); + } + } + if (/fonts\.(?:googleapis|gstatic)\.com/i.test(contents)) { + errors.push(`${relative}: remote font URL`); + } + } + + for (const relative of htmlFiles) { + const parsed = parsedPages.get(relative); + for (const raw of parsed.images) { + if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(raw)) { + errors.push(`${relative}: image must use a site-relative URL: ${raw}`); + } + } + for (const raw of parsed.urls) { + const target = targetForUrl(site, relative, raw); + if (target === null) continue; + if (target.error || !fileSet.has(target.relative)) { + errors.push(`${relative}: broken local URL ${raw}`); + continue; + } + if ( + target.fragment && + target.fragment !== "__skip" && + target.relative.endsWith(".html") + ) { + const destination = parsedPages.get(target.relative); + if (destination && !destination.anchors.has(target.fragment)) { + errors.push(`${relative}: broken local fragment ${raw}`); + } + } + } + } + + const markdownFiles = files.filter((relative) => relative.endsWith(".md")); + for (const relative of htmlFiles) { + if (relative.endsWith("404.html")) continue; + const peer = markdownPeer(relative); + if (peer === null) { + errors.push(`${relative}: unsupported substantive HTML route`); + continue; + } + if (!fileSet.has(peer)) errors.push(`${relative}: missing Markdown peer ${peer}`); + } + for (const relative of markdownFiles) { + const rendered = htmlForMarkdown(relative); + if (!fileSet.has(rendered)) errors.push(`${relative}: missing HTML route ${rendered}`); + if (relative === "index.md" || relative === "guide.md") { + await compareSource(site, relative, path.join(sources.website, relative), errors); + } else if (relative.startsWith("docs/")) { + const sourceRelative = relative.slice("docs/".length); + await compareSource(site, relative, path.join(sources.docs, sourceRelative), errors); + } + } + + if (!fileSet.has("llms.txt")) { + errors.push("llms.txt: missing from site root"); + } else { + const llms = await readFile(path.join(site, "llms.txt"), "utf8"); + const indexed = new Set( + [...llms.matchAll(/https:\/\/docbank\.ai(\/[^)\s]+\.md)/g)].map( + (match) => match[1], + ), + ); + for (const relative of markdownFiles) { + const route = `/${relative}`; + if (!indexed.has(route)) errors.push(`llms.txt: missing ${route}`); + } + for (const route of indexed) { + if (!fileSet.has(route.slice(1))) errors.push(`llms.txt: missing page ${route}`); + } + } + + const generatedPrefix = "assets/generated/"; + const generated = files + .filter((relative) => relative.startsWith(generatedPrefix)) + .map((relative) => relative.slice(generatedPrefix.length)); + const declared = [...sources.assets].sort(); + for (const name of generated) { + if (!declared.includes(name)) errors.push(`undeclared generated asset: ${name}`); + } + for (const name of declared) { + if (!generated.includes(name)) errors.push(`missing generated asset: ${name}`); + } + + for (const relative of files.filter((name) => name.endsWith(".css"))) { + const contents = await readFile(path.join(site, ...relative.split("/")), "utf8"); + if (/fonts\.(?:googleapis|gstatic)\.com/i.test(contents)) { + errors.push(`${relative}: remote font URL`); + } + } + + if (errors.length > 0) { + throw new Error(`built site validation failed:\n ${errors.join("\n ")}`); + } + return { htmlPages: htmlFiles.length, markdownPages: markdownFiles.length }; +} diff --git a/scripts/docs/verify-site.test.mjs b/scripts/docs/verify-site.test.mjs new file mode 100644 index 00000000..4b66a6d2 --- /dev/null +++ b/scripts/docs/verify-site.test.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { verifySite } from "./verify-site.mjs"; + + +async function write(root, relative, contents) { + const destination = path.join(root, relative); + await mkdir(path.dirname(destination), { recursive: true }); + await writeFile(destination, contents); +} + + +function page({ canonical, body = "", extraHead = "" }) { + return ` + +Fixture page + + + + + + + + + + +${extraHead}

Fixture page

${body}
`; +} + + +async function fixture(t) { + const root = await mkdtemp(path.join(tmpdir(), "docbank-site-verify-")); + t.after(() => rm(root, { recursive: true, force: true })); + const site = path.join(root, "site"); + const website = path.join(root, "website"); + const docs = path.join(root, "docs"); + await write( + site, + "index.html", + page({ + canonical: "https://docbank.ai/", + body: 'GuideSynthetic capture', + }), + ); + await write( + site, + "guide/index.html", + page({ canonical: "https://docbank.ai/guide/", body: 'Docs' }), + ); + await write( + site, + "docs/index.html", + page({ canonical: "https://docbank.ai/docs/", body: 'Setup' }), + ); + await write( + site, + "docs/setup/index.html", + page({ canonical: "https://docbank.ai/docs/setup/", body: 'Overview' }), + ); + const markdown = { + "index.md": "# Product\n", + "guide.md": "# Guide\n", + "docs/index.md": "# Operating docs\n", + "docs/setup.md": "# Setup\n", + }; + for (const [relative, contents] of Object.entries(markdown)) { + await write(site, relative, contents); + } + await write(website, "index.md", markdown["index.md"]); + await write(website, "guide.md", markdown["guide.md"]); + await write(docs, "index.md", markdown["docs/index.md"]); + await write(docs, "setup.md", markdown["docs/setup.md"]); + await write(site, "assets/generated/capture.png", "png fixture"); + await write( + site, + "llms.txt", + [ + "# Docbank", + "- [Product](https://docbank.ai/index.md)", + "- [Guide](https://docbank.ai/guide.md)", + "- [Docs](https://docbank.ai/docs/index.md)", + "- [Setup](https://docbank.ai/docs/setup.md)", + "", + ].join("\n"), + ); + return { + site, + sources: { + website, + docs, + assets: ["capture.png"], + }, + }; +} + + +test("accepts complete routes, Markdown peers, links, and assets", async (t) => { + const input = await fixture(t); + await verifySite(input); +}); + + +test("accepts the generated 404 page without a canonical peer", async (t) => { + const input = await fixture(t); + const notFound = page({ + canonical: "https://docbank.ai/docs/", + body: 'Skip', + }).replace('', ""); + await write(input.site, "docs/404.html", notFound); + + await verifySite(input); +}); + + +test("rejects a substantive HTML file that is not a directory route", async (t) => { + const input = await fixture(t); + await write( + input.site, + "docs/orphan.html", + page({ canonical: "https://docbank.ai/docs/orphan.html" }), + ); + + await assert.rejects( + () => verifySite(input), + /docs\/orphan\.html: unsupported substantive HTML route/, + ); +}); + + +test("rejects an HTML route without its Markdown peer", async (t) => { + const input = await fixture(t); + await rm(path.join(input.site, "guide.md")); + await assert.rejects(() => verifySite(input), /guide\/index\.html: missing Markdown peer guide\.md/); +}); + + +test("rejects a stale llms index", async (t) => { + const input = await fixture(t); + await write( + input.site, + "llms.txt", + "- [Product](https://docbank.ai/index.md)\n- [Guide](https://docbank.ai/guide.md)\n- [Docs](https://docbank.ai/docs/index.md)\n", + ); + await assert.rejects(() => verifySite(input), /llms\.txt: missing \/docs\/setup\.md/); +}); + + +test("rejects a broken local link", async (t) => { + const input = await fixture(t); + await write( + input.site, + "index.html", + page({ canonical: "https://docbank.ai/", body: 'Missing' }), + ); + await assert.rejects(() => verifySite(input), /index\.html: broken local URL \/missing\//); +}); + + +test("rejects an image that is not site-relative", async (t) => { + for (const src of [ + "https://raw.githubusercontent.com/example/capture.png", + "https://docbank.ai/assets/generated/capture.png", + "//docbank.ai/assets/generated/capture.png", + ]) { + const { site, sources } = await fixture(t); + await write( + site, + "docs/setup/index.html", + page({ + canonical: "https://docbank.ai/docs/setup/", + body: `Capture`, + }), + ); + await assert.rejects(() => verifySite({ site, sources }), /image must use a site-relative URL/); + } +}); + + +test("rejects a private publishing path", async (t) => { + const input = await fixture(t); + await write(input.site, "internal/plan.md", "private\n"); + await assert.rejects(() => verifySite(input), /publishing boundary leaked internal\/plan\.md/); +}); + + +test("rejects an undeclared generated asset", async (t) => { + const input = await fixture(t); + await write(input.site, "assets/generated/extra.png", "extra\n"); + await assert.rejects(() => verifySite(input), /undeclared generated asset: extra\.png/); +}); + + +test("rejects a Markdown peer that differs from source", async (t) => { + const input = await fixture(t); + await write(input.site, "docs/setup.md", "changed\n"); + await assert.rejects(() => verifySite(input), /docs\/setup\.md: published Markdown differs from source/); +}); + + +test("rejects remote font hosts", async (t) => { + const input = await fixture(t); + await write( + input.site, + "index.html", + page({ + canonical: "https://docbank.ai/", + extraHead: '', + }), + ); + await assert.rejects(() => verifySite(input), /index\.html: remote font URL/); +}); diff --git a/scripts/sync-docs-assets.sh b/scripts/sync-docs-assets.sh new file mode 100755 index 00000000..b4e492c0 --- /dev/null +++ b/scripts/sync-docs-assets.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +remote="${DOCBANK_DOCS_ASSETS_REMOTE:-https://github.com/kenn-io/docbank.git}" +manifest="${DOCBANK_DOCS_ASSETS_MANIFEST:-$script_dir/docs-assets.txt}" +ref_file="${DOCBANK_DOCS_ASSETS_REF:-$script_dir/docs-assets.ref}" +cache_root="${DOCBANK_DOCS_ASSETS_CACHE:-$repo_root/.cache/docs-assets}" +max_png_bytes=$((10 * 1024 * 1024)) + +if command -v sha256sum >/dev/null 2>&1; then + checksum() { sha256sum "$@"; } + checksum_check() { sha256sum -c "$@"; } +elif command -v shasum >/dev/null 2>&1; then + checksum() { shasum -a 256 "$@"; } + checksum_check() { shasum -a 256 -c "$@"; } +else + printf 'SHA-256 checksum tool not found\n' >&2 + exit 127 +fi + +if [[ ! -f "$manifest" ]]; then + printf 'docs asset manifest is missing: %s\n' "$manifest" >&2 + exit 1 +fi +if [[ ! -f "$ref_file" ]]; then + printf 'docs asset ref is missing: %s\n' "$ref_file" >&2 + exit 1 +fi + +asset_ref="$(tr -d '\r\n' < "$ref_file")" +if [[ ! "$asset_ref" =~ ^[0-9a-f]{40}$ ]]; then + printf 'docs asset ref must be one full lowercase commit SHA\n' >&2 + exit 1 +fi +if [[ ! -s "$manifest" ]] || grep -q '^$' "$manifest"; then + printf 'docs asset manifest must contain no blank entries\n' >&2 + exit 1 +fi +if ! LC_ALL=C sort -c "$manifest" 2>/dev/null; then + printf 'docs asset manifest must be sorted\n' >&2 + exit 1 +fi +if [[ -n "$(LC_ALL=C sort "$manifest" | uniq -d)" ]]; then + printf 'docs asset manifest contains duplicate entries\n' >&2 + exit 1 +fi +while IFS= read -r name; do + if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.png$ ]]; then + printf 'invalid docs asset manifest entry: %s\n' "$name" >&2 + exit 1 + fi +done < "$manifest" + +destination="$cache_root/$asset_ref" + +cache_is_valid() { + [[ -d "$destination" && -f "$destination/.sha256" ]] || return 1 + local actual_files recorded_files + actual_files="$( + find "$destination" -maxdepth 1 -type f ! -name '.sha256' -exec basename {} \; | + LC_ALL=C sort + )" + recorded_files="$(awk '{name=$2; sub(/^\*/, "", name); print name}' "$destination/.sha256")" + [[ "$actual_files" == "$(<"$manifest")" ]] || return 1 + [[ "$recorded_files" == "$(<"$manifest")" ]] || return 1 + (cd "$destination" && checksum_check .sha256 >/dev/null 2>&1) +} + +if cache_is_valid; then + printf 'docs assets ready at %s\n' "$destination" + exit 0 +fi + +mkdir -p "$cache_root" +scratch="$(mktemp -d)" +staging="$cache_root/.${asset_ref}.next.$$" +previous="$cache_root/.${asset_ref}.previous" +cleanup() { + rm -rf -- "$scratch" + if [[ -n "${staging:-}" ]]; then + rm -rf -- "$staging" + fi +} +trap cleanup EXIT INT TERM + +export GIT_CONFIG_GLOBAL="$scratch/gitconfig" +export GIT_CONFIG_NOSYSTEM=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_NAMESPACE + +git_repo="$scratch/repository.git" +git init --bare --quiet "$git_repo" +git -C "$git_repo" fetch --quiet --depth=1 "$remote" "$asset_ref" +fetched="$(git -C "$git_repo" rev-parse 'FETCH_HEAD^{commit}')" +if [[ "$fetched" != "$asset_ref" ]]; then + printf 'fetched docs asset commit differs from pinned ref\n' >&2 + exit 1 +fi +if [[ -n "$(git -C "$git_repo" show -s --format='%P' "$asset_ref")" ]]; then + printf 'docs asset commit must be an orphan commit\n' >&2 + exit 1 +fi + +tree_files="$scratch/tree-files" +git -C "$git_repo" ls-tree -r --name-only "$asset_ref" | LC_ALL=C sort > "$tree_files" +if ! diff -u "$manifest" "$tree_files" >/dev/null; then + printf 'docs asset commit tree differs from manifest\n' >&2 + diff -u "$manifest" "$tree_files" >&2 || true + exit 1 +fi +if git -C "$git_repo" ls-tree -r "$asset_ref" | awk '$1 != "100644" { found=1 } END { exit !found }'; then + printf 'docs asset commit contains unsupported entry modes\n' >&2 + exit 1 +fi + +mkdir "$staging" +while IFS= read -r name; do + git -C "$git_repo" show "$asset_ref:$name" > "$staging/$name" + size="$(wc -c < "$staging/$name" | tr -d ' ')" + if (( size > max_png_bytes )); then + printf 'oversized docs asset: %s\n' "$name" >&2 + exit 1 + fi + signature="$(od -An -tx1 -N8 "$staging/$name" | tr -d ' \n')" + if [[ "$signature" != "89504e470d0a1a0a" ]]; then + printf 'invalid PNG signature: %s\n' "$name" >&2 + exit 1 + fi + (cd "$staging" && checksum "$name") >> "$staging/.sha256" +done < "$manifest" +(cd "$staging" && checksum_check .sha256 >/dev/null) + +rm -rf -- "$previous" +had_destination=false +if [[ -e "$destination" ]]; then + mv "$destination" "$previous" + had_destination=true +fi +if ! mv "$staging" "$destination"; then + if [[ "$had_destination" == true ]]; then + mv "$previous" "$destination" + fi + exit 1 +fi +staging="" +rm -rf -- "$previous" +printf 'docs assets ready at %s\n' "$destination" diff --git a/scripts/validate-docs-release.sh b/scripts/validate-docs-release.sh new file mode 100755 index 00000000..5489abe4 --- /dev/null +++ b/scripts/validate-docs-release.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +source_sha=${1:-} +expected_tag=${2:-} + +fail() { + printf 'documentation release validation failed: %s\n' "$1" >&2 + exit 1 +} + +if [ "${#source_sha}" -ne 40 ]; then + fail "source must be a full 40-character commit SHA" +fi +case "$source_sha" in + *[!0-9a-fA-F]*) fail "source must be a full 40-character commit SHA" ;; +esac +if ! printf '%s\n' "$expected_tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + fail "expected release tag must use vX.Y.Z form" +fi + +git fetch --quiet origin refs/heads/main:refs/remotes/origin/main --tags +git cat-file -e "$source_sha^{commit}" 2>/dev/null || fail "source commit does not exist: $source_sha" +git rev-parse --verify --quiet "$expected_tag^{commit}" >/dev/null || fail "release tag does not exist: $expected_tag" + +if ! git merge-base --is-ancestor "$source_sha" origin/main; then + fail "documentation source $source_sha is not on origin/main" +fi +if ! git merge-base --is-ancestor "$expected_tag" "$source_sha"; then + fail "release tag $expected_tag is not an ancestor of documentation source $source_sha" +fi + +latest_tag=$(git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' origin/main 2>/dev/null) || { + fail "origin/main has no release tag" +} +if [ "$latest_tag" != "$expected_tag" ]; then + fail "latest release on origin/main changed from $expected_tag to $latest_tag" +fi + +git diff --name-only "$expected_tag..$source_sha" | while IFS= read -r changed_path; do + case "$changed_path" in + .github/workflows/deploy-docs.yml | \ + .vercelignore | \ + AGENTS.md | \ + Makefile | \ + README.md | \ + scripts/deploy-docs.sh | \ + scripts/docs-assets.ref | \ + scripts/docs-assets.txt | \ + scripts/sync-docs-assets.sh | \ + scripts/validate-docs-release.sh | \ + scripts/vercel-build-docs.sh | \ + scripts/vercel-install-docs.sh | \ + vercel.json | \ + LICENSES/* | \ + docs/* | \ + scripts/docs/* | \ + website/*) + ;; + *) + fail "release-gated documentation source contains product change: $changed_path" + ;; + esac +done + +printf 'validated documentation source %s at release %s\n' "$source_sha" "$expected_tag" diff --git a/scripts/validate-docs-release.test.sh b/scripts/validate-docs-release.test.sh new file mode 100755 index 00000000..6e9e8f85 --- /dev/null +++ b/scripts/validate-docs-release.test.sh @@ -0,0 +1,91 @@ +#!/bin/sh +set -eu + +repository_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +validator="$repository_root/scripts/validate-docs-release.sh" +scratch=$(mktemp -d -t docbank-release-validation.XXXXXX) +trap 'find "$scratch" -depth -delete' EXIT HUP INT TERM +tests=0 + + +new_fixture() { + fixture="$scratch/repo-$tests" + remote="$scratch/remote-$tests.git" + git init --quiet --bare "$remote" + git init --quiet -b main "$fixture" + git -C "$fixture" config user.name "Release Test" + git -C "$fixture" config user.email "release-test@example.invalid" + git -C "$fixture" remote add origin "$remote" + mkdir -p "$fixture/cmd/docbank" "$fixture/docs" + printf 'package main\n' > "$fixture/cmd/docbank/main.go" + printf '# Documentation\n' > "$fixture/docs/index.md" + git -C "$fixture" add . + git -C "$fixture" commit --quiet -m "initial release" + git -C "$fixture" tag v1.0.0 + git -C "$fixture" push --quiet -u origin main --tags + + printf '\nRelease notes.\n' >> "$fixture/docs/index.md" + git -C "$fixture" commit --quiet -am "docs: add release notes" + git -C "$fixture" push --quiet origin main + source_sha=$(git -C "$fixture" rev-parse HEAD) +} + + +expect_pass() { + expected_tag=$1 + output=$(cd "$fixture" && "$validator" "$source_sha" "$expected_tag" 2>&1) || { + printf 'expected success, got:\n%s\n' "$output" >&2 + exit 1 + } + printf '%s\n' "$output" | grep -F "validated documentation source $source_sha at release $expected_tag" >/dev/null + tests=$((tests + 1)) +} + + +expect_fail() { + expected_tag=$1 + expected_message=$2 + if output=$(cd "$fixture" && "$validator" "$source_sha" "$expected_tag" 2>&1); then + printf 'expected failure, got success:\n%s\n' "$output" >&2 + exit 1 + fi + printf '%s\n' "$output" | grep -F "$expected_message" >/dev/null || { + printf 'expected failure containing %s, got:\n%s\n' "$expected_message" "$output" >&2 + exit 1 + } + tests=$((tests + 1)) +} + + +new_fixture +expect_pass v1.0.0 + +new_fixture +git -C "$fixture" switch --quiet --orphan unrelated +git -C "$fixture" rm --quiet -rf --ignore-unmatch . +printf 'unrelated\n' > "$fixture/unrelated.txt" +git -C "$fixture" add unrelated.txt +git -C "$fixture" commit --quiet -m "unrelated release" +git -C "$fixture" tag v9.0.0 +git -C "$fixture" switch --quiet main +expect_fail v9.0.0 "is not an ancestor of documentation source" + +new_fixture +printf '\nUnpublished.\n' >> "$fixture/docs/index.md" +git -C "$fixture" commit --quiet -am "docs: unpublished edit" +source_sha=$(git -C "$fixture" rev-parse HEAD) +expect_fail v1.0.0 "is not on origin/main" + +new_fixture +printf 'package main\n\nvar version = 2\n' > "$fixture/cmd/docbank/main.go" +git -C "$fixture" commit --quiet -am "feat: change product" +git -C "$fixture" push --quiet origin main +source_sha=$(git -C "$fixture" rev-parse HEAD) +expect_fail v1.0.0 "release-gated documentation source contains product change: cmd/docbank/main.go" + +new_fixture +git -C "$fixture" tag v1.1.0 "$source_sha" +git -C "$fixture" push --quiet origin v1.1.0 +expect_fail v1.0.0 "latest release on origin/main changed from v1.0.0 to v1.1.0" + +printf '%s release validation tests passed\n' "$tests" diff --git a/scripts/vercel-build-docs.sh b/scripts/vercel-build-docs.sh new file mode 100755 index 00000000..2457612f --- /dev/null +++ b/scripts/vercel-build-docs.sh @@ -0,0 +1,15 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) +export PATH="$repo_root/.vercel-tools/bin:$PATH" +export DOCBANK_REPO_ROOT="$repo_root" + +if [ ! -x "$repo_root/.vercel-tools/bin/uv" ]; then + printf 'pinned uv is missing; the Vercel install step did not complete\n' >&2 + exit 1 +fi + +cd "$repo_root" +node scripts/docs/build.mjs diff --git a/scripts/vercel-install-docs.sh b/scripts/vercel-install-docs.sh new file mode 100755 index 00000000..69e393ad --- /dev/null +++ b/scripts/vercel-install-docs.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH='' cd -- "$script_dir/.." && pwd) +tools_root="$repo_root/.vercel-tools" +uv_version=0.12.7 + +if [ "$(uname -s)" != "Linux" ]; then + printf 'Vercel documentation install requires Linux\n' >&2 + exit 1 +fi + +case "$(uname -m)" in + x86_64 | amd64) + target=x86_64-unknown-linux-gnu + expected_sha=788f18abea7c5f55d6216e4f5613fd89d4d59b631efeec117b2b07fe72f1da21 + ;; + aarch64 | arm64) + target=aarch64-unknown-linux-gnu + expected_sha=66393193038dd7eb108abd7a218d9cec04ac70ab98242b0720fa94de19223b7c + ;; + *) + printf 'unsupported Vercel build architecture: %s\n' "$(uname -m)" >&2 + exit 1 + ;; +esac + +scratch=$(mktemp -d) +trap 'rm -rf -- "$scratch"' EXIT HUP INT TERM +archive="$scratch/uv.tar.gz" +url="https://github.com/astral-sh/uv/releases/download/$uv_version/uv-$target.tar.gz" + +curl --fail --location --silent --show-error "$url" --output "$archive" +printf '%s %s\n' "$expected_sha" "$archive" | sha256sum -c - +tar -xzf "$archive" -C "$scratch" + +mkdir -p "$tools_root/bin" +install -m 0755 "$scratch/uv-$target/uv" "$tools_root/bin/uv" +install -m 0755 "$scratch/uv-$target/uvx" "$tools_root/bin/uvx" +PATH="$tools_root/bin:$PATH" uv sync --project "$repo_root/docs" --frozen diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..4e94d940 --- /dev/null +++ b/vercel.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "./scripts/vercel-install-docs.sh", + "buildCommand": "./scripts/vercel-build-docs.sh", + "outputDirectory": "site", + "trailingSlash": true, + "redirects": [ + { + "source": "/:path*", + "has": [ + { + "type": "host", + "value": "www.docbank.ai" + } + ], + "destination": "https://docbank.ai/:path*", + "permanent": true + } + ] +} diff --git a/website/assets/authority-ledger.svg b/website/assets/authority-ledger.svg new file mode 100644 index 00000000..a14293c3 --- /dev/null +++ b/website/assets/authority-ledger.svg @@ -0,0 +1,25 @@ + + Authority ledger + Exact source bytes remain authoritative while approved processing creates version-bound derivatives. + + + + + + + + + + SOURCE AUTHORITY + Exact bytes · stable identity · immutable version + 01 / AUTHORIZE + Operator policy + Provider and outputs + 02 / PROCESS + Bounded work + Against one version + 03 / RETAIN + Derivatives + Traceable to source + + diff --git a/website/assets/derivative-cycle.svg b/website/assets/derivative-cycle.svg new file mode 100644 index 00000000..4bc7ae04 --- /dev/null +++ b/website/assets/derivative-cycle.svg @@ -0,0 +1,47 @@ + + Docbank derivative cycle + Consent, derivation, retrieval, and purge cycle around a source that stays exact. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THE RECORD + source stays exact + + CONSENT + DERIVE + RETRIEVE + PURGE + + + durable · revocable + rendition · embedding + evidence-backed + originals untouched + + + diff --git a/website/assets/intelligence-pipeline.svg b/website/assets/intelligence-pipeline.svg new file mode 100644 index 00000000..bdbc317d --- /dev/null +++ b/website/assets/intelligence-pipeline.svg @@ -0,0 +1,62 @@ + + Docbank intelligence pipeline + One exact source version feeds governed rendition, chunk, embedding, and index derivatives that can be purged and rebuilt without touching the record. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + THE RECORD + GOVERNED DERIVATIVES + CONSENT + + SOURCE + RENDITION + CHUNKS + EMBEDDINGS + INDEX + + + exact bytes · v3 + ocr · markdown + deterministic + generation-bound + rebuildable + + purge · re-derive · swap providers · rebuild — the record never moves + + diff --git a/website/assets/interface-map.svg b/website/assets/interface-map.svg new file mode 100644 index 00000000..f803eb65 --- /dev/null +++ b/website/assets/interface-map.svg @@ -0,0 +1,61 @@ + + Docbank interface map + CLI, web, TUI, HTTP, embedded Go, and MCP reach the same vault authority. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DOCBANK VAULT + one authority + stable identity · exact bytes + + CLI + WEB + TUI + HTTP + GO + MCP + + + scriptable + visual + keyboard + api + embedded + agents + + + diff --git a/website/assets/recovery-flow.svg b/website/assets/recovery-flow.svg new file mode 100644 index 00000000..083e99c6 --- /dev/null +++ b/website/assets/recovery-flow.svg @@ -0,0 +1,25 @@ + + Provider-independent recovery + A verified snapshot restores exact content, metadata, and history without making a processing provider authoritative. + + + + + + + + + + + + 01 / SNAPSHOT + Exact record + content · metadata + 02 / VERIFY + Before use + checksums · history + 03 / RESTORE + Operator control + no provider required + + diff --git a/website/favicon.svg b/website/favicon.svg new file mode 100644 index 00000000..7a4ff4d9 --- /dev/null +++ b/website/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/website/fonts/Inter-Medium.woff2 b/website/fonts/Inter-Medium.woff2 new file mode 100644 index 00000000..fdfdcc69 Binary files /dev/null and b/website/fonts/Inter-Medium.woff2 differ diff --git a/website/fonts/Inter-Regular.woff2 b/website/fonts/Inter-Regular.woff2 new file mode 100644 index 00000000..2bcd222e Binary files /dev/null and b/website/fonts/Inter-Regular.woff2 differ diff --git a/website/fonts/Inter-SemiBold.woff2 b/website/fonts/Inter-SemiBold.woff2 new file mode 100644 index 00000000..fbae113d Binary files /dev/null and b/website/fonts/Inter-SemiBold.woff2 differ diff --git a/website/fonts/JetBrainsMono-Bold.woff2 b/website/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 00000000..4917f434 Binary files /dev/null and b/website/fonts/JetBrainsMono-Bold.woff2 differ diff --git a/website/fonts/JetBrainsMono-Regular.woff2 b/website/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 00000000..40da4276 Binary files /dev/null and b/website/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/website/fonts/JetBrainsMono-SemiBold.woff2 b/website/fonts/JetBrainsMono-SemiBold.woff2 new file mode 100644 index 00000000..5ead7b0d Binary files /dev/null and b/website/fonts/JetBrainsMono-SemiBold.woff2 differ diff --git a/website/guide.md b/website/guide.md new file mode 100644 index 00000000..f4488088 --- /dev/null +++ b/website/guide.md @@ -0,0 +1,123 @@ +# The document authority lifecycle + +One document moves through nine explicit boundaries. Exact source bytes remain +authoritative at every stop. + +1. [Ingest](#ingest) +2. [Identify](#identify) +3. [Authorize](#authorize) +4. [Render](#render) +5. [Embed](#embed) +6. [Retrieve](#retrieve) +7. [Replace](#replace) +8. [Serve](#serve) +9. [Prove](#prove) + +## Ingest + +Docbank copies bytes into the vault without making the source path part of +document identity. The virtual tree gives operators a familiar hierarchy while +retained content stays addressable by checksum. + +![Synthetic Docbank vault after document +ingestion](https://docbank.ai/assets/generated/web-vault-browser.png) + +[Importing documents](/docs/usage/importing/) + +## Identify + +A stable node identity survives moves and renames. Replacement creates an +immutable content version, preserving checksums and history instead of silently +changing what an identifier means. + +![Docbank retained version history and download +action](https://docbank.ai/assets/generated/web-retained-version-download.png) + +[Editing and versions](/docs/architecture/editing-and-versions/) + +## Authorize + +Before processing, the operator chooses where document bytes may go and which +outputs may be retained. Authorization governs a task; it does not transfer +source authority to the processor. + +![Source authority above authorized, version-bound processing +steps](https://docbank.ai/assets/authority-ledger.svg) + +[Document understanding](/docs/document-understanding/) + +## Render + +OCR and rendition providers turn the source into normalized Markdown and text +through an enforceable contract. A rendition is a governed derivative of one +exact version; it can improve retrieval or be regenerated without becoming the +original record. + +![Derivatives retained beneath an exact authoritative source +version](https://docbank.ai/assets/authority-ledger.svg) + +[Understanding outputs](/docs/document-understanding/) + +## Embed + +Chunks, embedding generations, and vector indexes enter the same catalog as +every other derivative: fingerprinted, bound to provider and model identity, +and rebuildable from retained evidence. The catalog records what produced +every vector. + +![A source version feeding rendition, chunk, embedding, and index derivatives +behind a consent boundary](https://docbank.ai/assets/intelligence-pipeline.svg) + +[Embedding plans](/docs/document-understanding/) + +## Retrieve + +Hybrid search fuses lexical and semantic signals, optionally expands and +reranks, and explains each ranked result. The fence holds: every answer keeps +the document, version, and evidence behind it. An index helps locate the +record; it does not define it. + +![Docbank ranked search over a synthetic document +catalog](https://docbank.ai/assets/generated/web-search-results.png) + +[Searching](/docs/usage/searching/) + +## Replace + +Consent can be revoked and derived state is disposable. Purge renditions, +embeddings, and indexes, re-derive them through a different provider, or +restore them from backup with no provider in the loop. The originals are never +touched. + +![Consent, derivation, retrieval, and purge cycling around a source that stays +exact](https://docbank.ai/assets/derivative-cycle.svg) + +[Backup and restore](/docs/usage/backup/) + +## Serve + +The CLI, web application, TUI, HTTP API, embedded Go package, and agent tools +operate over the same vault model. Each surface inherits the same identity, +revision, and integrity boundaries. + +![CLI, web, TUI, HTTP, Go, and MCP interfaces sharing one Docbank +vault](https://docbank.ai/assets/interface-map.svg) + +[Docbank for agents](/docs/agents/) + +## Prove + +Verification checks content and history before an operator needs them. +Incremental snapshots restore exact bytes, metadata, and retained history +without relying on a processing or search provider. + +![Docbank evidence view for a protected synthetic document +scope](https://docbank.ai/assets/generated/web-audit-evidence.png) + +![Verified snapshot restoring a vault without a processing +provider](https://docbank.ai/assets/recovery-flow.svg) + +[Backup and restore](/docs/usage/backup/) + +Continue with the [quickstart](/docs/quickstart/) or open the complete [operating +documentation](/docs/). diff --git a/website/guide/index.html b/website/guide/index.html new file mode 100644 index 00000000..5903f2a7 --- /dev/null +++ b/website/guide/index.html @@ -0,0 +1,281 @@ + + + + + + The document authority lifecycle — Docbank + + + + + + + + + + + + + + + + + +
+
+

Operator guide

+

The document authority lifecycle

+

One document moves through nine explicit boundaries. Exact source bytes remain authoritative at every stop.

+
    +
  1. 01 Ingest
  2. +
  3. 02 Identify
  4. +
  5. 03 Authorize
  6. +
  7. 04 Render
  8. +
  9. 05 Embed
  10. +
  11. 06 Retrieve
  12. +
  13. 07 Replace
  14. +
  15. 08 Serve
  16. +
  17. 09 Prove
  18. +
+
+ +
    +
  1. +
    +

    Ingest the exact source

    +

    Docbank copies bytes into the vault without making the source path part of document identity. The virtual tree gives operators a familiar hierarchy while retained content stays addressable by checksum.

    + Importing documents +
    +
    +
    + + Synthetic Docbank vault after document ingestion + +
    A generated vault in the real web interface.
    +
    +
    +
  2. + +
  3. +
    +

    Identify the record

    +

    A stable node identity survives moves and renames. Replacement creates an immutable content version, preserving checksums and history instead of silently changing what an identifier means.

    + Editing and versions +
    +
    +
    + + Docbank retained version history and download action + +
    Retained versions remain inspectable and retrievable.
    +
    +
    +
  4. + +
  5. +
    +

    Authorize the boundary

    +

    Before processing, the operator chooses where document bytes may go and which outputs may be retained. Authorization governs a task; it does not transfer source authority to the processor.

    + Document understanding +
    +
    +
    + Source authority above authorized, version-bound processing steps +
    +
    +
  6. + +
  7. +
    +

    Render without replacing

    +

    OCR and rendition providers turn the source into normalized Markdown and text through an enforceable contract. A rendition is a governed derivative of one exact version; it can improve retrieval or be regenerated without becoming the original record.

    + Understanding outputs +
    +
    +
    + Derivatives retained beneath an exact authoritative source version +
    +
    +
  8. + +
  9. +
    +

    Embed and index deterministically

    +

    Chunks, embedding generations, and vector indexes enter the same catalog as every other derivative: fingerprinted, bound to provider and model identity, and rebuildable from retained evidence. The catalog records what produced every vector.

    + Embedding plans +
    +
    +
    + A source version feeding rendition, chunk, embedding, and index derivatives behind a consent boundary +
    +
    +
  10. + +
  11. +
    +

    Retrieve behind a source fence

    +

    Hybrid search fuses lexical and semantic signals, optionally expands and reranks, and explains each ranked result. The fence holds: every answer keeps the document, version, and evidence behind it. An index helps locate the record; it does not define it.

    + Searching +
    +
    +
    + + Docbank ranked search over a synthetic document catalog + +
    Search results in the generated documentation vault.
    +
    +
    +
  12. + +
  13. +
    +

    Replace intelligence, keep the record

    +

    Consent can be revoked and derived state is disposable. Purge renditions, embeddings, and indexes, re-derive them through a different provider, or restore them from backup with no provider in the loop. The originals are never touched.

    + Backup and restore +
    +
    +
    + Consent, derivation, retrieval, and purge cycling around a source that stays exact +
    +
    +
  14. + +
  15. +
    +

    Serve one authority

    +

    The CLI, web application, TUI, HTTP API, embedded Go package, and agent tools operate over the same vault model. Each surface inherits the same identity, revision, and integrity boundaries.

    + Docbank for agents +
    +
    +
    + CLI, web, TUI, HTTP, Go, and MCP interfaces sharing one Docbank vault +
    +
    +
  16. + +
  17. +
    +

    Prove and recover

    +

    Verification checks content and history before an operator needs them. Incremental snapshots restore exact bytes, metadata, and retained history without relying on a processing or search provider.

    + Backup and restore +
    +
    +
    + + Docbank evidence view for a protected synthetic document scope + +
    Evidence is visible before recovery is required.
    +
    +
    + Verified snapshot restoring a vault without a processing provider +
    +
    +
  18. +
+ +
+
+
+

Next

+

Operate the vault.

+
+
+

Move from the lifecycle model to setup, exact commands, automation contracts, storage policy, and recovery procedures.

+ +
+
+
+
+ + + + +
+ + +
+ Expanded documentation capture +
+ + + diff --git a/website/index.html b/website/index.html new file mode 100644 index 00000000..68d40535 --- /dev/null +++ b/website/index.html @@ -0,0 +1,329 @@ + + + + + + Docbank — document authority for people and agents + + + + + + + + + + + + + + + + + +
+
+

The system of record for documents your agents can use.

+

Docbank is a local-first, open-source document vault. Originals stay exact and authoritative while governed processing makes them searchable for people, systems, and agents.

+
+ curl -fsSL https://docbank.ai/install.sh | sh + +
+

macOS and Linux · Windows install

+ +

Local-first · Apache-2.0 · CLI · Web · TUI · HTTP API · Go · MCP

+
+ +
+
+
+

01 / Authority

+

One record. Explicit states.

+
+

A document can be copied, indexed, transformed, and served through several interfaces. Its authority does not move implicitly with those operations.

+
+
    +
  1. SourceExact retained bytes and stable identity.
  2. +
  3. AuthorizationA reviewed processing boundary.
  4. +
  5. UnderstandingDerivatives bound to one source version.
  6. +
  7. RetrievalResults backed by evidence and source identity.
  8. +
  9. RecoveryVerified restore under operator control.
  10. +
+
+ +
+
+
+

02 / Guarantees

+

Built around the record, not the index.

+
+

Docbank separates durable document authority from the systems that make documents useful. That boundary stays visible in routine operator work.

+
+
+
+

Source sovereign

+

Keep the original exact.

+

Content-addressed bytes, stable document identity, immutable versions, and checksums preserve what entered the system.

+
+
+

Govern understanding

+

Approve what may leave control.

+

Processing policy names the provider boundary and retained outputs. Derived text and embeddings remain tied to the source version that produced them.

+
+
+

Constrain answers

+

Retrieve with evidence.

+

Search and agent access operate over verified current content while preserving the document and version behind a result.

+
+
+

Restore the record

+

Recover without a provider.

+

Incremental snapshots retain content, metadata, and history so recovery does not depend on a search or processing service.

+
+
+
+ +
+
+
+

03 / Operation

+

A vault operators can inspect.

+
+

Browse a filesystem-shaped document tree, inspect document identity and storage state, and move through retained authority without leaving the vault interface.

+
+
+ + Docbank web vault showing a synthetic technical document collection + +
The real web interface against a generated synthetic vault. Select the image to inspect it at full size.
+
+
+ +
+
+
+

04 / Processing

+

Choose the boundary per workload.

+
+

Understanding is governed work, not an invisible side effect of storage. Operators decide where it runs and which outputs become retained derivatives.

+
+
+
+

Local

+

Keep processing beside the vault.

+

Use local tooling when document bytes should remain on operator-controlled hardware.

+
+
+

Self-hosted

+

Run a controlled service boundary.

+

Point governed jobs at infrastructure your organization operates and audits.

+
+
+

Hosted

+

Use bounded external capability.

+

Authorize a provider for a defined task while keeping the original and resulting record in Docbank.

+
+
+

None

+

Store without processing.

+

A document remains a complete authoritative record even when no derivative is produced.

+
+
+
+ +
+
+
+

05 / Intelligence

+

AI understanding, on the record.

+
+

OCR output, renditions, chunks, embeddings, and vector indexes are records too: cataloged, fingerprinted, and bound to the exact source version that produced them.

+
+
+ One exact source version feeding governed rendition, chunk, embedding, and index derivatives that can be purged and rebuilt without touching the record +
+
+
+

Catalog derivatives

+

Bind understanding to its source.

+

Renditions, chunk sets, embedding generations, and vector indexes publish atomically into a durable catalog, each fingerprinted against one immutable source version.

+
+
+

Replace providers

+

Swap models without losing the record.

+

Local, self-hosted, and hosted OCR and embedding providers plug into standard bridges. Consent is durable and revocable, and uploads bind to the exact inspected bytes.

+
+
+

Rebuild intelligence

+

Treat derived state as disposable.

+

Purge, re-derive, and rebuild renditions, embeddings, and indexes deterministically. Derivatives back up and restore without any provider, and originals are never touched.

+
+
+

Explain retrieval

+

Rank with reasons, answer with evidence.

+

Hybrid lexical and semantic search, with optional expansion and reranking, explains each ranked result and keeps the document and version behind it.

+
+
+
+ +
+
+
+

06 / Interfaces

+

One authority across every surface.

+

People, applications, and agents work through interfaces suited to the task. They do not create separate copies of record.

+
    +
  • CLIScriptable operator workflows.
  • +
  • WebVisual browsing and maintenance.
  • +
  • TUIKeyboard-first vault inspection.
  • +
  • HTTPAuthenticated filesystem-shaped API.
  • +
  • GoEmbedded vault ownership.
  • +
  • MCPBounded tools for agent work.
  • +
+ +
+
+ Six Docbank interfaces converging on one vault authority +
+
+
+ +
+
+
+

07 / Boundary

+

Not a cloud drive. Not a git repo.

+
+

Docbank is closer to a storage appliance with an evidence-aware intelligence layer: the operator owns the vault, its policy, and its recovery path.

+
+
+
+

Cloud drive

+

The service is the record.

+

Identity, search, processing, and recovery are coupled to one provider account and its lifecycle.

+
+
+

Git repository

+

The ledger expects rewriting.

+

Rebase and force-push rewrite history by design, identity follows paths, and large originals or AI derivatives overflow into bolt-on stores with no consent or provenance contract.

+
+
+

Docbank

+

The vault is the record.

+

Storage locations, processors, and interfaces are replaceable participants around operator-controlled authority, including every AI-derived record.

+
+
+
+ +
+
+
+

08 / Start

+

Follow one document through the system.

+
+
+

The guide makes each authority boundary concrete. The operating documentation carries installation, workflows, architecture, and exact command behavior.

+ +
+
+
+
+ + + + +
+ + +
+ Expanded documentation capture +
+ + + diff --git a/website/index.md b/website/index.md new file mode 100644 index 00000000..ad18056b --- /dev/null +++ b/website/index.md @@ -0,0 +1,145 @@ +# The system of record for documents your agents can use + +Docbank is a local-first, open-source document vault. Originals stay exact and +authoritative while governed processing makes them searchable for people, +systems, and agents. + +Docbank is usable through the CLI, web application, terminal interface, HTTP +API, embedded Go package, and agent tooling. + +## Install + +On macOS or Linux: + +```sh +curl -fsSL https://docbank.ai/install.sh | sh +``` + +On Windows: + +```powershell +irm https://docbank.ai/install.ps1 | iex +``` + +Then [follow the authority lifecycle](/guide/) or read the +[setup documentation](/docs/setup/). + +## One record. Explicit states. + +A document can be copied, indexed, transformed, and served through several +interfaces. Its authority does not move implicitly with those operations. + +1. **Source** — exact retained bytes and stable identity. +2. **Authorization** — a reviewed processing boundary. +3. **Understanding** — derivatives bound to one source version. +4. **Retrieval** — results backed by evidence and source identity. +5. **Recovery** — verified restore under operator control. + +## Built around the record, not the index + +Docbank separates durable document authority from the systems that make +documents useful. That boundary stays visible in routine operator work. + +### Source sovereign + +Keep the original exact. Content-addressed bytes, stable document identity, +immutable versions, and checksums preserve what entered the system. + +### Govern understanding + +Approve what may leave control. Processing policy names the provider boundary +and retained outputs. Derived text and embeddings remain tied to the source +version that produced them. + +### Constrain answers + +Retrieve with evidence. Search and agent access operate over verified current +content while preserving the document and version behind a result. + +### Restore the record + +Recover without a provider. Incremental snapshots retain content, metadata, and +history so recovery does not depend on a search or processing service. + +## A vault operators can inspect + +[![Docbank web vault showing a synthetic technical document +collection](https://docbank.ai/assets/generated/web-vault-browser.png)](https://docbank.ai/assets/generated/web-vault-browser.png) + +Browse a filesystem-shaped document tree, inspect document identity and +storage state, and move through retained authority without leaving the vault +interface. The capture shows the real web interface against a generated +synthetic vault. + +## Choose the boundary per workload + +Understanding is governed work, not an invisible side effect of storage. +Operators decide where it runs and which outputs become retained derivatives. + +- **Local:** use local tooling when document bytes should remain on + operator-controlled hardware. +- **Self-hosted:** point governed jobs at infrastructure your organization + operates and audits. +- **Hosted:** authorize a provider for a defined task while keeping the original + and resulting record in Docbank. +- **None:** a document remains a complete authoritative record even when no + derivative is produced. + +## AI understanding, on the record + +OCR output, renditions, chunks, embeddings, and vector indexes are records +too: cataloged, fingerprinted, and bound to the exact source version that +produced them. + +![One exact source version feeding governed rendition, chunk, embedding, and +index derivatives that can be purged and rebuilt without touching the +record](https://docbank.ai/assets/intelligence-pipeline.svg) + +- **Catalog derivatives:** renditions, chunk sets, embedding generations, and + vector indexes publish atomically into a durable catalog, each fingerprinted + against one immutable source version. +- **Replace providers:** local, self-hosted, and hosted OCR and embedding + providers plug into standard bridges. Consent is durable and revocable, and + uploads bind to the exact inspected bytes. +- **Rebuild intelligence:** purge, re-derive, and rebuild renditions, + embeddings, and indexes deterministically. Derivatives back up and restore + without any provider, and originals are never touched. +- **Explain retrieval:** hybrid lexical and semantic search, with optional + expansion and reranking, explains each ranked result and keeps the document + and version behind it. + +## One authority across every surface + +People, applications, and agents work through interfaces suited to the task. +They do not create separate copies of record. + +- **CLI:** scriptable operator workflows. +- **Web:** visual browsing and maintenance. +- **TUI:** keyboard-first vault inspection. +- **HTTP:** an authenticated filesystem-shaped API. +- **Go:** embedded vault ownership. +- **MCP:** bounded tools for agent work. + +[Integrate an agent](/docs/agents/) or [inspect the HTTP +contract](/docs/architecture/http-api/). + +## Not a cloud drive. Not a git repo. + +Docbank is closer to a storage appliance with an evidence-aware intelligence +layer: the operator owns the vault, its policy, and its recovery path. + +- **Cloud drive:** the service is the record. Identity, search, processing, and + recovery are coupled to one provider account and its lifecycle. +- **Git repository:** the ledger expects rewriting. Rebase and force-push + rewrite history by design, identity follows paths, and large originals or AI + derivatives overflow into bolt-on stores with no consent or provenance + contract. +- **Docbank:** the vault is the record. Storage locations, processors, and + interfaces are replaceable participants around operator-controlled + authority, including every AI-derived record. + +## Follow one document through the system + +The [authority guide](/guide/) makes each boundary concrete. The [operating +documentation](/docs/) carries installation, workflows, architecture, and +exact command behavior. diff --git a/website/scripts/site.js b/website/scripts/site.js new file mode 100644 index 00000000..129dc8d4 --- /dev/null +++ b/website/scripts/site.js @@ -0,0 +1,127 @@ +const repoApi = "https://api.github.com/repos/kenn-io/docbank"; +const cacheMaxAgeMs = 60 * 60 * 1000; + +function installLightboxes() { + const dialog = document.querySelector("[data-lightbox-dialog]"); + if (!(dialog instanceof HTMLDialogElement)) return; + + const image = dialog.querySelector("img"); + const title = dialog.querySelector("[data-lightbox-title]"); + const close = dialog.querySelector("[data-lightbox-close]"); + let trigger = null; + + for (const link of document.querySelectorAll("a[data-lightbox]")) { + link.addEventListener("click", (event) => { + if (!(image instanceof HTMLImageElement)) return; + event.preventDefault(); + trigger = link; + const source = link.getAttribute("href"); + const preview = link.querySelector("img"); + if (!source || !(preview instanceof HTMLImageElement)) return; + image.src = source; + image.alt = preview.alt; + if (title) title.textContent = preview.alt; + dialog.showModal(); + if (close instanceof HTMLElement) close.focus(); + }); + } + + close?.addEventListener("click", () => dialog.close()); + dialog.addEventListener("click", (event) => { + if (event.target === dialog) dialog.close(); + }); + dialog.addEventListener("close", () => { + if (image instanceof HTMLImageElement) image.removeAttribute("src"); + if (trigger instanceof HTMLElement) trigger.focus(); + }); +} + +function installCopyButton() { + const root = document.querySelector("[data-install-command]"); + const status = document.querySelector("[data-install-status]"); + const button = root?.querySelector("[data-install-copy]"); + const command = root instanceof HTMLElement ? root.dataset.command : undefined; + if (!(button instanceof HTMLButtonElement) || !(status instanceof HTMLElement) || !command) return; + + let resetTimer; + button.addEventListener("click", async () => { + clearTimeout(resetTimer); + try { + await navigator.clipboard.writeText(command); + status.textContent = "Copied"; + resetTimer = setTimeout(() => { + status.textContent = ""; + }, 2000); + } catch { + status.textContent = "Copy failed — select the command text instead"; + } + }); +} + +function readCache(key, now) { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + const entry = JSON.parse(raw); + if (now - entry.at > cacheMaxAgeMs) return null; + return entry.value; + } catch { + return null; + } +} + +function writeCache(key, value, now) { + try { + localStorage.setItem(key, JSON.stringify({ at: now, value })); + } catch { + // Storage can be unavailable (private browsing); facts refetch next visit. + } +} + +async function cachedJson(key, url) { + const now = Date.now(); + const cached = readCache(key, now); + if (cached !== null) return cached; + const response = await fetch(url, { headers: { Accept: "application/vnd.github+json" } }); + if (!response.ok) return null; + const value = await response.json(); + writeCache(key, value, now); + return value; +} + +function setFact(name, text) { + const fact = document.querySelector(`[data-fact="${name}"]`); + if (!(fact instanceof HTMLElement)) return; + const label = fact.querySelector("[data-fact-text]"); + if (!label) return; + label.textContent = text; + fact.hidden = false; + const row = document.querySelector("[data-facts]"); + if (row instanceof HTMLElement) row.hidden = false; +} + +function formatCount(count) { + if (count < 1000) return String(count); + const thousands = count / 1000; + const rounded = thousands >= 10 ? Math.round(thousands) : Math.round(thousands * 10) / 10; + return `${rounded}k`; +} + +async function installRepoFacts() { + if (!document.querySelector("[data-facts]")) return; + const [repo, release] = await Promise.all([ + cachedJson("docbank:repo", repoApi), + cachedJson("docbank:release", `${repoApi}/releases/latest`), + ]); + if (repo) { + setFact("stars", formatCount(repo.stargazers_count)); + setFact("forks", formatCount(repo.forks_count)); + } + if (release) setFact("version", release.tag_name); +} + +installLightboxes(); +installCopyButton(); +installRepoFacts().catch((error) => { + console.warn("github api unavailable, keeping the static header", error); +}); diff --git a/website/styles/site.css b/website/styles/site.css new file mode 100644 index 00000000..0b9e32fc --- /dev/null +++ b/website/styles/site.css @@ -0,0 +1,771 @@ +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-Regular.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-Medium.woff2") format("woff2"); + font-style: normal; + font-weight: 500; + font-display: swap; +} + +@font-face { + font-family: "Inter"; + src: url("/fonts/Inter-SemiBold.woff2") format("woff2"); + font-style: normal; + font-weight: 600; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2"); + font-style: normal; + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("/fonts/JetBrainsMono-SemiBold.woff2") format("woff2"); + font-style: normal; + font-weight: 600; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2"); + font-style: normal; + font-weight: 700; + font-display: swap; +} + +:root { + color-scheme: dark; + --ink: #071411; + --panel: #0c1e1a; + --panel-raised: #102822; + --rule: #25473f; + --teal: #41d7bd; + --teal-strong: #78ead6; + --text: #edf8f4; + --muted: #a7c1ba; + --body: "Inter", system-ui, sans-serif; + --mono: "JetBrains Mono", ui-monospace, monospace; + background: var(--ink); + color: var(--text); + font-family: var(--body); + font-synthesis: none; + line-height: 1.6; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--ink); +} + +body > header, +body > main, +body > footer { + width: min(100% - 2.5rem, 76rem); + margin-inline: auto; +} + +a { + color: var(--teal-strong); + text-decoration-thickness: 1px; + text-underline-offset: 0.2em; +} + +a:hover { + color: var(--text); +} + +:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 4px; +} + +.skip-link { + position: fixed; + z-index: 20; + top: 0.75rem; + left: 0.75rem; + padding: 0.55rem 0.75rem; + color: var(--ink); + background: var(--teal); + transform: translateY(-200%); +} + +.skip-link:focus { + transform: none; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 4.75rem; + gap: 2rem; + border-bottom: 1px solid var(--rule); +} + +.site-header nav, +.footer-links, +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1.25rem; +} + +.site-header nav a { + color: var(--muted); + text-decoration: none; +} + +.site-header nav a:hover, +.site-header nav a[aria-current="page"] { + color: var(--text); +} + +.sr-only { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + white-space: nowrap; + clip-path: inset(50%); + border: 0; +} + +.repo { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.2rem 0.3rem; + border-radius: 3px; + font-family: var(--mono); +} + +.site-header nav a.repo:hover { + background: var(--panel); +} + +.repo > svg { + flex: 0 0 auto; +} + +.repo-meta { + display: flex; + flex-direction: column; + gap: 0.1rem; + line-height: 1.2; +} + +.repo-name { + font-size: 0.72rem; +} + +.facts { + display: flex; + gap: 0.5rem; + font-size: 0.64rem; +} + +.facts[hidden], +.fact[hidden] { + display: none; +} + +.fact { + display: inline-flex; + align-items: center; + gap: 0.22rem; +} + +.icon-link { + display: inline-flex; +} + +.wordmark, +.eyebrow, +.section-index, +.ledger strong, +.proof-tag, +.choice-label, +code, +kbd { + font-family: var(--mono); +} + +.wordmark { + color: var(--text); + font-family: var(--mono); + font-size: 1.05rem; + font-weight: 700; + letter-spacing: -0.03em; + text-decoration: none; +} + +.wordmark::before { + color: var(--teal); + content: "["; +} + +.wordmark::after { + color: var(--teal); + content: "]"; +} + +main { + display: block; +} + +.hero { + position: relative; + padding: clamp(5rem, 12vw, 9rem) 0 clamp(4rem, 9vw, 7rem); + border-bottom: 1px solid var(--rule); +} + +.hero::before { + content: ""; + position: absolute; + inset: 0; + background: radial-gradient(42rem 26rem at 18% 8%, rgb(65 215 189 / 7%), transparent 70%); + pointer-events: none; +} + +.hero > * { + position: relative; +} + +.hero.compact { + padding-bottom: clamp(3rem, 7vw, 5rem); +} + +.eyebrow, +.section-index, +.proof-tag, +.choice-label { + margin: 0 0 1rem; + color: var(--teal); + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1, +h2, +h3 { + line-height: 1.08; + text-wrap: balance; +} + +h1 { + max-width: 19ch; + margin-bottom: 1.5rem; + font-size: clamp(2.75rem, 7.5vw, 5.75rem); + font-weight: 500; + letter-spacing: -0.055em; +} + +h2 { + max-width: 24ch; + margin-bottom: 1rem; + font-size: clamp(1.8rem, 4vw, 3rem); + font-weight: 500; + letter-spacing: -0.035em; +} + +h3 { + margin-bottom: 0.65rem; + font-size: 1.08rem; + font-weight: 600; +} + +.lede { + max-width: 56rem; + margin-bottom: 2rem; + color: var(--muted); + font-size: clamp(1.08rem, 2vw, 1.35rem); +} + +.caption, +.fine-print, +.install-hint { + color: var(--muted); + font-size: 0.88rem; +} + +.install-command { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.5rem; + width: fit-content; + max-width: 100%; + margin-top: 0.5rem; + padding: 0.45rem 0.45rem 0.45rem 1.1rem; + border: 1px solid var(--rule); + background: var(--panel); +} + +.install-command code { + color: var(--teal-strong); + font-size: 0.92rem; + overflow-wrap: anywhere; +} + +.install-command code::before { + color: var(--muted); + content: "$ "; +} + +.install-hint { + margin-top: 0.75rem; +} + +.install-hint [data-install-status]:not(:empty)::before { + content: " · "; +} + +.hero-meta { + margin: 3rem 0 0; + color: var(--muted); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.04em; +} + +.actions { + margin-top: 2rem; +} + +.button, +button.button { + min-height: 2.8rem; + border: 1px solid var(--rule); + border-radius: 3px; + font: inherit; +} + +.button, +button.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + color: var(--text); + background: var(--panel); + cursor: pointer; + text-decoration: none; +} + +.button:hover, +button.button:hover { + border-color: var(--teal); + color: var(--text); + background: var(--panel-raised); +} + +.button.primary { + border-color: var(--teal); + color: var(--ink); + background: var(--teal); + font-weight: 600; +} + +.button.primary:hover { + color: var(--ink); + background: var(--teal-strong); +} + +.site-section { + padding: clamp(4rem, 9vw, 7rem) 0; + border-bottom: 1px solid var(--rule); +} + +.section-heading { + display: grid; + grid-template-columns: minmax(0, 0.65fr) minmax(0, 1.35fr); + gap: 2rem; + margin-bottom: clamp(2rem, 5vw, 4rem); +} + +.section-heading p:last-child { + max-width: 44rem; + color: var(--muted); +} + +.ledger { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + margin: 0; + padding: 1px; + background: var(--rule); + list-style: none; +} + +.ledger li { + min-height: 10rem; + padding: 1.25rem; + background: var(--panel); +} + +.ledger strong, +.ledger span { + display: block; +} + +.ledger strong { + margin-bottom: 2rem; + color: var(--teal-strong); + font-size: 0.76rem; + text-transform: uppercase; +} + +.ledger span { + color: var(--muted); +} + +.proof-grid, +.choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + padding: 1px; + background: var(--rule); +} + +.boundary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + padding: 1px; + background: var(--rule); +} + +.pipeline { + margin: 0 0 clamp(2rem, 5vw, 3rem); + border: 1px solid var(--rule); + background: var(--panel); +} + +.proof, +.choice, +.boundary { + padding: clamp(1.5rem, 4vw, 2.5rem); + background: var(--panel); +} + +.proof p:last-child, +.choice p:last-child, +.boundary p:last-child { + margin-bottom: 0; + color: var(--muted); +} + +.capture { + margin: 0; +} + +.capture > a { + display: block; + border: 1px solid var(--rule); + background: var(--panel); +} + +.capture > a:hover { + border-color: var(--teal); +} + +.capture img, +.diagram img, +dialog img { + display: block; + width: 100%; + height: auto; +} + +.caption { + margin: 0.8rem 0 0; +} + +.interface-layout { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(18rem, 0.8fr); + gap: clamp(2rem, 6vw, 5rem); + align-items: center; +} + +.interface-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem 2rem; + margin: 2rem 0 0; + padding: 0; + list-style: none; +} + +.interface-list strong, +.interface-list span { + display: block; +} + +.interface-list strong { + font-family: var(--mono); + font-size: 0.86rem; +} + +.interface-list span { + color: var(--muted); +} + +.guide-nav { + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + gap: 1px; + margin: 2.5rem 0 0; + padding: 1px; + background: var(--rule); + list-style: none; +} + +.guide-nav a { + display: block; + height: 100%; + padding: 0.8rem; + color: var(--muted); + background: var(--panel); + font-family: var(--mono); + font-size: 0.75rem; + text-decoration: none; +} + +.guide-nav a:hover { + color: var(--text); + background: var(--panel-raised); +} + +.guide-stops { + margin: 0; + padding: 0; + list-style: none; + counter-reset: guide; +} + +.guide-stop { + display: grid; + grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr); + gap: clamp(2rem, 7vw, 6rem); + padding: clamp(4rem, 9vw, 7rem) 0; + border-bottom: 1px solid var(--rule); + scroll-margin-top: 2rem; + counter-increment: guide; +} + +.guide-copy h2::before { + display: block; + margin-bottom: 1rem; + color: var(--teal); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.08em; + content: "0" counter(guide) " / 09"; +} + +.guide-copy p { + color: var(--muted); +} + +.guide-copy a { + font-weight: 600; +} + +.guide-media { + align-self: center; +} + +.guide-media .capture, +.guide-media .diagram { + margin: 0; +} + +.guide-media .diagram { + border: 1px solid var(--rule); + background: var(--panel); +} + +.site-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 2rem; + padding: 2rem 0 3rem; + color: var(--muted); + font-size: 0.88rem; +} + +.site-footer p { + margin: 0; +} + +dialog { + width: min(96vw, 90rem); + max-height: 94vh; + padding: 0; + border: 1px solid var(--teal); + border-radius: 4px; + color: var(--text); + background: var(--ink); +} + +dialog::backdrop { + background: rgb(2 10 8 / 88%); +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 0.8rem 1rem; + border-bottom: 1px solid var(--rule); +} + +.dialog-header p { + margin: 0; + font-family: var(--mono); + font-size: 0.8rem; +} + +.dialog-close { + min-width: 2.5rem; + min-height: 2.5rem; + border: 1px solid var(--rule); + border-radius: 3px; + color: var(--text); + background: var(--panel); + font: inherit; + cursor: pointer; +} + +dialog img { + max-height: calc(94vh - 4.25rem); + object-fit: contain; +} + +@media (max-width: 900px) { + .section-heading, + .interface-layout, + .guide-stop { + grid-template-columns: 1fr; + } + + .ledger { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .ledger li:last-child { + grid-column: 1 / -1; + } + + .guide-nav { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .boundary-grid { + grid-template-columns: 1fr; + } + + .guide-media { + order: -1; + } +} + +@media (max-width: 620px) { + body > header, + body > main, + body > footer { + width: min(100% - 1.5rem, 76rem); + } + + .site-header { + align-items: flex-start; + flex-direction: column; + gap: 0.7rem; + padding: 1rem 0; + } + + .site-header nav { + width: 100%; + justify-content: space-between; + } + + .repo-meta { + display: none; + } + + h1 { + font-size: clamp(2.5rem, 13vw, 4rem); + } + + .ledger, + .proof-grid, + .choice-grid, + .boundary-grid, + .interface-list, + .guide-nav { + grid-template-columns: 1fr; + } + + .ledger li:last-child { + grid-column: auto; + } + + .site-footer { + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +}