diff --git a/.gitignore b/.gitignore
index 07c5ad5..d2cc5d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ npm-debug.log*
*.log
.env
.env.*
+dist/
+.site-build/
diff --git a/docs/deploy.md b/docs/deploy.md
index 324c509..3538aba 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -3,7 +3,29 @@
patchling.app is a Cloudflare Pages project (`patchling`) connected to
`255BITS/patchling` through the Cloudflare Workers and Pages GitHub app.
There is no `wrangler.toml` and no deploy workflow in this repo: Cloudflare
-builds from Git on its side.
+builds from Git on its side, running `scripts/build-site.sh`.
+
+## What the build produces
+
+`bash scripts/build-site.sh` writes `dist/`:
+
+| Path | Source |
+| --- | --- |
+| `/` | `landing/` copied as-is (the demo page) |
+| `/py/` | `255BITS/patchling-py` `main` docs (`mkdocs.yml`, `site_url` set to `https://patchling.app/py/`) |
+| `/js/` | this repo's `README.md`, `CHANGELOG.md` and `docs/*.md`, rendered with `scripts/site/mkdocs.js.yml` |
+| `/docs/*` | 301 to `/py/*` (`scripts/site/_redirects`) |
+
+It makes a shallow public clone of patchling-py (no token) and installs
+`mkdocs-material==9.6.22` into a throwaway venv under `.site-build/`. That's
+the same version pinned by patchling-py's Deploy Documentation workflow.
+Nothing is added to `package.json`. The npm package still ships only `src/`.
+`PATCHLING_PY_REF` picks another patchling-py branch or tag.
+
+Each Pages build fetches patchling-py `main` fresh. patchling-py's "Trigger
+patchling.app deploy" workflow calls a Pages deploy hook when its docs or
+package change on `main`. That rebuilds this site with the new `/py/` docs,
+without a push here.
## Expected Pages settings
@@ -14,11 +36,14 @@ builds from Git on its side.
| Automatic production deployments | enabled |
| Preview deployments | `all` |
| PR preview comments | enabled |
-| Build command | *(none)* |
-| Build output directory | `landing` |
+| Build command | `bash scripts/build-site.sh` |
+| Build output directory | `dist` |
| Root directory | *(repo root)* |
| Custom domains (exact set) | `patchling.app`, `patchling.pages.dev` |
+Environment variable (production and preview): `PYTHON_VERSION` = `3.12`.
+The checker below does not read environment variables.
+
`scripts/check-pages-settings.mjs` holds these values as `EXPECTED`, and
`test/check_pages_settings.test.js` fails if this table and `EXPECTED`
disagree. "Preview deployments `all`" means every non-production branch gets
@@ -26,9 +51,9 @@ a preview, and "PR preview comments" (`pr_comments_enabled`) posts its URL
on each PR. The domain set is exact: a
missing or extra domain is drift.
-Because the build output is `landing/` with no build step, everything the
-page loads must live under `landing/`. `landing/lib/*.js` are byte-identical
-copies of `src/*.js`.
+Everything the landing page loads must live under `landing/`.
+`landing/lib/*.js` are byte-identical copies of `src/*.js`. `landing/` must
+not contain `py/`, `js/` or `docs/`, because the build owns those paths.
## What CI checks (from the repo alone)
@@ -70,7 +95,7 @@ invalid JSON), even when the project data would match. It never writes.
`npm test` runs it offline against `test/fixtures/pages/` with a negative case
for each check.
-Last verified 2026-10-13 via the API: production branch `main`,
+Last verified 2026-10-13 via the API, before the `dist/` build: production branch `main`,
automatic production deployments enabled, preview deployments `all`,
build output `landing`, no build command, domains `patchling.pages.dev` and
`patchling.app`, latest production deployment = `main` @ `58a3858` (#5).
diff --git a/landing/index.html b/landing/index.html
index 704b979..27f9186 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -352,6 +352,8 @@
Built with it
How it works
Any model
+ JS docs
+ Py docs
GitHub
@@ -697,6 +699,7 @@
JavaScript
@@ -710,7 +713,7 @@ Python
diff --git a/scripts/build-site.sh b/scripts/build-site.sh
new file mode 100755
index 0000000..225b4a6
--- /dev/null
+++ b/scripts/build-site.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# Build the patchling.app Cloudflare Pages output into dist/:
+# dist/ landing/ as-is (the demo page)
+# dist/py/ patchling-py docs (mkdocs), fetched from 255BITS/patchling-py
+# dist/js/ this repo's README, CHANGELOG and docs/ rendered with mkdocs
+# dist/_redirects /docs/* -> /py/*
+# dist/build-info.json commit SHAs of both repos used for this build
+# Build tooling (mkdocs-material in a throwaway venv) lives only here and in
+# .site-build/; nothing is added to package.json or the npm package.
+# Public clone, no token. Env:
+# PATCHLING_PY_REF branch/tag of patchling-py to build (default main)
+# MKDOCS_MATERIAL pip spec (default mkdocs-material==9.6.22, same pin as
+# patchling-py's Deploy Documentation workflow)
+# PYTHON interpreter (default python3)
+set -euo pipefail
+root=$(cd "$(dirname "$0")/.." && pwd)
+cd "$root"
+out="$root/dist"
+work="$root/.site-build"
+ref="${PATCHLING_PY_REF:-main}"
+py="${PYTHON:-python3}"
+
+rm -rf "$out" "$work"
+mkdir -p "$work"
+cp -R landing "$out"
+
+echo "== fetch 255BITS/patchling-py@$ref"
+git clone --quiet --depth 1 --branch "$ref" https://github.com/255BITS/patchling-py.git "$work/patchling-py"
+py_sha=$(git -C "$work/patchling-py" rev-parse HEAD)
+self_sha=$(git -C "$root" rev-parse HEAD 2>/dev/null || echo "${CF_PAGES_COMMIT_SHA:-unknown}")
+echo "patchling commit: $self_sha"
+echo "patchling-py commit: $py_sha (ref $ref)"
+
+echo "== mkdocs (${MKDOCS_MATERIAL:-mkdocs-material==9.6.22})"
+"$py" -m venv "$work/venv"
+"$work/venv/bin/python" -m pip install --quiet --disable-pip-version-check "${MKDOCS_MATERIAL:-mkdocs-material==9.6.22}"
+mkdocs="$work/venv/bin/mkdocs"
+
+echo "== /py/"
+# Same source and nav as gptdiff.255labs.xyz; only site_url changes.
+cat > "$work/patchling-py/mkdocs.patchling-app.yml" < "$js/docs/index.md"
+cp infographic.svg "$js/docs/"
+cp CHANGELOG.md docs/*.md "$js/docs/"
+cp scripts/site/mkdocs.js.yml "$js/mkdocs.yml"
+"$mkdocs" build --strict --quiet -f "$js/mkdocs.yml" -d "$out/js"
+
+cp scripts/site/_redirects "$out/_redirects"
+# Record exactly what went into this build (served at /build-info.json).
+cat > "$out/build-info.json" < {
const negatives = {
'preview deployments none': [(p) => { p.source.config.preview_deployment_setting = 'none'; }, ['previewDeploymentSetting']],
'root_dir src': [(p) => { p.build_config.root_dir = 'src'; }, ['rootDir']],
- 'destination_dir changed': [(p) => { p.build_config.destination_dir = 'dist'; }, ['destinationDir']],
- 'build command added': [(p) => { p.build_config.build_command = 'npm run build'; }, ['buildCommand']],
+ 'destination_dir still landing': [(p) => { p.build_config.destination_dir = 'landing'; }, ['destinationDir']],
+ 'build command missing': [(p) => { p.build_config.build_command = ''; }, ['buildCommand']],
'missing patchling.pages.dev': [(p) => { p.domains = ['patchling.app']; }, ['domains']],
'missing patchling.app': [(p) => { p.domains = ['patchling.pages.dev']; }, ['domains']],
'extra domain': [(p) => { p.domains.push('www.patchling.app'); }, ['domains']],
diff --git a/test/deploy_config.test.js b/test/deploy_config.test.js
index 392d3c3..a09b47f 100644
--- a/test/deploy_config.test.js
+++ b/test/deploy_config.test.js
@@ -1,5 +1,6 @@
/**
- * Cloudflare Pages serves landing/ as-is (no build step, see docs/deploy.md).
+ * Cloudflare Pages runs scripts/build-site.sh and serves dist/: landing/ at /,
+ * patchling-py docs at /py/, this repo's docs at /js/ (see docs/deploy.md).
* These are the deploy assumptions CI can verify from the repo alone.
*/
import { test } from 'node:test';
@@ -28,10 +29,10 @@ test('landing/index.html is the Pages entry point', () => {
assert.ok(existsSync(join(landing, 'index.html')));
});
-test('docs/deploy.md pins production branch main and output dir landing', () => {
+test('docs/deploy.md pins production branch main and output dir dist', () => {
const doc = readFileSync(join(root, 'docs', 'deploy.md'), 'utf8');
assert.match(doc, /Production branch \| `main`/);
- assert.match(doc, /Build output directory \| `landing`/);
+ assert.match(doc, /Build output directory \| `dist`/);
});
test('every relative import under landing/ resolves inside landing/', () => {
@@ -48,3 +49,38 @@ test('every relative import under landing/ resolves inside landing/', () => {
}
assert.deepEqual(problems, []);
});
+
+test('landing links the in-site docs, and /py/ and /js/ are left to the build', () => {
+ const html = readFileSync(join(landing, 'index.html'), 'utf8');
+ assert.match(html, /href="\/py\/"/);
+ assert.match(html, /href="\/js\/"/);
+ assert.doesNotMatch(html, /255bits\.github\.io\/patchling-py/);
+ for (const dir of ['py', 'js', 'docs']) {
+ assert.ok(!existsSync(join(landing, dir)), `landing/${dir} would collide with the build output`);
+ }
+});
+
+test('/docs/ redirects to /py/ and the build script wires everything up', () => {
+ const redirects = readFileSync(join(root, 'scripts', 'site', '_redirects'), 'utf8');
+ assert.match(redirects, /^\/docs\/\* \/py\/:splat 301$/m);
+ assert.match(redirects, /^\/docs \/py\/ 301$/m);
+ const sh = readFileSync(join(root, 'scripts', 'build-site.sh'), 'utf8');
+ assert.match(sh, /https:\/\/github\.com\/255BITS\/patchling-py\.git/);
+ assert.match(sh, /-d "\$out\/py"/);
+ assert.match(sh, /-d "\$out\/js"/);
+});
+
+test('every site build records both repos\' commit SHAs', () => {
+ const sh = readFileSync(join(root, 'scripts', 'build-site.sh'), 'utf8');
+ assert.match(sh, /PATCHLING_PY_REF:-main/);
+ assert.match(sh, /\$out\/build-info\.json/);
+ assert.match(sh, /"patchling":.*"commit": "\$self_sha"/);
+ assert.match(sh, /"patchling_py":.*"commit": "\$py_sha"/);
+ assert.match(sh, /echo "patchling-py commit: \$py_sha/);
+});
+
+test('site build tooling stays out of the npm package', () => {
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
+ assert.deepEqual(pkg.files, ['src']);
+ assert.equal(pkg.dependencies, undefined);
+});
diff --git a/test/fixtures/pages/project-ok.json b/test/fixtures/pages/project-ok.json
index 477ff2e..70ba9fa 100644
--- a/test/fixtures/pages/project-ok.json
+++ b/test/fixtures/pages/project-ok.json
@@ -17,7 +17,7 @@
"deployments_enabled": true
}
},
- "build_config": { "build_command": "", "destination_dir": "landing", "root_dir": "" },
+ "build_config": { "build_command": "bash scripts/build-site.sh", "destination_dir": "dist", "root_dir": "" },
"canonical_deployment": {
"environment": "production",
"created_on": "2026-10-13T16:50:51.866675Z",