diff --git a/.github/workflows/deploy-shell.yml b/.github/workflows/deploy-shell.yml index 2416ae84..ebc4457e 100644 --- a/.github/workflows/deploy-shell.yml +++ b/.github/workflows/deploy-shell.yml @@ -19,7 +19,6 @@ on: push: branches: [main] tags: ['v*'] - pull_request: # Four chances an hour, off the busy top of the hour. GitHub runs a # schedule best-effort and drops slots under load: at one an hour, three # of five slots in a row never ran while a new release waited. A run whose @@ -32,14 +31,9 @@ permissions: contents: read concurrency: - group: ${{ github.event_name == 'pull_request' && format('deploy-shell-pr-{0}', github.event.pull_request.number) || 'deploy-shell' }} - # A preview of a superseded commit is worth nothing, so a new push replaces - # the run it obsoletes. Queueing them instead made six runs pile up behind - # one another in twenty minutes, each waiting out the one before it. - # - # Never for a push to trunk: that job syncs S3 and invalidates CloudFront, - # and a deploy cancelled halfway through leaves the site inconsistent. - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: deploy-shell + # Let each production sync finish before the next one starts. + cancel-in-progress: false jobs: # The locale list comes from site/src/i18n/locales.ts, the same module the @@ -233,147 +227,6 @@ jobs: skip-unchanged.mjs retention-days: 1 - build-preview: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 - with: - node-version: '26' - cache: pnpm - - name: Install audit tools - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends -y ripgrep - - run: pnpm install --frozen-lockfile - - - name: Resolve integrated Ruby and Lua sources - id: integrated-sources - run: | - echo "ruby=$(jq -r .revision site/src/data/api/ruby.json)" >> "$GITHUB_OUTPUT" - echo "lua=$(jq -r .revision site/src/data/api/lua.json)" >> "$GITHUB_OUTPUT" - - - uses: actions/checkout@v7 - with: - repository: libtmux/libtmux-ruby - ref: ${{ steps.integrated-sources.outputs.ruby }} - path: .port-sources/ruby - persist-credentials: false - - uses: actions/checkout@v7 - with: - repository: libtmux/libtmux-lua - ref: ${{ steps.integrated-sources.outputs.lua }} - path: .port-sources/lua - persist-credentials: false - - - name: Assemble and check preview - env: - PREVIEW_PREFIX: pr-${{ github.event.pull_request.number }} - LIBTMUX_DOCS_LOCALES_ROOT: /pr-${{ github.event.pull_request.number }} - LIBTMUX_DOCS_VERSION: pr-${{ github.event.pull_request.number }} - LIBTMUX_DOCS_VERSION_KIND: pr - LIBTMUX_DOCS_IS_DEFAULT: 'false' - LIBTMUX_DOCS_CHECKOUT_RUBY: ${{ github.workspace }}/.port-sources/ruby - LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }}/.port-sources/lua - run: | - pnpm build:site --versions latest - bash scripts/check-preview.sh _site "$PREVIEW_PREFIX" - - uses: actions/upload-artifact@v7 - with: - name: preview-dist - path: _site/pr-${{ github.event.pull_request.number }}/ - if-no-files-found: error - retention-days: 1 - - # Same-repo PRs only. Fork PRs get no secrets on `pull_request` by - # design (a fork's build step above still runs, with no OIDC and no - # secrets in scope — GITHUB_TOKEN aside — so a malicious build script - # gains nothing); this job's `if` is what keeps it that way, not the - # trigger. - # - # The repository went public on 2026-09-06, so a fork pull request is now - # a real scenario rather than a hypothetical one. The guard is unchanged - # and still correct: a fork PR builds and is checked, and simply gets no - # preview URL. That is a degraded experience, not an exposure. - # - # The documented improvement is a `workflow_run` handoff that publishes a - # fork's already-built artifact from a trusted context. It is deliberately - # NOT taken here: such a workflow runs with secrets against a ref the - # forker controls, and every published mistake in that pattern comes from - # trusting `head_sha` or `head_repository` without re-validating them. It - # is worth building carefully, on its own, with its own review — not - # bolted on the day the repo's visibility changed. Widening this `if` is - # never the answer (notes/research/06, 07). - publish-preview: - needs: build-preview - if: | - github.event_name == 'pull_request' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: read - id-token: write - uses: ./.github/workflows/reusable-deploy.yml - with: - path-prefix: pr-${{ github.event.pull_request.number }} - artifact: preview-dist - version-kind: pr - environment: docs-preview - secrets: - role-arn: ${{ secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN }} - bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} - distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} - - # `check-publish` needs the production build's artifact, which only a push - # produces, so a pull request never exercised the publisher at all. A preview - # assembly has the same shape one locale down, so the same two scripts run - # against it here and a mistake in either surfaces on the pull request rather - # than on the deploy that uploads. No credentials: the recording stand-in - # prints what would have been called. - check-publish-preview: - if: github.event_name == 'pull_request' - needs: build-preview - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - name: preview-dist - path: _site - - name: Exercise the publisher against the preview assembly - env: - LOCALE: en - BUCKET: reproduction - run: | - set -euo pipefail - node scripts/publication-metadata.mjs - cp scripts/publish-root.sh publish-root.sh - cp scripts/publish-default-versions.sh publish-default-versions.sh - mv "_site/$LOCALE" dist - mkdir bin - # Prints each call, and answers the two store reads the - # default-version publisher parses. - cat > bin/aws <<'SH' - #!/usr/bin/env bash - case "$2" in - describe-key-value-store) echo '{"ETag":"dry-run"}' ;; - list-keys) echo '{"Items":[]}' ;; - *) printf 'aws'; printf ' %q' "$@"; printf '\n' ;; - esac - SH - chmod +x bin/aws - PATH="$PWD/bin:$PATH" bash publish-root.sh - - # Against the build's own versions.json: a preview merges no published - # fragments. - - name: Exercise the default-version publisher against the preview assembly - env: - KVS_ARN: reproduction - run: | - set -euo pipefail - PATH="$PWD/bin:$PATH" bash publish-default-versions.sh - check-publish: if: github.event_name != 'pull_request' needs: [locales, build] diff --git a/.github/workflows/port-docs.yml b/.github/workflows/port-docs.yml index 45a9cbe3..c82943c5 100644 --- a/.github/workflows/port-docs.yml +++ b/.github/workflows/port-docs.yml @@ -86,6 +86,15 @@ jobs: matrix: ${{ steps.family.outputs.matrix }} should-publish: ${{ steps.identity.outputs.should_publish }} steps: + - name: Validate reusable workflow identity + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + [[ "$WORKFLOW_REPOSITORY" == libtmux/docs && "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::GitHub Cloud job.workflow_repository/job.workflow_sha are required" >&2 + exit 1 + } - uses: actions/checkout@v7 with: repository: ${{ job.workflow_repository }} @@ -127,11 +136,22 @@ jobs: build: needs: identity - runs-on: ubuntu-latest + # Match the Clang 18/libc++ distribution used by the selected C++ preset. + runs-on: ubuntu-24.04 + timeout-minutes: 30 strategy: fail-fast: false matrix: ${{ fromJSON(needs.identity.outputs.matrix) }} steps: + - name: Validate reusable workflow identity + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + [[ "$WORKFLOW_REPOSITORY" == libtmux/docs && "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::GitHub Cloud job.workflow_repository/job.workflow_sha are required" >&2 + exit 1 + } - uses: actions/checkout@v7 with: ref: ${{ needs.identity.outputs.source-ref }} @@ -155,49 +175,6 @@ jobs: } echo "sha=$sha" >> "$GITHUB_OUTPUT" - - if: inputs.port == 'py' - uses: astral-sh/setup-uv@v10.0.1 - with: - enable-cache: false - python-version: '3.14' - - name: Install Python's native documentation dependencies - if: inputs.port == 'py' - working-directory: port - # Reproduce the selected lock, including resolutions made with - # contributor-specific package cooldowns. - run: uv sync --frozen --all-extras --dev - - # C++'s reference is read from the Doxygen XML its own build produces - # (scripts/gen-api-model.mjs), which must be newer than the headers it - # describes, so it is generated here, after the checkout. The same - # doxygen serves gen-api-model's own run over the workspace and MCP - # headers. - - name: Generate the Doxygen XML - if: matrix.port == 'cxx' - working-directory: port - run: | - set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq doxygen - doxygen --version - OUTPUT_DIRECTORY=. doxygen Doxyfile - - # Swift's reference is read from the symbol graph its compiler emits, - # for the same reason and with the same freshness check. The resolved - # versions are forced so the build neither re-resolves nor rewrites - # Package.resolved. - - if: matrix.port == 'swift' - uses: swift-actions/setup-swift@v2 - with: - swift-version: '6.2' - - name: Emit the Swift symbol graph - if: matrix.port == 'swift' - working-directory: port - run: >- - swift build --force-resolved-versions - -Xswiftc -emit-symbol-graph - -Xswiftc -emit-symbol-graph-dir -Xswiftc "$PWD/symbolgraph" - - uses: actions/checkout@v7 with: repository: ${{ job.workflow_repository }} @@ -242,6 +219,167 @@ jobs: - run: pnpm install --frozen-lockfile working-directory: docs + - name: Snapshot source inputs before native generation + working-directory: docs + env: + LIBTMUX_DOCS_PORT: ${{ matrix.port }} + LIBTMUX_DOCS_VERSION: ${{ matrix.version }} + LIBTMUX_DOCS_SOURCE_SHA: ${{ steps.source.outputs.sha }} + PORT_CHECKOUT: ${{ github.workspace }}/port + LIBTMUX_DOCS_WORKSPACE_PY: ${{ github.workspace }}/python-workspace + LIBTMUX_DOCS_MCP_PY: ${{ github.workspace }}/python-mcp + run: | + set -euo pipefail + export "LIBTMUX_DOCS_CHECKOUT_${LIBTMUX_DOCS_PORT^^}=$PORT_CHECKOUT" + node scripts/publication-provenance.mjs snapshot "$RUNNER_TEMP/build-inputs.json" "$PWD" + + - name: Resolve MCP availability from the port catalog + id: mcp + working-directory: docs + env: + PORT: ${{ matrix.port }} + run: | + node --input-type=module <<'JS' + import { appendFileSync } from 'node:fs' + import { PORT_BY_SLUG, productAvailable } from './site/src/lib/ports.ts' + const port = PORT_BY_SLUG[process.env.PORT] + if (!port) throw new Error(`Unknown port: ${process.env.PORT}`) + appendFileSync(process.env.GITHUB_OUTPUT, `required=${productAvailable(port, 'mcp')}\n`) + JS + + - name: Read selected Rust and Swift toolchains + id: native-version + if: matrix.port == 'rs' || matrix.port == 'swift' + env: + PORT: ${{ matrix.port }} + run: | + python3 - <<'PY' + import os, pathlib, re, tomllib + port = os.environ['PORT'] + if port == 'rs': + version = tomllib.loads(pathlib.Path('port/rust-toolchain.toml').read_text())['toolchain']['channel'] + pattern = r'(?:[0-9]+\.[0-9]+\.[0-9]+|(?:nightly|beta)-[0-9]{4}-[0-9]{2}-[0-9]{2})' + elif port == 'swift': + version = tomllib.loads(pathlib.Path('port/.mise.toml').read_text())['tools']['swift'] + pattern = r'[0-9]+\.[0-9]+\.[0-9]+' + else: + raise ValueError(f'Unsupported native toolchain: {port}') + if not isinstance(version, str) or not re.fullmatch(pattern, version): + raise ValueError(f'{port}: selected source must pin an exact toolchain version') + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'version={version}\n') + PY + + - name: Install native system dependencies + if: steps.mcp.outputs.required == 'true' || matrix.port == 'cxx' + env: + PORT: ${{ matrix.port }} + run: | + set -euo pipefail + packages=(tmux) + if [[ "$PORT" == cxx ]]; then + packages+=(clang-18 libc++-18-dev libc++abi-18-dev ninja-build doxygen) + fi + sudo apt-get update -qq + sudo apt-get install --no-install-recommends -y -qq "${packages[@]}" + if [[ "$PORT" == cxx ]]; then + sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-18 100 + sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-18 100 + fi + tmux -V + + - if: matrix.port == 'go' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: port/mcp/go.mod + cache: false + - if: matrix.port == 'rs' + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: ${{ steps.native-version.outputs.version }} + - if: matrix.port == 'ts' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: port/package.json + no-cache: true + - name: Install TypeScript MCP dependencies + if: matrix.port == 'ts' + working-directory: port + run: bun install --frozen-lockfile + - if: matrix.port == 'java' + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + with: + distribution: temurin + # The repository's Gradle conventions compile and run on JDK 25. + java-version: '25' + - if: matrix.port == 'java' + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 + with: + validate-wrappers: true + cache-disabled: true + - if: matrix.port == 'dotnet' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: port/global.json + cache: false + - if: matrix.port == 'ruby' + uses: ruby/setup-ruby@762794c140bbeda0f1224786aa33b4b46783a6c1 # v1.326.0 + with: + working-directory: port + bundler-cache: false + - name: Install Ruby MCP dependencies + if: matrix.port == 'ruby' + working-directory: port + env: + BUNDLE_FROZEN: 'true' + run: bundle install + + - if: inputs.port == 'py' + uses: astral-sh/setup-uv@v10.0.1 + with: + enable-cache: false + python-version: '3.14' + - name: Install Python's native documentation dependencies + if: inputs.port == 'py' + working-directory: port + # Reproduce the selected lock, including resolutions made with + # contributor-specific package cooldowns. + run: uv sync --frozen --all-extras --dev + - name: Install Python's separate MCP runtime + if: inputs.port == 'py' + working-directory: python-mcp + run: uv sync --frozen --no-dev + + # C++'s reference is read from the Doxygen XML its own build produces + # (scripts/gen-api-model.mjs), which must be newer than the headers it + # describes, so it is generated here, after the checkout. The same + # doxygen serves gen-api-model's own run over the workspace and MCP + # headers. + - name: Generate the Doxygen XML + if: matrix.port == 'cxx' + working-directory: port + run: | + set -euo pipefail + doxygen --version + OUTPUT_DIRECTORY=. doxygen Doxyfile + + # Swift's reference is read from the symbol graph its compiler emits, + # for the same reason and with the same freshness check. The resolved + # versions are forced so the build neither re-resolves nor rewrites + # Package.resolved. + - if: matrix.port == 'swift' + uses: swift-actions/setup-swift@7ca6abe6b3b0e8b5421b88be48feee39cbf52c6a # v2 + with: + swift-version: ${{ steps.native-version.outputs.version }} + - name: Emit the Swift symbol graph + if: matrix.port == 'swift' + working-directory: port + run: >- + swift build --force-resolved-versions + --jobs 2 + -Xswiftc -emit-symbol-graph + -Xswiftc -emit-symbol-graph-dir -Xswiftc "$PWD/symbolgraph" + - name: Build this port's tree working-directory: docs env: @@ -250,6 +388,7 @@ jobs: LIBTMUX_DOCS_VERSION_KIND: ${{ matrix.kind }} LIBTMUX_DOCS_IS_DEFAULT: ${{ matrix.isDefault }} LIBTMUX_DOCS_RESOLVES_TO: ${{ matrix.resolvesTo }} + LIBTMUX_DOCS_INPUT_SNAPSHOT: ${{ runner.temp }}/build-inputs.json LIBTMUX_DOCS_SOURCE_REF: ${{ needs.identity.outputs.source-ref }} LIBTMUX_DOCS_SOURCE_SHA: ${{ steps.source.outputs.sha }} PORT_CHECKOUT: ${{ github.workspace }}/port @@ -276,9 +415,28 @@ jobs: # The version directory itself: its contents become # //. - - uses: actions/upload-artifact@v7 + - id: content + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docs-${{ matrix.port }}-${{ matrix.version }} path: docs/_site/en/${{ matrix.port }}/${{ matrix.version }} if-no-files-found: error retention-days: 1 + include-hidden-files: true + + # Matrix jobs each produce their own descriptor. Run/attempt and archive + # digest are deliberately outside the immutable version directory. + - name: Describe the uploaded artifact + working-directory: docs + env: + ARTIFACT_ID: ${{ steps.content.outputs.artifact-id }} + ARTIFACT_DIGEST: ${{ steps.content.outputs.artifact-digest }} + ARTIFACT_NAME: docs-${{ matrix.port }}-${{ matrix.version }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + run: node scripts/publication-provenance.mjs descriptor "$RUNNER_TEMP/publication.json" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-${{ matrix.port }}-${{ matrix.version }}-publication + path: ${{ runner.temp }}/publication.json + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml index 8883bf8d..3976e3ce 100644 --- a/.github/workflows/pr-preview-cleanup.yml +++ b/.github/workflows/pr-preview-cleanup.yml @@ -1,8 +1,8 @@ name: pr-preview-cleanup # Deletes pr-/ the moment its PR closes, merged or not. Same-repo PRs -# get a preview published by deploy-shell.yml's publish-preview job; fork -# PRs never do (deploy-shell.yml, "Fork PRs get no secrets"), so this job +# get a preview published by test.yml's publish-preview job; fork +# PRs never do (test.yml, same-repository guard), so this job # is a no-op delete of a prefix that was never written for those — fine, # `aws s3 rm --recursive` on an absent prefix is not an error. # @@ -11,7 +11,7 @@ name: pr-preview-cleanup # already validated — so it is safe to run unconditionally, including for # forks. It also always runs the workflow file from the default branch, so # a PR branch can't alter its own cleanup logic. This job gets its own -# GitHub Environment rather than reusing deploy-shell.yml's docs-preview: +# GitHub Environment rather than reusing test.yml's docs-preview: # whether pull_request_target's OIDC `sub` claim matches pull_request's # documented repo:ORG/REPO:pull_request format is unverified # (notes/research/06), so its trust-policy entry is kept separate and diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3977f0eb..a14c879e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,7 +8,7 @@ name: publish # # The plan runs everywhere and needs no credentials, so a dry run (the # default) shows exactly what would be published. A real run dispatches each -# port's own docs.yml, which builds and publishes through the reviewed +# port's catalogued caller, which builds and publishes through the reviewed # reusable workflows under that port's own role; this workflow never touches # the bucket. GITHUB_TOKEN cannot dispatch another repository's workflow, so # a real run needs the LIBTMUX_DOCS_DISPATCH_TOKEN secret (or the optional @@ -90,6 +90,7 @@ jobs: needs: plan if: ${{ !inputs.dry-run && needs.plan.outputs.count != '0' }} runs-on: ubuntu-latest + timeout-minutes: 45 strategy: fail-fast: false matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} @@ -102,15 +103,16 @@ jobs: with: app-id: ${{ vars.LIBTMUX_DOCS_APP_ID }} private-key: ${{ secrets.LIBTMUX_DOCS_APP_KEY }} - owner: libtmux + owner: ${{ matrix.repoOwner }} repositories: ${{ matrix.repoName }} - # Dispatch from the default branch, the one ref besides release tags - # that the port's `docs` environment admits, then wait for the port's - # run so a failed publish fails this job too. + permission-actions: write + # The catalog selects a reviewed caller branch independently of source-ref. + # Wait for its run so a failed publication fails this job too. - name: Publish ${{ matrix.port }} ${{ matrix.version }} env: GH_TOKEN: ${{ steps.app.outputs.token || secrets.LIBTMUX_DOCS_DISPATCH_TOKEN }} REPO: ${{ matrix.repo }} + WORKFLOW: ${{ matrix.workflow }} DISPATCH_REF: ${{ matrix.dispatchRef }} SOURCE_REF: ${{ matrix.sourceRef }} VERSION: ${{ matrix.version }} @@ -121,20 +123,28 @@ jobs: run: | set -euo pipefail [[ -n "$GH_TOKEN" ]] || { - echo "::error::No dispatch credential: set the LIBTMUX_DOCS_DISPATCH_TOKEN secret on this repository, a fine-grained token with Actions: read and write on the port repositories (docs/ci.md)." >&2 + echo "::error::No dispatch credential for $REPO: configure the publisher App or LIBTMUX_DOCS_DISPATCH_TOKEN with Actions: write on that repository (docs/ci.md)." >&2 exit 1 } # Family callers default to every sibling. Select only this leg's # language; other port workflows have no language input. language_args=() if [[ -n "$LANGUAGE" ]]; then language_args=(-f "language=$LANGUAGE"); fi - url=$(gh workflow run docs.yml --repo "$REPO" --ref "$DISPATCH_REF" \ + url=$(gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \ -f source-ref="$SOURCE_REF" -f version="$VERSION" -f version-kind="$KIND" \ -f is-default="$IS_DEFAULT" -f resolves-to="$RESOLVES_TO" -f publish=true "${language_args[@]}") run_id=${url##*/} [[ "$run_id" =~ ^[0-9]+$ ]] || { echo "::error::no run id in: $url" >&2; exit 1; } echo "- [$REPO $VERSION]($url)" >> "$GITHUB_STEP_SUMMARY" - gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30 > /dev/null + if gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30; then + echo "- Completed: $url" >> "$GITHUB_STEP_SUMMARY" + else + status=$? + echo "::error::Port publication failed: $url" >&2 + echo "- Failed: $url" >> "$GITHUB_STEP_SUMMARY" + gh run view "$run_id" --repo "$REPO" --log-failed + exit "$status" + fi # The shell merges every port's manifest into versions.json, so it # redeploys once the ports have published. GITHUB_TOKEN may dispatch a @@ -145,11 +155,27 @@ jobs: # versions.json; the failed legs still fail the run. if: ${{ !cancelled() && needs.dispatch.result != 'skipped' }} runs-on: ubuntu-latest + timeout-minutes: 45 permissions: actions: write steps: - - env: + - name: Refresh the shell and wait for publication + env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} REF: ${{ github.event.repository.default_branch }} - run: gh workflow run deploy-shell.yml --repo "$REPO" --ref "$REF" + run: | + set -euo pipefail + url=$(gh workflow run deploy-shell.yml --repo "$REPO" --ref "$REF") + run_id=${url##*/} + [[ "$run_id" =~ ^[0-9]+$ ]] || { echo "::error::no run id in: $url" >&2; exit 1; } + echo "- [Shell refresh]($url)" >> "$GITHUB_STEP_SUMMARY" + if gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30; then + echo "- Completed: $url" >> "$GITHUB_STEP_SUMMARY" + else + status=$? + echo "::error::Shell refresh failed: $url" >&2 + echo "- Failed: $url" >> "$GITHUB_STEP_SUMMARY" + gh run view "$run_id" --repo "$REPO" --log-failed + exit "$status" + fi diff --git a/.github/workflows/reusable-deploy.yml b/.github/workflows/reusable-deploy.yml index 977d2445..65292db5 100644 --- a/.github/workflows/reusable-deploy.yml +++ b/.github/workflows/reusable-deploy.yml @@ -1,56 +1,18 @@ name: reusable-deploy -# Shared publish step for every port on libtmux.org (all ten, Python from -# its own docs branch) plus this repo's own PR previews. The port roles -# trust only this workflow (job_workflow_ref), so it is the one path from a -# port into the bucket. A caller builds with its own toolchain and uploads -# an artifact; this workflow only downloads it, syncs it to its one -# exclusive prefix, and upserts that port's manifest fragment. It never -# installs a language toolchain, so it can't couple -# one port's toolchain choice to another's (docs/ci.md, "shared file vs -# shared execution"). +# Callers pin this workflow and port-docs.yml to the same full commit SHA. +# Port artifacts carry a deterministic build inventory plus a separate upload +# descriptor. This workflow checks both with code from its own trusted SHA, +# before requesting AWS credentials. GitHub Cloud job.workflow_* is required. # -# Callers pin a full-length commit SHA, with the release name in a trailing -# comment: +# Each call writes its validated locale/port/version prefix and that port's +# manifest fragment. IAM independently limits the role and reviewed workflow +# refs. Immutable versions must match every existing byte; identical reruns +# preserve their original receipt. Mutable versions use a five-minute edge TTL. # -# uses: libtmux/docs/.github/workflows/reusable-deploy.yml@ # v0.1.0-alpha.1 -# -# Not the tag. This workflow is not an inert dependency: `uses:` executes it -# inside the caller's repository with `id-token: write` and a role that can -# PutObject and DeleteObject on the live bucket. A tag can be repointed, so -# pinning one means this repository can change what runs in ten other -# repositories with no diff and no review in any of them — which is the same -# objection that rules out `@main`, only slower. A SHA cannot be repointed. -# GitHub's own hardening guidance says the same, and its Actions policy can now -# enforce SHA pinning on reusable-workflow references. -# -# Expect to bump the SHA by hand. Dependabot and Renovate can read the trailing -# comment and offer the bump, but only where the caller has enabled the -# `github-actions` ecosystem — and as of 2026-09-06 none of the ten port -# repositories has: most carry a dependabot.yml for their own language -# ecosystem (cargo, nuget, npm and so on) and none lists github-actions, and -# the Python repository has no dependabot.yml at all. So the comment is -# documentation here, not automation, and a release of this workflow reaches a -# caller when a human moves it. Enabling that ecosystem in a port is a separate -# change with its own review, not something to fold into a deploy PR. -# -# The tags are a 0.x prerelease series and will keep moving: this workflow has -# not published successfully from any port yet, so a `v1` would claim a settled -# interface that does not exist. The series name is for humans and changelogs; -# the SHA is what callers depend on. It becomes v1 when a port has actually -# published through it. -# -# Pin the same SHA in BOTH places a caller touches this repository: the `uses:` -# below, and the `actions/checkout` of libtmux/docs that a port needs to run -# scripts/build-site.sh. The failure this prevents is specific — a caller pins -# `uses:` and leaves the checkout on the default branch, so the workflow reads -# as pinned in review while the build script it actually runs drifts with this -# repository's main. A SHA is also the only ref for which "the same" still -# means the same thing an hour later. -# -# This file never sets `concurrency`: groups are workflow-level on -# the *caller*, so a shared group name here would silently merge queues -# across repositories that have nothing to do with each other. +# Callers own concurrency and dependency updates. A shared concurrency group +# here would merge unrelated queues. Root previews retain their separate +# artifact contract until shared-root provenance is implemented. on: workflow_call: @@ -67,6 +29,11 @@ on: description: Name of the build artifact (from actions/upload-artifact) to publish. required: true type: string + artifact-id: + description: Immutable artifact ID from this run's successful preview audit. Required for shared previews. + required: false + type: string + default: '' version-kind: description: > A site/src/lib/versions.ts VersionKind: 'trunk' | 'tag' | 'branch' @@ -161,8 +128,12 @@ jobs: run: | set -euo pipefail p="$PREFIX_IN" - if [[ -z "$p" || "$p" == /* || "$p" == */ || "$p" == *..* ]]; then - echo "::error::path-prefix '$p' must be non-empty, unrooted, and contain no '..' segment" >&2 + if [[ -z "${PORT_IN:-}" && "${GITHUB_REPOSITORY:-}" != libtmux/docs && "${GITHUB_REPOSITORY:-}" != tony/libtmux-docs ]]; then + echo "::error::caller '${GITHUB_REPOSITORY:-}' must supply a port and its publication provenance" >&2 + exit 1 + fi + if [[ ! "$p" =~ ^[a-zA-Z0-9][a-zA-Z0-9._/-]*$ || "$p" == */ || "$p" == *..* ]]; then + echo "::error::path-prefix '$p' must be an unrooted path using letters, digits, '.', '_', '-' and '/', without '..'" >&2 exit 1 fi reserved=(py ruby lua ts rs go java kotlin scala dotnet fsharp cxx swift manifest _shell) @@ -177,6 +148,10 @@ jobs: py|ruby|lua|ts|rs|go|java|kotlin|scala|dotnet|fsharp|cxx|swift) ;; *) echo "::error::unknown port '$PORT_IN'" >&2; exit 1 ;; esac + if [[ ! "$VERSION_IN" =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ || "$VERSION_IN" == *..* ]]; then + echo "::error::invalid publication version" >&2 + exit 1 + fi if [[ -z "$VERSION_IN" || "$VERSION_IN" == */* || "$p" != "$PORT_IN/$VERSION_IN" ]]; then echo "::error::port publication prefix must be exactly '$PORT_IN/$VERSION_IN'" >&2 exit 1 @@ -200,6 +175,10 @@ jobs: # whole prefix and nests the site inside it, so it keeps the shape # its own build was given. locale="${LIBTMUX_DOCS_LOCALE:-en}" + if [[ ! "$locale" =~ ^[a-z]{2}(-[A-Z]{2})?$ ]]; then + echo "::error::invalid publication locale" >&2 + exit 1 + fi if [[ -n "${PORT_IN:-}" ]]; then p="$locale/$p" fi @@ -225,29 +204,85 @@ jobs: ;; esac - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Validate reusable workflow identity + if: inputs.port != '' + env: + WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + [[ "$WORKFLOW_REPOSITORY" == libtmux/docs && "$WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::GitHub Cloud job.workflow_repository/job.workflow_sha are required" >&2 + exit 1 + } + - if: inputs.port != '' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - name: ${{ inputs.artifact }} - path: dist - - - name: Normalize native shell asset URLs + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: publisher + sparse-checkout: | + scripts + site/src/lib + persist-credentials: false + - if: inputs.port != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + - if: inputs.port != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact }}-publication + path: publication + digest-mismatch: error + - name: Select the builder's exact artifact if: inputs.port != '' + id: descriptor + env: + ARTIFACT_NAME: ${{ inputs.artifact }} + run: node publisher/scripts/publication-provenance.mjs select publication/publication.json + - if: inputs.port != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ steps.descriptor.outputs.artifact-id }} + path: archive + skip-decompress: true + digest-mismatch: error + - name: Verify artifact and build provenance + if: inputs.port != '' + env: + ARTIFACT_NAME: ${{ inputs.artifact }} + PORT: ${{ inputs.port }} + VERSION: ${{ inputs.version }} + PUBLISHER_REPOSITORY: ${{ job.workflow_repository }} + PUBLISHER_SHA: ${{ job.workflow_sha }} run: | - python3 - <<'PY' - import os - import re - from pathlib import Path + set -euo pipefail + node publisher/scripts/publication-provenance.mjs unpack archive publication/publication.json dist + node publisher/scripts/publication-provenance.mjs verify dist publication/publication.json "$RUNNER_TEMP/publication-receipt.json" - # Matches scripts/normalize-native-shell.mjs without a private checkout. - root = "/" + os.environ["PREFIX"].split("/", 1)[0] - shell_url = re.compile(r"""(['"(])(?:https?://libtmux\.org)?/_shell/""") - for path in Path("dist").rglob("*"): - if path.is_file() and path.suffix in {".html", ".css"}: - original = path.read_text() - normalized = shell_url.sub(lambda match: match[1] + root + "/_shell/", original) - if normalized != original: - path.write_text(normalized) - PY + - name: Validate preview artifact identity + if: inputs.port == '' && inputs.version-kind == 'pr' + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + run: | + [[ "$ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] || { + echo '::error::preview requires one audited artifact ID from this run' >&2 + exit 1 + } + # Without github-token or run-id, the action resolves IDs in this run. + # A deleted/replaced artifact fails; it cannot fall back to a newer name. + - if: inputs.port == '' && inputs.version-kind == 'pr' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: dist + digest-mismatch: error + - if: inputs.port == '' && inputs.version-kind != 'pr' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.artifact }} + path: dist + digest-mismatch: error - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: @@ -325,11 +360,19 @@ jobs: # construction"). for attempt in 1 2 3; do put_args=(--content-type application/json) - if etag=$(aws s3api head-object --bucket "$BUCKET" --key "$key" --query ETag --output text 2>/dev/null); then - aws s3api get-object --bucket "$BUCKET" --key "$key" "$RUNNER_TEMP/manifest-current.json" >/dev/null + # Read the document and its ETag together for the conditional write. + if etag=$(aws s3api get-object \ + --bucket "$BUCKET" --key "$key" --query ETag --output text \ + "$RUNNER_TEMP/manifest-current.json" 2>"$RUNNER_TEMP/manifest-read-error.txt"); then current=$(cat "$RUNNER_TEMP/manifest-current.json") put_args+=(--if-match "$etag") else + read_status=$? + if ! grep -Eq '^An error occurred \((404|NoSuchKey)\) when calling the GetObject operation:' "$RUNNER_TEMP/manifest-read-error.txt"; then + echo "::error::could not read $key" >&2 + cat "$RUNNER_TEMP/manifest-read-error.txt" >&2 + exit "$read_status" + fi current='{"schema":1,"ports":{},"defaultVersion":{}}' put_args+=(--if-none-match "*") fi @@ -342,15 +385,23 @@ jobs: --arg kind "$KIND" \ --argjson isDefault "$IS_DEFAULT" \ --arg resolvesTo "$RESOLVES_TO" \ + --slurpfile receipt "$RUNNER_TEMP/publication-receipt.json" \ + --arg skipSync "${SKIP_SYNC:-false}" \ ' # Both bindings are parenthesised because `as` binds looser than # `+`: `A + B as $x | C` is `A + (B as $x | C)`, which added the # entry array to the rest of the pipeline and failed with # "array and object cannot be added". - ({slug: $slug, label: $label, kind: $kind, supported: true} + ($doc.ports[$port] // []) as $existing + | ($existing | map(select(.slug == $slug)) | first) as $previous + | (if $kind == "tag" and $skipSync == "true" and $previous.publication != null + then if $previous.publication.build == $receipt[0].build and $previous.publication.destination == $receipt[0].destination + then $previous.publication + else error("immutable publication receipt disagrees with verified bytes") end + else $receipt[0] + {operation: (if $skipSync == "true" then "verified-existing" else "published" end)} end) as $publication + | ({slug: $slug, label: $label, kind: $kind, supported: true, publication: $publication} + (if $kind == "alias" and $resolvesTo != "" then {resolvesTo: $resolvesTo} else {} end) ) as $entry - | ($doc.ports[$port] // []) as $existing | (($existing | map(select(.slug != $slug))) + [$entry]) as $entries | $doc | .ports[$port] = $entries @@ -362,6 +413,11 @@ jobs: --key "$key" \ --body "$RUNNER_TEMP/manifest-fragment.json" \ "${put_args[@]}" >/dev/null 2>"$RUNNER_TEMP/put-error.txt"; then + if [[ "${SKIP_SYNC:-false}" == true ]]; then + echo "Verified identical immutable bytes for $PREFIX; any original receipt was preserved." >> "$GITHUB_STEP_SUMMARY" + else + echo "Published $PREFIX; provenance is recorded in $key." >> "$GITHUB_STEP_SUMMARY" + fi exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6e5a0b26..f7026500 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,22 +1,8 @@ name: test -# Everything that can say the docs are wrong, on every push and pull request. -# -# `deploy-shell.yml` builds and publishes; it does not run the suite, so until -# now nothing stopped a change that type-checks and builds cleanly from -# shipping a reference full of dangling anchors. -# -# This runs `scripts/test-all.sh`, the same command used locally, rather than -# restating its steps. A CI file that lists them separately drifts from the -# script, and then "it passes locally" and "it passes in CI" stop meaning the -# same thing. -# -# The port references are deliberately not built. Seven of the eight need -# their own toolchain — Sphinx, docfx, Doxygen, a Swift compiler — each port -# already runs its own, and installing all seven to check a Markdown change -# would trade ten minutes for something no port owner asked for. The Ruby and -# Lua source guides are shell input, not native references, so this job reads -# their model-pinned sources and stages them before the shell audit. +# The publication audit builds the PR preview once. Its immutable artifact +# passes the publisher dry-run before a same-repository PR may publish it. +# Pushes keep the production assembly audit; deploy-shell owns production. on: push: @@ -27,14 +13,18 @@ permissions: contents: read concurrency: - group: test-${{ github.ref }} + group: ${{ github.event_name == 'pull_request' && format('deploy-shell-pr-{0}', github.event.pull_request.number) || format('test-{0}', github.ref) }} cancel-in-progress: true jobs: test: runs-on: ubuntu-latest + outputs: + preview-artifact-id: ${{ steps.preview-artifact.outputs.artifact-id }} steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 with: @@ -79,10 +69,94 @@ jobs: # cheapest-first inside the script so a failure arrives as early as it # can. - name: test-all - run: pnpm test:publication + run: | + if [[ "$GITHUB_EVENT_NAME" == pull_request ]]; then + pnpm test:publication --preview "$PREVIEW_PREFIX" + else + pnpm test:publication + fi env: + PREVIEW_PREFIX: pr-${{ github.event.pull_request.number }} LIBTMUX_DOCS_CHECKOUT_RUBY: ${{ github.workspace }}/.port-sources/ruby LIBTMUX_DOCS_CHECKOUT_LUA: ${{ github.workspace }}/.port-sources/lua # Raw source proves the staged guides match the integrated models. # Native exporter artifacts stay checked by each port's docs-site job. LIBTMUX_DOCS_SKIP_NATIVE_MODEL_PORTS: ruby,lua + + - name: Upload audited preview + if: github.event_name == 'pull_request' + id: preview-artifact + uses: actions/upload-artifact@v7 + with: + name: preview-dist + path: _site/pr-${{ github.event.pull_request.number }}/ + if-no-files-found: error + retention-days: 1 + + # No credentials or OIDC in the audit or dry-run, including fork PRs. + check-publish-preview: + if: github.event_name == 'pull_request' + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.test.outputs.preview-artifact-id }} + digest-mismatch: error + path: _site + - name: Exercise the publisher against the preview assembly + env: + LOCALE: en + BUCKET: reproduction + run: | + set -euo pipefail + node scripts/publication-metadata.mjs + cp scripts/publish-root.sh publish-root.sh + cp scripts/publish-default-versions.sh publish-default-versions.sh + mv "_site/$LOCALE" dist + mkdir bin + # Prints each call, and answers the two store reads the + # default-version publisher parses. + cat > bin/aws <<'SH' + #!/usr/bin/env bash + case "$2" in + describe-key-value-store) echo '{"ETag":"dry-run"}' ;; + list-keys) echo '{"Items":[]}' ;; + *) printf 'aws'; printf ' %q' "$@"; printf '\n' ;; + esac + SH + chmod +x bin/aws + PATH="$PWD/bin:$PATH" bash publish-root.sh + + # Against the build's own versions.json: a preview merges no published + # fragments. + - name: Exercise the default-version publisher against the preview assembly + env: + KVS_ARN: reproduction + run: | + set -euo pipefail + PATH="$PWD/bin:$PATH" bash publish-default-versions.sh + + # Forks are audited but never enter an AWS environment or receive secrets. + publish-preview: + needs: [test, check-publish-preview] + if: | + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + permissions: + contents: read + id-token: write + uses: ./.github/workflows/reusable-deploy.yml + with: + path-prefix: pr-${{ github.event.pull_request.number }} + artifact: preview-dist + artifact-id: ${{ needs.test.outputs.preview-artifact-id }} + version-kind: pr + environment: docs-preview + secrets: + role-arn: ${{ secrets.LIBTMUX_DOCS_PREVIEW_ROLE_ARN }} + bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} + distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2dc1b8fd..f13a8b54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,8 +73,8 @@ asset normalization: $ pnpm test:inner ``` -Run workspace unit suites, lint, and generated mention/navigation freshness -checks in the medium loop: +Run workspace unit suites, lint, API links, and generated mention/navigation +freshness checks in the medium loop: ```console $ pnpm test:medium @@ -96,6 +96,15 @@ medium, and outer respectively; measure the complete pnpm command when changing a loop. `test:fast` aliases medium. The runner stops an over-budget loop and fails. +The medium and outer loops first stage the integrated Ruby and Lua guides +from local Git objects. Each revision comes from `site/src/data/api/.json`; +the checkout's current branch and uncommitted files do not affect these guides. +Keep those repositories at their standard `checkout` paths in `ports.ts`, or +set `LIBTMUX_DOCS_CHECKOUT_RUBY` and `LIBTMUX_DOCS_CHECKOUT_LUA` to local clones +containing the required commits. Missing repositories or commits fail with +the required revision and environment variable. The gate does not fetch; +a fresh docs clone needs these source checkouts before `pnpm test`. + Browser checks use Playwright's installed Chromium by default. To use local Chrome: diff --git a/WRITING.md b/WRITING.md index e996e0e6..576a1ebb 100644 --- a/WRITING.md +++ b/WRITING.md @@ -79,9 +79,12 @@ than translating another port's spelling by analogy. Prefer a tested source example to a manually copied snippet. This site's [`remark-port-code.mjs`](site/src/plugins/remark-port-code.mjs) reads fences -with `file="..."` from its configured port checkout or docs worktree; +with `file="..."` from the revision-bound example cache; `region="..."` selects text between the source's region markers. Read that -plugin's mapping before editing an example source. Port mutations follow +plugin's mapping before editing an example source. Regenerate the cache with +`node scripts/gen-example-sources.mjs`: it reads committed files at the source +revision recorded by the API model or wrapper guide artifact. Working-tree +edits and a newer HEAD do not change the documented example. Port mutations follow [Repository boundaries](AGENTS.md#repository-boundaries). Preserve source metadata, region markers, doctest prompts, and expected @@ -92,21 +95,76 @@ paths and a coverage floor, but it does not execute every language example. It scans `.md` pages, not MDX, and cannot check source existence when the relevant checkout is absent. -For inline examples, record the verification performed in the change's -review notes. Do not claim a code fence is executed merely because it has a -language tag. Preserve collected examples when changing their formatting. +Every executable example must work when copied with its displayed setup. +Include imports, an entry point, required inputs, and cleanup. Show dependency +and run commands. Do not rely on variables or helper code from another example. +A source file that only declares functions is not a runnable program. + +Run the exact displayed program against the documented library revision. +Record its commands, source revision, result, and content hash in the review. +Tests that add a hidden prelude or execute a larger source file do not verify +the copied example. Preserve collected examples when changing formatting. + +Put explanatory comments on separate lines above the code they describe. +Wrap example comments at 80 columns, including indentation. Put long source +links and attribution in prose outside the code block. ### Examples across ports +Port pages teach only their selected language. Root pages explain tmux behavior +and may compare languages or show equivalent examples in tabs. + +Keep a language's prose, headings, caveats and examples in an ownership region: + +```markdown + +Pass a context to each operation and check the returned error. + +``` + +Comma-separated port slugs select several languages. Regions can nest; +`port:root` marks framing that appears only on the shared page. Root builds +keep every region. The same selection applies to HTML, headings, search, +Markdown copies and LLM exports. A page's `supportedPorts` array restricts +shared prose to ports with verified coverage; native source guides supply +the other ports' documentation and task equivalents. + Keep equivalent examples together under one task heading, using the actual -language fence tags. The site groups alternative ports into tabs; a build -for one port filters out the others. Language-specific lead-ins should stay -with their example. Separate sequential examples and distinct tasks with -their own explanation rather than forcing them into an alternatives group. +language fence tags. Separate sequential examples and distinct tasks with +their own explanation. Check the selected page for empty sections and links +that accidentally leave its port or version. `console`, JSON, and other shared fences survive port filtering. Check that shared setup still makes sense in every port's rendered page. +Run the Go examples against isolated servers when changing their calls: + +```console +$ python3 scripts/check-go-prose.py --checkout /path/to/libtmux-go +``` + +This checks eight examples covering transport, input, capture, options, hooks, +waiting, and cleanup at the integrated Go revision. It requires Go and tmux +on `PATH`; it is separate from the ordinary docs test loop. + +For workspace command examples, build the native CLI from the source revision +linked by the page, then run: + +```console +$ python3 scripts/check-workspace-prose.py \ + --port go \ + --binary /path/to/tmux-workspace +``` + +The runner executes the command, automation, export and troubleshooting examples, +then loads the configuration and gallery documents. It checks pane counts, +options, focus, directories, launch environment, overwrite refusal, editor errors, +invalid patterns and unsupported fields on a private tmux socket. +Node, tmux and the selected CLI's runtime must be on `PATH`. Set +`TMUX_WORKSPACE_PYTHON` to a compatible interpreter to include optional shell +inspection; its absence is reported as a skip. Use `--report` to save command +results and content hashes, and record the native build revision with that report. + ## Content collections and MDX [`site/src/content.config.ts`](site/src/content.config.ts) owns collection diff --git a/docs/ci.md b/docs/ci.md index a5e8d0c9..5d532def 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,34 +1,22 @@ # CI and deployment -Eleven repositories exist in this scheme — this shell repo and the ten port -repos named in `site/src/lib/ports.ts` — and all eleven write into the -site's bucket. Each port owns and deploys its own prefix exclusively (see -the table below); Rust, Go and Java also link out to an ecosystem host for -their API reference. No repository shares a -build environment, a build job, or a piece of mutable state with any other — -the only shared executable contract is this repo's -`.github/workflows/reusable-deploy.yml`, pinned by full commit SHA like any other -dependency. - -## Why per-repo prefix ownership - -The distinction that decides every choice below is **shared file vs. shared -execution**. A reusable workflow (`workflow_call`) is a shared file: each -caller pins a tag and bumps it on review, so a bad change breaks nothing -until a repo opts in. A shared CI image, matrix job, or cross-repo credential -is shared execution: one failure or one leaked credential reaches repos that -had nothing to do with it. - -This repo publishes `reusable-deploy.yml`. It never installs a language -toolchain, never checks out a caller's source, and never sees a caller's -secrets except the three passed explicitly (`role-arn`, `bucket`, -`distribution`) — `secrets: inherit` is never used anywhere in this scheme. A -caller builds with its own toolchain, uploads the result as an artifact, and -hands this workflow a prefix to write it to. That is the entire contract. +Port repositories build their version trees and publish to their own prefixes. +The shared shell publishes root pages, navigation and manifests. Java owns its +Kotlin and Scala prefixes; .NET owns F#. `site/src/lib/ports.ts` defines the +repositories, callers and ownership flags. + +Each caller pins the builder and publisher to the same full commit SHA. The +builder resolves the requested source ref and assembles the version tree. The +publisher checks the artifact and its provenance before requesting AWS +credentials. A port's IAM role independently limits its writable prefixes and +accepted publisher revisions. + +Callers pass the role and bucket secrets explicitly. The optional distribution +secret remains accepted for older callers; port publication does not use it. ## Which repos call `reusable-deploy.yml` -Every port publishes its own version tree into the bucket: +The current ownership split is: | Port | Built by | Tree ownership (`ports.ts`) | |---|---|---| @@ -43,9 +31,10 @@ Every port publishes its own version tree into the bucket: | Lua (`lua`) | its own exporter | `publishesOwnTree` | | Python (`py`) | its own Sphinx build | `publishesOwnApi`; the shell publishes the rest | -`ports.ts` is the single source of truth for this split (see -**Contradictions** below — some research notes in `../notes/research/` say -otherwise and are wrong). +Python's reviewed caller branch can build and publish a complete tree, but its +ownership flag still lets the shell publish the non-API pages. Migrating that +flag and the corresponding shell IAM policy remains necessary before Python +has exclusive ownership of its whole tree. ## Version, prefix and cache policy @@ -125,6 +114,10 @@ $ aws s3api put-object \ --if-match "$ETAG" ``` +The document and ETag come from the same GET. Only a 404 or `NoSuchKey` +response starts an empty manifest; permission, throttling, and network errors +fail the step with their original diagnostics and exit status. + `aws s3api put-object` is required here, not `aws s3 cp`/`sync` — neither exposes `--if-match`/`--if-none-match`. A 412 means another run of the same port's own workflow raced this one; the step re-reads and retries up to @@ -137,12 +130,94 @@ locale's runtime `versions.json`. Both switchers read that file. A port upload therefore makes a version eligible for the next shell/search publication; it does not claim that shared Pagefind or the sitemap changed in the port job. +## Port build provenance + +The shared `port-docs.yml` builder writes `build-provenance.json` inside each +version tree. Every HTML page links to it with `rel="describedby"`. The record +contains the actual source checkout HEAD, the docs checkout HEAD, each +checkout's dirty state, and a sorted SHA-256 inventory of every regular file. +Python also records the separate workspace and MCP source checkouts. Only the +record itself is excluded from the inventory; symlinks are rejected. + +Ruby and Lua also record `nativeGenerator`: the native exporter's repository, +commit, and dirty state. Their custom callers keep the source, exporter, and +shared docs in separate checkouts and set `LIBTMUX_DOCS_GENERATOR_CHECKOUT`. +Local native builds default to the exporter in the selected source checkout. +The publisher requires a clean exporter from the port's owning repository. + +Fork pull requests can set `LIBTMUX_DOCS_SOURCE_REPOSITORY` to the selected +fork's origin for build-only previews. The record retains that origin; the +publisher still rejects source inputs from outside the catalogued owner. + +The shared builder snapshots inputs before native generators run and rechecks +their Git HEADs when assembly starts. Source-bound local builds capture dirty +state **before** generators update API models. They can be previewed, but the publisher rejects dirty inputs. Full +local assemblies without a selected source checkout make no provenance claim. +Run IDs and timestamps are excluded from the version tree so an identical +immutable rerun can produce identical bytes. + +Selected-source builds also capture MCP contracts from the selected product's +runtime. The shared job installs its language tools before assembly: Rust and +Swift use the checkout's exact toolchain version, Go its MCP module, Bun its +package manifest, and .NET its `global.json`. Java uses the repository's JDK 25 +baseline and validates the Gradle wrapper. Python installs the separate MCP +checkout's frozen lock into that checkout's virtual environment. Ruby installs +its selected locked bundle. These installations follow the input snapshot; +missing tools or contracts fail the build. Wrapper languages and Lua have no +MCP runtime and are excluded through the product catalog. + +The job runs on Ubuntu 24.04 with a 30-minute limit and no restored dependency +caches. C++ uses that distribution's Clang 18/libc++ packages and the source's +compiler checks. Locked dependencies and selected toolchains constrain the +build, but the hosted image and system package updates are not byte-pinned. +Runtime capture requests advertised contracts only; the builder has no AWS +credentials or OIDC permission. Each port still needs a cold hosted build to +establish its runtime cost and compatibility. + +After uploading the content artifact, the builder uploads a separate +`-publication` descriptor containing its artifact ID, archive digest, +source SHA, repository, run ID, and attempt. The publisher uses the existing +same-run artifact token; callers do not need `actions: read`. It downloads the +exact ID, fails on GitHub digest mismatches, checks the downloaded ZIP against +the descriptor digest, and rejects unsafe archive paths before extraction. +The current run may reuse a completed earlier build attempt. + +Before requesting AWS credentials, the publisher verifies repository ownership, +port/version/locale, clean inputs, source SHA, every content byte, and equality +between the builder's docs SHA and the publisher's own workflow SHA. Native +shell URL normalization runs before hashing and again during verification; +any later byte change fails the inventory check. + +This contract requires **GitHub Cloud**. Its documented +[`job.workflow_repository` and `job.workflow_sha` contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-job-context-workflow-identity) +identify the called reusable workflow. Both workflows reject absent contexts or +a non-full SHA before checkout; GitHub Enterprise Server is not supported by +this contract. `github.workflow_sha` identifies the caller and is unsuitable. + +After a successful sync, `manifest/.json` records the build-record digest +and URL, artifact ID/name/digest, builder run/attempt, publisher SHA, and site +destination. The next shell publish preserves these receipts in `versions.json`. +The artifact may expire; its ID and digest remain in the receipt, and the +version's file inventory remains on the site. + +For an identical immutable rerun, the publisher preserves the original receipt +and reports verification in the job summary. If those same bytes have no prior +receipt, `operation: verified-existing` describes the **current verification**; +it does not identify the original publisher. An older immutable tree without +`build-provenance.json` differs from the new artifact and fails visibly. The +publisher never adds metadata to an existing immutable tree. + +This is a build and publication trace, not an attestation of which workflow +produced a caller-supplied artifact. IAM's reviewed-workflow boundary is a +separate control. Ruby/Lua custom builders and shared-root artifacts still need +their own caller migration and run evidence before claiming this contract. + ## Opting in a port repo ### With `port-docs.yml` A port whose reference this repository can build from source calls two -reusable workflows, pinned to one commit. `port-docs.yml` decides which +reusable workflows, pinned to one commit approved by its IAM trust policy. `port-docs.yml` decides which versions the event builds and builds each from the port's source, checking out this repository at its own commit (`job.workflow_sha`), so the caller never pins it twice. `reusable-deploy.yml` publishes each version: @@ -203,17 +278,22 @@ The events it answers (`scripts/port-docs-identity.sh`): -f publish=true ``` - Dispatch from the default branch: the `docs` environment admits it and the - port's release tags, and nothing else. + Use the caller branch configured in `ports.ts`. Its `docs` environment must + admit that branch; the selected source ref is independent of the caller ref. Set `publishesOwnTree` for the port in `site/src/lib/ports.ts` once it publishes this way, or every shell deploy overwrites its `latest` tree. ### Publishing several ports at once -`publish.yml` in this repository dispatches each selected port's `docs.yml` -and waits for it. It always plans first, and by default stops there, so a -dry run shows exactly what a real run would publish: +`publish.yml` dispatches each selected port through the reviewed caller in +`ports.ts` and waits for it. The catalog selects the workflow file and caller +branch independently of the source ref. Python uses `docs.yml` on +`docs-site-deploy`; `latest` still builds the core repository's `master`. +Other ports use their default branch unless the catalog names a caller branch. +A caller branch must be allowed by the port's docs environment. + +The dispatcher plans first and stops there by default: | `ports` | `ref` | Publishes | |---|---|---| @@ -234,126 +314,65 @@ Kotlin and Scala dispatch through the Java repository; F# dispatches through only Kotlin and `ports=all` publishes each family member once. Siblings share one repository lookup for their default branch and release tags. -A real run needs a credential that can dispatch a workflow in another -repository, which `GITHUB_TOKEN` cannot: the `LIBTMUX_DOCS_DISPATCH_TOKEN` -secret on this repository, a fine-grained token with Actions: read and -write on the port repositories. A `libtmux-docs-publisher` GitHub App works -instead, with its ID in the `LIBTMUX_DOCS_APP_ID` variable and its key in -the `LIBTMUX_DOCS_APP_KEY` secret. After the ports publish, it redeploys the -shell so `versions.json` lists what they published. py is not dispatchable: -its workflow lives in tmux-python/libtmux and takes no inputs. +For all ports in one run, install the `libtmux-docs-publisher` GitHub App on +the selected repositories in both `libtmux` and `tmux-python`. Set +`LIBTMUX_DOCS_APP_ID` and `LIBTMUX_DOCS_APP_KEY` on this repository. Each leg +requests an Actions-write installation token for its owner and one repository. +The dispatcher has no AWS identity. A `LIBTMUX_DOCS_DISPATCH_TOKEN` fallback +works for the repositories that token can access; a fine-grained token is +limited to one resource owner. -### With its own toolchain - -A self-hosted port's own `docs.yml` builds with its own toolchain, uploads an -artifact, then calls this repo's reusable workflow: - -```yaml -name: docs +The workflow waits for each port's run and the final shell refresh, so success +includes the `versions.json` update. Its summary shows the caller, source and +child run. A failed dispatch, port publication or shell refresh fails the +initiating run and prints the failed steps. Each waiting job has a 45-minute +limit. -on: - push: - branches: [master] - tags: ['v*'] - -permissions: - contents: read - id-token: write - -concurrency: - group: docs-deploy-${{ github.repository }} - queue: max - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - run: cd docs && just html - - uses: actions/upload-artifact@v7 - with: - name: docs-html - path: docs/_build/html - retention-days: 1 - -### What the artifact must contain - -The assembled `//` tree, not the port's own doc-tool -output. That tree is the shared shell rendered with the port's code fences, -with the port's reference nested at `api/` inside it — `build-site.sh --ports -` produces it, and a caller uploads `_site/en//latest` verbatim. - -Uploading the port's own build instead replaces the whole tree with it. That -failure publishes cleanly: the run is green and the URL returns 200, serving -the wrong site. It happened to Python's first publish, where `/en/py/latest/` -served Furo and `/en/py/latest/concepts/` 403'd. - -`--skip-refs` is a per-port judgement, not a default. For nine ports `api/` -is a redirect to `/reference//`, so skipping the reference generators -costs nothing. Python's `api/` is the real gp-sphinx render that -`site/scripts/check-style-parity.mjs` measures against, so its build must not -skip them — and its runner needs `uv`. - - publish: - needs: build - permissions: - contents: read - id-token: write - # Full-length SHA, release name in the comment: this runs with id-token: - # write and a bucket-writing role, and a tag can be repointed. - uses: libtmux/docs/.github/workflows/reusable-deploy.yml@ce9d7edd63f6a543801d9b93366ecad5e158c0ec # v0.1.0-alpha.2 - with: - path-prefix: py/v0.46.2 - artifact: docs-html - version-kind: tag - port: py - version: v0.46.2 - is-default: false - environment: docs - secrets: - role-arn: ${{ secrets.LIBTMUX_DOCS_ROLE_ARN }} - bucket: ${{ secrets.LIBTMUX_DOCS_BUCKET }} - distribution: ${{ secrets.LIBTMUX_DOCS_DISTRIBUTION }} -``` +### With its own toolchain -Four things every caller needs, none of which lives in this repo: - -- `concurrency` at **workflow level in the caller**, with `queue: max`. - Groups don't cross repository boundaries, so `docs-deploy-` needs no - further suffix. `queue: max` cannot combine with `cancel-in-progress` — - don't add one. -- An IAM role trusted for OIDC, scoped to that port's own prefix only - (`s3:PutObject`/`s3:DeleteObject` on `libtmux-docs/py/*`, - `cloudfront:CreateInvalidation` on the one distribution — that action - can't be scoped by path, so the S3 statement is the real containment - boundary). Defining these policies is `infra/`'s concern, not this - workflow's. -- A `docs` GitHub Environment with a deployment-branch-and-tag policy - restricting who can trigger it (`master`, `v*`) — this is what actually - enforces "trunk, tags, release branches only," since setting - `environment:` rewrites the OIDC `sub` claim to drop any `ref:` clause - entirely. Pass its name through the `environment` input, not as - `environment:` on the calling job — `workflow_call` does not support that - keyword on the caller, and `reusable-deploy.yml`'s own `publish` job is - the one whose OIDC token actually needs `environment:docs` in its `sub` - claim. -- The three secrets passed explicitly, never `secrets: inherit`, and **as - repository- or organization-level secrets, not Environment-scoped ones.** - `${{ secrets.LIBTMUX_DOCS_ROLE_ARN }}` in the `publish` job above is - evaluated in *that job's* context, and that job cannot declare - `environment:` (`workflow_call` does not support it) — so if a secret only - exists on the `docs` Environment, this expression resolves to nothing and - `role-arn` reaches `reusable-deploy.yml` empty. `~/work/python/libtmux`'s - existing `docs.yml` reads `LIBTMUX_DOCS_ROLE_ARN` from a job that itself - declares `environment: docs` — moving to this scheme means moving that - secret (and `_BUCKET`, `_DISTRIBUTION`) up to the repository or - organization, if it lives on that Environment today. The Environment - still does real work — its deployment branch/tag policy, and the OIDC - `sub` claim — just applied to `reusable-deploy.yml`'s own `publish` job - through the `environment` input, not to secret storage. - -Fork PRs on a port repo are that repo's own concern; this repo's shell -handling (below) is the only fork-PR path owned here. +A custom builder must produce the same assembled tree and publication +metadata as `port-docs.yml`. Its native exporter output alone is insufficient. +Ruby and Lua still use their existing custom-builder contract; migrate their +callers before selecting this publisher revision. + +The required sequence is: + +1. Check out the approved docs SHA and the selected port source. Snapshot + their identities before generators write files. +2. Assemble `//` with the selected source revision, + including `build-provenance.json` and its complete file inventory. +3. Upload the version directory with hidden files included and empty uploads + rejected. Create a separate publication descriptor from the upload's + artifact ID and digest, source SHA, run ID and attempt. +4. Upload that descriptor as `-publication`, then call + `reusable-deploy.yml` at the same approved docs SHA. + +The shared builder is the executable example for this sequence. Custom +builders must verify their output through the publisher before migration. +Python's native Sphinx reference must be included; other ports whose `api/` +route redirects to the shared reference can use `--skip-refs`. + +### Caller configuration + +Each caller needs: + +- A concurrency group covering its repository's publications, with + `queue: max`. Keep family publications serialized where they share a + manifest or role. Do not combine queued publication with cancellation. +- An OIDC role whose trust policy admits the repository, environment and + explicit reviewed publisher SHAs. Object writes and bucket listing must + stay inside its locale/port or family prefixes and manifest keys. Port + roles do not need CloudFront invalidation permission. +- A `docs` environment whose deployment policy admits the intended caller + branches and tags. Pass its name through the reusable workflow's + `environment` input: the called publishing job obtains the OIDC token. +- Repository- or organization-level role and bucket secrets. A calling + `uses:` job cannot declare `environment:`, so Environment-scoped secrets + are unavailable when that job passes them to the reusable workflow. + +Add a new publisher SHA to the IAM allowlist before updating caller pins. +Retain the previous SHA until its callers have migrated and their runs pass. +A branch or tag containing the publisher is insufficient for role assumption. ## This repo's own deploy (`deploy-shell.yml`) @@ -372,8 +391,6 @@ Triggers and jobs: | push to `main` | `registry` → `build` → `publish-root` | bucket root, `docs` environment | | push tag `v*` | `registry` → `build` → `publish-root` | bucket root (same as trunk) | | `schedule` (four times an hour), `workflow_dispatch` | `registry`, then `build` → `publish-root` when the registry moved (dispatch always rebuilds) | bucket root | -| `pull_request`, same-repo head | `build` → `publish-preview` | `pr-/`, `docs-preview` environment | -| `pull_request`, fork head | `build` only | no publish — see below | `registry` resolves `site/src/data/registry.json` against the live package registries, falling back to the `/registry.json` the last deploy published, @@ -393,39 +410,42 @@ the handful of root-level files — the workflow-level twin of the IAM `NotResource` policy that should back it, so a bug here fails the run rather than depending on IAM alone. -`publish-preview` fits `reusable-deploy.yml` cleanly: `pr-/` is an -exclusive prefix like any port's, so it calls the same reusable workflow with -`version-kind: pr` and no `port` (no manifest entry for a preview). - -### Fork PRs: build-only, no `workflow_run` handoff — for now - -Fork PRs get no secrets on `pull_request` by design; `pull_request_target` -with a checkout of PR content is the documented foot-gun this avoids -entirely — this repo never checks out PR code under `pull_request_target`. -The two options considered: - -1. **Build-only on forks** (chosen): the `build` job runs unconditionally — - with no OIDC and no secrets in scope regardless of who owns the PR head — - and `publish-preview`'s `if` gates on - `github.event.pull_request.head.repo.full_name == github.repository`. A - fork PR gets a green build check and no preview URL. -2. **`workflow_run` handoff**: a `pull_request` build with no secrets - uploads an artifact; a separate workflow, triggered by `workflow_run` and - so running from the default branch in this repo's own trust context, - downloads it by run ID and publishes to a preview-only role and prefix. - -This repo went public on 2026-09-06, so a fork PR is now a real scenario and -option 1 is what ships: a fork's PR builds and is checked, and gets no -preview URL. That is a degraded experience, not an exposure — no secret and -no OIDC token is in scope for a fork's `pull_request` run. - -Option 2 remains the documented improvement and has not been taken. A -`workflow_run` handoff runs with secrets against a ref the forker controls, -and every published failure of that pattern comes from trusting `head_sha` -or `head_repository` without re-validating them in the trusted context. It -deserves its own change and its own review rather than being added the day -the repository's visibility changed. Widening the `if` on option 1 is never -the alternative. +## PR audit and preview (`test.yml`) + +Every PR runs source checks before generators can refresh committed data, +then assembles one preview under `pr-/`. The publication output tests, +link audit and preview isolation check read that exact tree. Fresh production +root renders for each locale retain sitemap, robots and product indexing +checks without rebuilding every port. Pushes to `main` retain the full +production assembly audit. + +Run the same PR audit locally: + +```console +$ pnpm test:publication --preview pr-42 +``` + +The successful `test` job uploads `preview-dist` and passes its immutable +artifact ID to `check-publish-preview` and `publish-preview`. Both downloads +are confined to the same workflow run and fail on a digest mismatch. A missing +or replaced artifact cannot fall back to another artifact with the same name. +Publication depends on the audit and both publisher dry-runs succeeding. +The privileged job treats the artifact as files to publish; it executes no +script from that artifact. + +`publish-preview` calls `reusable-deploy.yml` with `version-kind: pr`, the +artifact ID, and no `port`. Its exclusive prefix is `pr-/`; it writes no +port manifest. Only callers from `libtmux/docs` or `tony/libtmux-docs` may omit +`port`. Port callers retain their source and artifact provenance checks. + +Fork PRs run the same audit and dry-run with read-only repository access, +no OIDC and no secrets. Only a head in the same repository may enter the +`docs-preview` environment and publish. There is no `workflow_run` handoff +or checkout of PR code under `pull_request_target`. + +The PR workflow shares `deploy-shell-pr-` concurrency with preview cleanup. +A superseding PR commit cancels the old audit/preview; production publication +keeps its separate serialized, non-cancelling queue in `deploy-shell.yml`. ## `pr-preview-cleanup.yml` @@ -454,13 +474,13 @@ concern, not this workflow's. | Secret | Used by | Scope | Storage level | |---|---|---|---| | `LIBTMUX_DOCS_BUCKET` | every workflow above | bucket name, not prefix-scoped | repo or org | -| `LIBTMUX_DOCS_DISTRIBUTION` | every workflow above | one CloudFront distribution — `CreateInvalidation` can't be scoped narrower | repo or org | +| `LIBTMUX_DOCS_DISTRIBUTION` | shell publication; optional and unused by the port publisher | one CloudFront distribution | repo or org | | `LIBTMUX_DOCS_ROLE_ARN` | `deploy-shell.yml`'s `publish-root` (direct job, may be Environment-scoped); each port's own `docs.yml` (passed through a `uses:` job, must be repo/org) | production write role, scoped to that caller's own prefix(es) | see "Used by" | -| `LIBTMUX_DOCS_PREVIEW_ROLE_ARN` | `deploy-shell.yml`'s `publish-preview` (passed through a `uses:` job) | scoped to `pr-*/` only | repo or org | +| `LIBTMUX_DOCS_PREVIEW_ROLE_ARN` | `test.yml`'s `publish-preview` (passed through a `uses:` job) | scoped to `pr-*/` only | repo or org | | `LIBTMUX_DOCS_PREVIEW_CLEANUP_ROLE_ARN` | `pr-preview-cleanup.yml` (direct job, may be Environment-scoped) | scoped to `pr-*/` delete only | repo, org, or the `docs-preview-cleanup` Environment | Any secret that flows through a `uses:`/`secrets:` pass-through — every -secret named in the "Opting in" recipe above, and `publish-preview`'s three +secret named in the "Opting in" recipe above, and `publish-preview`'s inputs — must live at the repository or organization level, never on a GitHub Environment: the job doing the passing can't declare `environment:`, so an Environment-scoped secret resolves empty at that point (see "Opting in a @@ -479,8 +499,7 @@ the Ruby or Lua caller may publish, a maintainer must verify all of these: - The selected site commit is public and both the docs checkout and reusable workflow use that identical full SHA. -- The port has repository- or organization-level bucket, distribution, and - role secrets. The `docs` and `docs-preview` environments admit only their +- The port has repository- or organization-level bucket and role secrets. The `docs` and `docs-preview` environments admit only their intended refs and produce OIDC claims trusted by prefix-scoped roles. - The production role owns only `en//*` and `manifest/.json`; the preview role owns only diff --git a/notes/adding-a-port.md b/notes/adding-a-port.md index 96237bf7..82e461bf 100644 --- a/notes/adding-a-port.md +++ b/notes/adding-a-port.md @@ -1,6 +1,6 @@ # Adding a port -`site/src/lib/ports.ts` is the single source of truth for the ten ports: nav, +`site/src/lib/ports.ts` is the single source of truth for supported ports: nav, the port switcher, the sidebar scope, the parity table, sitemap generation, and the build script all read it. Adding a port means editing it and the handful of places that can't derive from it — nowhere @@ -27,6 +27,14 @@ contracts apply. Decide the renderer and hosting ownership first: existing renderer before inventing a fourth. See `notes/architecture.md` for what each renderer expects as input. +Set `docsDispatch` when the port has a reviewed workflow that accepts the +shared dispatch inputs. `workflow` names its YAML file; optional `ref` names +an approved caller branch instead of the repository's default branch. +The caller branch must be admitted by its docs environment. `source-ref` +selects the source to build independently and never changes that branch. +Family callers also declare a `language` selector; wrappers inherit the +workflow and select their own language. + `slug` becomes the URL segment (`///...`) and the manifest key in `versions.json` (§4) — pick it once, since changing it later is a URL break for every published version. diff --git a/package.json b/package.json index 6a9f41a8..3f4aa00e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "packageManager": "pnpm@12.6.0", "devDependencies": { "@biomejs/biome": "catalog:", - "typescript": "catalog:", - "oxlint": "catalog:" + "happy-dom": "catalog:", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-to-markdown": "2.1.2", + "oxlint": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/api-model/src/mentions.ts b/packages/api-model/src/mentions.ts index 5ba892de..c8393b90 100644 --- a/packages/api-model/src/mentions.ts +++ b/packages/api-model/src/mentions.ts @@ -101,11 +101,12 @@ const FENCE_PORT: Record = { } /** Inline references in prose, including port sections, tables, and existing links. */ -export function proseMentions(markdown: string, portByLabel: Record): ProseMention[] { +export function proseMentions(markdown: string, portByLabel: Record, portAt?: (offset: number) => string | undefined): ProseMention[] { const out: ProseMention[] = [] const lines = markdown.split('\n') const context: { port?: string; before: string }[] = [] - const bodyLines = lines.map(() => '') + // Keep offsets stable when frontmatter, headings and examples are masked. + const bodyLines = lines.map((line) => ' '.repeat(line.length)) const sections: { depth: number; port?: string }[] = [] let fence: string | undefined let fencePort: string | undefined @@ -157,7 +158,7 @@ export function proseMentions(markdown: string, portByLabel: Record|\.\.\./ }, { why: 'expression, not a symbol', test: /^\(|,\s/ }, @@ -175,7 +177,7 @@ const NOT_API: { why: string; test: RegExp }[] = [ { why: 'method without a receiver', test: /^[.:]/ }, // Test and example fixtures live in files the extractors exclude, so they // are real classes that are deliberately not public API. - { why: 'test or example fixture', test: /(Tests?|TestCase|RunTest)$|^Test[A-Z]/ }, + { why: 'test or example fixture', test: /(Tests?|TestCase|RunTest)$|^Test[A-Z]|^Example(?:\(\))?$/ }, ] /** Why this span is not an API reference, or undefined if it might be. */ @@ -248,16 +250,21 @@ export function decideMention( const named = ctx.before ? portFromSentence(ctx.before) : undefined const tried: string[] = [] + let ambiguous = false for (const port of [named, ctx.pagePort]) { if (!port || !models[port]) continue const res = resolver.resolve(port, text, ctx.product) + ambiguous ||= res.how === 'ambiguous' tried.push(`${port}:${res.how}`) const hit = link(port, res) if (hit) return hit + const builtin = builtinHref(port, text) + if (builtin) return { kind: 'link', port, href: builtin, title: `${text}: ${PORT_NAME[port]}`, external: true } } - // Product pages have an authored port. Shared comparisons retain their - // cross-port fallback when a preceding fence only suggests a language. - if (ctx.product && tried.length) return { kind: 'unresolved', why: 'not defined in the stated port', tried } + // A known language must never resolve a similarly named API in another port. + if (tried.length) return ambiguous + ? { kind: 'skip', why: 'ambiguous within the stated port; qualify the receiver to link it' } + : { kind: 'unresolved', why: 'not defined in the stated port', tried } // Nothing said which language. One claimant is an answer; several are not. const claims: { port: string; decision: MentionDecision }[] = [] diff --git a/packages/api-model/src/resolver.ts b/packages/api-model/src/resolver.ts index 95d63849..b6f4d6fc 100644 --- a/packages/api-model/src/resolver.ts +++ b/packages/api-model/src/resolver.ts @@ -267,6 +267,11 @@ export class Resolver { const member = parts[parts.length - 1] const candidates = Resolver.preferTypeOverConstructor(this.members(port, member)) + if (parts.length > 2) { + const suffix = `.${parts.join('.')}` + const qualified = candidates.filter((row) => `.${toPath(row.qualified).join('.')}`.endsWith(suffix)) + if (qualified.length === 1) return { how: 'scoped', symbol: qualified[0].symbol, port } + } if (candidates.length === 1) return { how: 'unique', symbol: candidates[0].symbol, port } let local = Resolver.preferProduct(candidates, product) if (parts.length === 1) { diff --git a/packages/api-model/test/prose-audit.test.ts b/packages/api-model/test/prose-audit.test.ts index 710ad642..a8da1a02 100644 --- a/packages/api-model/test/prose-audit.test.ts +++ b/packages/api-model/test/prose-audit.test.ts @@ -68,8 +68,26 @@ describe('product context in prose', () => { it('does not borrow another language when the page already names its port', () => { const other = { port: 'py', version: '0', symbols: [{ id: 'Other', name: 'Other', kind: 'class', signatures: [] }] } as ApiModel - const result = decideMention('Other', { pagePort: 'go', product: 'workspace' }, new Resolver([model, other]), { go: model, py: other }) - expect(result.kind).toBe('unresolved') + for (const product of [undefined, 'workspace'] as const) { + const result = decideMention('Other', { pagePort: 'go', product }, new Resolver([model, other]), { go: model, py: other }) + expect(result.kind).toBe('unresolved') + } + }) + + it('links standard-library types through the existing language catalog', () => { + const rust = { port: 'rs', version: '0', symbols: [] } as unknown as ApiModel + expect(decideMention('BTreeMap', { pagePort: 'rs' }, new Resolver([rust]), { rs: rust })) + .toMatchObject({ kind: 'link', port: 'rs', href: 'https://doc.rust-lang.org/std/collections/struct.BTreeMap.html', external: true }) + }) + + it('uses the enclosing type to distinguish nested builders', () => { + const java = { port: 'java', version: '0', symbols: ['SessionSpec', 'WindowSpec'].map((name) => ({ + id: `io.example.${name}.${name}.Builder.environment`, name: 'environment', kind: 'method', signatures: [], + })) } as unknown as ApiModel + const r = new Resolver([java]) + const found = r.resolve('java', 'SessionSpec.Builder.environment(Map)') + expect('symbol' in found && found.symbol.id).toBe('io.example.SessionSpec.SessionSpec.Builder.environment') + expect(r.resolve('java', 'Builder.environment(Map)').how).toBe('ambiguous') }) it('classifies MCP resource URIs and newly authored filenames explicitly', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b912787d..8a8b696c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,15 @@ importers: '@biomejs/biome': specifier: 'catalog:' version: 2.5.14 + happy-dom: + specifier: 'catalog:' + version: 20.14.5 + mdast-util-from-markdown: + specifier: 2.0.3 + version: 2.0.3 + mdast-util-to-markdown: + specifier: 2.1.2 + version: 2.1.2 oxlint: specifier: 'catalog:' version: 1.85.0(oxlint-tsgolint@7.0.2002) diff --git a/scripts/build-site.sh b/scripts/build-site.sh index b1f08c75..c8763cb9 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -5,11 +5,9 @@ # self-hosted port's reference where its toolchain is present, and one # Pagefind index over the whole tree. # -# Runs end-to-end with only Node + pnpm on PATH. Every reference generator -# is optional — an absent toolchain is a skip, printed in the summary -# table, never a failed run. Ecosystem ports (Rust, Go, Java; see -# site/src/lib/ports.ts) produce no local output at all: the site links out -# to their canonical host. +# Integrated builds use committed MCP catalogs. A selected-source build +# requires its MCP toolchain and captures that source's runtime contracts. +# Optional native reference generators report missing toolchains as skips. # # See scripts/README.md for usage. set -euo pipefail @@ -22,7 +20,7 @@ Usage: scripts/build-site.sh [options] (default: latest,stable) --ports p1,p2,... Limit to these port slugs (default: all self-hosted ports from site/src/lib/ports.ts) - --skip-refs Skip every reference generator (shell + search only) + --skip-refs Skip native reference renderers (selected-source MCP is required) --no-cache Rebuild every shell even if its inputs are unchanged --skip-pagefind Skip the final Pagefind indexing pass -h, --help Show this message @@ -293,13 +291,14 @@ if [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ] || [ -n "${LIBTMUX_DOCS_SOURCE_REF:-}" source_resolved="$(git -C "$source_checkout" rev-parse "$LIBTMUX_DOCS_SOURCE_REF^{commit}")" [ "$source_head" = "$LIBTMUX_DOCS_SOURCE_SHA" ] || die "checkout HEAD $source_head differs from selected source $LIBTMUX_DOCS_SOURCE_SHA" [ "$source_resolved" = "$LIBTMUX_DOCS_SOURCE_SHA" ] || die "source ref resolves to $source_resolved, expected $LIBTMUX_DOCS_SOURCE_SHA" + # Capture dirty state before generators rewrite the committed API models. + node "$script_dir/publication-provenance.mjs" snapshot "$scratch/build-inputs.json" "$repo_root" fi -# Source-owned guides are ephemeral build input. Always clear the staging -# tree first so a guide removed on a branch cannot survive from an earlier -# build. Exact port callers must have their native artifact; a full local -# assembly stages both new ports only when both artifacts are present. +# Ordinary builds use committed, revision-bound guides. A selected source +# replaces only its port's guides after native artifact validation. rm -rf "$site_dir/src/content/docs/_staged" +node "$script_dir/stage-port-docs.mjs" --cached if [ -n "${LIBTMUX_DOCS_PORT:-}" ] && { [ "$LIBTMUX_DOCS_PORT" = ruby ] || [ "$LIBTMUX_DOCS_PORT" = lua ]; }; then node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT" node "$script_dir/stage-port-docs.mjs" --port "$LIBTMUX_DOCS_PORT" @@ -314,15 +313,14 @@ elif [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ]; then # last refreshed. The generator re-checks the SHA and records it. node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT" node "$script_dir/gen-api-model.mjs" --port "$LIBTMUX_DOCS_PORT" --nav -elif [ -f "${LIBTMUX_DOCS_CHECKOUT_RUBY:-$HOME/work/libtmux/libtmux-ruby-docs}/docs/_build/api.json" ] && - [ -f "${LIBTMUX_DOCS_CHECKOUT_LUA:-$HOME/work/libtmux/libtmux-lua-docs}/docs/_build/api.json" ]; then - node "$script_dir/gen-api-model.mjs" --port ruby - node "$script_dir/gen-api-model.mjs" --port lua - node "$script_dir/stage-port-docs.mjs" -elif [ -n "${LIBTMUX_DOCS_CHECKOUT_RUBY:-}" ] && [ -n "${LIBTMUX_DOCS_CHECKOUT_LUA:-}" ]; then - node "$script_dir/stage-port-docs.mjs" --from-source fi node "$script_dir/stage-port-docs.mjs" --wrappers +if [ -n "${LIBTMUX_DOCS_SOURCE_SHA:-}" ]; then + # The selected API and wire contracts must come from the same product source. + # Python's MCP product has its own checkout and revision. + node "$script_dir/gen-mcp-protocol.mjs" --port "$LIBTMUX_DOCS_PORT" --source-bound + node "$script_dir/gen-mcp-tools.mjs" --port "$LIBTMUX_DOCS_PORT" --source-bound +fi node "$script_dir/gen-example-sources.mjs" node "$script_dir/gen-mentions.mjs" @@ -1076,7 +1074,9 @@ while IFS='|' read -r slug name versioned renderer generator checkout ecosystem_ if [ "$ref_status" = "built" ]; then mkdir -p "$port_out/api" cp -a "$ref_outdir/." "$port_out/api/" - node "$script_dir/normalize-native-shell.mjs" "$port_out/api" "$LIBTMUX_DOCS_PORT_ROOT" + native_shell_args=() + if [ "$renderer" = sphinx ]; then native_shell_args+=("$slug" "$version"); fi + node "$script_dir/normalize-native-shell.mjs" "$port_out/api" "$LIBTMUX_DOCS_PORT_ROOT" "${native_shell_args[@]}" node "$script_dir/brand-native-pages.mjs" "$port_out/api" "$slug" "$LIBTMUX_DOCS_PORT_ROOT" elif [ "$ref_status" = "skipped" ]; then mkdir -p "$port_out/api" @@ -1227,3 +1227,10 @@ elif ! node "$(dirname "$0")/check-links.mjs" "$out_dir" --all "${vendored_args[ printf 'build-site: broken internal links from pages this repo generates (see the list above)\n' >&2 exit 1 fi + +# Run identity lives in a separate artifact; these bytes remain identical on +# a rerun of the same clean inputs. Full local assemblies have no source claim. +if [ -f "$scratch/build-inputs.json" ]; then + node "$script_dir/publication-provenance.mjs" record \ + "$site_out/$LIBTMUX_DOCS_PORT/$LIBTMUX_DOCS_VERSION" "$scratch/build-inputs.json" +fi diff --git a/scripts/check-api-links.mjs b/scripts/check-api-links.mjs index 76e02953..f7ab5f3f 100755 --- a/scripts/check-api-links.mjs +++ b/scripts/check-api-links.mjs @@ -24,7 +24,7 @@ import { Resolver, decideFilePath, decideMention, isLikelyReference, looksLikeAp const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const { API_MODEL_PORTS: PORT_DEFS, PORT_BY_SLUG } = await import(`file://${resolve(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) -const { KNOWN_PORTS, resolvePortBody } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) +const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) const SHARED = join(root, 'site/src/content/_workspace-shared') const CONTENT = join(root, 'site/src/content/docs') const started = Date.now() @@ -157,9 +157,10 @@ for (const file of targets) { if (PORT_BY_SLUG[authoredPort]?.referenceKind === 'guide') continue const product = /^product:\s*['"]?(core|workspace|mcp)['"]?\s*$/m.exec(frontmatter)?.[1] ?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file.replace(`${CONTENT}/`, ''))?.[1] - for (const { text, port: ctxPort, before, line, linked } of proseMentions(raw, PORT_BY_LABEL)) { + const selected = resolvePortContent(raw, authoredPort) + for (const { text, port: ctxPort, before, line, linked } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) { if (linked) { tally.alreadyLinked++; continue } - const pagePort = ctxPort ?? authoredPort + const pagePort = authoredPort ?? ctxPort if (FILE_RE.test(text) || text.endsWith('/')) { const d = decideFilePath(text, { before, pagePort }, trees) @@ -175,17 +176,14 @@ for (const file of targets) { if (notASymbol(text) || !looksLikeApiMention(text)) { tally.notASymbol++; continue } - const linkable = (authoredPort ? [pagePort] : [ctxPort, ...PORTS]).some((port) => { - if (!port) return false - const decision = decideMention(text, { pagePort: port, product, before }, resolver, models) - return decision.kind === 'link' - }) + const decisions = (pagePort ? [pagePort] : PORTS) + .map((port) => decideMention(text, { pagePort: port, product, before }, resolver, models)) // `notApiReason` gates *reporting*, not linking — exactly as the plugin // does. A span it names still gets offered to the resolver, because a // `TMUX_TMPDIR` that happens to resolve is a link worth having; it simply // is not a dangling reference when it does not. - if (linkable) tally.willLink++ - else if (!isLikelyReference(text) || notApiReason(text) || EXCEPTIONS.has(text)) tally.notASymbol++ + if (decisions.some((decision) => decision.kind === 'link')) tally.willLink++ + else if (decisions.some((decision) => decision.kind === 'skip') || !isLikelyReference(text) || notApiReason(text) || EXCEPTIONS.has(text)) tally.notASymbol++ else { tally.unresolved++; unresolved.push({ file, line, text }) } } } diff --git a/scripts/check-canonicals.mjs b/scripts/check-canonicals.mjs index b65d0cab..08de1f46 100644 --- a/scripts/check-canonicals.mjs +++ b/scripts/check-canonicals.mjs @@ -32,6 +32,7 @@ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))) const defaultSite = join(repoRoot, '_site') const siteDir = process.argv.slice(2).find((a) => !a.startsWith('--')) ?? defaultSite const LOCALE = process.env.LIBTMUX_DOCS_LOCALE ?? 'en' +const root = `${process.env.LIBTMUX_DOCS_LOCALES_ROOT ?? ''}/${LOCALE}/` if (!existsSync(siteDir)) { console.error(`check-canonicals: no site at ${siteDir} — run ./scripts/build-site.sh`) @@ -96,7 +97,8 @@ for (const file of pages) { // below is relative to the site root, which is that segment. Strip it so the // two describe the same thing, and so this check reads the same on a tree // built with a prefix and one built without. - const declared = new URL(found[1]).pathname.replace(new RegExp(`^/${LOCALE}/`), '/') + const pathname = new URL(found[1]).pathname + const declared = pathname.startsWith(root) ? `/${pathname.slice(root.length)}` : pathname // The page's own path, as served: the directory holding its index.html. const own = `${file.slice(siteDir.length, -'index.html'.length)}` // Its canonical twin: the same page under this port's default version. diff --git a/scripts/check-go-prose.py b/scripts/check-go-prose.py new file mode 100644 index 00000000..abbc3af1 --- /dev/null +++ b/scripts/check-go-prose.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Compile and run eight shared Go examples on isolated tmux servers. + +Usage: python3 scripts/check-go-prose.py --checkout PATH [--ref REF] +The default ref is the source revision in the integrated Go API model. +This is an optional native verification gate; it needs Go and tmux on PATH. +""" +from pathlib import Path +import argparse +import json +import os +import re +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--checkout', type=Path, required=True) +parser.add_argument('--ref') +args = parser.parse_args() +repo = Path(__file__).resolve().parent.parent +model = json.loads((repo / 'site/src/data/api/go.json').read_text()) +revision = subprocess.check_output( + ['git', '-C', str(args.checkout), 'rev-parse', (args.ref or model['revision']) + '^{commit}'], text=True +).strip() +root = repo / 'site/src/content/docs/topics' + +def verify(root, out, source, revision): + go_version = re.search(r'^go (.+)$', (source / 'go.mod').read_text(), re.M).group(1) + (out / 'go.mod').write_text( + f'module docs-check\n\ngo {go_version}\n\n' + 'require github.com/libtmux/libtmux-go v0.0.0\n\n' + f'replace github.com/libtmux/libtmux-go => {source.resolve()}\n' + ) + parts=['''package docscheck + import ( + "context" + "errors" + "fmt" + "testing" + "os" + "time" + "github.com/libtmux/libtmux-go/tmux" + "github.com/libtmux/libtmux-go/tmux/tmuxtest" + ) + '''] + givens={'pane-interaction':['pane tmux.Pane','pane tmux.Pane'], 'options-and-hooks':['window tmux.Window','session tmux.Session'], 'waiting-and-retry':['session tmux.Session','server tmux.Server']} + for name in [*givens, 'context-managers']: + matches = list(re.finditer(r'^```go[^\n]*\n(.*?)^```', (root/(name+'.md')).read_text(), re.M|re.S)) + assert len(matches) == (1 if name == 'context-managers' else 2), f'Update verification for changed {name} examples' + for index, match in enumerate(matches): + code=match[1] + if name=='context-managers':parts.append(code);continue + fn=name.replace('-','')+str(index) + parts.append('func '+fn+'(ctx context.Context, '+givens[name][index]+') error {\n'+code+'\nreturn nil\n}\n') + transports = list(re.finditer(r'^```go[^\n]*\n(.*?)^```', + (root.parent/'concepts/transports.md').read_text(), re.M|re.S)) + assert len(transports) == 1, 'Update verification for changed transport examples' + parts.append('func transportExample() error {\n'+transports[0][1]+'\n}\n') + parts.append(''' + func TestMain(m *testing.M) { os.Exit(tmuxtest.Main(m)) } + + func TestTransportExample(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + server := tmuxtest.NewServerWithOptions(ctx, t, tmuxtest.ServerOptions{FixedShell: true}) + t.Setenv("TMUX", server.SocketPath()+",0,0") + if err := transportExample(); err != nil { t.Fatal(err) } + filter := tmux.TmuxFilter("#{==:#{session_name},work}") + sessions, err := server.SearchSessions(ctx, &filter) + if err != nil || len(sessions) != 1 { t.Fatalf("created session: %v, %v", sessions, err) } + panes, err := sessions[0].SearchPanes(ctx, nil) + if err != nil || len(panes) != 1 { t.Fatalf("created pane: %v, %v", panes, err) } + tmuxtest.WaitForLine(ctx, t, panes[0], "hello") + } + + func TestPublishedExamples(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + server := tmuxtest.NewServerWithOptions(ctx, t, tmuxtest.ServerOptions{Config: []byte("set-window-option -g automatic-rename off\\n"), FixedShell: true}) + session := tmuxtest.NewSession(ctx, t, server, tmux.NewSessionRequest{}) + name := "build" + window := tmuxtest.NewWindow(ctx, t, session, tmux.NewWindowRequest{Name: &name}) + panes, err := window.SearchPanes(ctx, nil) + if err != nil || len(panes) != 1 { t.Fatalf("setup panes: %v, %v", panes, err) } + pane := panes[0] + calls := []struct { name string; run func() error }{ + {"send", func() error { return paneinteraction0(ctx, pane) }}, + {"capture", func() error { return paneinteraction1(ctx, pane) }}, + {"options", func() error { return optionsandhooks0(ctx, window) }}, + {"hooks", func() error { return optionsandhooks1(ctx, session) }}, + {"poll", func() error { return waitingandretry0(ctx, session) }}, + {"channel", func() error { return waitingandretry1(ctx, server) }}, + {"cleanup", func() error { return temporarySession(ctx, server) }}, + } + for _, call := range calls {t.Run(call.name, func(t *testing.T) { + if err := call.run(); err != nil { t.Fatal(err) } + })} + if err := tmuxtest.WaitFor(ctx, 10*time.Millisecond, func(ctx context.Context) (bool, error) { + lines, err := pane.Capture(ctx, tmux.CapturePaneRequest{}) + for _, line := range lines { if line == "hello" { return true, err } } + return false, err + }); err != nil { t.Fatalf("literal input did not execute: %v", err) } + if err := paneinteraction0(ctx, tmux.Pane{}); err == nil { t.Fatal("invalid pane error was swallowed") } + expired, stop := context.WithCancel(ctx); stop() + if err := waitingandretry0(expired, session); !errors.Is(err, context.Canceled) {t.Fatalf("cancellation lost: %v", err)} + sessions, err := server.Sessions(ctx) + if err != nil || len(sessions) != 1 || sessions[0].ID() != session.ID() {t.Fatalf("owned cleanup changed sessions: %v, %v", sessions, err)} + } + ''') + (out/'examples_test.go').write_text('\n'.join(parts)) + + env = {key: value for key, value in os.environ.items() if key not in ['TMUX', 'TMUX_PANE', 'GOWORK']} + env['GOWORK'] = 'off' + env['TMUX_TMPDIR'] = str(out) + print(f'Go prose: {revision}; eight examples from five pages', flush=True) + subprocess.run(['go', 'test', '-count=1', '-v', '.'], cwd=out, env=env, check=True) + +with tempfile.TemporaryDirectory(prefix='libtmux-go-prose-') as temporary: + out = Path(temporary) + source = out / 'source' + source.mkdir() + archive = subprocess.check_output(['git', '-C', str(args.checkout), 'archive', revision]) + subprocess.run(['tar', '-xf', '-', '-C', str(source)], input=archive, check=True) + verify(root, out, source, revision) diff --git a/scripts/check-sidebar-refs.mjs b/scripts/check-sidebar-refs.mjs index 38c2e496..d7a5966f 100755 --- a/scripts/check-sidebar-refs.mjs +++ b/scripts/check-sidebar-refs.mjs @@ -31,14 +31,17 @@ const PORTS = PORT_DEFS.map((p) => p.slug) // The locale segment a served href carries, stripped before comparison. The // tree itself is whatever root this check was handed. const LOCALE = process.env.LIBTMUX_DOCS_LOCALE ?? 'en' +const servedRoot = `${process.env.LIBTMUX_DOCS_LOCALES_ROOT ?? ''}/${LOCALE}/` function pageFor(port) { return pageUnder(join(SITE, port)) } /** A served href as a path below the site root, with the locale removed. */ -const pathOf = (href) => - href.replace(/^https?:\/\/[^/]+/, '').replace(new RegExp(`^/${LOCALE}/`), '/') +const pathOf = (href) => { + const path = href.replace(/^https?:\/\/[^/]+/, '') + return path.startsWith(servedRoot) ? `/${path.slice(servedRoot.length)}` : path +} function pageUnder(portDir) { const unversioned = join(portDir, 'concepts', 'index.html') diff --git a/scripts/check-sidebar-refs.negative.sh b/scripts/check-sidebar-refs.negative.sh index 93a4ca0d..e347666c 100755 --- a/scripts/check-sidebar-refs.negative.sh +++ b/scripts/check-sidebar-refs.negative.sh @@ -22,8 +22,8 @@ trap 'rm -rf "$tmp"' EXIT locale="${LIBTMUX_DOCS_LOCALE:-en}" # The site root within the tree. `_site` is the bucket root and also holds # robots.txt, which sits above every locale; the check reads the site. -site_out="_site/$locale" -[ -d "$site_out" ] || site_out=_site +site_out="${1:-${LIBTMUX_DOCS_OUT_DIR:-_site}/$locale}" +if [[ $# == 0 && ! -d "$site_out" ]]; then site_out="${LIBTMUX_DOCS_OUT_DIR:-_site}"; fi page_for() { local port="$1" root candidate @@ -73,6 +73,19 @@ drop 'our reference removed' "$rs" '/rs/latest/reference/' 'rs: sidebar does n drop 'ecosystem link removed' "$rs" 'docs.rs' 'rs: sidebar does not link docs.rs' || fails=1 drop 'upstream reference gone' "$py" '/api/' 'py: sidebar does not link the upstream gp-sphinx reference' || fails=1 +# Keep the link, remove its actual destination, and require the path check to +# reject it. This also proves prefixed hrefs are resolved against this tree. +reference="$(dirname "$(dirname "$rs")")/reference/index.html" +cp "$reference" "$tmp/reference.bak" || exit 1 +rm "$reference" +out=$(node scripts/check-sidebar-refs.mjs "$site_out" 2>&1); code=$? +cp "$tmp/reference.bak" "$reference" +if [[ "$code" == 1 && "$out" == *'rs: '*' is linked but not built'* ]]; then + printf ' ok %-32s exit 1\n' 'reference target missing' +else + printf ' FAIL %-32s expected missing target diagnostic\n' 'reference target missing'; fails=1 +fi + if node scripts/check-sidebar-refs.mjs "$site_out" >/dev/null 2>&1; then printf ' ok %-32s exit 0\n' 'restored' else diff --git a/scripts/check-workspace-prose.py b/scripts/check-workspace-prose.py new file mode 100644 index 00000000..3276e762 --- /dev/null +++ b/scripts/check-workspace-prose.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Run native workspace CLI examples and failure checks on a private tmux socket. + +Build the CLI from the source revision cited by the pages first. This runner +checks the supplied executable; it does not establish its build provenance. +Usage: python3 scripts/check-workspace-prose.py --port go --binary /path/to/tmux-workspace +Node, tmux and the CLI runtime must be on PATH. --report saves the run results. +""" + +import argparse +import hashlib +import json +import os +import pathlib +import re +import shutil +import subprocess +import tempfile +import time + +ROOT = pathlib.Path(__file__).resolve().parent.parent +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument( + "--port", + choices=["ts", "rs", "go", "java", "dotnet", "cxx", "swift"], + required=True, +) +parser.add_argument("--binary", required=True, type=pathlib.Path) +parser.add_argument("--report", type=pathlib.Path) +args = parser.parse_args() +BINARY = args.binary.resolve() +NODE = shutil.which("node") or parser.error("node is required") +TMUX = shutil.which("tmux") or parser.error("tmux is required") +PAGES = [ + "cli/" + name + for name in [ + "index", + "convert", + "edit", + "freeze", + "ls", + "debug-info", + "search", + "load", + "import", + "import-teamocil", + "import-tmuxinator", + "completion", + ] +] +if os.environ.get("TMUX_WORKSPACE_PYTHON"): + PAGES.append("cli/shell") +CONFIGURATIONS = [ + "index", + "session", + "windows", + "panes", + "commands", + "directories", + "environment", + "layouts", + "hooks", +] +PAGES.extend("configuration/" + name for name in CONFIGURATIONS) +PAGES.extend( + [ + "guides/discovery", + "guides/automation", + "guides/export-session", + "guides/troubleshooting", + "reference/output", + "examples/gallery", + ] +) +extract = """import {readFileSync} from 'node:fs'; +import {resolvePortBody} from './site/src/lib/workspace-shared-slots.ts'; +const pages=JSON.parse(process.argv[1]); +console.log(JSON.stringify(Object.fromEntries(pages.map(name=>[name,resolvePortBody(readFileSync('site/src/content/_workspace-shared/workspace/'+name+'.md','utf8'),process.argv[2])])))); +""" +bodies = json.loads( + subprocess.check_output( + [NODE, "--input-type=module", "-e", extract, json.dumps(PAGES), args.port], + cwd=ROOT, + text=True, + ) +) +evidence = { + "port": args.port, + "binary": str(BINARY), + "entrypoint_sha256": hashlib.sha256(BINARY.read_bytes()).hexdigest(), + "pages": { + name: hashlib.sha256(body.encode()).hexdigest() for name, body in bodies.items() + }, + "positive": [], + "negative": [], + "skipped": [] + if "cli/shell" in PAGES + else ["shell: set TMUX_WORKSPACE_PYTHON to a compatible runtime"], +} +# Unix sockets require the Linux filesystem when running under WSL. +fixture_root = ( + pathlib.Path(tempfile.gettempdir()) + if args.port == "ts" + else pathlib.Path(f"/tmp/libtmux-{args.port}-test") +) +fixture_root.mkdir(exist_ok=True) +with tempfile.TemporaryDirectory( + prefix="ltx-doc-" if args.port == "ts" else "docs-", dir=fixture_root +) as directory: + here = pathlib.Path(directory) + socket = here / "tmux.sock" + bindir = here / "bin" + bindir.mkdir() + (bindir / "tmux-workspace").symlink_to(BINARY) + editor = bindir / "vi" + editor.write_text( + '#!/bin/sh\n[ -f "$1" ] || exit 42\nprintf "%s" "$1" > "$WORKSPACE_TMP/editor-argument"\n' + ) + editor.chmod(0o755) + configs = here / "config" + configs.mkdir() + doc = "session_name: workspace-guide\nwindows:\n - window_name: editor\n layout: even-horizontal\n panes:\n - printf ready\n - printf second\n" + (here / "workspace.yaml").write_text(doc) + (configs / "workspace.yaml").write_text(doc) + for body in bodies.values(): + for match in re.finditer( + r'```(?:yaml|json) title="([\w.-]+)"\n(.*?)\n```', body, re.S + ): + (here / match[1]).write_text(match[2] + "\n") + env = dict( + os.environ, + PATH=f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}", + WORKSPACE_TMP=str(here), + TMUXP_CONFIGDIR=str(configs), + XDG_CONFIG_HOME=str(configs), + TMUX_TMPDIR=str(here), + SHELL="/bin/sh", + ENV="/dev/null", + BASH_ENV="/dev/null", + ZDOTDIR=str(here), + ) + env.pop("TMUX", None) + env.pop("TMUX_PANE", None) + env.pop("VISUAL", None) + for name in ["DOC_SESSION", "DOC_WINDOW", "DOC_PANE"]: + env.pop(name, None) + + def run(command, success=True): + result = subprocess.run( + command, + cwd=here, + env=env, + text=True, + capture_output=True, + timeout=30, + shell=isinstance(command, str), + ) + if success and result.returncode: + raise AssertionError((command, result.returncode, result.stderr[-1500:])) + if not success and not result.returncode: + raise AssertionError(("expected failure", command)) + return result + + try: + run( + [ + str(BINARY), + "load", + "-S", + str(socket), + "-f", + "/dev/null", + "-d", + "--json", + "workspace.yaml", + ] + ) + for name, body in bodies.items(): + for match in re.finditer(r"```console\n(.*?)\n```", body, re.S): + command = match[1].removeprefix("$ ") + result = run(command) + if "--json" in command: + json.loads(result.stdout) + elif "--ndjson" in command: + for line in result.stdout.splitlines(): + json.loads(line) + evidence["positive"].append( + {"page": name, "command": command, "exit": result.returncode} + ) + for name in CONFIGURATIONS: + body = bodies["configuration/" + name] + document = re.search(r'```yaml title="([\w.-]+)"\n(.*?)\n```', body, re.S) + assert document, name + result = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", document[1]] + ) + json.loads(result.stdout) + evidence["positive"].append( + { + "page": "configuration/" + name, + "document": document[1], + "exit": result.returncode, + } + ) + + for document in ["gallery-blank.yaml", "gallery-commands.yaml"]: + result = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", document] + ) + json.loads(result.stdout) + evidence["positive"].append( + { + "page": "examples/gallery", + "document": document, + "exit": result.returncode, + } + ) + + def tmux(*command): + return run([TMUX, "-S", str(socket), *command]).stdout.strip() + + expected_panes = { + "configuration-example": 2, + "session-example": 1, + "windows-example": 2, + "panes-example": 2, + "commands-example": 1, + "directories-example": 1, + "environment-example": 2, + "layouts-example": 3, + "hooks-example": 1, + } + for session, count in expected_panes.items(): + panes = tmux( + "list-panes", "-s", "-t", "=" + session, "-F", "#{pane_id}" + ).splitlines() + assert len(panes) == count, (session, panes) + session_ids = dict( + line.split("\t") + for line in tmux( + "list-sessions", "-F", "#{session_name}\t#{session_id}" + ).splitlines() + ) + assert ( + tmux("show-options", "-v", "-t", session_ids["session-example"], "status") + == "off" + ) + assert ( + tmux( + "show-window-options", + "-v", + "-t", + tmux( + "list-windows", + "-t", + session_ids["windows-example"], + "-F", + "#{window_id}", + ), + "synchronize-panes", + ) + == "on" + ) + assert ( + tmux("list-windows", "-t", "=windows-example", "-F", "#{window_index}") + == "2" + ) + assert tmux( + "list-panes", "-t", "=panes-example:work", "-F", "#{pane_active}" + ).splitlines() == ["0", "1"] + assert tmux( + "display-message", + "-p", + "-t", + "=directories-example:shell", + "#{pane_current_path}", + ) == str(here) + environment_panes = tmux( + "list-panes", "-t", "=environment-example:shell", "-F", "#{pane_id}" + ).splitlines() + + def await_text(pane, expected): + deadline = time.monotonic() + 3 + while True: + output = tmux("capture-pane", "-p", "-t", pane) + if expected in output: + return output + if time.monotonic() >= deadline: + raise AssertionError((args.port, pane, expected, output)) + time.sleep(0.02) + + for pane, expected in zip( + environment_panes, ["ENV=session||pane", "ENV=session|window|"], strict=True + ): + await_text(pane, expected) + synchronized = tmux( + "list-panes", "-t", "=windows-example:tools", "-F", "#{pane_id}" + ).splitlines() + assert "right" not in await_text(synchronized[0], "left") + assert "left" not in await_text(synchronized[1], "right") + evidence["configuration_checks"] = [ + "pane counts", + "session options", + "option timing", + "window index", + "pane focus", + "working directory", + "launch environment", + ] + assert (here / "workspace.json").is_file() + assert (here / "captured-workspace.yaml").is_file() + assert (here / "editor-argument").read_text().endswith("workspace.yaml") + before = (here / "workspace.json").read_bytes() + failed = run( + [ + str(BINARY), + "convert", + "--json", + "--workspace-format", + "json", + "--save-to", + "workspace.json", + "workspace.yaml", + ], + False, + ) + assert before == (here / "workspace.json").read_bytes() + evidence["negative"].append( + {"case": "existing destination preserved", "exit": failed.returncode} + ) + editor.write_text("#!/bin/sh\nexit 17\n") + failed = run("EDITOR=vi tmux-workspace edit workspace.yaml", False) + assert failed.returncode == 17 + evidence["negative"].append( + {"case": "editor status propagated", "exit": failed.returncode} + ) + failed = run([str(BINARY), "search", "--json", "["], False) + evidence["negative"].append( + {"case": "invalid regex fails", "exit": failed.returncode} + ) + (here / "invalid.yaml").write_text( + "session_name: invalid\nbogus: true\nwindows: [{window_name: shell, panes: [null]}]\n" + ) + failed = run( + [str(BINARY), "load", "-S", str(socket), "-d", "--json", "invalid.yaml"], + False, + ) + evidence["negative"].append( + {"case": "invalid workspace fails", "exit": failed.returncode} + ) + finally: + if socket.exists(): + subprocess.run( + [TMUX, "-S", str(socket), "kill-server"], + capture_output=True, + check=True, + ) +if args.report: + args.report.write_text(json.dumps(evidence, indent=2) + "\n") +print( + f"PASS: {len(evidence['positive'])} documented commands, {len(evidence['negative'])} failure checks for {args.port}" +) +for skipped in evidence["skipped"]: + print(f"SKIPPED: {skipped}") diff --git a/scripts/gen-example-sources.mjs b/scripts/gen-example-sources.mjs index 26e7940e..0482b9a7 100755 --- a/scripts/gen-example-sources.mjs +++ b/scripts/gen-example-sources.mjs @@ -1,35 +1,9 @@ #!/usr/bin/env node -/** - * Cache the example sources that prose inlines, so a build needs no port - * checkouts. - * - * `remark-port-code.mjs` turns a fence like - * - * ```typescript file="examples/quickstart/quickstart.ts" - * - * into the real contents of that file, read out of the port's own checkout. - * That is the point: the code on the page is the code the port tests, and a - * missing source fails the build rather than shipping an empty fence. - * - * It also means the build cannot run anywhere the eight checkouts are absent, - * which is every CI runner. This writes what those fences resolve to into a - * committed file, exactly as `gen-api-model.mjs` does for the extracted - * models and for the same reason — CI builds from committed data, and - * `--check` is what stops that data rotting, since nothing else compares it - * against the source it came from. - * - * Whole files are cached, not the sliced regions: `sliceRegion` stays the one - * implementation, so a cached read and a live read cannot disagree about what - * a region means. - * - * Usage: - * node scripts/gen-example-sources.mjs # rewrite the cache - * node scripts/gen-example-sources.mjs --check # fail if it is stale - * node scripts/gen-example-sources.mjs --out PATH # a fixture, for the - * # negative test - */ +/** Cache example files at the exact revision recorded by each port's documentation. */ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { fileURLToPath } from 'node:url' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -76,45 +50,43 @@ for (const md of markdownFiles(CONTENT)) { } } -const cache = {} -const missing = [] -for (const [key, { owner, file }] of [...wanted].sort((a, b) => a[0].localeCompare(b[0]))) { - const checkout = checkoutFor(owner) - const abs = join(checkout, file) - if (!existsSync(abs)) { - missing.push(`${key} (looked in ${checkout})`) - continue - } - cache[key] = readFileSync(abs, 'utf8') -} +const digest = (content) => createHash('sha256').update(content).digest('hex') -const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '' - -/* - * A checkout that is not here cannot be read, and its cached entry cannot be - * confirmed either way. Reported rather than treated as agreement: silence - * would read as "verified" on a machine that verified nothing. - */ -if (missing.length) { - console.error(`gen-example-sources: ${missing.length} source(s) unreadable — checkout absent:`) - for (const m of missing) console.error(` ${m}`) - const kept = missing.filter((m) => Object.hasOwn(JSON.parse(current || '{}'), m.split(' ')[0])) - if (kept.length) { - console.error(` ${kept.length} of these are in the cache already; keeping the cached copy.`) - for (const m of kept) cache[m.split(' ')[0]] = JSON.parse(current)[m.split(' ')[0]] +/** Read committed bytes; a working tree can be dirty or on a different branch. */ +export function cachedExample({ repository, revision, file, checkout, current }) { + if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error(`Invalid example revision: ${revision}`) + if (existsSync(join(checkout, '.git'))) { + const content = execFileSync('git', ['-C', checkout, 'show', `${revision}:${file}`], + { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }) + return { repository, revision, sha256: digest(content), content } + } + if (current?.repository !== repository || current?.revision !== revision || + typeof current.content !== 'string' || current.sha256 !== digest(current.content)) { + throw new Error(`${repository}:${file}: no verified cache for ${revision}; provide its checkout and regenerate example sources`) } + return current } -const merged = `${JSON.stringify(Object.fromEntries(Object.entries(cache).sort()), null, 2)}\n` - -if (check) { - if (merged !== current) { - console.error(`\ngen-example-sources: ${OUT.replace(`${root}/`, '')} is stale. Rerun:`) - console.error(' node scripts/gen-example-sources.mjs') - process.exit(1) +export function run() { + const current = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '' + const previous = JSON.parse(current || '{}') + const cache = {} + const offline = new Set() + for (const [key, { owner, file }] of [...wanted].sort((a, b) => a[0].localeCompare(b[0]))) { + const modelPath = join(root, `site/src/data/api/${owner}.json`) + const provenance = existsSync(modelPath) ? JSON.parse(readFileSync(modelPath, 'utf8')) + : JSON.parse(readFileSync(join(root, `site/src/data/port-guides/${owner}.json`), 'utf8')).source + const repository = provenance.repository ?? provenance.repo + const revision = provenance.revision + const checkout = checkoutFor(owner) + if (!existsSync(join(checkout, '.git'))) offline.add(owner) + cache[key] = cachedExample({ repository, revision, file, checkout, current: previous[key] }) } - console.log(`gen-example-sources: cache matches ${Object.keys(cache).length} example source(s)`) -} else { - writeFileSync(OUT, merged) - console.log(`gen-example-sources: wrote ${Object.keys(cache).length} example source(s) to ${OUT.replace(`${root}/`, '')}`) + const merged = `${JSON.stringify(cache, null, 2)}\n` + if (check && merged !== current) throw new Error(`${OUT}: stale example sources; run node scripts/gen-example-sources.mjs`) + if (!check) writeFileSync(OUT, merged) + console.log(`gen-example-sources: ${check ? 'cache matches' : 'wrote'} ${Object.keys(cache).length} revision-bound example sources`) + if (offline.size) console.log(`gen-example-sources: cached sources for ${[...offline].join(', ')}; checksums and revisions checked, source checkouts unavailable`) } + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) run() diff --git a/scripts/gen-example-sources.negative.mjs b/scripts/gen-example-sources.negative.mjs index ac9f66b3..971155a2 100755 --- a/scripts/gen-example-sources.negative.mjs +++ b/scripts/gen-example-sources.negative.mjs @@ -1,98 +1,38 @@ #!/usr/bin/env node -/* - * Proof that `gen-example-sources.mjs --check` fails on a stale cache. - * - * The control is a freshly generated fixture, because a check that always - * failed would satisfy the drift case on its own. - * - * Two ways to be stale are tested, because they are different failures. An - * edited source is the everyday one: a port changes the example it tests and - * the committed copy still shows the old code, which is the drift the cache - * exists to make visible rather than to hide. A dropped entry is the one that - * would break a build rather than mislead a reader — `readFence` throws when - * a source is in neither the checkout nor the cache. - */ -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' +/** Exercise revision binding and deliberately damaged example caches. */ +import assert from 'node:assert/strict' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join, dirname } from 'node:path' +import { join } from 'node:path' import { execFileSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' -import { CHECKOUTS } from '../site/src/plugins/remark-port-code.mjs' -import sources from '../site/src/data/example-sources.json' with { type: 'json' } - -const script = join(dirname(fileURLToPath(import.meta.url)), 'gen-example-sources.mjs') - -function run(args) { - try { - return { code: 0, out: execFileSync('node', [script, ...args], { encoding: 'utf8', stdio: 'pipe', env }) } - } catch (err) { - return { code: err.status, out: `${err.stdout ?? ''}${err.stderr ?? ''}` } - } -} - -const dir = mkdtempSync(join(tmpdir(), 'gen-example-sources-')) -const fixture = join(dir, 'cache.json') -const env = { ...process.env } -for (const port of Object.keys(CHECKOUTS)) env[`LIBTMUX_DOCS_CHECKOUT_${port.toUpperCase()}`] = join(dir, port) -const sample = Object.keys(sources).sort()[0] -const sampleText = '// isolated example source\n' -for (const [key, text] of Object.entries(sources)) { - const [port, file] = key.split(':') - const path = join(dir, port, file) - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, key === sample ? sampleText : text) -} -process.on('exit', () => rmSync(dir, { recursive: true, force: true })) - -let failures = 0 -const check = (name, ok, detail) => { - if (ok) console.log(`ok ${name}`) - else { - console.error(`FAIL ${name} — ${detail}`) - failures += 1 - } -} - -const generated = run(['--out', fixture]) -if (generated.code !== 0) { - console.error(`FAIL could not generate a control fixture — ${generated.out}`) - process.exit(1) -} -const pristine = readFileSync(fixture, 'utf8') -if (JSON.parse(pristine)[sample] !== sampleText) throw new Error('Generator ignored the fixture checkout') - -{ - const res = run(['--check', '--out', fixture]) - check('a freshly generated cache passes', res.code === 0 && /matches/.test(res.out), `exit ${res.code}: ${res.out.trim()}`) -} - -const mutations = [ - [ - 'an edited source is caught', - (d) => { - const k = Object.keys(d).sort()[0] - return { ...d, [k]: `${d[k]}\n// drifted\n` } - }, - ], - [ - 'a dropped entry is caught', - (d) => { - const { [Object.keys(d).sort()[0]]: _gone, ...rest } = d - return rest - }, - ], -] - -for (const [name, mutate] of mutations) { - const before = JSON.parse(pristine) - const after = mutate(before) - writeFileSync(fixture, `${JSON.stringify(after, null, 2)}\n`) - const res = run(['--check', '--out', fixture]) - check(name, res.code === 1 && /stale/.test(res.out), `exit ${res.code}: ${res.out.trim()}`) -} - -if (failures) { - console.error(`\ngen-example-sources.negative: ${failures} case(s) did not behave as required.`) - process.exit(1) +import { cachedExample } from './gen-example-sources.mjs' + +const scratch = mkdtempSync(join(tmpdir(), 'libtmux-example-cache-')) +const git = (...args) => execFileSync('git', ['-C', scratch, ...args], { encoding: 'utf8', stdio: 'pipe' }).trim() +try { + git('init', '--quiet') + writeFileSync(join(scratch, 'example.go'), '// committed example\n') + git('add', 'example.go') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'Example') + const revision = git('rev-parse', 'HEAD') + const request = { repository: 'fixture/example', revision, file: 'example.go', checkout: scratch } + const initial = cachedExample(request) + assert.equal(initial.content, '// committed example\n') + writeFileSync(join(scratch, 'example.go'), '// uncommitted replacement\n') + assert.deepEqual(cachedExample(request), initial) + git('add', 'example.go') + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet', '-m', 'Later revision') + assert.deepEqual(cachedExample(request), initial) + console.log('ok examples ignore a dirty working tree and a later HEAD') + + const offline = { ...request, checkout: join(scratch, 'absent'), current: initial } + assert.deepEqual(cachedExample(offline), initial) + assert.throws(() => cachedExample({ ...offline, current: { ...initial, content: 'damaged' } }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, revision: git('rev-parse', 'HEAD') }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, repository: 'another/repo' }), /no verified cache/) + assert.throws(() => cachedExample({ ...offline, current: undefined }), /no verified cache/) + assert.throws(() => cachedExample({ ...request, file: 'missing.go' })) + console.log('ok missing files, wrong revisions, wrong repositories and damaged bytes fail') +} finally { + rmSync(scratch, { recursive: true, force: true }) } -console.log('gen-example-sources.negative: the staleness check can fail, and passes when current') diff --git a/scripts/gen-mcp-protocol.mjs b/scripts/gen-mcp-protocol.mjs index f07729c0..e329e193 100644 --- a/scripts/gen-mcp-protocol.mjs +++ b/scripts/gen-mcp-protocol.mjs @@ -11,13 +11,18 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const expand = (path) => path.startsWith('~/') ? join(homedir(), path.slice(2)) : path const only = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : undefined const checking = process.argv.includes('--check') +const sourceBound = process.argv.includes('--source-bound') +if (only && !PORTS.some((port) => port.slug === only)) throw new Error(`Unknown MCP port: ${only}`) +if (sourceBound && (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? ''))) { + throw new Error('source-bound MCP discovery requires --port, matching LIBTMUX_DOCS_PORT and LIBTMUX_DOCS_SOURCE_SHA') +} const checkoutFor = (port) => expand(port.slug === 'py' ? process.env.LIBTMUX_DOCS_MCP_PY || '~/work/python/libtmux-mcp' : process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || port.worktree) const commands = { py: ['.venv/bin/python', '-m', 'libtmux_mcp'], ruby: [ - 'mise', 'exec', '--', 'bundle', 'exec', 'libtmux-mcp', + 'bundle', 'exec', 'libtmux-mcp', '--socket-name', 'libtmux-docs-protocol', '--endpoint', 'docs', '--enable-tool', 'tmux_capture', @@ -29,7 +34,6 @@ const commands = { ], ts: ['bun', 'packages/mcp/src/server.ts'], rs: ['target/debug/tmux-mcp'], - go: ['go', 'run', './cmd/libtmux-mcp'], java: ['libtmux-mcp/build/install/libtmux-mcp/bin/libtmux-mcp'], dotnet: ['dotnet', 'src/LibTmux.Mcp/bin/Release/net10.0/LibTmux.Mcp.dll'], cxx: ['build/cxx-dev/apps/mcp/libtmux-mcp-server', '--socket-name', 'libtmux-docs-protocol'], @@ -43,7 +47,7 @@ const builds = { ['cmake', '--preset', 'cxx-dev', '-DLIBTMUX_BUILD_TESTS=OFF', '-DLIBTMUX_BUILD_EXAMPLES=OFF'], ['cmake', '--build', '--preset', 'cxx-dev', '--target', 'libtmux-mcp-server', '--parallel', '2'], ], - swift: [['swift', 'build', '--product', 'libtmux-mcp', '--jobs', '2']], + swift: [['swift', 'build', '--force-resolved-versions', '--product', 'libtmux-mcp', '--jobs', '2']], } const selections = { py: { LIBTMUX_TOOLSETS: 'inspect,manage,execute,teardown' }, @@ -60,12 +64,15 @@ const selections = { cxx: { LIBTMUX_TOOLSETS: 'inspect,manage,execute,teardown' }, } const selectionVariables = [...new Set(Object.values(selections).flatMap(Object.keys)), 'LIBTMUX_TOOLS', 'LIBTMUX_EXCLUDE_TOOLS', 'LIBTMUX_MCP_TOOLS', 'LIBTMUX_SAFETY', 'TMUX_MCP_SAFETY', 'LIBTMUX_MCP_CAPABILITIES', 'LIBTMUX_MCP_PROMPTS_AS_TOOLS', 'LIBTMUX_SOCKET_NAME', 'LIBTMUX_SOCKET_PATH'] +const sourceStatus = (checkout, slug) => { + // Publication records the clean/dirty state before native generation. + // Generated, untracked artifacts such as Swift's symbolgraph are not edits. + const args = sourceBound ? ['--untracked-files=no'] : slug === 'ruby' ? ['--', 'gems/libtmux-mcp/lib'] : [] + return execFileSync('git', ['-C', checkout, 'status', '--porcelain', ...args], { encoding: 'utf8' }).trim() +} const sourceRevision = (checkout, slug) => { const revision = execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() - const statusArgs = slug === 'ruby' - ? ['-C', checkout, 'status', '--porcelain', '--', 'gems/libtmux-mcp/lib'] - : ['-C', checkout, 'status', '--porcelain'] - const dirty = execFileSync('git', statusArgs, { encoding: 'utf8' }).trim() + const dirty = sourceStatus(checkout, slug) if (dirty) throw new Error('source checkout must be clean before recording a protocol snapshot') return revision } @@ -82,7 +89,7 @@ const relevant = PORTS.filter((port) => (!only || only === port.slug) && product const checkoutStates = new Map(relevant.map((port) => { const checkout = checkoutFor(port) if (!existsSync(checkout)) return [port.slug, 'missing'] - const status = execFileSync('git', ['-C', checkout, 'status', '--porcelain'], { encoding: 'utf8' }).trim() + const status = sourceStatus(checkout) return [port.slug, status ? 'dirty' : 'ready'] })) const missing = [...checkoutStates].filter(([, state]) => state === 'missing').map(([slug]) => slug) @@ -92,7 +99,7 @@ if (missing.length || dirty.length) { missing.length && `no checkout for ${missing.join(', ')}`, dirty.length && `uncommitted changes in ${dirty.join(', ')}`, ].filter(Boolean).join('; ')}` - if (checking) { + if (checking && !sourceBound) { console.log(`${note} — skipping the comparison`) process.exit(0) } @@ -103,31 +110,64 @@ if (missing.length || dirty.length) { for (const port of relevant) { const checkout = checkoutFor(port) const revision = sourceRevision(checkout, port.slug) - const override = process.env[`LIBTMUX_DOCS_MCP_COMMAND_${port.slug.toUpperCase()}`] - if (!override) for (const [build, ...buildArgs] of builds[port.slug] ?? []) { - execFileSync(build, buildArgs, { cwd: checkout, stdio: 'inherit' }) + const repository = port.slug === 'py' ? 'tmux-python/libtmux-mcp' : port.repo + if (sourceBound) { + const model = JSON.parse(readFileSync(join(root, 'site/src/data/api', `${port.slug}.json`), 'utf8')) + const source = model.sources?.find((entry) => entry.product === 'mcp' && entry.repo === repository) + if (model.port !== port.slug || model.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || + (source?.extractedRevision ?? source?.revision) !== revision) { + throw new Error(`${port.slug}: API model must describe the selected source and actual MCP checkout ${revision}`) + } } - const [command, ...args] = override ? JSON.parse(override) : commands[port.slug] + const override = process.env[`LIBTMUX_DOCS_MCP_COMMAND_${port.slug.toUpperCase()}`] const cwd = port.slug === 'go' ? join(checkout, 'mcp') : checkout const selection = selections[port.slug] const socketRoot = mkdtempSync(join(tmpdir(), `libtmux-docs-mcp-${port.slug}-`)) + const socket = join(socketRoot, `tmux-${process.getuid?.() ?? ''}`, 'libtmux-docs-protocol') const environment = { ...Object.fromEntries(selectionVariables.map((key) => [key, undefined])), ...(port.slug === 'ruby' ? {} : selection), TMUX: undefined, TMUX_PANE: undefined, TMUX_TMPDIR: socketRoot, LIBTMUX_SOCKET: 'libtmux-docs-protocol', } let protocol + let failure try { + const goBinary = join(socketRoot, 'libtmux-mcp') + // Dependency downloads and compilation precede the protocol deadline. + if (!override) { + for (const [build, ...buildArgs] of builds[port.slug] ?? []) { + execFileSync(build, buildArgs, { cwd: checkout, stdio: 'inherit' }) + } + if (port.slug === 'go') execFileSync('go', ['build', '-mod=readonly', '-o', goBinary, './cmd/libtmux-mcp'], { cwd, stdio: 'inherit' }) + } + // Ruby borrows an existing daemon even for protocol discovery. + if (port.slug === 'ruby') execFileSync('tmux', [ + '-L', 'libtmux-docs-protocol', '-f', '/dev/null', 'new-session', '-d', '-s', 'docs', '/bin/cat', + ], { env: { ...process.env, ...environment }, stdio: 'inherit' }) + const [command, ...args] = override ? JSON.parse(override) : port.slug === 'go' ? [goBinary] : commands[port.slug] protocol = await captureProtocol({ command, args, cwd, env: environment }) if (sourceRevision(checkout, port.slug) !== revision) throw new Error(`${port.slug}: source revision changed during discovery`) + } catch (error) { + failure = error + throw error } finally { - const socket = join(socketRoot, `tmux-${process.getuid?.() ?? ''}`, 'libtmux-docs-protocol') - if (existsSync(socket)) execFileSync('tmux', ['-S', socket, 'kill-server'], { stdio: 'ignore' }) - rmSync(socketRoot, { recursive: true, force: true }) + const cleanupErrors = [] + try { + if (existsSync(socket)) execFileSync('tmux', ['-S', socket, 'kill-server'], { stdio: 'inherit' }) + } catch (error) { + cleanupErrors.push(error) + } + try { + rmSync(socketRoot, { recursive: true, force: true }) + } catch (error) { + cleanupErrors.push(error) + } + if (cleanupErrors.length) throw new AggregateError(failure ? [failure, ...cleanupErrors] : cleanupErrors, + `${port.slug}: MCP discovery cleanup failed`) } const payload = { generated: 'scripts/gen-mcp-protocol.mjs', - repo: port.slug === 'py' ? 'tmux-python/libtmux-mcp' : port.repo, + repo: repository, revision, selection, protocol, } const out = join(root, 'site/src/data/mcp-protocol', `${port.slug}.json`) diff --git a/scripts/gen-mcp-tools.mjs b/scripts/gen-mcp-tools.mjs index 349e7eb7..0513c6f2 100644 --- a/scripts/gen-mcp-tools.mjs +++ b/scripts/gen-mcp-tools.mjs @@ -19,10 +19,10 @@ * exactly 54 tool pages. An extractor that agrees with the reference * implementation's own documentation, name for name, is not guessing. * - * Usage: node scripts/gen-mcp-tools.mjs [--out ] [--check] + * Usage: node scripts/gen-mcp-tools.mjs [--port ] [--source-bound] [--out ] [--check] */ import { execFileSync } from 'node:child_process' -import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { readFileSync, writeFileSync, existsSync, globSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join, dirname, relative, resolve } from 'node:path' import { mapLine, parseHunks } from '../packages/api-model/src/source-lines.ts' @@ -30,6 +30,7 @@ import { fileURLToPath } from 'node:url' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const checking = process.argv.includes('--check') +const sourceBound = process.argv.includes('--source-bound') const only = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : undefined const outFlag = process.argv.indexOf('--out') const out = outFlag === -1 ? join(repoRoot, 'site/src/data/mcp-tools.json') : process.argv[outFlag + 1] @@ -128,6 +129,11 @@ const PORTS = [ const portsModule = resolve(dirname(dirname(fileURLToPath(import.meta.url))), 'site/src/lib/ports.ts') const { PORTS: PORT_DEFS, productAvailable } = await import(`file://${portsModule}`) const portBySlug = Object.fromEntries(PORT_DEFS.map((port) => [port.slug, port])) +if (only && !portBySlug[only]) throw new Error(`Unknown MCP port: ${only}`) +if (sourceBound && (!only || process.env.LIBTMUX_DOCS_PORT !== only || !/^[0-9a-f]{40}$/.test(process.env.LIBTMUX_DOCS_SOURCE_SHA ?? ''))) { + throw new Error('source-bound MCP catalog requires --port, matching LIBTMUX_DOCS_PORT and LIBTMUX_DOCS_SOURCE_SHA') +} +if (only && !productAvailable(portBySlug[only], 'mcp')) process.exit(0) for (const port of PORTS) { const definition = portBySlug[port.slug] const configured = process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || definition.worktree @@ -152,14 +158,9 @@ if (absentHere.length || absentThere.length) { function filesIn(dir, glob, exclude = []) { - try { - const args = ['--type', 'f', '--glob', glob, '--base-directory', dir, '--absolute-path'] - for (const e of exclude) args.push('--exclude', e) - const out = execFileSync('fd', args, { encoding: 'utf8' }) - return out.split('\n').filter(Boolean).filter((f) => !f.includes('__pycache__')) - } catch { - return [] - } + if (!existsSync(dir)) return [] + return globSync(glob, { cwd: dir, exclude }).map((file) => join(dir, file)) + .filter((file) => !file.includes('__pycache__') && statSync(file).isFile()).sort() } /** @@ -190,9 +191,9 @@ function selectsToolsets(dir) { const git = (checkout, ...args) => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8' }).trim() const previous = only && existsSync(out) ? JSON.parse(readFileSync(out, 'utf8')) : undefined const results = previous ? { ...previous.ports } : {} +const relevant = PORTS.filter((port) => !only || port.slug === only) const missing = [] -for (const port of PORTS) { - if (only && port.slug !== only) continue +for (const port of relevant) { const dir = expand(port.dir) if (!existsSync(dir)) { missing.push(port.slug) @@ -200,7 +201,15 @@ for (const port of PORTS) { } const head = git(port.checkout, 'rev-parse', 'HEAD') const apiModel = JSON.parse(readFileSync(join(repoRoot, 'site/src/data/api', `${port.slug}.json`), 'utf8')) - const revision = apiModel.sources?.find((source) => source.repo === port.repo && source.product === 'mcp')?.revision ?? head + const source = apiModel.sources?.find((entry) => entry.repo === port.repo && entry.product === 'mcp') + if (sourceBound && (apiModel.port !== port.slug || apiModel.revision !== process.env.LIBTMUX_DOCS_SOURCE_SHA || + (source?.extractedRevision ?? source?.revision) !== head)) { + throw new Error(`${port.slug}: API model must describe the selected source and actual MCP checkout ${head}`) + } + if (sourceBound && git(port.checkout, 'status', '--porcelain', '--untracked-files=no')) { + throw new Error(`${port.slug}: MCP checkout has tracked source changes`) + } + const revision = sourceBound ? head : source?.revision ?? head const names = new Map() for (const file of filesIn(dir, port.glob, port.exclude)) { const text = readFileSync(file, 'utf8') @@ -215,7 +224,8 @@ for (const port of PORTS) { } const snapshotFile = join(repoRoot, 'site/src/data/mcp-protocol', `${port.slug}.json`) const snapshot = existsSync(snapshotFile) ? JSON.parse(readFileSync(snapshotFile, 'utf8')) : undefined - if (snapshot && snapshot.revision !== head) throw new Error(`${port.slug}: MCP protocol snapshot describes another source revision`) + if (snapshot && (snapshot.revision !== head || snapshot.repo !== port.repo)) throw new Error(`${port.slug}: MCP protocol snapshot describes another source revision or repository`) + if (sourceBound && !snapshot) throw new Error(`${port.slug}: source-bound MCP catalog requires a runtime protocol snapshot`) const protocols = new Map((snapshot?.protocol.tools ?? []).map((tool) => [tool.name, tool])) const registrations = [...names.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, registration]) => { const tool = protocols.get(registration.wireName) @@ -228,6 +238,8 @@ for (const port of PORTS) { }) const unregistered = [...protocols.keys()].filter((name) => !registrations.some((registration) => registration.wireName === name)) if (unregistered.length) throw new Error(`${port.slug}: runtime tools absent from source registrations: ${unregistered.join(', ')}`) + const unavailable = registrations.filter((entry) => entry.schemaStatus !== 'runtime' || !entry.inputSchema) + if (sourceBound && unavailable.length) throw new Error(`${port.slug}: runtime schemas missing for ${unavailable.map((entry) => entry.wireName).join(', ')}`) results[port.slug] = { tools: [...names.keys()].sort(), registrations, wirePrefix: port.wirePrefix ?? '', @@ -242,7 +254,7 @@ if (missing.length) { // no matrix, so neither mode proceeds. Only --check tolerates it: CI clones // this repository alone and the comparison happens where the ports are. const note = `gen-mcp-tools: no checkout for ${missing.join(', ')}` - if (checking) { + if (checking && !sourceBound) { console.log(`${note} — skipping the comparison`) process.exit(0) } @@ -254,7 +266,7 @@ if (missing.length) { // this the prefix-stripping pattern is self-confirming: an unprefixed tool // would simply not be found, and the claim that a port prefixes every tool // would be true only of the tools the instrument can see. -for (const port of PORTS) { +for (const port of relevant) { if (!port.prefixProbe) continue const violations = new Set() for (const file of filesIn(expand(port.dir), port.glob, port.exclude)) { @@ -272,10 +284,10 @@ for (const port of PORTS) { } // Cross-check against the reference implementation's own documentation. -const docsDir = expand('~/work/python/libtmux-mcp/docs/tools') -let documented = null -if (existsSync(docsDir)) { - documented = filesIn(docsDir, '*.md') +const docsDir = join(PORTS.find((port) => port.slug === 'py').checkout, 'docs/tools') +let referenceDocumented = previous?.referenceDocumented ?? null +if ((!only || only === 'py') && existsSync(docsDir)) { + const documented = filesIn(docsDir, '*.md') .map((f) => f.split('/').pop().replace(/\.md$/, '').replaceAll('-', '_')) .filter((n) => n !== 'index') .sort() @@ -288,9 +300,10 @@ if (existsSync(docsDir)) { if (onlyCode.length) console.error(` extracted, not documented: ${onlyCode.join(', ')}`) process.exit(1) } + referenceDocumented = documented.length } -const slugs = PORTS.map((p) => p.slug) +const slugs = PORTS.map((p) => p.slug).filter((slug) => results[slug]) /* * A port whose server is on disk always registers something. Zero means the @@ -299,7 +312,7 @@ const slugs = PORTS.map((p) => p.slug) * kept exiting 0 while reporting `dotnet:0` until the staleness check noticed * the file had emptied. */ -const silent = PORTS.filter((p) => results[p.slug].tools.length === 0 && existsSync(expand(p.dir))) +const silent = relevant.filter((p) => results[p.slug].tools.length === 0 && existsSync(expand(p.dir))) if (silent.length) { console.error('gen-mcp-tools: a port with a server on disk matched no tools') for (const p of silent) console.error(` ${p.slug}: ${p.dir} (${p.glob})`) @@ -316,7 +329,7 @@ const payload = { ports: results, universe, coverage, - referenceDocumented: documented?.length ?? null, + referenceDocumented, } const text = JSON.stringify(payload, null, 2) + '\n' diff --git a/scripts/gen-mentions.mjs b/scripts/gen-mentions.mjs index e7043f09..185af8e9 100755 --- a/scripts/gen-mentions.mjs +++ b/scripts/gen-mentions.mjs @@ -14,7 +14,7 @@ const check = process.argv.includes('--check') const { PORTS: PORT_DEFS } = await import(`file://${resolve(root, 'site/src/lib/ports.ts')}`) const PORTS = PORT_DEFS.map((p) => p.slug) -const { KNOWN_PORTS, resolvePortBody } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) +const { KNOWN_PORTS, resolvePortBody, resolvePortContent } = await import(`file://${resolve(root, 'site/src/lib/workspace-shared-slots.ts')}`) /** The first column's label, as the prose writes it. */ const PORT_BY_LABEL = { @@ -159,8 +159,9 @@ for (const { file, source } of [...realEntries, ...sharedWorkspaceEntries(realFi if (PORT_DEFS.find((port) => port.slug === authoredPort)?.referenceKind === 'guide') continue const product = frontmatterValue(source, 'product') ?? /^ports\/[^/]+\/(workspace|mcp)\//.exec(file)?.[1] - for (const { port: contextPort, text, line, before, linked } of proseMentions(source, PORT_BY_LABEL)) { - const pagePort = contextPort ?? authoredPort + const selected = resolvePortContent(source, authoredPort) + for (const { port: contextPort, text, line, before, linked } of proseMentions(selected.body, PORT_BY_LABEL, selected.portAt)) { + const pagePort = authoredPort ?? contextPort if (notASymbol(text)) continue const decision = decideMention(text, { pagePort, product, before }, resolver, models) if (decision.kind !== 'link') { diff --git a/scripts/merge-version-manifests.mjs b/scripts/merge-version-manifests.mjs index 2259fdb4..1c20f949 100644 --- a/scripts/merge-version-manifests.mjs +++ b/scripts/merge-version-manifests.mjs @@ -9,6 +9,20 @@ function fail(message) { throw new Error(`merge-version-manifests: ${message}`) } +export function validateReceipt(value, port, version) { + const check = (condition, message) => { if (!condition) fail(message) } + const DIGEST = /^[0-9a-f]{64}$/ + const prefix = value?.destination?.prefix + const parts = typeof prefix === 'string' ? prefix.split('/') : [] + check(parts.length === 4 && /^[a-z]{2}(?:-[A-Z]{2})?$/.test(parts[0]) && parts[1] === port && parts[2] === version && parts[3] === '' && !prefix.includes('..'), + 'publication receipt belongs to another destination') + check(value.destination.url === `https://libtmux.org/${prefix}` && value.build?.url === `/${prefix}build-provenance.json` && DIGEST.test(value.build?.sha256 ?? ''), 'invalid publication build/destination') + check(value.publisher?.repository === 'libtmux/docs' && /^[0-9a-f]{40}$/.test(value.publisher?.sha ?? ''), 'invalid publication publisher') + check(['published', 'verified-existing'].includes(value.operation), 'invalid publication operation') + check(Number.isSafeInteger(value.artifact?.id) && value.artifact.id > 0 && typeof value.artifact.name === 'string' && DIGEST.test(value.artifact.sha256 ?? ''), 'invalid publication artifact') + check(/^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/[0-9]+$/.test(value.run?.url ?? '') && Number.isSafeInteger(value.run?.attempt) && value.run.attempt > 0, 'invalid publication run') +} + function parseArgs(argv) { const options = { base: undefined, fragments: undefined, out: undefined } for (let i = 0; i < argv.length; i += 1) { @@ -43,6 +57,9 @@ export function merge(base, fragments) { if (entries.some((entry) => entry.kind === 'pr' || /^pr-\d+$/.test(entry.slug))) { fail(`${port}.json contains a preview entry`) } + for (const entry of entries) { + if (entry.publication) validateReceipt(entry.publication, port, entry.slug) + } const slugs = new Set(entries.map((entry) => entry.slug)) const selectedDefault = manifest.defaultVersion[port] if (selectedDefault && !slugs.has(selectedDefault)) { diff --git a/scripts/normalize-native-shell.mjs b/scripts/normalize-native-shell.mjs index 2ec6e2c8..1b28c729 100644 --- a/scripts/normalize-native-shell.mjs +++ b/scripts/normalize-native-shell.mjs @@ -1,30 +1,96 @@ #!/usr/bin/env node -import { readFileSync, readdirSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' +import { Window } from 'happy-dom' +import { PORT_BY_SLUG } from '../site/src/lib/ports.ts' -/** Keep generated native shell assets inside the assembly's locale and preview. */ -export function normalizeNativeShell(directory, prefix) { +const currentPage = '__LIBTMUX_NATIVE_CURRENT_PAGE__' +const escapeAttribute = (text) => text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<') + +/** Render the runtime's own chrome once per port/version, without network access. */ +async function renderChrome(root, port, version) { + const window = new Window({ url: `https://libtmux.org${root}/${port.slug}/${version}/api/` }) + window.fetch = async () => ({ ok: false }) + try { + window.eval(readFileSync(new URL('../site/public/_shell/shell.js', import.meta.url), 'utf8')) + window.document.dispatchEvent(new window.Event('DOMContentLoaded')) + const header = window.document.querySelector('[data-lt-shell="header"]') + const footer = window.document.querySelector('[data-lt-shell="footer"]') + const style = window.document.getElementById('lt-shell-style') + if (!header || !footer || !style) throw new Error('Native shell did not render its header, footer and styles') + for (const link of header.querySelectorAll('[data-page-port-switcher] a[aria-current], a[lang="en"]')) { + link.setAttribute('href', currentPage) + } + return { header: header.outerHTML, footer: footer.outerHTML, style: style.outerHTML } + } finally { + await window.happyDOM.close() + } +} + +/** + * Keep generated native shell assets inside the assembly's locale and preview. + * @param {string} directory + * @param {string} prefix + * @param {{ sphinxPort?: string, version?: string }} [options] + */ +export async function normalizeNativeShell(directory, prefix, { sphinxPort, version = 'latest' } = {}) { + const port = sphinxPort ? PORT_BY_SLUG[sphinxPort] : undefined + if (sphinxPort && port?.renderer !== 'sphinx') throw new Error(`Not a Sphinx port: ${sphinxPort}`) const root = prefix.replace(/\/+$/, '') + const chrome = port ? await renderChrome(root, port, version) : undefined + const normalize = (content) => content.replace(/(['"(])(?:https?:\/\/libtmux\.org)?\/_shell\//g, `$1${root}/_shell/`) + const adapterPath = join(directory, '_static/libtmux-org.css') + if (port) { + mkdirSync(dirname(adapterPath), { recursive: true }) + writeFileSync(adapterPath, normalize(readFileSync(new URL('../site/public/_shell/sphinx.css', import.meta.url), 'utf8'))) + } let changed = 0 - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const path = join(directory, entry.name) - if (entry.isDirectory()) { - changed += normalizeNativeShell(path, root) - } else if (/\.(html|css)$/.test(entry.name)) { - const before = readFileSync(path, 'utf8') - const after = before.replace(/(['"(])(?:https?:\/\/libtmux\.org)?\/_shell\//g, `$1${root}/_shell/`) - if (after !== before) { - writeFileSync(path, after) - changed++ + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name !== '_sources') walk(path) + } else if (/\.(html|css)$/.test(entry.name)) { + const before = readFileSync(path, 'utf8') + let after = normalize(before) + if (port && entry.name.endsWith('.html') && !/]*http-equiv=["']refresh["']/i.test(after)) { + if (!/<\/head>/i.test(after)) throw new Error(`Native page has no closing head: ${path}`) + // Old source refs may predate the shell; replace existing integration once. + after = after + .replace(/]*\bsrc=["'][^"']*\/_shell\/shell\.js(?:\?[^"']*)?["'][^>]*>[\s\S]*?<\/script>\s*/gi, '') + .replace(/]*\bhref=["'][^"']*\blibtmux-org\.css(?:\?[^"']*)?["'][^>]*>\s*/gi, '') + .replace(/]*\bid=["']lt-shell-style["'][^>]*>[\s\S]*?<\/style>\s*/gi, '') + .replace(/[\s\S]*?\s*/g, '') + const cssUrl = relative(dirname(path), adapterPath).split(sep).join('/') + after = after.replace(/<\/head>/i, `\n${chrome.style}\n\n`) + if (!/]*>/i.test(after) || !/<\/body>/i.test(after)) throw new Error(`Native page has no body: ${path}`) + const pageUrl = `${root}/${port.slug}/${version}/api/${relative(directory, path).split(sep).join('/').replace(/index\.html$/, '')}` + const header = chrome.header.replaceAll(currentPage, escapeAttribute(pageUrl)) + after = after + .replace(/(]*>)\s*/i, `$1\n${header}\n`) + .replace(/\s*<\/body>/i, `\n${chrome.footer}\n`) + if (!/]*)>/i, (_tag, attributes) => { + const clean = attributes.replace(/\sdata-pagefind-(?:body|filter)(?:=["'][^"']*["'])?/gi, '') + return `` + }) + } + if (after !== before) { + writeFileSync(path, after) + changed++ + } } } } + walk(directory) return changed } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { - const [directory, prefix] = process.argv.slice(2) - if (!directory || !prefix?.startsWith('/')) throw new Error('Usage: normalize-native-shell.mjs DIRECTORY /LOCALE_PREFIX') - console.log(`Native shell URLs: normalized ${normalizeNativeShell(directory, prefix)} HTML/CSS files`) + const [directory, prefix, sphinxPort, version] = process.argv.slice(2) + if (!directory || !prefix?.startsWith('/')) { + throw new Error('Usage: normalize-native-shell.mjs DIRECTORY /LOCALE_PREFIX [SPHINX_PORT VERSION]') + } + console.log(`Native shell: prepared ${await normalizeNativeShell(directory, prefix, { sphinxPort, version })} HTML/CSS files`) } diff --git a/scripts/publication-provenance.mjs b/scripts/publication-provenance.mjs new file mode 100644 index 00000000..114f8736 --- /dev/null +++ b/scripts/publication-provenance.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +/** Deterministic build inputs and bytes; run-specific artifact identity stays outside the tree. */ +import { appendFileSync, lstatSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PORT_BY_SLUG } from '../site/src/lib/ports.ts' + +export const RECORD = 'build-provenance.json' +const SHA = /^[0-9a-f]{40}$/ +const DIGEST = /^[0-9a-f]{64}$/ +const segment = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/ +const fail = (message) => { throw new Error(`publication-provenance: ${message}`) } +const check = (condition, message) => { if (!condition) fail(message) } +const json = (file) => JSON.parse(readFileSync(file, 'utf8')) +export const digest = (bytes) => createHash('sha256').update(bytes).digest('hex') +const write = (file, value) => writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) +const git = (directory, ...args) => execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8' }).trim() + +function validateRoute(version, locale) { + check(typeof version === 'string' && segment.test(version) && !/[\r\n]/.test(version) && !version.includes('..'), 'invalid version') + check(typeof locale === 'string' && /^[a-z]{2}(?:-[A-Z]{2})?$/.test(locale) && !/[\r\n]/.test(locale), 'invalid locale') +} + +export function workflowIdentity(repository, sha) { + check(repository === 'libtmux/docs' && SHA.test(sha ?? ''), + 'GitHub Cloud job.workflow_repository/job.workflow_sha must identify libtmux/docs at a full commit SHA') + return { repository, sha } +} + +export function checkoutIdentity(directory) { + const origin = git(directory, 'remote', 'get-url', 'origin') + const repository = /^(?:https:\/\/github\.com\/|git@github\.com:|(?:git\+)?ssh:\/\/git@github\.com\/)([^/]+\/[^/]+?)(?:\.git)?$/.exec(origin)?.[1] + check(repository, `checkout has no GitHub origin: ${directory}`) + const sha = git(directory, 'rev-parse', 'HEAD') + check(SHA.test(sha), 'checkout HEAD must be a full SHA') + return { repository, sha, dirty: git(directory, 'status', '--porcelain', '--untracked-files=normal') !== '' } +} + +export function snapshot(docsRoot, env = process.env) { + const port = PORT_BY_SLUG[env.LIBTMUX_DOCS_PORT] + check(port, 'unknown source port') + const locale = env.LIBTMUX_DOCS_LOCALE || 'en' + validateRoute(env.LIBTMUX_DOCS_VERSION, locale) + const core = checkoutIdentity(env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`]) + // Fork pull requests can build, but publication still requires the catalog's + // owning repository in verifyBuild(). Record the selected checkout's origin. + const sourceRepository = env.LIBTMUX_DOCS_SOURCE_REPOSITORY || port.repo + check(core.repository === sourceRepository, `source repository must be ${sourceRepository}`) + check(core.sha === env.LIBTMUX_DOCS_SOURCE_SHA, 'source SHA differs from actual checkout HEAD') + const sources = [{ product: 'core', ...core }] + if (port.slug === 'py') { + for (const [product, variable, fallback, repository] of [ + ['workspace', 'LIBTMUX_DOCS_WORKSPACE_PY', 'work/python/tmuxp', 'tmux-python/tmuxp'], + ['mcp', 'LIBTMUX_DOCS_MCP_PY', 'work/python/libtmux-mcp', 'tmux-python/libtmux-mcp'], + ]) { + const input = checkoutIdentity(env[variable] || join(homedir(), fallback)) + check(input.repository === repository, `source repository must be ${repository}`) + sources.push({ product, ...input }) + } + } + const docs = checkoutIdentity(docsRoot) + check(docs.repository === 'libtmux/docs', 'docs checkout must belong to libtmux/docs') + const nativeGenerator = ['ruby', 'lua'].includes(port.slug) + ? checkoutIdentity(env.LIBTMUX_DOCS_GENERATOR_CHECKOUT || env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`]) : undefined + if (nativeGenerator) check(nativeGenerator.repository === port.repo, `native generator repository must be ${port.repo}`) + const current = { schema: 1, port: port.slug, version: env.LIBTMUX_DOCS_VERSION, locale, docs, sources, + ...(nativeGenerator ? { nativeGenerator } : {}), + } + if (!env.LIBTMUX_DOCS_INPUT_SNAPSHOT) return current + const captured = json(env.LIBTMUX_DOCS_INPUT_SNAPSHOT) + // The shared builder captures inputs before native generators emit files + // such as Swift symbolgraph/. Their output must not masquerade as a source + // edit. Recheck every actual HEAD when assembly consumes that snapshot. + const revisions = (value) => ({ ...value, + docs: { ...value.docs, dirty: undefined }, + ...(value.nativeGenerator ? { nativeGenerator: { ...value.nativeGenerator, dirty: undefined } } : {}), + sources: value.sources.map((source) => ({ ...source, dirty: undefined })), + }) + check(JSON.stringify(revisions(captured)) === JSON.stringify(revisions(current)), 'input snapshot no longer matches checkout revisions') + check(typeof captured.docs.dirty === 'boolean' && captured.sources.every((source) => typeof source.dirty === 'boolean') && + (!nativeGenerator || typeof captured.nativeGenerator?.dirty === 'boolean'), 'input snapshot has no dirty state') + return captured +} + +function paths(root, prefix = '') { + const result = [] + for (const name of readdirSync(join(root, prefix)).sort()) { + const path = prefix ? `${prefix}/${name}` : name + check(!path.includes('\\') && !/[\r\n]/.test(path), `unsafe file path: ${path}`) + const stat = lstatSync(join(root, path)) + check(!stat.isSymbolicLink(), `symlink is forbidden: ${path}`) + if (stat.isDirectory()) result.push(...paths(root, path)) + else { check(stat.isFile(), `non-regular file: ${path}`); result.push(path) } + } + return result.sort() +} + +export function inventory(root) { + return paths(root).filter((path) => path !== RECORD).map((path) => { + const bytes = readFileSync(join(root, path)) + return { path, size: bytes.length, sha256: digest(bytes) } + }) +} + +export function normalize(root, locale) { + for (const path of paths(root).filter((path) => /\.(html|css)$/.test(path))) { + const file = join(root, path) + const before = readFileSync(file, 'utf8') + const after = before.replace(/(['"(])(?:https?:\/\/libtmux\.org)?\/_shell\//g, `$1/${locale}/_shell/`) + if (before !== after) writeFileSync(file, after) + } +} + +export function recordBuild(root, inputs) { + normalize(root, inputs.locale) + const href = `/${inputs.locale}/${inputs.port}/${inputs.version}/${RECORD}` + const link = `` + for (const path of paths(root).filter((path) => path.endsWith('.html'))) { + const file = join(root, path) + const before = readFileSync(file, 'utf8').replace(/]*>/g, '') + const after = /<\/head>/i.test(before) ? before.replace(/<\/head>/i, `${link}`) + : /^(\s*]*>)/i.test(before) ? before.replace(/^(\s*]*>)/i, `$1${link}`) : `${link}${before}` + writeFileSync(file, after) + } + const record = { ...inputs, files: inventory(root) } + check(record.files.some((file) => file.path === 'index.html'), 'version tree has no index.html') + write(join(root, RECORD), record) + return record +} + +export function validateDescriptor(value, expected) { + check(value?.schema === 1, 'missing artifact descriptor schema') + check(value.artifact?.name === expected.name, 'artifact name differs from requested artifact') + check(Number.isSafeInteger(value.artifact?.id) && value.artifact.id > 0, 'invalid artifact ID') + check(DIGEST.test(value.artifact?.sha256 ?? ''), 'invalid artifact SHA256') + check(/^[0-9]+$/.test(value.run?.id ?? ''), 'invalid artifact run ID') + check(value.run?.repository === expected.repository && value.run?.id === String(expected.runId), 'artifact belongs to another repository/run') + check(Number.isSafeInteger(value.run?.attempt) && value.run.attempt > 0 && value.run.attempt <= Number(expected.attempt), 'invalid artifact run attempt') + check(SHA.test(value.sourceSha ?? ''), 'invalid source SHA') + return value +} + +// The pinned download action validates against GitHub's digest. Check the +// descriptor's digest too, before extracting any caller-controlled archive. +export function unpackArchive(directory, descriptor, out) { + const files = paths(directory) + check(files.length === 1, 'expected exactly one downloaded archive') + const archive = join(directory, files[0]) + check(digest(readFileSync(archive)) === descriptor.artifact.sha256, 'artifact archive SHA256 differs from descriptor') + execFileSync('python3', ['-c', ` +import pathlib, stat, sys, zipfile +root = pathlib.Path(sys.argv[2]) +root.mkdir(parents=True, exist_ok=False) +with zipfile.ZipFile(sys.argv[1]) as archive: + seen = set() + for item in archive.infolist(): + path = pathlib.PurePosixPath(item.filename) + if not path.parts or path.as_posix() != item.filename.rstrip("/") or path.is_absolute() or ".." in path.parts or "\\\\" in item.filename or path.as_posix() in seen: + raise ValueError("unsafe or duplicate artifact path: " + item.filename) + seen.add(path.as_posix()) + mode = item.external_attr >> 16 + if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))): + raise ValueError("non-regular artifact entry: " + item.filename) + archive.extractall(root) +`, archive, out], { stdio: 'pipe' }) +} + +export function verifyBuild(root, expected) { + validateRoute(expected.version, expected.locale) + const record = json(join(root, RECORD)) + const port = PORT_BY_SLUG[expected.port] + check(port?.repo === expected.repository, 'caller repository does not own this port') + workflowIdentity(expected.publisherRepository, expected.publisherSha) + check(record.schema === 1 && record.port === expected.port && record.version === expected.version && record.locale === expected.locale, + 'build identity differs from publication destination') + check(record.docs?.repository === expected.publisherRepository && record.docs?.sha === expected.publisherSha, + 'builder docs SHA differs from publisher workflow SHA') + check(record.docs.dirty === false, 'docs inputs are dirty') + if (['ruby', 'lua'].includes(record.port)) { + check(record.nativeGenerator?.repository === port.repo && SHA.test(record.nativeGenerator?.sha ?? ''), 'missing or invalid native generator repository/SHA') + check(record.nativeGenerator.dirty === false, 'native generator inputs are dirty') + } else check(record.nativeGenerator === undefined, 'unexpected native generator') + const products = record.port === 'py' ? ['core', 'workspace', 'mcp'] : ['core'] + check(JSON.stringify(record.sources?.map((source) => source.product)) === JSON.stringify(products), 'missing or unexpected source products') + const repositories = [port.repo, 'tmux-python/tmuxp', 'tmux-python/libtmux-mcp'] + record.sources.forEach((source, index) => { + check(source.repository === repositories[index] && SHA.test(source.sha ?? ''), 'invalid source repository/SHA') + check(source.dirty === false, 'source inputs are dirty') + }) + check(record.sources[0].sha === expected.sourceSha, 'source SHA differs from builder artifact descriptor') + check(expected.prefix === `${record.locale}/${record.port}/${record.version}`, 'destination prefix differs from build') + // Normalization must already have happened in the builder. Any byte changed + // here makes the inventory fail, rather than silently publishing other bytes. + normalize(root, record.locale) + check(Array.isArray(record.files) && record.files.some((file) => file.path === 'index.html'), 'version tree has no index.html') + check(JSON.stringify(record.files) === JSON.stringify(inventory(root)), 'content inventory differs (changed, missing, extra, or duplicate files)') + return record +} + +export function receipt(root, descriptor, expected) { + verifyBuild(root, { ...expected, sourceSha: descriptor.sourceSha }) + return { + build: { url: `/${expected.prefix}/${RECORD}`, sha256: digest(readFileSync(join(root, RECORD))) }, + artifact: descriptor.artifact, + run: { url: `https://github.com/${descriptor.run.repository}/actions/runs/${descriptor.run.id}`, attempt: descriptor.run.attempt }, + publisher: workflowIdentity(expected.publisherRepository, expected.publisherSha), + destination: { prefix: `${expected.prefix}/`, url: `https://libtmux.org/${expected.prefix}/` }, + } +} + + +function expected(env) { + return { port: env.PORT, version: env.VERSION, locale: env.LOCALE || 'en', prefix: env.PREFIX, + publisherRepository: env.PUBLISHER_REPOSITORY, publisherSha: env.PUBLISHER_SHA, + name: env.ARTIFACT_NAME, repository: env.GITHUB_REPOSITORY, runId: env.GITHUB_RUN_ID, attempt: env.GITHUB_RUN_ATTEMPT } +} + +function main() { + const [command, first, second, third] = process.argv.slice(2) + const env = process.env + if (command === 'snapshot') write(first, snapshot(second, env)) + else if (command === 'record') recordBuild(first, json(second)) + else if (command === 'workflow') workflowIdentity(env.PUBLISHER_REPOSITORY, env.PUBLISHER_SHA) + else if (command === 'descriptor') { + const value = { schema: 1, artifact: { id: Number(env.ARTIFACT_ID), name: env.ARTIFACT_NAME, sha256: env.ARTIFACT_DIGEST }, + run: { repository: env.GITHUB_REPOSITORY, id: env.GITHUB_RUN_ID, attempt: Number(env.GITHUB_RUN_ATTEMPT) }, sourceSha: env.SOURCE_SHA } + write(first, validateDescriptor(value, expected(env))) + } else if (command === 'select') { + const value = validateDescriptor(json(first), expected(env)) + appendFileSync(env.GITHUB_OUTPUT, `artifact-id=${value.artifact.id}\n`) + } else if (command === 'unpack') unpackArchive(first, validateDescriptor(json(second), expected(env)), third) + else if (command === 'verify') write(third, receipt(first, validateDescriptor(json(second), expected(env)), expected(env))) + else fail(`unknown command: ${command}`) +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main() diff --git a/scripts/publish-plan.mjs b/scripts/publish-plan.mjs index 35201daf..092e5e42 100644 --- a/scripts/publish-plan.mjs +++ b/scripts/publish-plan.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -// Plan a publish.yml dispatch: which ports' docs.yml to run, and with which -// inputs. One dispatch publishes any set of ports at a ref: +// Plan a publish.yml dispatch: which reviewed port workflows and inputs to use. +// One dispatch publishes any set of ports at a ref: // // - `latest` publishes each port's default branch as `latest`. It is the // default version only while the port has no stable release, the rule a @@ -9,8 +9,8 @@ // prerelease or `stable` (the default) for a release, as a tag push does. // - Any other ref is one port's exact ref, published as the inputs name it. // -// Only ports whose docs.yml takes dispatch inputs are planned: those in the -// libtmux organization. py's workflow lives elsewhere and takes none. +// Dispatch capability and the caller branch come from the port catalog. +// User-selected source refs never choose which workflow revision executes. // // Prints the matrix as JSON on stdout and a Markdown table on stderr. @@ -31,22 +31,16 @@ function versionOf(tag, port) { return port.tagPrefix ? tag.slice(port.tagPrefix.length) : tag } -/** Any letter after the leading `v` marks a prerelease, in every grammar. */ -function isPrerelease(version) { - return /[A-Za-z]/.test(version.replace(/^v/, '')) -} - /** * The dispatches one publish.yml run makes. * * @param {{ ports: string, ref: string, version?: string, versionKind?: string, isDefault?: boolean, resolvesTo?: string }} inputs - * @param {{ slug: string, repo: string, tagPrefix?: string, tagGrammar: string, parentLibrary?: { slug: string } }[]} catalog + * @param {readonly { slug: string, repo: string, tagPrefix?: string, tagGrammar: string, docsDispatch?: { workflow: string, ref?: string, language?: string } }[]} catalog * @param {(repo: string) => { defaultBranch: string, tags: string[] }} lookup - * @param {{ releaseTag: Function, newestPublishedTag: Function }} versions + * @param {{ releaseTag: Function, newestPublishedTag: Function, packageVersionIsPrerelease: Function }} versions */ export function plan(inputs, catalog, lookup, versions) { - const dispatchable = catalog.filter((port) => port.repo.startsWith('libtmux/')) - const familyParents = new Set(catalog.flatMap((port) => port.parentLibrary ? [port.parentLibrary.slug] : [])) + const dispatchable = catalog.filter((port) => port.docsDispatch) const wanted = inputs.ports.trim() === 'all' ? dispatchable : inputs.ports.split(',').map((slug) => slug.trim()).filter(Boolean).map((slug) => { @@ -58,6 +52,7 @@ export function plan(inputs, catalog, lookup, versions) { if (wanted.length === 0) fail('no ports selected') const ref = inputs.ref.trim() + if (!ref) fail('source ref is required') const exact = ref !== 'latest' && ref !== 'release' if (exact && wanted.length !== 1) fail(`an exact ref (${ref}) names one port's source; select one port`) if (!exact && (inputs.version || (inputs.versionKind && inputs.versionKind !== 'auto') || inputs.resolvesTo)) { @@ -67,19 +62,31 @@ export function plan(inputs, catalog, lookup, versions) { const entries = [] const sources = new Map() for (const port of wanted) { + const repository = /^([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9_.-]+)$/.exec(port.repo) + if (!repository) fail(`invalid repository for ${port.slug}: ${port.repo}`) + const dispatch = port.docsDispatch + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.ya?ml$/.test(dispatch.workflow)) { + fail(`invalid docs workflow for ${port.slug}: ${dispatch.workflow}`) + } + if (dispatch.ref !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(dispatch.ref)) { + fail(`invalid docs workflow ref for ${port.slug}: ${dispatch.ref}`) + } + if (dispatch.language !== undefined && dispatch.language !== port.slug) { + fail(`docs language for ${port.slug} must select itself`) + } if (!sources.has(port.repo)) sources.set(port.repo, lookup(port.repo)) const { defaultBranch, tags } = sources.get(port.repo) - const base = { port: port.slug, repo: port.repo, repoName: port.repo.split('/')[1], dispatchRef: defaultBranch, - language: port.parentLibrary || familyParents.has(port.slug) ? port.slug : '' } + const base = { port: port.slug, repo: port.repo, repoOwner: repository[1], repoName: repository[2], + workflow: dispatch.workflow, dispatchRef: dispatch.ref ?? defaultBranch, language: dispatch.language ?? '' } const releases = tags.filter((tag) => versions.releaseTag(tag, port) !== null) if (ref === 'latest') { - const stable = releases.some((tag) => !isPrerelease(versionOf(tag, port))) + const stable = releases.some((tag) => !versions.packageVersionIsPrerelease(versionOf(tag, port), port.tagGrammar)) entries.push({ ...base, sourceRef: defaultBranch, version: 'latest', kind: 'trunk', isDefault: !stable, resolvesTo: '' }) } else if (ref === 'release') { const tag = versions.newestPublishedTag(releases, port, null) if (!tag) continue const version = versionOf(tag, port) - const alias = isPrerelease(version) ? 'next' : 'stable' + const alias = versions.packageVersionIsPrerelease(version, port.tagGrammar) ? 'next' : 'stable' entries.push({ ...base, sourceRef: tag, version, kind: 'tag', isDefault: false, resolvesTo: '' }) entries.push({ ...base, sourceRef: tag, version: alias, kind: 'alias', isDefault: alias === 'stable', resolvesTo: version }) } else { @@ -97,12 +104,12 @@ export function plan(inputs, catalog, lookup, versions) { /** The Markdown summary of a plan. */ export function summary(entries, dryRun) { const rows = entries.map((e) => - `| ${e.port} | \`${e.sourceRef}\` | ${e.version} | ${e.kind}${e.resolvesTo ? ` → ${e.resolvesTo}` : ''} | ${e.isDefault ? 'yes' : ''} |`) + `| ${e.port} | \`${e.repo}/${e.workflow}@${e.dispatchRef}\` | \`${e.sourceRef}\` | ${e.version} | ${e.kind}${e.resolvesTo ? ` → ${e.resolvesTo}` : ''} | ${e.isDefault ? 'yes' : ''} |`) return [ `### ${dryRun ? 'Dry run: would publish' : 'Publishing'} ${entries.length} version(s)`, '', - '| Port | Source | Version | Kind | Default |', - '| --- | --- | --- | --- | --- |', + '| Port | Caller | Source | Version | Kind | Default |', + '| --- | --- | --- | --- | --- | --- |', ...rows, '', ].join('\n') diff --git a/scripts/stage-port-docs.mjs b/scripts/stage-port-docs.mjs index fbaefbae..27936fa4 100644 --- a/scripts/stage-port-docs.mjs +++ b/scripts/stage-port-docs.mjs @@ -5,6 +5,8 @@ import { execFileSync } from 'node:child_process' import { homedir } from 'node:os' import { dirname, join, posix, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' import { PORTS } from '../site/src/lib/ports.ts' import { sourceGuidesFor, SOURCE_GUIDE_PORTS } from '../site/src/lib/port-documentation.ts' @@ -13,6 +15,8 @@ const output = join(root, 'site/src/content/docs/_staged') const selectedPort = process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : undefined const check = process.argv.includes('--check') const fromSource = process.argv.includes('--from-source') +const integrated = process.argv.includes('--integrated') +const cached = process.argv.includes('--cached') const wrappersOnly = process.argv.includes('--wrappers') const refreshCache = process.argv.includes('--refresh-cache') const cacheRoot = join(root, 'site/src/data/port-guides') @@ -47,6 +51,14 @@ function external(value) { export function rewriteLinks(content, sourcePath, route, routes, repo, revision) { const targetUrl = (destination, image = false) => { + const ownSource = `https://github.com/${repo}/blob/` + if (!image && destination.startsWith(ownSource)) { + const [ref, ...path] = destination.slice(ownSource.length).split('/') + const [target, fragment = ''] = path.join('/').split('#', 2) + if (['master', 'main', revision].includes(ref)) { + destination = `${posix.relative(posix.dirname(sourcePath), target)}${fragment ? `#${fragment}` : ''}` + } + } if (external(destination)) return destination const [target, fragment = ''] = destination.split('#', 2) const normalized = posix.normalize(posix.join(posix.dirname(sourcePath), target)) @@ -58,11 +70,21 @@ export function rewriteLinks(content, sourcePath, route, routes, repo, revision) } return `https://github.com/${repo}/${image ? 'raw' : 'blob'}/${revision}/${normalized}${fragment ? `#${fragment}` : ''}` } + // Scala calls such as resource[IO](config) look like Markdown links. + // Parse first so code remains byte-for-byte source-owned, then replace + // only real links; the rest of the guide keeps its authored formatting. + const editsFor = (node) => { + const children = (node.children ?? []).flatMap(editsFor) + if (!['link', 'image', 'definition'].includes(node.type)) return children + const url = targetUrl(node.url, node.type === 'image') + if (url === node.url) return children + node.url = url + return [{ start: node.position.start.offset, end: node.position.end.offset, + text: toMarkdown(node).trimEnd() }] + } + const edits = editsFor(fromMarkdown(content)).sort((a, b) => b.start - a.start) + for (const { start, end, text } of edits) content = content.slice(0, start) + text + content.slice(end) return content - .replace(/(!?)\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, - (_all, image, label, destination) => `${image}[${label}](${targetUrl(destination, Boolean(image))})`) - .replace(/^(\[[^\]]+\]:)\s*\n?\s*(\S+)/gm, - (_all, label, destination) => `${label} ${targetUrl(destination)}`) } export function stagedPortGuides(port, artifact) { @@ -92,20 +114,23 @@ export function stagedPortGuides(port, artifact) { files.set(`${port}/${route}/index.md`, `---\n${frontmatter}\n---\n\n${rewritten.trim()}\n`) } const identity = PORTS.find((entry) => entry.slug === port) - if (identity.parentLibrary) { + { const source = { repo: artifact.source.repository, path: Object.keys(routes)[0], ref: artifact.source.revision } const writeIndex = (route, title, body, cards = []) => { const data = { title, description: `${title} for ${identity.packageName}.`, port, route, source, cards, - sidebar: { group: route === 'reference' ? 'API reference' : 'Guides', order: 0 } } + sidebar: { group: route === 'reference' ? 'API reference' : route[0].toUpperCase() + route.slice(1), order: 0 } } const frontmatter = Object.entries(data).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join('\n') files.set(`${port}/${route}/index.md`, `---\n${frontmatter}\n---\n\n${body}\n`) } - const cards = Object.entries(routes).filter(([, entry]) => entry.route.startsWith('guides/')) - .map(([path, entry]) => ({ label: titleAndBody(guides.get(path), path).title, - href: `./${entry.route.slice('guides/'.length)}/`, body: `Read the ${identity.name} package guide.` })) - writeIndex('guides', `${identity.name} guides`, - `These guides come from the ${identity.packageName} sources at this documentation revision.`, cards) - if (identity.ecosystemHost) writeIndex('reference', `${identity.name} API reference`, + for (const section of identity.parentLibrary ? ['guides'] : ['guides', 'examples', 'topics']) { + const ownSection = Object.entries(routes).filter(([, entry]) => entry.route.startsWith(`${section}/`)) + const selected = ownSection.length ? ownSection : Object.entries(routes).filter(([, entry]) => entry.domain === 'core' && entry.route.startsWith('guides/')) + const cards = selected.map(([path, entry]) => ({ label: titleAndBody(guides.get(path), path).title, + href: `../${entry.route}/`, body: `Read the ${identity.name} guide and its examples.` })) + writeIndex(section, `${identity.name} ${section}`, + `Use these ${identity.packageName} guides for the APIs and examples in this version.`, cards) + } + if (identity.parentLibrary && identity.ecosystemHost) writeIndex('reference', `${identity.name} API reference`, `Use the [${identity.ecosystemHost.name} reference](${identity.ecosystemHost.url}) for published package versions.\n\nThe [source at this documentation revision](https://github.com/${source.repo}/tree/${source.ref}/${posix.dirname(source.path)}/src/main) contains the wrapper declarations and their documentation.\n\n${identity.name} and its ${identity.parentLibrary.runtime} core share a release version.`) } return files @@ -114,43 +139,68 @@ export function stagedPortGuides(port, artifact) { function artifactFromSource(port, checkout) { const modelPath = join(root, `site/src/data/api/${port.slug}.json`) const model = port.parentLibrary ? undefined : JSON.parse(readFileSync(modelPath, 'utf8')) - const head = execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() - if (model && head !== model.revision) { - throw new Error(`${port.slug}: checkout ${head} differs from integrated model ${model.revision}`) - } + const revision = model?.revision ?? execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() return { - source: { repository: port.repo, revision: head }, + source: { repository: port.repo, revision }, guides: Object.keys(ROUTES[port.slug]).map((path) => ({ path, - content: readFileSync(join(checkout, path), 'utf8'), + content: execFileSync('git', ['-C', checkout, 'show', `${revision}:${path}`], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }), })), } } +/** Read the integrated commit even when a contributor's working tree has advanced. */ +export function artifactFromRevision(port, checkout, revision) { + if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error(`${port.slug}: invalid integrated source revision`) + const git = (...args) => execFileSync('git', ['-C', checkout, ...args], { encoding: 'utf8', stdio: 'pipe' }) + try { + git('cat-file', '-e', `${revision}^{commit}`) + } catch (cause) { + throw new Error(`${port.slug}: integrated guides need ${port.repo}@${revision} in ${checkout}. Set LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()} to a local checkout containing that commit; this check does not fetch.`, { cause }) + } + return { + source: { repository: port.repo, revision }, + guides: Object.keys(ROUTES[port.slug]).map((path) => ({ path, content: git('show', `${revision}:${path}`) })), + } +} + export function run() { if (selectedPort && !(selectedPort in ROUTES)) throw new Error(`unsupported staged port: ${selectedPort}`) + if (integrated && (fromSource || refreshCache || process.env.LIBTMUX_DOCS_SOURCE_SHA)) { + throw new Error('--integrated cannot replace selected-source publication inputs') + } + const publicationPort = process.env.LIBTMUX_DOCS_SOURCE_SHA && process.env.LIBTMUX_DOCS_PORT + if (cached && selectedPort && selectedPort === publicationPort) { + throw new Error(`--cached cannot stage selected publication port ${selectedPort}; regenerate its source guides first`) + } const generated = new Map() - const selected = PORTS.filter((entry) => entry.slug in ROUTES && (!selectedPort || entry.slug === selectedPort) && (!wrappersOnly || entry.parentLibrary)) - if (refreshCache && (!selectedPort || !selected[0]?.parentLibrary)) throw new Error('--refresh-cache requires one wrapper --port') + const selected = PORTS.filter((entry) => entry.slug in ROUTES && (!selectedPort || entry.slug === selectedPort) + && (!wrappersOnly || entry.parentLibrary) && !(cached && entry.slug === publicationPort)) + if (refreshCache && !selectedPort) throw new Error('--refresh-cache requires one --port') for (const port of selected) { - const checkout = expand(process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || port.worktree) + const checkout = expand(process.env[`LIBTMUX_DOCS_CHECKOUT_${port.slug.toUpperCase()}`] || (integrated ? port.checkout : port.worktree)) const artifactPath = join(checkout, 'docs/_build/api.json') const selectedSource = process.env.LIBTMUX_DOCS_PORT === port.slug && process.env.LIBTMUX_DOCS_SOURCE_SHA const cachePath = join(cacheRoot, `${port.slug}.json`) const liveWrapper = port.parentLibrary && (refreshCache || selectedSource || (selectedPort && fromSource)) - if (!port.parentLibrary && !fromSource && !existsSync(artifactPath)) throw new Error(`${port.slug}: native artifact missing at ${artifactPath}`) - const artifact = port.parentLibrary && !liveWrapper + if (!cached && !port.parentLibrary && !fromSource && !integrated && !existsSync(artifactPath)) throw new Error(`${port.slug}: native artifact missing at ${artifactPath}`) + const artifact = cached || (port.parentLibrary && !liveWrapper) ? JSON.parse(readFileSync(cachePath, 'utf8')) + : integrated ? artifactFromRevision(port, checkout, JSON.parse(readFileSync(join(root, `site/src/data/api/${port.slug}.json`), 'utf8')).revision) : fromSource || liveWrapper ? artifactFromSource(port, checkout) : JSON.parse(readFileSync(artifactPath, 'utf8')) if (artifact.source.repository !== port.repo || !/^[a-f0-9]{40}$/.test(artifact.source.revision)) { throw new Error(`${port.slug}: invalid source guide provenance`) } + if (!port.parentLibrary) { + const model = JSON.parse(readFileSync(join(root, `site/src/data/api/${port.slug}.json`), 'utf8')) + if (artifact.source.revision !== model.revision) throw new Error(`${port.slug}: guide source ${artifact.source.revision} differs from integrated model ${model.revision}`) + } const expected = selectedSource if (expected && artifact.source?.revision !== expected) { throw new Error(`${port.slug}: expected source ${expected}, artifact records ${artifact.source?.revision}`) } - if (port.parentLibrary && !check && (refreshCache || selectedSource)) { + if (!check && (refreshCache || selectedSource)) { mkdirSync(cacheRoot, { recursive: true }) writeFileSync(cachePath, `${JSON.stringify(artifact, null, 2)}\n`) } diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 44fb857d..f2b1af00 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -2,7 +2,7 @@ # Publication audit: source checks, complete assembly, output and browser audits. # This has no development-loop time budget; use pnpm test for fresh sampled rendering. # -# Usage: scripts/test-all.sh [--skip-build] +# Usage: scripts/test-all.sh [--skip-build | --preview pr-N | --output-only pr-N] set -euo pipefail cd "$(dirname "$0")/.." @@ -12,9 +12,24 @@ export LIBTMUX_DOCS_TEST_SITE="$out" SERVE_URL="${LIBTMUX_DOCS_SERVE:-http://localhost:8080}" # The served site, one locale segment below the origin. serve.sh serves the # bucket root, so a page URL carries the prefix the assembly built under. -SERVE_SITE="$SERVE_URL/${LIBTMUX_DOCS_LOCALE:-en}" +preview="" skip_build=false -[[ "${1:-}" == "--skip-build" ]] && skip_build=true +output_only=false +case "${1:-}" in + '') ;; + --skip-build) skip_build=true ;; + --preview|--output-only) + [[ "${2:-}" =~ ^pr-[1-9][0-9]*$ && $# == 2 ]] || { echo 'expected one preview prefix: pr-N' >&2; exit 1; } + preview="/$2" + [[ "$1" == --output-only ]] && output_only=true + ;; + *) echo "unknown publication audit option: $1" >&2; exit 1 ;; +esac +export LIBTMUX_DOCS_TEST_PREFIX="$preview" +SERVE_SITE="$SERVE_URL$preview/${LIBTMUX_DOCS_LOCALE:-en}" +if [[ -n "$preview" ]]; then + export LIBTMUX_DOCS_TEST_PRODUCTION_SITE="${out}.production" +fi # shellcheck source=scripts/skip-summary.sh . "$(dirname "$0")/skip-summary.sh" @@ -24,6 +39,9 @@ note_skip() { skipped="${skipped:+$skipped, }$1"; } step() { printf '\n\033[1m== %s\033[0m\n' "$1"; } +if [[ "$output_only" == true ]]; then note_skip 'source checks and build (explicit --output-only)'; fi + +if [[ "$output_only" == false ]]; then step 'cached inventories' # The inventories are committed, so this only reports their absence — but a # build without them silently produces fewer links rather than failing, which @@ -183,7 +201,40 @@ fi # is what `scripts/serve.sh` publishes — so what is checked here is the tree a # reader gets, not a near-copy of it. step 'build' -./scripts/build-site.sh +if [[ -n "$preview" ]]; then + LIBTMUX_DOCS_LOCALES_ROOT="$preview" LIBTMUX_DOCS_VERSION="${preview#/}" \ + LIBTMUX_DOCS_VERSION_KIND=pr LIBTMUX_DOCS_IS_DEFAULT=false \ + ./scripts/build-site.sh --versions latest,stable + + # Production robots, sitemaps and indexing must still render on a PR. + # Two root locale renders cover those contracts without rebuilding ports. + step 'production root renders' + ( + flock -x 9 + defaults="$(node -e 'console.log(JSON.stringify(JSON.parse(require("fs").readFileSync(process.argv[1])).defaultVersion))' "$out$preview/en/versions.json")" + locales="$(node --input-type=module -e 'import { LOCALES } from "./site/src/i18n/locales.ts"; console.log(LOCALES.join(" "))')" + for locale in $locales; do + (cd site && LIBTMUX_DOCS_LOCALES_ROOT='' LIBTMUX_DOCS_PORT='' \ + LIBTMUX_DOCS_BASE="/$locale/" LIBTMUX_DOCS_ROOT="/$locale/" \ + LIBTMUX_DOCS_PORT_ROOT=/en LIBTMUX_DOCS_LOCALE="$locale" \ + LIBTMUX_DOCS_PORT_DEFAULTS="$defaults" LIBTMUX_DOCS_VERSION=latest \ + LIBTMUX_DOCS_VERSION_KIND=trunk LIBTMUX_DOCS_IS_DEFAULT=true \ + LIBTMUX_DOCS_SKIP_PAGEFIND=true pnpm exec astro build \ + --outDir "$LIBTMUX_DOCS_TEST_PRODUCTION_SITE/$locale") + done + cp "$LIBTMUX_DOCS_TEST_PRODUCTION_SITE/en/robots.txt" "$LIBTMUX_DOCS_TEST_PRODUCTION_SITE/robots.txt" + ) 9>.build.lock +else + ./scripts/build-site.sh +fi +fi + +[[ -f "$out$preview/en/index.html" ]] || { echo "missing audited assembly: $out$preview/en" >&2; exit 1; } +if [[ -n "$preview" ]]; then + [[ -f "$LIBTMUX_DOCS_TEST_PRODUCTION_SITE/en/sitemap-index.xml" ]] || { echo 'missing production root render' >&2; exit 1; } + step 'preview isolation' + bash scripts/check-preview.sh "$out" "${preview#/}" +fi # Named explicitly rather than left to the default. It is the same directory, # and saying so is what stops the next person reintroducing a scratch build @@ -224,15 +275,15 @@ fi # simply never emitted. # The checks below read the site; `$out` is the bucket root, which also holds # robots.txt above every locale. The site itself is one segment in. -site_out="$out/${LIBTMUX_DOCS_LOCALE:-en}" +site_out="$out$preview/${LIBTMUX_DOCS_LOCALE:-en}" step 'sidebar references' -node scripts/check-sidebar-refs.mjs "$site_out" +LIBTMUX_DOCS_LOCALES_ROOT="$preview" node scripts/check-sidebar-refs.mjs "$site_out" # A cross-reference that stops resolving still renders, as plain code, so no # link breaks and nothing else fails. Only a floor catches it. step 'sidebar references (negative)' -./scripts/check-sidebar-refs.negative.sh +LIBTMUX_DOCS_LOCALES_ROOT="$preview" ./scripts/check-sidebar-refs.negative.sh "$site_out" step 'cross-reference resolution' node scripts/check-xrefs.mjs "$site_out" @@ -254,7 +305,7 @@ node scripts/check-type-links.negative.mjs # The reference tree carries no version and no locale, so a page there has no # other page to point at. Nothing else asserts a canonical anywhere. step 'reference canonicals' -node scripts/check-canonicals.mjs "$site_out" +LIBTMUX_DOCS_LOCALES_ROOT="$preview" node scripts/check-canonicals.mjs "$site_out" step 'reference canonicals (negative)' node scripts/check-canonicals.negative.mjs @@ -282,7 +333,7 @@ node scripts/check-api-fidelity.mjs "$site_out" # twin as well — which only a full assembly produces. Both run against a # running server; `pnpm test:publication` reports that they were not run rather than # implying they passed. -if curl -sf -o /dev/null "$SERVE_SITE/py/stable/reference/libtmux-server/"; then +if curl --connect-timeout 1 --max-time 3 -sf -o /dev/null "$SERVE_SITE/py/stable/reference/libtmux-server/"; then # Type is checked here rather than with the static suites because half of # it is a rendering question: which faces a page opens with is answered by # laying the page out, not by reading its HTML. @@ -307,7 +358,7 @@ if curl -sf -o /dev/null "$SERVE_SITE/py/stable/reference/libtmux-server/"; then step 'Ruby and Lua browser coverage' (cd site && node scripts/check-ruby-lua.mjs "$SERVE_SITE") - if curl -sf -o /dev/null "$SERVE_SITE/py/stable/api/api/libtmux.server/"; then + if curl --connect-timeout 1 --max-time 3 -sf -o /dev/null "$SERVE_SITE/py/stable/api/api/libtmux.server/"; then step 'native page navigation' (cd site && node scripts/check-native-shell.mjs "$SERVE_SITE") diff --git a/scripts/test-loop.mjs b/scripts/test-loop.mjs index 9c47a6b1..5faa4cfb 100644 --- a/scripts/test-loop.mjs +++ b/scripts/test-loop.mjs @@ -62,6 +62,7 @@ const tests = (directory, names = []) => node(vitest, 'run', '--root', directory ...names.map((name) => `test/${name}.test.ts`)) try { + if (loop !== 'inner') await node('scripts/stage-port-docs.mjs', '--integrated') const checks = loop === 'inner' ? [ tests('packages/api-model', ['concepts', 'resolver', 'mentions']), tests('site', ['native-switchers', 'normalize-native-shell']), @@ -69,11 +70,13 @@ try { if (loop !== 'inner') checks.push( pnpm('run', '--recursive', 'lint'), pnpm('exec', 'oxlint', 'scripts'), + node('scripts/check-api-links.mjs'), node('scripts/gen-mentions.mjs', '--check'), node('scripts/gen-shell-ports.mjs', '--check'), node('scripts/gen-brand-css.mjs', '--check'), ) if (loop === 'outer') checks.push( + node('scripts/gen-example-sources.mjs', '--check'), pnpm('run', '--recursive', 'type-check'), node('site/scripts/check-dev.mjs'), ) diff --git a/site/public/_shell/README.md b/site/public/_shell/README.md index dd9d87ba..9705be82 100644 --- a/site/public/_shell/README.md +++ b/site/public/_shell/README.md @@ -1,219 +1,66 @@ -# `/_shell/` — the design-token bridge +# Native reference navigation and theme -This file lives under `site/public/`, so Astro publishes it verbatim to -`libtmux.org/_shell/README.md` on every build — the same passthrough that -publishes `tokens.css` and `shell.js` from this directory. It is not -gated behind anything private; the `~/work/...` paths and the -`build-site.sh` bug narrative below are written the way they are (no -absolute machine paths, no assumptions about who is reading) because this -is effectively a public document already. +The shared assets give native reference pages the site's navigation, version +switcher, API equivalents, and theme. Assets live under the locale's `_shell/` +directory, including the preview prefix when present. -Closes the largest open item in `notes/status.md`: Python and C++ rendered -as stock Furo — their own title, their own header, no port switcher, no -version switcher, none of the site's palette. A reader moving from `/ts/` -to `/py/` landed on what looked like a different website. Full design in -`notes/research/03-design-token-bridge.md`. +| File | Purpose | +| --- | --- | +| `shell.js` | Header, footer, port and version switching, and theme synchronization. | +| `tokens.css` | Shared light and dark theme values. | +| `sphinx.css` | Maps shared tokens to Furo's CSS variables. | +| `brand.css` | Port colors and artwork used by native pages. | -## The three artifacts +## Sphinx integration -| File | What it is | Who loads it | -|---|---|---| -| `tokens.css` | ~25 semantic `--lt-*` custom properties (light default, `[data-theme="dark"]` override, `prefers-color-scheme` fallback) | Never linked directly by a foreign generator — pulled in transitively via each generator's own adapter stylesheet's `@import` | -| `shell.js` | Header/footer injection, the version switcher, and a dark-mode shim | Loaded directly via each generator's script-injection flag (Sphinx: `html_js_files`) | -| `README.md` | This file | Nobody; documentation only | +After Sphinx builds the selected source revision, `scripts/build-site.sh` +runs `scripts/normalize-native-shell.mjs`. It copies `sphinx.css` to the +reference's `_static/libtmux-org.css`, then adds its stylesheet and the shared +script to each page. It renders the script's own header, footer and styles once +per port/version, using Happy DOM without network access. The header is present +at first paint, so loading the script does not push the article down. +Existing integration is replaced once; redirects retain +their original behavior. This step does not edit source checkouts or their +Sphinx configuration. -Both are served from a **stable, unversioned URL** -(`https://libtmux.org/_shell/tokens.css`, `.../shell.js`) — not copied into -each build. That is deliberate: a chrome-color fix or a header bug fix -reaches every already-published, immutable version prefix (`/py/v0.46.2/`, -`/cxx/v1.2.0/`, …) without rebuilding that version. See -`notes/research/00-DECISIONS.md` §6 ("load the shared header, footer, -version switcher and tokens at runtime from a stable URL"). +The stylesheet loads after the native theme and imports the locale's +`_shell/tokens.css`. Its fallbacks retain Furo colors if shared tokens cannot +load. Other native asset URLs are normalized to the same locale and preview. -Two research documents (`03-design-token-bridge.md`, -`14-lang-rust.md`, `07-ci-topology.md`) describe this prefix as -**versioned**, `/_shell/v1/`, for exactly this reason — a breaking change -bumps to `/_shell/v2/` instead of breaking every generator that already -baked in the v1 URL. A third (`16-lang-java-kotlin.md`) and this -assignment's own file list use the **unversioned** `/_shell/` path. This -implementation follows the assignment's literal paths -(`site/public/_shell/{tokens,shell}.{css,js}`). Flagged as an open -contradiction between research documents; adding a `v1/` segment later is a -rename of these two files plus every conf.py that references them, not a -redesign. +This allows older library revisions to receive the current site navigation +without requiring shell integration in their own source tree. A standalone +Sphinx build remains controlled by its repository's configuration. -## Why the Astro shell itself does not consume these +## Runtime contracts -The Astro shell (`site/src/styles/global.css`) has its own, richer, -multi-theme token system (`emerald`/`amber`/`sky`/`purple` palettes via -`html[data-theme]`, light/dark via `html[data-theme-mode]`). `tokens.css` -is deliberately the narrow, lowest-common-denominator subset a *foreign* -generator's own CSS can plausibly repaint with — Furo has no slot for four -selectable brand hues, only one accent. The two systems name their -attributes the same way for unrelated things (`html[data-theme]` means -"which brand palette" in the shell, "resolved light/dark" for this -bridge) — this only matters if a future shell page ever loads `tokens.css` -directly, which none does today. Flagged as an open naming collision -rather than resolved, since resolving it means picking a side without a -second consumer to test against. +`shell.js` reads `versions.json` and `page-links.json` from the locale root. +The generated port table comes from `site/src/lib/ports.ts`; regenerate it +with `node scripts/gen-shell-ports.mjs` after changing port identities. -## The adapter problem +Native pages read Astro's `color-scheme` preference first, translating +`system` to Furo's `auto`. Native toggles update that key, Furo's `theme`, +and the legacy `libtmux-theme` key. Test navigation in both directions when +changing this contract. -`tokens.css` restyles nothing by itself. Each foreign generator paints -from its own variable system, so each needs a small **adapter -stylesheet** — a translation table from the ~25 `--lt-*` names onto that -generator's real variables, loaded through that generator's own override -mechanism. Today that is Furo, for the two Sphinx-rendered ports: +The script and tokens use stable URLs so navigation and theme fixes can reach +previously published references. Changes must preserve existing page contracts. +The script enhances existing navigation without replacing it, and still inserts +navigation on older pages that have no initial header. -- `~/work/python/libtmux-python-docs/docs/_static/libtmux-org.css` (Python - — `sphinx-gp-theme`, a Furo child theme) -- `~/work/libtmux/libtmux-cxx-docs/docs/_static/libtmux-org.css` (C++ — - vanilla Furo via Doxygen → Breathe) +## Verification -Both map the same ~25 names onto the same Furo `--color-*` contract -(harvested from upstream Furo's SCSS at -`~/work/python/gp-sphinx/packages/gp-furo-tokens/src/{light,dark}.ts` — -verified against the actual built output at -`_site/py/*/api/_static/styles/furo-tw.css` and -`_site/cxx/*/api/_static/styles/furo.css` before writing the mapping, not -assumed). The two files are near-identical and kept in sync by hand — -there is no shared npm package either Sphinx build could import from -(`gp-furo-tokens` is `"private": true`); this is the one duplication cost -the design accepts, and it is the *mapping* that is duplicated (~60 lines), -not the token *values* (which stay centralized in `tokens.css` via -`@import` — see "Rejected: vendoring" in -`notes/research/03-design-token-bridge.md` §1). - -Every mapping in the adapter carries Furo's own stock color as a `var()` -fallback (`--color-brand-primary: var(--lt-color-accent, #0a4bff)`). This -is load-bearing: the site has never been deployed (`notes/status.md`), so -every page loads `tokens.css` cross-origin from wherever it is actually -served today — a fetch that 404s until DNS resolves. Per the CSS custom -properties spec, `var()` on an undefined property with no fallback -resolves to the guaranteed-invalid value, which would make every rule -reading `--color-background-primary` compute to nothing — transparent -backgrounds, UA-default text — strictly worse than the stock-Furo glitch -this closes. The fallback is Furo's own value, not a libtmux one, so -degrading means "looks like unmodified Furo," not "looks half-skinned." - -Each conf.py wires the adapter in with two config values: - -```python -html_static_path = ["_static"] # cxx only — Python's already has this -html_css_files = ["libtmux-org.css"] # appended after any project CSS -html_js_files = [("https://libtmux.org/_shell/shell.js", {"defer": "defer"})] -``` - -`html_css_files`/`html_js_files` accept a full external URL verbatim -(Sphinx's own `add_css_file`/`add_js_file`: a filename containing `://` is -never rewritten to `_static/…`) — confirmed against the installed Sphinx -source, not assumed from documentation. `libtmux-org.css` must be **last** -in the list: Sphinx emits `html_css_files` after every extension's own -bundled CSS (including Furo's), so appending guarantees the adapter's -`body { --color-x: var(--lt-y) }` overrides land after Furo's own -`--color-x` declaration at equal specificity and wins. - -## The version switcher contract - -`shell.js`'s `` custom element is a hand-kept -port of `site/src/components/VersionSwitcher.astro`'s inline script — -same element name, same `data-port`/`data-current` dataset keys, same -`/versions.json` fetch and schema check, same supported-only filtering, -same eol-suffix labelling, same path-preserving navigation on change, and -the same ecosystem-port suppression (a port whose `referenceMode` is -`'ecosystem'` never gets a switcher — its versions live on docs.rs / -pkg.go.dev / javadoc.io, which have their own). If -`VersionSwitcher.astro`'s contract changes, `shell.js` must change with -it; nothing enforces that automatically. - -`shell.js` also carries a small, hand-kept-in-sync copy of the fields it -needs from `site/src/lib/ports.ts` (slug, display name, `referenceMode`, -and — for ecosystem ports — the exact `ecosystemHost.url`, copied -verbatim). A runtime script has no bundler and cannot import that module; -`ThemeScript.astro` already accepts the identical trade-off for -`theme-config.ts`, with the same comment shape, for the same reason. - -## The dark-mode shim - -"Shim, don't replace" (`03-design-token-bridge.md` §4): Furo keeps its own -toggle button, its own `theme` `localStorage` key, and its own -`body[data-theme]` attribute. `shell.js` never touches Furo's click -handler — it observes the resulting `body[data-theme]` mutation (via -`MutationObserver`, since Furo's toggle fires no event of its own) and -mirrors the *resolved* light/dark value onto `html[data-theme]`, which is -the attribute `tokens.css` actually reads. A page can legitimately show -two theme controls (Furo's native one, plus whatever the injected header -grows later) — both read the same synced state, so they cannot disagree, -which is the seam `03-design-token-bridge.md` §4 explicitly accepts rather -than papering over with a fork of Furo's toggle. - -`notes/research/00-DECISIONS.md` §7.13 leaves the canonical cross-site -`localStorage` key an open decision ("pick one before `shell.js` ships"). -This implementation picks `libtmux-theme` and records that choice in -`shell.js`'s own comments rather than in a separate document. It has not -been reconciled with `social-embed`'s `starlight-theme` key — that -remains open, and only matters if `libtmux.org` and `social-embed.org` -(or another `git-pull.com` property) are ever meant to share a dark-mode -preference across visits. - -One accepted flash: Furo's inline pre-paint script sets -`body[data-theme]` synchronously, before `shell.js` (deferred) runs. If -the reader's explicit preference disagrees with the OS (e.g. chose "dark" -while the OS is light), `tokens.css`'s `prefers-color-scheme` fallback -briefly disagrees with Furo's own paint until `shell.js` sets -`html[data-theme]` explicitly. The "auto" case has no flash at all, since -`tokens.css`'s media-query block matches with no attribute present. This -is the same one-frame trade-off `03-design-token-bridge.md` §4 names -explicitly rather than rewriting Furo's boot script to defer to ours. - -A sibling case is not just a flash: if `shell.js` itself fails to load -(same-origin as `tokens.css`, so this fails together with it, but keep the -two failure modes distinct) while the reader's explicit preference is -"dark" and the OS is light, `html[data-theme]` is simply never written — -Furo still paints dark correctly from its own `body[data-theme]`, but -`tokens.css`'s fallback block (`prefers-color-scheme: dark`) never -matches, so **every `--lt-*` token stays at its light value for the -session**, not just for one frame. The adapter's own `var(--lt-x, -)` fallback then supplies the *light* stock color inside -a page Furo has painted dark — a legible but visibly wrong combination, -not a broken one. This is the direct consequence of `shell.js` being the -only thing that writes `html[data-theme]`; there is no second mechanism to -fall back to. - -## Verifying it actually happened - -`scripts/inject-shell.mjs` is the post-build smoke test: it reads -`site/src/lib/ports.ts` for the self-hosted Sphinx ports, walks every -built `//api/` page, and asserts the adapter link and -`shell.js` script tag are present, the adapter still `@import`s -`tokens.css` from the real URL, every `--color-*` name the adapter writes -still exists in that build's own generated CSS (catches a Furo upgrade -silently renaming or dropping one), and every `--lt-*` name the adapter -reads exists in `tokens.css` (catches a typo on our side). Run it after -`scripts/build-site.sh`: +After assembling the site, run: ```console -$ node scripts/inject-shell.mjs +$ node scripts/inject-shell.mjs --site _site ``` -A port with nothing built yet is reported as skipped, not failed. Any -other failure exits non-zero. - -This script proves the wiring is present and internally consistent — the -right tags exist, the right variable names exist on both sides of the -mapping. It does not prove the injected header/footer actually look right -next to Furo's own flex/sticky layout in a real browser; that visual QA -has not been done and is unverified. +The check inspects every built Sphinx page for the script and stylesheet, +checks locale URLs, and compares the adapter's variables with the generated +theme and shared tokens. Missing references are reported as skips. Broken +integration fails the command. -**Known failure as of this writing**: running the check above reports -`py/stable` as failing every check, while `cxx/stable` passes. This is not -a bug in the adapter or in `shell.js` — direct `sphinx-build` runs against -`~/work/python/libtmux-python-docs/docs` (this file's own conf.py) produce -the correct `` tags every time. The cause is in -`scripts/build-site.sh`'s `build_reference()`: it redirects `cxx` and -`dotnet` to their `docs-site` worktree when that worktree carries the -generator's entrypoint, but the equivalent `py)` case is missing from that -same `case` statement, so the Python build actually runs against -`~/work/python/libtmux/docs/conf.py` (the main checkout) instead of the -worktree this fix lives in. `build-site.sh` is not owned by this piece of -work; see the top-level summary for this contradiction reported upstream. +The output tests cover native asset paths; `normalize-native-shell.test.ts` +covers older sources, existing integration, previews, redirects, repeated +assembly, and malformed HTML. Use a browser to verify switching, themes, and +layout on the assembled native pages. diff --git a/site/public/_shell/shell.js b/site/public/_shell/shell.js index 3023ba13..58115b54 100644 --- a/site/public/_shell/shell.js +++ b/site/public/_shell/shell.js @@ -206,7 +206,9 @@ ;[['Reference', '/reference/'], ['MCP', '/mcp/'], ['Search', '/search/']].forEach(function (entry) { var link = document.createElement('a') link.className = 'lt-shell-search-link' - link.href = siteRoot + entry[1] + link.href = entry[0] === 'Search' && currentPort && currentVersion + ? siteRoot + '/' + currentPort + '/' + currentVersion + entry[1] + : siteRoot + entry[1] link.textContent = entry[0] controls.appendChild(link) }) @@ -244,11 +246,13 @@ '.lt-shell-header,.lt-shell-footer{font-family:var(--lt-font-sans,sans-serif);' + 'font-size:0.875rem;background:var(--lt-color-bg,#fff);color:var(--lt-color-fg,#000);box-sizing:border-box}' + '.lt-shell-header *,.lt-shell-footer *{box-sizing:border-box}' + - '.lt-shell-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;' + + // Furo's fixed table of contents uses layer 50. + '.lt-shell-header{position:relative;z-index:51;display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;' + 'padding:0.6rem 1rem;border-bottom:1px solid var(--lt-color-border,#eeebee)}' + '.lt-shell-brand{font-family:var(--lt-font-mono,monospace);font-weight:600;' + 'font-size:1.05rem;color:var(--lt-color-fg,#000);text-decoration:none;letter-spacing:-0.01em}' + - '.lt-shell-nav{display:flex;flex-wrap:wrap;gap:0.15rem;flex:1}' + + // Wrap the controls before the language list can collapse into a column. + '.lt-shell-nav{display:flex;flex-wrap:wrap;gap:0.15rem;flex:1 1 15rem}' + '.lt-shell-nav-link{padding:0.25rem 0.5rem;border-radius:var(--lt-radius,0.375rem);' + 'color:var(--lt-color-fg-secondary,#5a5c63);text-decoration:none}' + '.lt-shell-nav-link:hover{background:var(--lt-color-bg-hover,#efeff4)}' + @@ -277,14 +281,19 @@ '.lt-shell-footer-list a:hover{text-decoration:underline}' function injectChrome() { - if (document.getElementById('lt-shell-style')) return - var style = document.createElement('style') - style.id = 'lt-shell-style' - style.textContent = CHROME_STYLE - document.head.appendChild(style) + if (!document.getElementById('lt-shell-style')) { + var style = document.createElement('style') + style.id = 'lt-shell-style' + style.textContent = CHROME_STYLE + document.head.appendChild(style) + } defineVersionSwitcher() - document.body.insertBefore(buildHeader(), document.body.firstChild) - document.body.appendChild(buildFooter()) + // Current native builds include chrome in the first paint. Keep the + // insertion fallback for references published before that integration. + if (!document.querySelector('[data-lt-shell="header"]')) { + document.body.insertBefore(buildHeader(), document.body.firstChild) + } + if (!document.querySelector('[data-lt-shell="footer"]')) document.body.appendChild(buildFooter()) refreshPagePorts() manifest.then(function (data) { if (!data || data.schema !== 1) return @@ -306,76 +315,68 @@ window.addEventListener('hashchange', refreshPagePorts) } - // --------------------------------------------------------------------- - // Dark-mode shim (design-token-bridge.md §4: shim, don't replace). - // - // Canonical key: `libtmux-theme`. notes/research/00-DECISIONS.md §7.13 - // leaves this an open decision ("pick one before shell.js ships"); this - // file makes the call and records it here rather than in a separate - // document. Furo's own key (`theme`) stays authoritative for Furo's own - // toggle and paint logic — this shim only mirrors the *resolved* value - // onto html[data-theme], which is the attribute tokens.css reads, and - // writes the canonical key through so a future non-Furo generator (or - // the Astro shell, if it ever adopts this key — see the open question in - // the same section) can agree on "what did the reader last choose" - // without re-deriving it from Furo's storage format. - // --------------------------------------------------------------------- - var CANONICAL_KEY = 'libtmux-theme' - var FURO_KEY = 'theme' // gp-furo-theme's furo.ts: localStorage.setItem('theme', mode) + // Astro stores `system`; Furo stores the same preference as `auto`. + var SITE_THEME_KEY = 'color-scheme' + var LEGACY_THEME_KEY = 'libtmux-theme' + var FURO_KEY = 'theme' + var themePreference function systemPrefersDark() { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches } + function nativePreference(value) { + if (value === 'system') return 'auto' + return value === 'light' || value === 'dark' || value === 'auto' ? value : null + } + function readPreference() { - // Furo's own key is authoritative for what actually painted this page - // (its inline pre-paint script already ran); fall back to the - // canonical key, then "auto". try { - var native = localStorage.getItem(FURO_KEY) - if (native === 'light' || native === 'dark' || native === 'auto') return native + var stored = nativePreference(localStorage.getItem(SITE_THEME_KEY)) || + nativePreference(localStorage.getItem(FURO_KEY)) || + nativePreference(localStorage.getItem(LEGACY_THEME_KEY)) + if (stored) return stored } catch (e) { /* storage disabled */ } + return themePreference || nativePreference(document.body && document.body.getAttribute('data-theme')) || 'auto' + } + + function storePreference(key, value) { try { - var canonical = localStorage.getItem(CANONICAL_KEY) - if (canonical === 'light' || canonical === 'dark' || canonical === 'auto') return canonical + // Avoid storage-event loops between pages mirroring the same choice. + if (localStorage.getItem(key) !== value) localStorage.setItem(key, value) } catch (e) { - /* storage disabled */ + /* The current page still follows the native toggle. */ } - return 'auto' } - function applyResolvedTheme() { - var pref = readPreference() - var resolved = pref === 'auto' ? (systemPrefersDark() ? 'dark' : 'light') : pref + function applyResolvedTheme(pref) { + themePreference = pref || readPreference() + var resolved = themePreference === 'auto' ? (systemPrefersDark() ? 'dark' : 'light') : themePreference + var sitePreference = themePreference === 'auto' ? 'system' : themePreference + var root = document.documentElement + root.setAttribute('data-color-scheme', sitePreference) + root.setAttribute('data-theme-mode', resolved) + root.style.colorScheme = resolved + if (themePreference === 'auto') root.removeAttribute('data-theme') + else root.setAttribute('data-theme', resolved) - // html[data-theme] is what tokens.css keys off. "auto" is never - // written here — the fallback in tokens.css already tracks the media - // query on its own, so an explicit attribute would only fight it on - // the next OS-level change. - if (pref === 'auto') { - document.documentElement.removeAttribute('data-theme') - } else { - document.documentElement.setAttribute('data-theme', resolved) - } - - // Write through the canonical key so it never drifts from what Furo's - // own toggle just decided. - try { - localStorage.setItem(CANONICAL_KEY, pref) - } catch (e) { - /* storage disabled */ + // Furo paints and cycles from its own body attribute and storage key. + if (document.body && document.body.getAttribute('data-theme') !== themePreference) { + document.body.setAttribute('data-theme', themePreference) } + storePreference(SITE_THEME_KEY, sitePreference) + storePreference(FURO_KEY, themePreference) + storePreference(LEGACY_THEME_KEY, themePreference) } function watchNativeToggle() { - // Furo's toggle button mutates document.body's data-theme attribute - // directly with no event of its own — observe that instead of - // reimplementing its click handler, which is the fork this bridge is - // built to avoid (design-token-bridge.md §4). if (!window.MutationObserver || !document.body) return - new MutationObserver(applyResolvedTheme).observe(document.body, { + new MutationObserver(function () { + var pref = nativePreference(document.body.getAttribute('data-theme')) + if (pref && pref !== themePreference) applyResolvedTheme(pref) + }).observe(document.body, { attributes: true, attributeFilter: ['data-theme'], }) @@ -384,13 +385,15 @@ function watchSystemPreference() { if (!window.matchMedia) return window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { - if (readPreference() === 'auto') applyResolvedTheme() + if (themePreference === 'auto') applyResolvedTheme('auto') }) } function watchCrossTab() { window.addEventListener('storage', function (e) { - if (e.key === FURO_KEY || e.key === CANONICAL_KEY) applyResolvedTheme() + if (e.key === SITE_THEME_KEY || e.key === FURO_KEY || e.key === LEGACY_THEME_KEY || e.key === null) { + applyResolvedTheme() + } }) } diff --git a/site/public/_shell/sphinx.css b/site/public/_shell/sphinx.css new file mode 100644 index 00000000..f98c86cc --- /dev/null +++ b/site/public/_shell/sphinx.css @@ -0,0 +1,96 @@ +/* Map shared site tokens to Furo, after the generated theme stylesheet. */ +@import url('/_shell/tokens.css'); + +body { + --color-background-primary: var(--lt-color-bg, white); + --color-background-secondary: var(--lt-color-bg-secondary, #f8f9fb); + --color-background-hover: var(--lt-color-bg-hover, #efeff4); + + --color-foreground-primary: var(--lt-color-fg, black); + --color-foreground-secondary: var(--lt-color-fg-secondary, #5a5c63); + --color-foreground-muted: var(--lt-color-fg-muted, #6b6f76); + + --color-background-border: var(--lt-color-border, #eeebee); + + --color-brand-primary: var(--lt-color-accent, #0a4bff); + --color-brand-content: var(--lt-color-link, #2757dd); + --color-brand-visited: var(--lt-color-link-visited, #872ee0); + + --color-inline-code-background: var(--lt-color-code-bg, #f8f9fb); + --color-highlighted-background: var(--lt-color-highlighted-bg, #ddeeff); + + --color-api-added: var(--lt-color-added, #21632c); + --color-api-removed: var(--lt-color-removed, #b30000); + --color-api-changed: var(--lt-color-changed, #046172); + --color-api-deprecated: var(--lt-color-deprecated, #605706); + + /* Keep Furo fallbacks when shared tokens are unavailable. */ + --color-admonition-title--danger: var(--lt-color-danger, #ff5252); + --color-admonition-title--error: var(--lt-color-danger, #ff5252); + --color-admonition-title--attention: var(--lt-color-danger, #ff5252); + --color-admonition-title--warning: var(--lt-color-warning, #ff9100); + --color-admonition-title--caution: var(--lt-color-warning, #ff9100); + --color-admonition-title--note: var(--lt-color-info, #00b0ff); + --color-admonition-title--seealso: var(--lt-color-info, #448aff); + --color-admonition-title--hint: var(--lt-color-success, #00c852); + --color-admonition-title--tip: var(--lt-color-success, #00c852); + + --font-stack: + var(--lt-font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, + sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji'); + --font-stack--monospace: + var(--lt-font-mono, 'SFMono-Regular', Menlo, Consolas, Monaco, 'Liberation Mono', + 'Lucida Console', monospace); +} + +@media not print { + body[data-theme='dark'] { + --color-background-primary: var(--lt-color-bg, #131416); + --color-background-secondary: var(--lt-color-bg-secondary, #1a1c1e); + --color-background-hover: var(--lt-color-bg-hover, #1e2124); + + --color-foreground-primary: var(--lt-color-fg, #cfd0d0); + --color-foreground-secondary: var(--lt-color-fg-secondary, #9ca0a5); + --color-foreground-muted: var(--lt-color-fg-muted, #81868d); + + --color-background-border: var(--lt-color-border, #303335); + + --color-brand-primary: var(--lt-color-accent, #3d94ff); + --color-brand-content: var(--lt-color-link, #5ca5ff); + --color-brand-visited: var(--lt-color-link-visited, #b27aeb); + + --color-inline-code-background: var(--lt-color-code-bg, #1a1c1e); + --color-highlighted-background: var(--lt-color-highlighted-bg, #083563); + + --color-api-added: var(--lt-color-added, #3db854); + --color-api-removed: var(--lt-color-removed, #ff7575); + --color-api-changed: var(--lt-color-changed, #09b0ce); + --color-api-deprecated: var(--lt-color-deprecated, #b1a10b); + } + + @media (prefers-color-scheme: dark) { + body:not([data-theme='light']) { + --color-background-primary: var(--lt-color-bg, #131416); + --color-background-secondary: var(--lt-color-bg-secondary, #1a1c1e); + --color-background-hover: var(--lt-color-bg-hover, #1e2124); + + --color-foreground-primary: var(--lt-color-fg, #cfd0d0); + --color-foreground-secondary: var(--lt-color-fg-secondary, #9ca0a5); + --color-foreground-muted: var(--lt-color-fg-muted, #81868d); + + --color-background-border: var(--lt-color-border, #303335); + + --color-brand-primary: var(--lt-color-accent, #3d94ff); + --color-brand-content: var(--lt-color-link, #5ca5ff); + --color-brand-visited: var(--lt-color-link-visited, #b27aeb); + + --color-inline-code-background: var(--lt-color-code-bg, #1a1c1e); + --color-highlighted-background: var(--lt-color-highlighted-bg, #083563); + + --color-api-added: var(--lt-color-added, #3db854); + --color-api-removed: var(--lt-color-removed, #ff7575); + --color-api-changed: var(--lt-color-changed, #09b0ce); + --color-api-deprecated: var(--lt-color-deprecated, #b1a10b); + } + } +} diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs index 207eef36..1c3066aa 100644 --- a/site/scripts/check-dev.mjs +++ b/site/scripts/check-dev.mjs @@ -7,7 +7,8 @@ import { dev } from 'astro' import { chromium, firefox, webkit } from 'playwright' import { PORTS, productAvailable } from '../src/lib/ports.ts' import { checkClipboard } from './check-clipboard.mjs' -import { checkNavigation } from './check-navigation.mjs' +import { checkApiNavigation, checkNavigation } from './check-navigation.mjs' +import { checkNativeLayout } from './check-native-layout.mjs' const workspacePortCount = PORTS.filter((port) => productAvailable(port, 'workspace')).length // `workspaceCli` alone also covers a port's local, unreleased dev CLI @@ -49,9 +50,10 @@ const terminate = async () => { } process.on('SIGTERM', terminate) process.on('SIGINT', terminate) -server = await dev({ root, cacheDir: join(mirror, 'cache'), +const startServer = () => dev({ root, cacheDir: join(mirror, 'cache'), vite: { cacheDir: join(mirror, 'vite') }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 } }) +server = await startServer() const base = `http://127.0.0.1:${server.address.port}/en` // Vite can reload once after its initial dependency optimization. @@ -69,6 +71,7 @@ try { const driver = { chromium, firefox, webkit }[engine] if (!driver) throw new Error(`Unknown browser: ${engine}`) browser = await driver.launch(engine === 'chromium' ? { channel: process.env.LIBTMUX_DOCS_BROWSER_CHANNEL } : {}) + const nativeLayout = checkNativeLayout(browser).then(() => null, (error) => error) const page = await browser.newPage({ reducedMotion: 'reduce' }) page.setDefaultTimeout(10000) const manifest = await page.request.get(`${base}/page-links.json`) @@ -214,6 +217,8 @@ try { console.log('Empty sidebars: article and reference index use their available width') const clipboardError = await clipboard if (clipboardError) throw clipboardError + const nativeLayoutError = await nativeLayout + if (nativeLayoutError) throw nativeLayoutError await page.goto(`${base}/`, { waitUntil: 'load' }) await page.locator('.scheme-switch input[value="dark"]').check({ force: true }) const chipPixel = await page.evaluate(() => { @@ -320,6 +325,14 @@ try { } } console.log('Heroes: 88px marks share the title row and stack at phone widths') + // Core API routes exist only in a port shell. Reuse the isolated fixture + // with its real Lua routes so the routine gate covers retained-document swaps. + await server.stop() + Object.assign(process.env, { + LIBTMUX_DOCS_PORT: 'lua', LIBTMUX_DOCS_BASE: '/en/lua/latest/', + }) + server = await startServer() + await checkApiNavigation(page, `http://127.0.0.1:${server.address.port}/en`) } finally { await browser?.close() await server.stop() diff --git a/site/scripts/check-mobile-nav.mjs b/site/scripts/check-mobile-nav.mjs index 700ce729..be241358 100755 --- a/site/scripts/check-mobile-nav.mjs +++ b/site/scripts/check-mobile-nav.mjs @@ -22,12 +22,21 @@ * Usage: node scripts/check-mobile-nav.mjs [base-url] */ import { chromium } from 'playwright' +import { checkApiNavigation } from './check-navigation.mjs' const BASE = (process.argv[2] ?? 'http://localhost:8080').replace(/\/$/, '') const b = await chromium.launch() const fails = [], ok = [] const note = (pass, msg) => (pass ? ok : fails).push(msg) const WITH_TOC = '/topics/traversal/', NO_TOC = '/concepts/' const ctx = await b.newContext() +const apiPage = await ctx.newPage() +try { + await checkApiNavigation(apiPage, BASE) +} catch (error) { + fails.push(`API navigation: ${error.message}`) +} finally { + await apiPage.close() +} async function page(path, w = 390) { const p = await ctx.newPage() await p.setViewportSize({ width: w, height: 800 }) diff --git a/site/scripts/check-native-layout.mjs b/site/scripts/check-native-layout.mjs new file mode 100644 index 00000000..c585b2c5 --- /dev/null +++ b/site/scripts/check-native-layout.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { normalizeNativeShell } from '../../scripts/normalize-native-shell.mjs' +import { PORTS } from '../src/lib/ports.ts' + +/** Keep every port visible in a short language list at intermediate widths. */ +export async function checkNativeHeader(page) { + const navigation = await page.locator('.lt-shell-nav').evaluate((nav) => { + const links = [...nav.querySelectorAll('a')] + const bounds = links.map((link) => link.getBoundingClientRect()) + return { + count: links.length, + rows: new Set(bounds.map((rect) => rect.top)).size, + visible: bounds.every((rect) => rect.width > 0 && rect.height > 0 && rect.left >= 0 && rect.right <= innerWidth), + covered: links.filter((link, i) => { + const rect = bounds[i] + return !link.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + }).map((link) => link.getAttribute('aria-label')), + } + }) + assert.equal(navigation.count, PORTS.length, 'Native header keeps every port') + assert(navigation.rows <= 3, `Native ports use at most three rows at ${page.viewportSize().width}px: ${navigation.rows}`) + assert(navigation.visible, 'Native port links fit the viewport') + assert.deepEqual(navigation.covered, [], 'Native header controls do not cover port links') +} + +/** Delay enhancement until the initial article and navigation can be inspected. */ +export async function checkNativeFirstPaint(page, url) { + let release + const delayedScript = new Promise((resolve) => { release = resolve }) + const pendingRoutes = [] + const routeScript = (route) => { + const pending = delayedScript.then(() => route.continue()) + pendingRoutes.push(pending) + return pending + } + await page.route('**/_shell/shell.js', routeScript) + try { + const response = await page.goto(url, { waitUntil: 'commit' }) + assert(response?.ok(), `Native page: HTTP ${response?.status()}`) + await page.locator('article h1').waitFor({ state: 'visible' }) + assert(await page.locator('[data-lt-shell="header"]').isVisible(), 'Native header is visible before shell.js arrives') + assert.equal(await page.evaluate(() => customElements.get('libtmux-version-switcher') !== undefined), false, + 'Native content and navigation are visible while shell.js is still unavailable') + await checkNativeHeader(page) + const initial = await page.locator('article h1').boundingBox() + await page.evaluate(() => { window.__initialNativeHeader = document.querySelector('[data-lt-shell="header"]') }) + release() + await page.waitForLoadState('networkidle') + assert.deepEqual(await page.locator('article h1').boundingBox(), initial, `Native first paint stays still at ${page.viewportSize().width}px`) + assert(await page.evaluate(() => window.__initialNativeHeader === document.querySelector('[data-lt-shell="header"]')), + 'Enhancement preserves the initial header') + assert.equal(await page.locator('[data-lt-shell="header"]').count(), 1) + } finally { + release() + await Promise.all(pendingRoutes) + await page.unroute('**/_shell/shell.js', routeScript) + } +} + +/** Exercise the real native adapter before, during and after script loading. */ +export async function checkNativeLayout(browser) { + const directory = mkdtempSync(join(tmpdir(), 'native-first-paint-')) + const root = '/pr-42/en', version = 'v0.62.0', pagePath = `${root}/py/${version}/api/session/` + const pageFile = join(directory, 'session/index.html') + mkdirSync(join(directory, 'session')) + writeFileSync(pageFile, '

Sessions

Read the native reference while its controls load.

Session
') + let server + try { + await normalizeNativeShell(directory, root, { sphinxPort: 'py', version }) + const assets = new Map([ + [pagePath, ['text/html', readFileSync(pageFile)]], + [`${root}/py/${version}/api/_static/libtmux-org.css`, ['text/css', readFileSync(join(directory, '_static/libtmux-org.css'))]], + ...['shell.js', 'tokens.css'].map((file) => [`${root}/_shell/${file}`, [file.endsWith('.js') ? 'text/javascript' : 'text/css', readFileSync(new URL(`../public/_shell/${file}`, import.meta.url))]]), + [`${root}/versions.json`, ['application/json', JSON.stringify({ schema: 1, defaultVersion: { ts: 'stable' }, ports: { + py: [{ slug: version, label: version, supported: true }, { slug: 'v0.63.0rc1', label: 'v0.63.0rc1', supported: true }], + } })]], + [`${root}/page-links.json`, ['application/json', JSON.stringify({ schema: 1, symbols: { py: {} }, indexes: {} })]], + ]) + server = createServer((request, response) => { + const asset = assets.get(request.url) + response.writeHead(asset ? 200 : 404, { 'Content-Type': asset?.[0] ?? 'text/plain', 'Cache-Control': 'no-store' }) + response.end(asset?.[1] ?? 'Not found') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${server.address().port}${pagePath}` + for (const width of [1440, 768, 688, 390]) { + const context = await browser.newContext({ viewport: { width, height: 900 } }) + try { + const page = await context.newPage() + await checkNativeFirstPaint(page, url) + assert.equal(await page.locator('libtmux-version-switcher option').count(), 2) + assert.equal(await page.locator('[data-port-home="ts"]').first().getAttribute('href'), `${root}/ts/stable/`) + } finally { + await context.close() + } + const noScript = await browser.newContext({ javaScriptEnabled: false, viewport: { width, height: 900 } }) + try { + const page = await noScript.newPage() + await page.goto(url) + assert(await page.locator('article h1').isVisible()) + await checkNativeHeader(page) + await page.locator('[data-page-port-switcher] summary').click() + assert(await page.locator('[data-page-port-switcher] a[aria-current]').isVisible(), 'Native disclosure works without JavaScript') + } finally { + await noScript.close() + } + } + console.log('Native first paint: compact header, visible content, stable geometry, enhanced controls and no-JS navigation at 1440/768/688/390px') + } finally { + if (server) await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/site/scripts/check-native-shell.mjs b/site/scripts/check-native-shell.mjs index 5ea3badb..8baa15a0 100644 --- a/site/scripts/check-native-shell.mjs +++ b/site/scripts/check-native-shell.mjs @@ -1,36 +1,54 @@ #!/usr/bin/env node import assert from 'node:assert/strict' import { chromium } from 'playwright' +import { checkNativeFirstPaint } from './check-native-layout.mjs' const base = (process.argv.find((arg) => arg.startsWith('http')) ?? 'http://localhost:8080/en').replace(/\/$/, '') +const version = process.argv.find((arg) => arg.startsWith('--version='))?.slice('--version='.length) ?? 'stable' const browser = await chromium.launch({ channel: process.env.LIBTMUX_DOCS_BROWSER_CHANNEL }) try { - const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + const page = await browser.newPage() page.setDefaultTimeout(10000) const loaded = new Set() page.on('response', (response) => { if (response.ok()) loaded.add(response.url()) }) - const response = await page.goto(`${base}/py/stable/api/api/libtmux.session/`) - assert(response?.ok(), `Native Session page: HTTP ${response?.status()}`) - const menu = page.locator('[data-page-port-switcher]') - await menu.waitFor() - await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session/"]')) - assert(loaded.has(`${base}/_shell/shell.js`), 'Native shell script did not load from this locale') - assert(loaded.has(`${base}/_shell/tokens.css`), 'Native shell tokens did not load from this locale') - await menu.locator('summary').click() - const bounds = await menu.evaluate((details) => { - const current = details.querySelector('summary').getBoundingClientRect() - const locale = details.nextElementSibling.querySelector('summary').getBoundingClientRect() - const panel = details.querySelector('ul').getBoundingClientRect() - return { sameRow: Math.abs(current.y - locale.y) <= 1, left: panel.left, right: panel.right, viewport: innerWidth } - }) - assert(bounds.sameRow && bounds.left >= 0 && bounds.right <= bounds.viewport, 'Native dropdowns split rows or leave the viewport') + for (const width of [1440, 768, 688, 390]) { + for (const colorScheme of ['light', 'dark']) { + await page.setViewportSize({ width, height: 900 }) + await page.emulateMedia({ colorScheme }) + await checkNativeFirstPaint(page, `${base}/py/${version}/api/api/libtmux.session/`) + const menu = page.locator('[data-page-port-switcher]') + await menu.waitFor() + await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session/"]')) + assert(loaded.has(`${base}/_shell/shell.js`), 'Native shell script did not load from this locale') + assert(loaded.has(`${base}/_shell/tokens.css`), 'Native shell tokens did not load from this locale') + await menu.locator('summary').focus() + await page.keyboard.press('Enter') + const bounds = await menu.evaluate((details) => { + const current = details.querySelector('summary').getBoundingClientRect() + const locale = details.nextElementSibling.querySelector('summary').getBoundingClientRect() + const panel = details.querySelector('ul') + const rect = panel.getBoundingClientRect() + const covered = [...panel.querySelectorAll('li')].filter((item) => { + const row = item.getBoundingClientRect() + return [rect.left + 12, rect.right - 12].some((x) => !item.contains(document.elementFromPoint(x, row.top + row.height / 2))) + }).map((item) => item.textContent.trim()) + return { open: details.open, sameRow: Math.abs(current.y - locale.y) <= 1, left: rect.left, right: rect.right, viewport: innerWidth, covered } + }) + const context = `${width}px ${colorScheme}` + assert(bounds.open, `${context}: keyboard did not open the port dropdown`) + assert(bounds.sameRow && bounds.left >= 0 && bounds.right <= bounds.viewport, `${context}: native dropdowns split rows or leave the viewport`) + assert.deepEqual(bounds.covered, [], `${context}: native content covers port menu entries`) + await page.keyboard.press('Enter') + assert.equal(await menu.evaluate((details) => details.open), false, `${context}: keyboard did not close the port dropdown`) + } + } await page.evaluate(() => { location.hash = 'sessions' }) await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session/"]')) await page.evaluate(() => { location.hash = 'libtmux.Session.windows' }) await page.waitForFunction(() => document.querySelector('[data-page-port-switcher] a[href$="/ts/latest/reference/session-session-windows/"]')) - console.log('Native shell: script, tokens, phone dropdowns and class/member equivalents passed') + console.log('Native shell: compact header, stable first paint, assets, keyboard, unobscured dropdowns at 1440/768/688/390px in light/dark, and class/member equivalents passed') } finally { await browser.close() } diff --git a/site/scripts/check-navigation.mjs b/site/scripts/check-navigation.mjs index 105b93fb..653f797c 100644 --- a/site/scripts/check-navigation.mjs +++ b/site/scripts/check-navigation.mjs @@ -1,5 +1,70 @@ import assert from 'node:assert/strict' +/** Keep the API drawer usable after the router replaces the document. */ +export async function checkApiNavigation(page, base) { + const server = `${base}/lua/latest/reference/libtmux-server/` + const snapshot = `${base}/lua/latest/reference/libtmux-server-snapshot/` + for (const width of [688, 390]) { + await page.setViewportSize({ width, height: 759 }) + await page.goto(server, { waitUntil: 'load' }) + await page.waitForLoadState('networkidle') + await page.evaluate(() => { window.__apiNavigationProbe = true }) + await page.locator('.api-member-link[href$="libtmux-server-snapshot/"]').click() + await page.waitForURL(snapshot) + assert(await page.evaluate(() => window.__apiNavigationProbe), 'API navigation retains the document') + assert(await page.locator('html').evaluate((el) => el.hasAttribute('data-api-nav')), + `API navigation restores the drawer styles at ${width}px`) + const nav = page.locator('#api-nav') + const toggle = page.locator('[data-api-nav-toggle]') + await nav.waitFor({ state: 'hidden' }) + const open = async () => { + await toggle.click() + await page.waitForFunction(() => document.querySelector('#api-nav').getBoundingClientRect().left >= 0) + assert.equal(await toggle.getAttribute('aria-expanded'), 'true') + } + for (const close of ['button', 'Escape', 'overlay']) { + await open() + await nav.locator('[role="tree"]').evaluate((el) => { el.scrollTop = el.scrollHeight }) + if (close === 'button') await nav.locator('[data-api-nav-close]').click() + else if (close === 'Escape') await page.keyboard.press('Escape') + else await page.locator('[data-api-nav-overlay]').click({ position: { x: width - 2, y: 400 } }) + await nav.waitFor({ state: 'hidden' }) + assert.equal(await toggle.getAttribute('aria-expanded'), 'false') + assert.equal(await page.evaluate(() => document.body.style.overflow), '') + } + await page.goBack() + await page.waitForURL(server) + await open() + const menu = nav.locator('.api-nav__menu') + const summary = menu.locator(':scope > summary') + const row = await summary.boundingBox(), section = await menu.boundingBox() + assert(Math.abs(row.y + row.height / 2 - section.y - section.height / 2) <= 1, + `Documentation disclosure is vertically centered at ${width}px`) + await summary.click() + assert.equal(await menu.evaluate((el) => el.open), true) + await summary.click() + assert.equal(await menu.evaluate((el) => el.open), false) + await nav.locator('[data-api-nav-close]').click() + } + for (const path of ['', 'guides/overview/']) { + await page.goto(`${base}/lua/latest/${path}`, { waitUntil: 'load' }) + await page.locator('#mobile-sidebar-toggle').click() + await page.locator('#mobile-sidebar a[href$="/reference/"]').click() + await page.waitForURL(`${base}/lua/latest/reference/`) + await page.locator('.api-index-card__link[href$="/reference/libtmux-server/"]').click() + await page.waitForURL(server) + assert(await page.locator('[data-api-nav-toggle]').isVisible(), `${path || 'Port home'} to API keeps the drawer toggle`) + await page.locator('[data-api-nav-toggle]').click() + await page.locator('[data-api-nav-close]').click() + await page.locator('#api-nav').waitFor({ state: 'hidden' }) + } + await page.setViewportSize({ width: 1440, height: 900 }) + await page.locator('#api-nav').waitFor({ state: 'visible' }) + assert.equal(await page.locator('#api-nav').evaluate((el) => el.inert), false) + assert.equal(await page.locator('[data-api-nav-toggle]').isVisible(), false) + console.log('API navigation: client swaps, Back, drawer close controls, disclosure alignment and desktop pass') +} + /** Check the controls attached to a document after its content is replaced. */ export async function checkNavigation(page, base) { await page.goto(`${base}/examples/attach-and-send-keys/`, { waitUntil: 'load' }) diff --git a/site/src/components/PortHero.astro b/site/src/components/PortHero.astro index 854cd711..108d3bf7 100644 --- a/site/src/components/PortHero.astro +++ b/site/src/components/PortHero.astro @@ -1,7 +1,7 @@ --- import { branding } from '../lib/branding' import { withRoot } from '../lib/site-root' -import { defaultVersionFor } from '../lib/versions' +import { buildTarget, defaultVersionFor } from '../lib/versions' import PortLinks from './PortLinks.astro' import { hasReference, PORT_BY_SLUG, portPageUrl, type Port } from '../lib/ports' @@ -29,6 +29,8 @@ interface Props { const { port } = Astro.props as Props const brand = branding(port.slug) +const { version } = buildTarget(process.env) +const revision = process.env.LIBTMUX_DOCS_PORT === port.slug ? process.env.LIBTMUX_DOCS_SOURCE_SHA : undefined ---
@@ -51,7 +53,7 @@ const brand = branding(port.slug) footer, pointing at the organisation rather than at this language, and the registry was nowhere. */} - + { hasReference(port) ? null : ( diff --git a/site/src/components/PortLinks.astro b/site/src/components/PortLinks.astro index 62eeed07..ab537797 100644 --- a/site/src/components/PortLinks.astro +++ b/site/src/components/PortLinks.astro @@ -1,6 +1,6 @@ --- import RegistryIcon from './icons/RegistryIcon.astro' -import type { DocProduct, Port } from '../lib/ports' +import { portSourceUrl, type DocProduct, type Port } from '../lib/ports' interface Props { port: Port @@ -18,7 +18,9 @@ const repo = source?.repo ?? port.repo const taggedVersion = /^v?\d/.test(version) && repo === port.repo ? `${port.tagPrefix ?? ''}${version}` : undefined const ref = version === 'latest' ? source?.ref : taggedVersion ?? revision ?? source?.ref -const sourceUrl = source ? `https://github.com/${repo}/tree/${ref}/${source.path}` : `https://github.com/${repo}` +const sourceUrl = product + ? source ? `https://github.com/${repo}/tree/${ref}/${source.path}` : `https://github.com/${repo}` + : portSourceUrl(port, version, revision) const registry = product ? pkg?.registry && port.registry ? { ...port.registry, url: pkg.registry } : undefined : port.registry diff --git a/site/src/components/SearchModal.astro b/site/src/components/SearchModal.astro index eae0381f..4420b42d 100644 --- a/site/src/components/SearchModal.astro +++ b/site/src/components/SearchModal.astro @@ -1,6 +1,13 @@ --- import SearchPanel from './SearchPanel.astro' -import { withRoot } from '../lib/site-root' +import { withPortRoot, withRoot } from '../lib/site-root' +import { PORT_BY_SLUG } from '../lib/ports' + +interface Props { + port?: string + version: string +} +const { port, version } = Astro.props /** * Search without leaving the page. @@ -10,11 +17,11 @@ import { withRoot } from '../lib/site-root' * /search/, and this intercepts the click — with no JavaScript the link still * works, which is why it is a link and not a button. */ -const searchHref = withRoot('/search/') +const searchHref = port ? withPortRoot(`/${port}/${version}/search/`) : withRoot('/search/') --- - +