diff --git "a/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png" "b/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png"
deleted file mode 100644
index acf097f..0000000
Binary files "a/.claude/Screenshot 2026-07-29 at 10.36.56\342\200\257AM.png" and /dev/null differ
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..102ad16
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,27 @@
+[flake8]
+# Copied from dash-documentation-boilerplate (the network template).
+#
+# Line length is not policed: this repo's comments carry a lot of explanation
+# and reflowing them to 79 columns would make them harder to read, not easier.
+max-line-length = 120
+extend-ignore = E203, W503, E501
+exclude =
+ .git,
+ .venv,
+ __pycache__,
+ node_modules,
+ vendor,
+ dist,
+ build,
+ .idea,
+ dash_leaflet2,
+ docs/*/,
+per-file-ignores =
+ # run.py imports Dash and the lib modules after `load_dotenv()`, which has
+ # to run first — the CLERK_* keys and CROSS_APP_WEBHOOK_SECRET must be in
+ # the environment before Dash construction imports anything that reads
+ # them. The appshell import at the bottom needs the page registry to be
+ # populated, so it cannot move to the top either.
+ run.py: E402
+ # usage.py is the compiled-package harness, same import-order constraint.
+ usage.py: E402
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..d02cdc7
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,49 @@
+# Version drift is the network's chronic disease — satellites were still
+# running a 2.0-era artifact the day 2.3.4 shipped, and nothing about a stale
+# host looks broken from the outside. This is the standing fix.
+#
+# Copied from dash-documentation-boilerplate. The `dash-network` group is the
+# point: a package release lands as ONE reviewable pull request per repo
+# instead of five, which is the difference between a rollout and a chore.
+#
+# npm is here and not in the template because this repo also builds the
+# dash-leaflet2 component bundle from src/ts.
+version: 2
+updates:
+ - package-ecosystem: pip
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ groups:
+ dash-network:
+ patterns:
+ - "dash*"
+ - "plotly*"
+ - "markdown2dash"
+
+ - package-ecosystem: npm
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ groups:
+ build-toolchain:
+ patterns:
+ - "*"
+
+ - package-ecosystem: github-actions
+ directory: "/"
+ schedule:
+ interval: monthly
+ groups:
+ actions:
+ patterns:
+ - "*"
+
+ - package-ecosystem: docker
+ directory: "/"
+ schedule:
+ interval: monthly
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..c74dc11
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,118 @@
+name: CD
+
+# Deploys leaflet.2plot.dev, then checks the live site.
+#
+# The deploy step POSTs to a Render deploy hook held in the
+# RENDER_DEPLOY_HOOK_URL secret. Without that secret the step is skipped and
+# the workflow goes straight to verification — which is the situation this repo
+# is in today: render.yaml sets `autoDeploy: true`, so Render is already
+# building from GitHub on its own. Adding the secret later moves the trigger
+# here without changing anything else.
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ target_url:
+ description: Site to verify (skips the deploy when set to another host)
+ required: false
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: cd-production
+ cancel-in-progress: false
+
+env:
+ PIP_DISABLE_PIP_VERSION_CHECK: "1"
+ SITE_URL: ${{ inputs.target_url || 'https://leaflet.2plot.dev' }}
+
+jobs:
+ test:
+ name: ci
+ uses: ./.github/workflows/ci.yml
+
+ deploy:
+ name: deploy to render
+ needs: [test]
+ runs-on: ubuntu-latest
+ # Long enough for the wait loop below (a 120s settle plus up to 40 × 15s)
+ # and no longer. Without it the job inherits GitHub's six-hour default,
+ # which is how a platform that never comes back healthy holds the
+ # `cd-production` concurrency group all day.
+ timeout-minutes: 20
+ environment:
+ name: production
+ url: https://leaflet.2plot.dev
+ outputs:
+ deployed: ${{ steps.hook.outputs.deployed }}
+ steps:
+ - name: Trigger the Render deploy hook
+ id: hook
+ env:
+ HOOK: ${{ secrets.RENDER_DEPLOY_HOOK_URL }}
+ run: |
+ if [ -z "$HOOK" ]; then
+ echo "::notice::RENDER_DEPLOY_HOOK_URL is not set. Skipping the deploy trigger and verifying whatever is currently live."
+ echo "deployed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ curl -fsS -X POST "$HOOK" > /dev/null
+ echo "deployed=true" >> "$GITHUB_OUTPUT"
+
+ - name: Wait for the new build to serve traffic
+ if: steps.hook.outputs.deployed == 'true'
+ run: |
+ # Render swaps instances rather than restarting in place, so the old
+ # build answers /healthz throughout. Waiting for a 200 proves
+ # nothing; give the build time, then require SUSTAINED health.
+ #
+ # This site is on Render's free tier (render.yaml), which also sleeps
+ # after ~15 minutes idle — so a single 200 can just as easily be a
+ # cold start as a finished deploy.
+ sleep 120
+ ok=0
+ for _ in $(seq 1 40); do
+ if curl -fsS "$SITE_URL/healthz" > /dev/null; then
+ ok=$((ok + 1))
+ [ "$ok" -ge 5 ] && break
+ else
+ ok=0
+ fi
+ sleep 15
+ done
+ if [ "$ok" -lt 5 ]; then
+ echo "::error::$SITE_URL never became reliably healthy"
+ exit 1
+ fi
+
+ verify:
+ name: verify the live site
+ needs: [deploy]
+ if: always() && needs.deploy.result != 'cancelled'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ # The network battery first: it is the same script, with the same check
+ # names, that CI ran against the container this deploy shipped. A name
+ # that passed in CI and fails here isolates the fault to the deploy.
+ - name: Network smoke battery
+ run: python scripts/network_smoke.py --base-url "$SITE_URL"
+
+ # Then the satellite-specific checks the battery does not make: every
+ # canonical, every crawler body, and every peer llms.txt in the
+ # directory actually resolving. Peer failures warn; this host's fail.
+ - name: Smoke-test the deployment
+ run: python scripts/smoke_live.py "$SITE_URL"
+
+ - name: Report
+ if: failure()
+ run: |
+ echo "::error::Live verification failed for $SITE_URL. Every failure these check for is silent in production: a site identity that fell back to a framework default, a stale dash-improve-my-llms artifact, a canonical on the wrong host, a page serving the JavaScript stub, a missing network directory, and dead peer llms.txt links."
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e2c0a83..983e7a8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,34 +1,181 @@
+# CI for leaflet.2plot.dev AND for the dash-leaflet2 PyPI package.
+#
+# This repo is unusual among the 2plot satellites: it ships two artifacts from
+# one tree. The network's CI baseline (copied from
+# dash-documentation-boilerplate, which 2plot.ai and 2plot.dev also run) covers
+# the documentation site; the `package*` jobs below are this repo's own and
+# cover the wheel. Both have to stay green.
+#
+# The network baseline, and why each piece is here:
+#
+# * least-privilege `permissions` and a cancel-in-progress `concurrency`
+# group, so a workflow cannot write more than it reads and a rapid second
+# push does not race the first;
+# * an explicit `timeout-minutes` on every job — the default is six hours,
+# which is how a hung `curl` burns a day of runner minutes unnoticed;
+# * `actionlint`, because an invalid workflow file is the one defect CI
+# structurally cannot report: the run dies before a job exists to fail;
+# * the real Docker image, built with a buildx GHA cache, then BOOTED, then
+# probed by the same battery that runs against production;
+# * version fingerprints asserted INSIDE the image, because pip metadata is
+# invisible from the outside and a stale artifact serves quietly;
+# * a secretless in-process pytest suite — no CLERK_*, no
+# CROSS_APP_WEBHOOK_SECRET — because the fail-closed behaviour is only
+# provable when nothing is configured;
+# * an advisory pip-audit.
+
name: CI
-# Every push and PR runs the smoke suite against the full supported Dash range.
-# pyproject declares `dash>=4.1`; this is what keeps that claim honest, and it
-# is the same harness scripts/compat_matrix.py runs locally.
+# Deliberately NOT `push: branches: [main]`. cd.yml runs on that push and its
+# first job `uses:` this workflow, so a push to main would otherwise start two
+# runs of it — which then contend for the `ci-${{ github.ref }}` concurrency
+# group below and cancel each other. The work still gets done, but every push
+# leaves a `cancelled` CI run next to the green CD one, which reads as a
+# failure at a glance.
+#
+# So: pull requests get their own CI, and `main` is owned by CD. There is no
+# coverage gap — CD cannot deploy without this workflow passing first.
on:
- push:
- branches: [main]
pull_request:
workflow_dispatch:
+ # Called by cd.yml so a deploy can never ship something the matrix rejected.
+ workflow_call:
+
+# Read-only. Nothing here publishes, comments or tags; the deploy lives in
+# cd.yml behind a `production` environment, and the PyPI release in release.yml.
+permissions:
+ contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
-# TWO different Python floors, which is what an earlier version of this file
-# got wrong by testing the docs site on 3.9:
-#
-# * the PACKAGE needs >= 3.9 — `dash` 4.4.1 itself requires 3.9, and that is
-# what `requires-python` in pyproject.toml promises. Verified by the
-# `package` job, which installs the wheel with nothing but Dash.
-# * the DOCS SITE needs >= 3.10 — `python-frontmatter` 1.3 imports
-# `typing.TypeGuard`, which is 3.10+. Nothing we can do about it here, and
-# it does not constrain the package at all.
-#
-# So the smoke job never runs below 3.10, and the package job never installs
-# the docs requirements.
+env:
+ PIP_DISABLE_PIP_VERSION_CHECK: "1"
+ FORCE_COLOR: "1"
+ # Never let a CI run inherit production behaviour: the satellite reporter
+ # keys off CROSS_APP_WEBHOOK_SECRET, which is absent here by design.
+ APP_ENV: ci
+
jobs:
- smoke:
+ lint:
+ name: lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ cache: pip
+ - run: pip install flake8
+ - name: flake8
+ run: flake8 lib components pages tests scripts run.py usage.py
+
+ # The workflows lint themselves. This is not belt-and-braces: an invalid
+ # workflow file is the one defect CI structurally cannot report, because
+ # the run dies before a job exists to fail. A double-quoted string inside
+ # a ${{ }} expression is a LEX error that invalidates the whole file, and
+ # it surfaces only as `conclusion: failure` with zero jobs and nothing to
+ # click. actionlint catches it in a second, with the column underlined.
+ - name: actionlint
+ run: |
+ bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) 1.7.7
+ ./actionlint -color
+
+ test:
+ name: pytest · ${{ matrix.backend }} · py${{ matrix.python }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ # Flask is what production runs (the Dockerfile sets DASH_BACKEND);
+ # FastAPI is run.py's local default, so both need coverage. The
+ # before_request ordering that makes bot_hits countable is a WSGI
+ # concern, which is exactly why Flask cannot be the only backend here.
+ python: ["3.12"]
+ backend: [flask, fastapi]
+ include:
+ # The docs site's Python floor and ceiling, on the default backend.
+ # 3.10 is the floor: python-frontmatter 1.3 imports typing.TypeGuard.
+ - python: "3.10"
+ backend: flask
+ - python: "3.13"
+ backend: flask
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python }}
+ cache: pip
+
+ - name: Install the app
+ run: |
+ pip install -r requirements.txt
+ # markdown2dash 0.1.2 declares gunicorn<22 against the CVE-driven
+ # gunicorn>=23 floor. Same two-command install as the Dockerfile.
+ pip install --no-deps markdown2dash==0.1.2
+ # Dash's own extras are required for the ASGI backends: a bare
+ # `fastapi` install is not enough for dash.backends._fastapi to
+ # import. httpx backs starlette's TestClient.
+ if [ "${{ matrix.backend }}" != "flask" ]; then
+ pip install "dash[${{ matrix.backend }}]" httpx
+ pip install "dash-improve-my-llms[${{ matrix.backend }}]>=2.3.4"
+ fi
+ pip install pytest
+
+ - name: Confirm the pinned dependency versions
+ run: |
+ python - <<'PY'
+ import dash, dash_improve_my_llms as pkg, gunicorn
+
+ def parts(v):
+ return tuple(int(x) for x in v.split(".")[:3] if x.isdigit())
+
+ # The docs site pins 4.4.1; the PACKAGE floor (dash>=4.1) is proven
+ # separately by the `package-python-range` job below.
+ assert parts(dash.__version__)[:2] >= (4, 1), dash.__version__
+ # 2.3.4 is the network standard: below it `resolve_site_title` does
+ # not exist and this site's published identity degrades to app.title.
+ assert parts(pkg.__version__) >= (2, 3, 4), pkg.__version__
+ # 21.x carried two request-smuggling CVEs (CVE-2024-6827,
+ # CVE-2024-1135). markdown2dash's spurious <22 pin must not win.
+ assert parts(gunicorn.__version__)[:2] >= (23, 0), gunicorn.__version__
+ print(f"dash {dash.__version__}, dash-improve-my-llms "
+ f"{pkg.__version__}, gunicorn {gunicorn.__version__}")
+ PY
+
+ # No CLERK_*, no CROSS_APP_WEBHOOK_SECRET, no SESSION_SECRET here ON
+ # PURPOSE. tests/conftest.py pins them empty and the fail-closed checks
+ # depend on that posture; a secret injected here would make the suite
+ # pass for the wrong reason.
+ - name: Test suite (${{ matrix.backend }}, zero secrets)
+ env:
+ DASH_BACKEND: ${{ matrix.backend }}
+ run: pytest tests -q
+
+ - name: Boot under a production server
+ if: matrix.backend == 'flask'
+ run: |
+ gunicorn run:server -b 127.0.0.1:8050 --daemon --access-logfile - --error-logfile -
+ for _ in $(seq 1 30); do
+ curl -sf http://127.0.0.1:8050/healthz && break
+ sleep 1
+ done
+ # A page that renders under the test client can still fail under a
+ # real WSGI worker — different import path, different working
+ # directory, no test-client conveniences.
+ curl -sf http://127.0.0.1:8050/ > /dev/null
+ curl -sf http://127.0.0.1:8050/pointer-events > /dev/null
+ # The battery, against the same server a satellite deploys.
+ python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050
+
+ docs-compat:
name: Docs · Dash ${{ matrix.dash }} · Python ${{ matrix.python }}
runs-on: ubuntu-latest
+ timeout-minutes: 25
strategy:
fail-fast: false
matrix:
@@ -71,6 +218,7 @@ jobs:
run: |
grep -v 'COMPAT-MATRIX: dash' requirements.txt > /tmp/reqs.txt
python -m pip install -r /tmp/reqs.txt
+ python -m pip install --no-deps markdown2dash==0.1.2
- name: Report the resolved Dash version
# A silent upgrade here would make the whole matrix meaningless.
@@ -91,9 +239,93 @@ jobs:
path: smoke-*.json
if-no-files-found: ignore
+ docker:
+ name: docker image · boot · battery
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ needs: [test]
+ steps:
+ - uses: actions/checkout@v4
+
+ # The same build Render runs. This is where a dependency-resolution
+ # failure surfaces — at CI time, not deploy time, where the only signal
+ # is a dashboard log while the old image keeps serving.
+ - uses: docker/setup-buildx-action@v3
+ - name: Build the production image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ tags: dash-leaflet2-docs:ci
+ load: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ # pip metadata is invisible from outside a running host, so the versions
+ # are asserted here, inside the artifact that actually ships.
+ - name: Version fingerprints inside the image
+ run: |
+ docker run --rm dash-leaflet2-docs:ci python -c "
+ from importlib.metadata import version
+
+ def parts(v):
+ return tuple(int(x) for x in v.split('.')[:3] if x.isdigit())
+
+ v = version('dash')
+ print('dash', v)
+ assert parts(v)[:2] >= (4, 1), f'expected dash >=4.1, image has {v}'
+
+ v = version('dash-improve-my-llms')
+ print('dash-improve-my-llms', v)
+ assert parts(v) >= (2, 3, 4), f'expected >=2.3.4 (resolve_site_title), image has {v}'
+
+ # markdown2dash installs with --no-deps to dodge its gunicorn<22
+ # pin; this assert is what proves the dodge kept working. 21.x
+ # carried two request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135).
+ v = version('gunicorn')
+ print('gunicorn', v)
+ assert parts(v)[:2] >= (23, 0), f'expected gunicorn>=23, image has {v}'
+
+ # ...and that skipping its dependency graph did not skip the package.
+ import markdown2dash # noqa: F401
+ print('markdown2dash importable')
+ "
+
+ # Boot with no secrets: Clerk falls open (dev mode) and the reporter
+ # stays dormant. What this catches is any import-time or preload crash —
+ # the class of failure where the platform loops the worker and the deploy
+ # never goes live.
+ - name: Boot the container and wait for /healthz
+ run: |
+ docker run -d --name docs -p 8050:8050 dash-leaflet2-docs:ci
+ for i in $(seq 1 60); do
+ if curl -sf http://127.0.0.1:8050/healthz > /dev/null; then
+ echo "healthy after ~$((i*2))s"
+ exit 0
+ fi
+ if [ "$(docker inspect -f '{{.State.Running}}' docs)" != "true" ]; then
+ echo "container exited during boot:"
+ docker logs docs
+ exit 1
+ fi
+ sleep 2
+ done
+ echo "never became healthy; last logs:"
+ docker logs --tail 100 docs
+ exit 1
+
+ # The SAME script CD runs against https://leaflet.2plot.dev, so a failure
+ # in CI and a failure in production read identically.
+ - name: Smoke battery against the booted container
+ run: python3 scripts/network_smoke.py --base-url http://127.0.0.1:8050
+
+ - name: Container logs (for the record)
+ if: always()
+ run: docker logs --tail 40 docs 2>/dev/null || true
+
package:
name: Build + verify the wheel
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
@@ -161,6 +393,7 @@ jobs:
name: Package · Python ${{ matrix.python }}
needs: package
runs-on: ubuntu-latest
+ timeout-minutes: 15
strategy:
fail-fast: false
matrix:
@@ -209,3 +442,22 @@ jobs:
print(f"dash={dash.__version__} dl2={dl2.__version__} "
f"components={len(dl2.__all__)} OK")
PY
+
+ pip-audit:
+ name: pip-audit (advisory)
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ # Advisory on purpose. A CVE in a transitive dependency of a docs site is
+ # worth knowing about the day it lands, and worth nobody's broken build at
+ # 2am. The report is the value; flip this off once the baseline is quiet.
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - run: pip install pip-audit
+ # Skip local vendor/ paths — pip-audit can only assess PyPI dists.
+ - run: |
+ grep -v '^\./vendor/' requirements.txt > /tmp/req-pypi.txt
+ pip-audit -r /tmp/req-pypi.txt --skip-editable
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dda3d3f..9dc0afa 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -22,10 +22,23 @@ on:
type: boolean
default: true
+# Read-only by default; the two jobs that need more ask for it themselves
+# (`id-token: write` to publish, `contents: write` to cut the GitHub Release).
+permissions:
+ contents: read
+
+# Never let two releases race. NOT cancel-in-progress: a half-cancelled
+# publish is the one state worth avoiding here, because a version can be
+# uploaded to PyPI exactly once and is not replaceable afterwards.
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
jobs:
verify:
name: Verify the tag
runs-on: ubuntu-latest
+ timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -52,6 +65,7 @@ jobs:
name: Build distributions
needs: verify
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -66,6 +80,12 @@ jobs:
python -m pip install --upgrade pip
grep -v 'COMPAT-MATRIX: dash' requirements.txt > /tmp/reqs.txt
python -m pip install "dash[fastapi]" -r /tmp/reqs.txt
+ # markdown2dash is NOT in requirements.txt: it declares gunicorn<22
+ # against the CVE-driven gunicorn>=23 floor, so it installs without
+ # its dependency graph. pages/markdown.py imports it, so the smoke
+ # test below cannot even reach a page without this line. Same pair as
+ # the Dockerfile and ci.yml.
+ python -m pip install --no-deps markdown2dash==0.1.2
python scripts/smoke_test.py
- name: Build
@@ -83,6 +103,7 @@ jobs:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
+ timeout-minutes: 15
# The environment name must match the pending publisher configured on PyPI.
# Add a required reviewer on this environment in repo settings if you want
# a human approval gate between the tag and the upload.
@@ -114,6 +135,7 @@ jobs:
needs: publish
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
+ timeout-minutes: 10
permissions:
contents: write
steps:
diff --git a/.gitignore b/.gitignore
index e026c03..932d1e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,3 +42,6 @@ visitor_analytics.json
.DS_Store
.idea/
.vscode/
+# Claude Code's per-project working directory: local settings, scratch writes
+# and screenshots. Nothing here is needed to build, run or deploy the project.
+.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dfd90d0..064b259 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,116 @@ Nothing yet.
---
+## [0.2.1] — 2026-07-31
+
+Brings this satellite onto the **2plot network standard** that 2plot.ai (root),
+2plot.dev (hub) and `dash-documentation-boilerplate` (the template) now ship.
+No `dl2.*` component changed; everything here is the documentation site, its
+analytics and its CI.
+
+### Fixed
+
+- **Every page shipped an empty `og:image`.** Dash emits `og:image` and
+ `twitter:image` for each page and leaves them `content=""` when it can find
+ no image, which unfurls as a *blank* preview card on Facebook, Twitter/X,
+ Slack, Discord and LinkedIn — strictly worse than declaring no image at all.
+ `register_page(image_url=...)` now supplies the real absolute URL, served
+ from the 2plot CDN so a sleeping free-tier container never costs a preview.
+ `templates/index.html` deliberately declares only the auxiliaries Dash omits
+ (`og:image:width` / `height` / `alt` / `type` / `secure_url`,
+ `twitter:image:alt`), so it cannot duplicate the URL.
+- **The web app manifest could never have offered an install.** Its `name` and
+ `short_name` were empty strings — which disqualifies a manifest outright —
+ and its icon `src` paths pointed at `/android-chrome-192x192.png` at the site
+ root, where nothing is served; the files live under `/assets/favicon_io/`.
+ Nothing linked to it either. Fixed, linked, and joined by
+ `apple-touch-icon` (iOS ignores the manifest and uses that for Add to Home
+ Screen) and the `msapplication-*` tiles.
+- **Crawler traffic was never counted.** The per-request tracker was a Flask
+ `before_request` handler registered *after* `add_llms_routes`, and
+ dash-improve-my-llms' bot middleware answers every crawler with prerendered
+ HTML — which short-circuits the remaining `before_request` handlers. No
+ crawler request ever reached the ledger, so this site reported
+ `bot_hits: 0` to 2plot.ai structurally, for every day it has been live,
+ with nothing visibly broken. The tracker now wraps the WSGI/ASGI callable
+ instead (`_wsgi_tracker` / `_asgi_tracker`), which sits outside the whole
+ application and cannot be short-circuited. Registration order was not a
+ usable fix: Flask runs `before_request` handlers first-registered-first,
+ while Starlette makes the last-added middleware outermost, so no single
+ ordering is correct on all three backends.
+- **The ad fetch and the traffic rollup polluted the hub's ledgers.** Both
+ server-to-server calls left as `python-requests/2.x`, which 2plot.dev and
+ 2plot.ai classify as a bot — so every docs page view here inflated the
+ hub's `bot_hits`. Both now send the network's internal-traffic User-Agent.
+- **A control-board toggle could rename the site.** `apply_llms_state`
+ re-registers a page's metadata whenever a visibility verdict changes, using
+ the name the markdown loader recorded — `"Home"` for this site's root. One
+ flip of the home page's llms.txt switch would have overwritten the site
+ brand at runtime, silently degrading the published identity to a generic
+ word. `lib.page_visibility.published_name` now pins the root to
+ `SITE_BRAND`.
+- **gunicorn was pinned under a security floor.** `gunicorn>=21.2,<22` was
+ holding the production server on a line carrying two HTTP request-smuggling
+ CVEs (CVE-2024-6827, CVE-2024-1135), because `markdown2dash` 0.1.2 declares
+ `gunicorn<22`. markdown2dash is now installed with `--no-deps` (its real
+ dependencies moved into `requirements.txt`, carrying its own version ranges)
+ and the floor is `gunicorn>=23.0.0`, asserted inside the built image by CI.
+
+### Added
+
+- **Explicit site identity.** `lib.constants.SITE_BRAND` —
+ *"dash-leaflet2 — Leaflet 2 maps for Dash"* — is now the one string on every
+ surface: `Dash(title=)`, `register_page_metadata(path="/")`, the home
+ markdown's H1 and the README. This matters because the home page is
+ registered as `"Home"`, which `resolve_site_title` skips as generic; without
+ the explicit registration the site published a framework fallback.
+- **`scripts/network_smoke.py`** — the network's named-check battery, run
+ against the CI container and against production with identical check names.
+ Proves identity, the agent-facing document surfaces, the robots fingerprint,
+ hidden-page 404s and content negotiation.
+- **`scripts/smoke_live.py`** — post-deploy checks: every canonical, every
+ crawler body, and every peer `llms.txt` in the directory. Peer failures warn
+ rather than fail, because gating a deploy on somebody else's certificate is
+ shared fate.
+- **`tests/`** — a secretless in-process suite (80 tests) covering site
+ identity, the internal-traffic contract in both directions, the agent and
+ crawler surfaces, the social card and manifest, and *the smoke scripts
+ themselves*, so a battery that has rotted into a silent pass fails here
+ first.
+- Two live battery checks for the surfaces above — `social_card_is_shareable`
+ (the image is declared once, is not empty, and actually resolves) and
+ `installable_as_an_app` (the manifest is linked, named, and its icons
+ resolve). Both fail invisibly in production otherwise: nobody sees their own
+ link previews, and no browser explains why it declined to offer an install.
+- **`.github/workflows/cd.yml`** — deploy plus live verification, waiting for
+ five consecutive healthy responses after a 120s settle rather than a single
+ 200 (Render swaps instances, so the old build answers throughout).
+- **`.github/dependabot.yml`** — weekly pip with a `dash-network` group,
+ weekly npm, monthly actions and Docker.
+
+### Changed
+
+- **`dash-improve-my-llms>=2.3.4`** (from 2.3.3), the network floor: 2.3.4 adds
+ `resolve_site_title`, without which the `/llms.txt` H1 and the llms viewer's
+ brand chip fall back to `app.title`.
+- **CI on the network baseline**: `permissions: contents: read`,
+ `timeout-minutes` on every job, an `actionlint` step (an invalid workflow
+ file is the one defect CI structurally cannot report), a real Docker
+ build → boot → battery job with buildx GHA caching, version fingerprints
+ asserted inside the image, and an advisory `pip-audit`. CI now runs on
+ pull requests and `workflow_call` only — `main` belongs to CD, which calls
+ it. The existing wheel and Dash-compatibility jobs are unchanged.
+- **The home page** is no longer the generated scaffold: it opens with the site
+ brand and describes what the library actually is.
+- `templates/index.html` no longer publishes `pip-install-python.com` as this
+ site's Organization URL, author URL or footer link — it is not a 2plot
+ network host. Those now point at https://github.com/2plotai.
+- The README's assets are served from `cdn.2plot.ai` rather than
+ `raw.githubusercontent.com`, so they render on PyPI (where the README is the
+ long description) as well as on GitHub.
+
+---
+
## [0.2.0] — 2026-07-28
First public release: the project splits into a private R&D checkout and this
diff --git a/Dockerfile b/Dockerfile
index 82127c2..8cb00f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,6 +28,12 @@ WORKDIR /app
COPY requirements.txt ./
COPY vendor/ ./vendor/
RUN pip install --no-cache-dir -r requirements.txt
+# markdown2dash pins gunicorn<22, against the CVE-driven gunicorn>=23 floor in
+# requirements.txt (CVE-2024-6827, CVE-2024-1135 — request smuggling). Its real
+# dependencies are all in requirements.txt already, so it installs without its
+# dependency graph. Same pair in .github/workflows/ci.yml; CI asserts the
+# resolved gunicorn version inside this image.
+RUN pip install --no-cache-dir --no-deps markdown2dash==0.1.2
# Copy the application. run.py resolves templates/, dash_leaflet2/, docs/,
# assets/, components/, lib/ and pages/ relative to the working directory, so it
diff --git a/README.md b/README.md
index e233e82..bdfa687 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,19 @@
+
+
+
+
+
+
+
+
+
# dash-leaflet2
-**Leaflet 2-native mapping components for [Plotly Dash](https://dash.plotly.com) 4.**
+**dash-leaflet2 — Leaflet 2 maps for Dash**
+
+Leaflet 2-native mapping components for [Plotly Dash](https://dash.plotly.com) 4.
No react-leaflet · unified Pointer Events · `BlanketOverlay` canvas/WebGL layers · ES6-class subclassing · `ResizeObserver` sizing · map rotation · liquid-glass theme · full Dash callback interoperability.
@@ -18,7 +29,15 @@ No react-leaflet · unified Pointer Events · `BlanketOverlay` canvas/WebGL laye
-_Maintained by **[Pip Install Python LLC](https://pip-install-python.com)**._
+
+
+
+
+_Live at **[leaflet.2plot.dev](https://leaflet.2plot.dev)** — every map on the docs site is a running Dash app._
+
+
+
+_Maintained by **[Pip Install Python LLC](https://github.com/2plotai)**._
@@ -304,15 +323,15 @@ Come build with us:
dash-leaflet2 is one of several tools built and maintained by **Pip Install Python LLC**:
-| Project | What it is |
-|--------------------------------------------------------------------------|-----------------------------------------------------------------|
-| 📚 **[Pip Install Python](https://pip-install-python.com)** | Open-source documentation index for the Python & Dash ecosystem |
-| 🎞️ **[dash-nle-timeline](https://pypi.org/project/dash-nle-timeline/)** | Frame-accurate NLE timeline & scene compositor for Dash |
-| 🔀 **[PiratesBargain.com](https://piratesbargain.com)** | E-commerce / digital commerce |
-| 🧠 **[ai-agent.buzz](https://ai-agent.buzz)** | Infinite AI canvas |
-| 🎬 **[2plot.media](https://2plot.media)** | Videography application |
+| Project | What it is |
+|---------------------------------------------------------------|-----------------------------------------------------------------|
+| 📚 **[Pip Install Python](https://2plot.dev)** | Open-source documentation index for the Python & Dash ecosystem |
+| 🔀 **[2plot.ai](https://2plot.ai)** | Frame-accurate NLE timeline & scene compositor for Dash |
+| 🛍️ **[PiratesBargain.com](https://piratesbargain.com/shop)** | E-commerce / digital commerce |
+| 🧠 **[ai-agent.buzz](https://ai-agent.buzz)** | Infinite AI canvas |
+| 🎬 **[2plot.media](https://2plot.media)** | Videography application |
## License
-MIT — see [LICENSE](LICENSE). Built by **[Pip Install Python LLC](https://pip-install-python.com)**
+MIT — see [LICENSE](LICENSE). Built by **[Pip Install Python LLC](https://github.com/2plotai)**
to bring a generation-ahead mapping stack into the Dash framework.
diff --git a/RELEASING.md b/RELEASING.md
index 1a77f17..414a6ee 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -10,7 +10,7 @@ them in any order, but this is the order that fails cheapest:
| 3 | Docs | `https://leaflet.2plot.dev` (Render) | redeploy the previous commit |
The one irreversible step is PyPI. A filename can never be reused, even after
-deletion, so a bad `0.2.0` costs you `0.2.1` forever. Everything below is
+deletion, so a bad `0.2.1` costs you `0.2.2` forever. Everything below is
arranged so the irreversible step happens last, after the reversible ones have
already proven the artifact.
@@ -25,7 +25,7 @@ These are the checks nobody can do for you later.
python scripts/smoke_test.py # expect 70/70
# 2. Version consistency, packaging leaks, stale bundle
-python scripts/check_release.py --version 0.2.0
+python scripts/check_release.py --version 0.2.1
# 3. The support claim, actually measured (needs network, ~15 min)
python scripts/compat_matrix.py # writes COMPATIBILITY.md
@@ -170,8 +170,8 @@ TestPyPI needs its own pending publisher (same form, on test.pypi.org).
### 2.4 Tag and publish
```bash
-git tag -a v0.2.0 -m "dash-leaflet2 0.2.0 — first public release"
-git push origin v0.2.0
+git tag -a v0.2.1 -m "dash-leaflet2 0.2.1 — first public release"
+git push origin v0.2.1
```
`release.yml` then: asserts the tag matches `pyproject.toml`, re-runs
@@ -246,7 +246,7 @@ of a bare slug. The ad network's `/admin/ad-board` keys off `AD_APP_ID`
### 3.5 Post-deploy checklist
-1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.0", "reporting": true}`.
+1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.1", "reporting": true}`.
`reporting: false` means `CROSS_APP_WEBHOOK_SECRET` is missing.
2. `/llms.txt`, `/robots.txt`, `/sitemap.xml` all 200, and sitemap URLs use
`leaflet.2plot.dev` (i.e. `DASH_LEAFLET2_BASE_URL` took effect).
diff --git a/assets/favicon_io/android-chrome-192x192.png b/assets/favicon_io/android-chrome-192x192.png
new file mode 100644
index 0000000..88dab0a
Binary files /dev/null and b/assets/favicon_io/android-chrome-192x192.png differ
diff --git a/assets/favicon_io/android-chrome-512x512.png b/assets/favicon_io/android-chrome-512x512.png
new file mode 100644
index 0000000..40a752e
Binary files /dev/null and b/assets/favicon_io/android-chrome-512x512.png differ
diff --git a/assets/favicon_io/apple-touch-icon.png b/assets/favicon_io/apple-touch-icon.png
new file mode 100644
index 0000000..c6a720a
Binary files /dev/null and b/assets/favicon_io/apple-touch-icon.png differ
diff --git a/assets/favicon_io/favicon-16x16.png b/assets/favicon_io/favicon-16x16.png
new file mode 100644
index 0000000..161b2ed
Binary files /dev/null and b/assets/favicon_io/favicon-16x16.png differ
diff --git a/assets/favicon_io/favicon-32x32.png b/assets/favicon_io/favicon-32x32.png
new file mode 100644
index 0000000..b3ba2e5
Binary files /dev/null and b/assets/favicon_io/favicon-32x32.png differ
diff --git a/assets/favicon_io/favicon.ico b/assets/favicon_io/favicon.ico
new file mode 100644
index 0000000..8f14613
Binary files /dev/null and b/assets/favicon_io/favicon.ico differ
diff --git a/assets/favicon_io/site.webmanifest b/assets/favicon_io/site.webmanifest
new file mode 100644
index 0000000..21f2850
--- /dev/null
+++ b/assets/favicon_io/site.webmanifest
@@ -0,0 +1,14 @@
+{
+ "name": "dash-leaflet2 — Leaflet 2 maps for Dash",
+ "short_name": "dash-leaflet2",
+ "description": "Leaflet 2 (alpha) mapping components for Plotly Dash 4, without react-leaflet.",
+ "icons": [
+ { "src": "/assets/favicon_io/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/assets/favicon_io/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/assets/favicon_io/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ],
+ "theme_color": "#2f9e44",
+ "background_color": "#ffffff",
+ "display": "standalone",
+ "start_url": "/"
+}
diff --git a/components/appshell.py b/components/appshell.py
index d194851..473a75f 100644
--- a/components/appshell.py
+++ b/components/appshell.py
@@ -57,7 +57,7 @@ def create_appshell(data):
# Border Radius System
"radius": {
"xs": "0.25rem", # 4px
- "sm": "0.375rem", # 6px
+ "sm": "0.375rem", # 6px
"md": "0.5rem", # 8px
"lg": "0.75rem", # 12px
"xl": "1rem", # 16px
@@ -311,4 +311,4 @@ def create_appshell(data):
Output("desktop-navbar-toggle", "opened"),
Input("url", "pathname"),
State("desktop-navbar-collapsed", "data"),
-)
\ No newline at end of file
+)
diff --git a/dash_leaflet2/package-info.json b/dash_leaflet2/package-info.json
index dac2a12..3ceeac8 100644
--- a/dash_leaflet2/package-info.json
+++ b/dash_leaflet2/package-info.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/docs/home/home.md b/docs/home/home.md
index 74c97a3..4265b3b 100644
--- a/docs/home/home.md
+++ b/docs/home/home.md
@@ -1,6 +1,6 @@
---
name: "Home"
-description: "prove Leaflet 2 alpha renders inside Dash 4."
+description: "Leaflet 2 (alpha) mapping components for Plotly Dash 4, without react-leaflet."
endpoint: "/"
package: dash-leaflet2
category: "Start here"
@@ -11,9 +11,28 @@ icon: "tabler:home"
.. toc::
+# dash-leaflet2 — Leaflet 2 maps for Dash
+
+> **`dash-leaflet2`** wraps **Leaflet 2 core directly** — no react-leaflet — and
+> ships it as real Dash components. By [Pip Install Python](https://github.com/2plotai).
+
### Overview
-This page demonstrates Home.
+`dash-leaflet` is frozen on react-leaflet, which has no Leaflet 2 line, so it
+cannot move past Leaflet 1.9. This library skips that abstraction entirely and
+drives Leaflet 2 core itself, which is what puts v2's headline features inside
+reach of a Python callback:
+
+- **Unified Pointer Events** — one event model for mouse, touch and stylus, with
+ `pointerType`, `pressure` and `tiltX` / `tiltY` reaching your callbacks
+- **`BlanketOverlay` canvas / WebGL layers** — your own renderer across the
+ whole viewport, instead of the DOM layer system
+- **ES6-class subclassing** — extend a Leaflet 2 class and mount the result
+- **`ResizeObserver` sizing** — no grey tiles for a map born in a hidden tab
+- **Map rotation** — `bearing` as a first-class, two-way prop
+
+The demo below is the whole claim in one page: Leaflet `2.0.0-alpha.1`,
+rendering inside Dash 4, with no JavaScript build step.
### Live demo
diff --git a/github_assets/leaflet2plotdevtakeflight.gif b/github_assets/leaflet2plotdevtakeflight.gif
new file mode 100644
index 0000000..7e9c263
Binary files /dev/null and b/github_assets/leaflet2plotdevtakeflight.gif differ
diff --git a/github_assets/light_mode_2plot.png b/github_assets/light_mode_2plot.png
new file mode 100644
index 0000000..70e8cfb
Binary files /dev/null and b/github_assets/light_mode_2plot.png differ
diff --git a/lib/ad_client.py b/lib/ad_client.py
index b7de275..834355c 100644
--- a/lib/ad_client.py
+++ b/lib/ad_client.py
@@ -26,6 +26,12 @@
Failure behaviour: if the ad server is unreachable the slot simply stays
hidden, and a 60s circuit breaker stops retrying so an outage never adds
the HTTP timeout to every page view.
+
+Analytics: the server-to-server fetch sends the network's internal-traffic
+User-Agent (``lib/constants.INTERNAL_UA``) so 2plot.dev does not count this
+app's ad requests as visits to itself. The click beacon deliberately does
+not — it is fired by the reader's own browser, which cannot set a
+User-Agent anyway, and a click IS a real person.
"""
from __future__ import annotations
@@ -63,11 +69,20 @@ def fetch_ad(page: str) -> dict | None:
with _breaker_lock:
if time.time() - _last_failure < _COOLDOWN:
return None
+ from lib.constants import internal_ua
+
try:
resp = _session.get(
f"{AD_SERVER_URL}/api/ad-network/serve",
params={"app": APP_ID, "page": page},
timeout=_TIMEOUT,
+ # The highest-volume outbound call this app makes — one per docs
+ # page view, server-to-server. Without the internal-traffic token
+ # every one of them reached 2plot.dev as `python-requests/2.x`,
+ # which its tracker classifies as a bot: this satellite's readers
+ # were being counted as crawler traffic on the hub. See
+ # lib/constants.INTERNAL_UA.
+ headers={"User-Agent": internal_ua("ad-client")},
)
if resp.status_code == 200 and resp.content:
return resp.json()
diff --git a/lib/constants.py b/lib/constants.py
index cc6ea0d..a5a6030 100644
--- a/lib/constants.py
+++ b/lib/constants.py
@@ -4,15 +4,112 @@
PRIMARY_COLOR = "green"
# Keep in step with pyproject.toml and package.json when cutting a release.
-APP_VERSION = "0.2.0"
+APP_VERSION = "0.2.1"
LEAFLET_VERSION = "2.0.0-alpha.1"
-SITE_TITLE = "dash-leaflet2 — Leaflet 2 on Dash 4"
+# ---------------------------------------------------------------------------
+# Site identity — one string, every surface
+# ---------------------------------------------------------------------------
+# The network standard (2plot.ai, 2plot.dev and the documentation boilerplate
+# all ship it): a site states what it is, in the same words, on every surface
+# an agent or a reader can reach. The surfaces this brand has to reach, and
+# what serves each:
+#
+# Dash(title=SITE_BRAND) -> , and the fallback identity
+# register_page_metadata(path="/", -> the /llms.txt H1 and the llms
+# name=SITE_BRAND) viewer's brand chip, both via
+# dash-improve-my-llms 2.3.4's
+# `resolve_site_title`
+# docs/home/home.md's opening `# ` -> the home page's own prose
+#
+# tests/test_site_identity.py pins all of them to this constant, because the
+# failure is silent: `resolve_site_title` SKIPS generic candidates ("Home",
+# "Index", Dash's default "Dash") rather than publishing them, so a site that
+# never states its identity falls through to whatever is left and nothing
+# looks broken. This host was one candidate away from that — docs/home/home.md
+# is registered as "Home", which is on the generic list.
+#
+# Naming rules, from the network standard:
+# - the PACKAGE NAME belongs in the description, not in the brand;
+# - "Pip Install Python" is the byline (who made it), never the site name.
+SITE_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash"
+
+SITE_DESCRIPTION = (
+ "dash-leaflet2 — Leaflet 2 (alpha) mapping components for Plotly Dash 4. "
+ "Wraps Leaflet 2 core directly instead of react-leaflet, exposing unified "
+ "Pointer Events, BlanketOverlay canvas/WebGL layers, ES6-class "
+ "subclassing, ResizeObserver sizing and map rotation as Dash components. "
+ "By Pip Install Python."
+)
# Public origin, used for canonical URLs, the sitemap and llms.txt. Override per
# deployment; the default is the 2plot network subdomain this site ships to.
BASE_URL = os.environ.get("DASH_LEAFLET2_BASE_URL", "https://leaflet.2plot.dev").rstrip("/")
+# ---------------------------------------------------------------------------
+# The social card
+# ---------------------------------------------------------------------------
+# Served from the 2plot CDN rather than this app's own /assets, deliberately:
+# a link preview is fetched by Facebook, Twitter/X, Slack, Discord and
+# LinkedIn — none of which wait for a free-tier container to wake from sleep.
+# The CDN answers immediately whether or not this site is cold.
+#
+# THE BUG THIS FIXES: Dash builds `og:image` and `twitter:image` for every page
+# from `register_page(image=...)` / `image_url=...`, and emits `content=""`
+# when it finds neither (dash/_pages.py). This site had no brand image, so
+# every page shipped an EMPTY og:image — which unfurls worse than having no
+# tag at all, because scrapers treat the empty value as the declared image and
+# render a blank card. `image_url` takes an absolute URL and wins over the
+# assets-derived one, so passing it at `register_page` time fixes every page at
+# the source instead of fighting tag order inside templates/index.html.
+#
+# 1280x515 (2.49:1) rather than the 1.91:1 the card specs ask for, so previews
+# centre-crop roughly 100px off the top and bottom. The wordmark sits in the
+# middle band and survives the crop.
+OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png"
+OG_IMAGE_WIDTH = 1280
+OG_IMAGE_HEIGHT = 515
+OG_IMAGE_ALT = "dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"
+
+# ---------------------------------------------------------------------------
+# The network's internal-traffic contract
+# ---------------------------------------------------------------------------
+# The analytics point of truth is https://2plot.ai/docs/satellite-analytics
+# ("Internal traffic"): any request whose User-Agent contains
+# INTERNAL_UA_TOKEN is 2plot network machinery talking to itself — the hub's
+# hourly health sweep, CI smoke batteries, the 4x-daily heartbeat, this app's
+# own server-to-server calls to the hub. It is counted NOWHERE.
+#
+# Two halves, and both are required for the contract to hold:
+#
+# inbound — every tracker drops a token-carrying request at WRITE time,
+# before device detection and before bot classification, so it
+# never reaches the ledger the rollup is built from;
+# outbound — every call this host makes to another network host sends
+# INTERNAL_UA, so the far side can apply the same rule.
+#
+# The outbound half is the one that was missing here. lib/ad_client.py fetched
+# a campaign from 2plot.dev on EVERY docs page view, arriving as
+# `python-requests/2.x` — which the hub's own tracker classifies as a bot, so
+# this satellite's readers were inflating 2plot.dev's bot_hits. The signed
+# rollup POST in lib/satellite_analytics.py had the same shape.
+#
+# The token string must stay byte-identical across the network; it mirrors
+# 2plotai/lib/constants.py, pip-docs+/lib/constants.py and the boilerplate's.
+INTERNAL_UA_TOKEN = "2plot-internal"
+INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)"
+
+
+def internal_ua(caller: str = "") -> str:
+ """``INTERNAL_UA`` with a caller suffix, e.g. ``"ad-client"``.
+
+ The suffix is for reading logs on the far side; only the token matters to
+ the contract, and it stays intact whatever the suffix says.
+ """
+ caller = (caller or "").strip()
+ return f"{INTERNAL_UA} {caller}" if caller else INTERNAL_UA
+
+
# 2plot network links, surfaced in the README and the docs footer/header.
GITHUB_URL = "https://github.com/pip-install-python/dash-leaflet2"
DISCORD_URL = "https://discord.gg/WEnZR35mrK"
diff --git a/lib/directives/kwargs.py b/lib/directives/kwargs.py
index bab7fa5..aade195 100644
--- a/lib/directives/kwargs.py
+++ b/lib/directives/kwargs.py
@@ -2,6 +2,7 @@
import inspect
from markdown2dash.src.directives.kwargs import Kwargs as KwargsBase
+
def convert_docstring_to_dict(docstring):
"""Convert numpy style parameter docstring to a list of dicts with keys name, type, description"""
@@ -21,6 +22,7 @@ def convert_docstring_to_dict(docstring):
return params
+
class Kwargs(KwargsBase):
def hook(self, md, state):
@@ -64,6 +66,6 @@ def hook(self, md, state):
else:
# If no proper docstring, use component's __init__ signature
attrs["kwargs"] = []
- except (ModuleNotFoundError, AttributeError, Exception) as e:
+ except (ModuleNotFoundError, AttributeError, Exception):
# If import fails, just skip kwargs generation
- attrs["kwargs"] = []
\ No newline at end of file
+ attrs["kwargs"] = []
diff --git a/lib/directives/source.py b/lib/directives/source.py
index 76a06dd..3452270 100644
--- a/lib/directives/source.py
+++ b/lib/directives/source.py
@@ -29,4 +29,8 @@ def render(self, renderer, title: str, content: str, **options) -> Component:
"icon": mapping[extension]["icon"],
}
)
- return dmc.CodeHighlightTabs(code=code, defaultExpanded=defaultExpanded=="true", withExpandButton=withExpandedButton=='true')
+ return dmc.CodeHighlightTabs(
+ code=code,
+ defaultExpanded=defaultExpanded == "true",
+ withExpandButton=withExpandedButton == "true",
+ )
diff --git a/lib/network_directory.py b/lib/network_directory.py
index 3680990..f1244ab 100644
--- a/lib/network_directory.py
+++ b/lib/network_directory.py
@@ -91,16 +91,16 @@
"url": "https://flows.2plot.dev",
"description": "Node-graph editors built on React Flow.",
},
-# {
-# "name": "dash-pannellum",
-# "url": "https://pannellum.2plot.dev",
-# "description": "360° panorama and virtual-tour viewer.",
-# },
-# {
-# "name": "dash-emoji-mart",
-# "url": "https://emojimart.2plot.dev",
-# "description": "Emoji picker component.",
-# },
+ # {
+ # "name": "dash-pannellum",
+ # "url": "https://pannellum.2plot.dev",
+ # "description": "360° panorama and virtual-tour viewer.",
+ # },
+ # {
+ # "name": "dash-emoji-mart",
+ # "url": "https://emojimart.2plot.dev",
+ # "description": "Emoji picker component.",
+ # },
{
"name": "dash-email",
"url": "https://email.2plot.dev",
@@ -110,8 +110,8 @@
AFFILIATED: List[Dict[str, str]] = [
{
- "name": "Pip Install Python",
- "url": "https://pip-install-python.com",
+ "name": "2plot.ai",
+ "url": "https://2plot.ai",
"description": "The original component documentation site.",
},
{
diff --git a/lib/page_visibility.py b/lib/page_visibility.py
index db14fb7..79f3346 100644
--- a/lib/page_visibility.py
+++ b/lib/page_visibility.py
@@ -176,6 +176,28 @@ def register_llms_doc(path: str, name: str, description: str, doc: str) -> None:
apply_llms_state(path)
+def published_name(path: str, name: str) -> str:
+ """The name this path publishes to agents — SITE_BRAND at the root.
+
+ The home page's registered `name` is not a nav label to
+ dash-improve-my-llms: 2.3.4 resolves it through `resolve_site_title` into
+ the /llms.txt H1, og:title and the viewer's brand chip. This site's home
+ page is registered as "Home", which `resolve_site_title` SKIPS as generic,
+ so publishing it would drop the site's identity to whatever candidate is
+ left.
+
+ This function is why the substitution lives here rather than only in
+ run.py. `apply_llms_state` re-registers the entry EVERY time a
+ control-board toggle changes a verdict — so a single flip of "/" would
+ otherwise overwrite run.py's SITE_BRAND with "Home" at runtime, with
+ nothing logged and nothing visibly broken. The nav keeps "Home"; only the
+ published identity changes.
+ """
+ from lib.constants import SITE_BRAND
+
+ return SITE_BRAND if path == "/" else name
+
+
def apply_llms_state(path: str) -> None:
"""Re-register this page's llms.txt body to match the current verdict."""
entry = _llms_docs.get(path)
@@ -186,6 +208,7 @@ def apply_llms_state(path: str) -> None:
from dash_improve_my_llms import register_page_metadata
except Exception: # optional dependency — nothing to sync
return
+ name = published_name(path, name)
body = doc if llms_accessible(path) else (
f"# {name}\n\n> This page is not publicly available.\n"
)
diff --git a/lib/satellite_analytics.py b/lib/satellite_analytics.py
index 1fdc411..202dfe5 100644
--- a/lib/satellite_analytics.py
+++ b/lib/satellite_analytics.py
@@ -185,10 +185,37 @@ def visitor_key(ip: str | None, user_agent: str | None) -> str:
# Ledger
# --------------------------------------------------------------------------
+def is_internal(user_agent: str | None) -> bool:
+ """Whether this request is 2plot machinery talking to itself.
+
+ The network's internal-traffic contract, matched case-insensitively:
+ https://2plot.ai/docs/satellite-analytics ("Internal traffic").
+ """
+ from lib.constants import INTERNAL_UA_TOKEN
+
+ return INTERNAL_UA_TOKEN in (user_agent or "").lower()
+
+
def track(path: str | None, user_agent: str | None, ip: str | None = None,
country: str | None = None) -> None:
"""Append one page view. Never raises."""
try:
+ # --- The network's internal-traffic contract, applied at WRITE time --
+ # Anything carrying INTERNAL_UA_TOKEN is the hub's health sweep, a CI
+ # smoke battery, the 4x-daily heartbeat or a sibling app — counted
+ # NOWHERE. This has to run BEFORE `is_bot` below, not after: the
+ # battery's crawler-shaped probes deliberately send a Googlebot token
+ # so the target exercises its bot path, and a drop made after
+ # classification would file every one of them under `bot_hits`.
+ #
+ # It also has to run at write time rather than at rollup time — the
+ # ledger is what the hub's numbers are built from, and a hit that
+ # reaches disk is a hit somebody eventually counts.
+ if is_internal(user_agent):
+ return
+ # `should_skip` covers `/healthz` (the hub sweeps it hourly and
+ # Render's own probe hits it constantly), `/api/`, static assets and
+ # Dash plumbing — see `_SKIP`.
if not enabled() or should_skip(path):
return
path = path.split('?', 1)[0][:_MAX_PATH]
@@ -359,6 +386,8 @@ def post_signed(route: str, payload: dict):
"""
import requests
+ from lib.constants import internal_ua
+
secret = _secret()
body = json.dumps(payload, separators=(",", ":")).encode()
ts = str(int(time.time()))
@@ -368,7 +397,13 @@ def post_signed(route: str, payload: dict):
f"{HUB_URL}{route}", data=body,
headers={"Content-Type": "application/json",
"X-AI-Canvas-Timestamp": ts,
- "X-AI-Canvas-Signature": sig},
+ "X-AI-Canvas-Signature": sig,
+ # The outbound half of the internal-traffic contract. Without
+ # it this rollup reaches 2plot.ai as `python-requests/2.x`,
+ # which the hub's own tracker classifies as a bot — so the act
+ # of reporting this satellite's traffic would inflate the
+ # hub's. See lib/constants.INTERNAL_UA.
+ "User-Agent": internal_ua("satellite-analytics")},
timeout=POST_TIMEOUT_S,
)
@@ -621,6 +656,70 @@ def _pageview_path(raw: bytes) -> str | None:
return path if isinstance(path, str) and path.startswith("/") else None
+# ---------------------------------------------------------------------------
+# Per-request tracking, installed OUTSIDE the framework's handler chain
+# ---------------------------------------------------------------------------
+# These wrap the WSGI/ASGI callable rather than registering a `before_request`
+# handler, and that is the whole point.
+#
+# Flask (and Quart) stop running `before_request` handlers the moment one
+# returns a response. dash-improve-my-llms installs `_bot_middleware`, which
+# does exactly that for every crawler — it answers with prerendered HTML. As a
+# `before_request`, this tracker therefore never saw a single crawler request,
+# and this satellite reported `bot_hits: 0` to 2plot.ai structurally, for every
+# day it was live, with nothing visibly broken anywhere.
+#
+# Registration order looked like the fix and is not: it cannot be right on all
+# three backends at once. Flask runs `before_request` handlers in registration
+# order (tracker must go FIRST), while Starlette's `add_middleware` makes the
+# LAST-added middleware outermost (tracker must go LAST). One call site cannot
+# satisfy both, and any rule written down here is one refactor from silently
+# inverting.
+#
+# A WSGI/ASGI wrapper sits outside the entire application, so it observes every
+# request whatever any handler does and in whatever order anything was
+# registered. tests/test_internal_traffic.py pins the resulting counts on every
+# backend CI runs.
+
+def _wsgi_tracker(wsgi_app):
+ """Wrap a WSGI app (Flask) so every request is counted."""
+
+ def middleware(environ, start_response):
+ try:
+ headers = {
+ key[5:].replace("_", "-").lower(): value
+ for key, value in environ.items()
+ if key.startswith("HTTP_")
+ }
+ _track_from(headers, environ.get("PATH_INFO", ""),
+ environ.get("REMOTE_ADDR"))
+ except Exception: # noqa: BLE001 — analytics never breaks a request
+ logger.debug("wsgi track failed", exc_info=True)
+ return wsgi_app(environ, start_response)
+
+ return middleware
+
+
+def _asgi_tracker(asgi_app):
+ """Wrap an ASGI app (Quart) so every request is counted."""
+
+ async def middleware(scope, receive, send):
+ if scope.get("type") == "http":
+ try:
+ headers = {
+ key.decode("latin-1").lower(): value.decode("latin-1")
+ for key, value in scope.get("headers") or []
+ }
+ client = scope.get("client")
+ _track_from(headers, scope.get("path", ""),
+ client[0] if client else None)
+ except Exception: # noqa: BLE001
+ logger.debug("asgi track failed", exc_info=True)
+ return await asgi_app(scope, receive, send)
+
+ return middleware
+
+
def register_routes(app, backend: str) -> None:
"""Install per-request tracking, ``/healthz`` and ``/api/pageview``.
@@ -633,6 +732,14 @@ def register_routes(app, backend: str) -> None:
if backend == "fastapi":
from starlette.responses import JSONResponse
+ # Starlette builds its middleware stack at startup and makes the
+ # last-added middleware outermost, so `@server.middleware("http")` is
+ # the one branch whose visibility depends on when `register()` is
+ # called relative to add_llms_routes. Wrapping `build_middleware_stack`
+ # is not public API; instead this stays a plain http middleware and
+ # run.py keeps the analytics call AFTER add_llms_routes — the order
+ # that makes it outermost here. Flask and Quart below are wrapped at
+ # the WSGI/ASGI boundary and do not care about order at all.
@server.middleware("http")
async def _satellite_track(request: Request, call_next): # pragma: no cover
try:
@@ -659,9 +766,8 @@ async def _pageview(request: Request): # pragma: no cover
elif backend == "quart":
from quart import jsonify, request
- @server.before_request
- async def _satellite_track(): # pragma: no cover
- _track_from(dict(request.headers), request.path, request.remote_addr)
+ # ASGI-level, NOT `before_request` — see `_asgi_tracker`.
+ server.asgi_app = _asgi_tracker(server.asgi_app)
@server.get("/healthz")
async def _healthz(): # pragma: no cover
@@ -677,9 +783,9 @@ async def _pageview(): # pragma: no cover
else:
from flask import jsonify, request
- @server.before_request
- def _satellite_track():
- _track_from(dict(request.headers), request.path, request.remote_addr)
+ # WSGI-level, NOT `before_request` — see `_wsgi_tracker`. This is the
+ # branch production runs (the Dockerfile sets DASH_BACKEND=flask).
+ server.wsgi_app = _wsgi_tracker(server.wsgi_app)
@server.get("/healthz")
def _healthz():
diff --git a/package-info.json b/package-info.json
index 4a32206..0bb1bec 100644
--- a/package-info.json
+++ b/package-info.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/package.json b/package.json
index dac2a12..3ceeac8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "dash-leaflet2",
- "version": "0.2.0",
+ "version": "0.2.1",
"description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.",
"main": "src/ts/index.ts",
"repository": {
diff --git a/pages/control_board.py b/pages/control_board.py
index 42db49e..9890700 100644
--- a/pages/control_board.py
+++ b/pages/control_board.py
@@ -25,7 +25,7 @@
from dash_iconify import DashIconify
from lib.auth import admin_access_open, clerk_enabled, current_user, is_admin_user
-from lib.constants import PAGE_TITLE_PREFIX
+from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX
from lib.page_visibility import (
TIERS,
controllable_pages,
@@ -42,6 +42,10 @@
name="Control Board",
title=PAGE_TITLE_PREFIX + "Control Board",
description="Admin control board for page visibility and llms.txt exposure.",
+ # Not for sharing — this page is marked hidden and Disallowed — but Dash
+ # emits an empty og:image without it, and "every page" should mean every
+ # page. See lib.constants.OG_IMAGE_URL.
+ image_url=OG_IMAGE_URL,
)
_TIER_COLORS = {"public": "teal", "auth": "blue", "admin": "grape", "hidden": "gray"}
diff --git a/pages/markdown.py b/pages/markdown.py
index 09a420d..d4ab575 100644
--- a/pages/markdown.py
+++ b/pages/markdown.py
@@ -10,7 +10,7 @@
from pydantic import BaseModel
from lib.ad_client import inject_ad_into_aside
-from lib.constants import PAGE_TITLE_PREFIX, NAME_CONTENT_MAP
+from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX, NAME_CONTENT_MAP
from lib.directives.kwargs import Kwargs
from lib.directives.llms_copy import LlmsCopy
from lib.directives.source import SC
@@ -139,6 +139,12 @@ def _build_llms_doc(name: str, description: str, expanded_markdown: str, path: s
name=metadata.name,
title=PAGE_TITLE_PREFIX + metadata.name,
description=metadata.description,
+ # The social card. Without this Dash emits `og:image content=""` and
+ # `twitter:image content=""` on every page — an empty image unfurls as
+ # a blank card, which is worse than declaring no image at all. An
+ # absolute `image_url` also beats Dash's assets-derived path, which
+ # would be relative and therefore useless to a scraper.
+ image_url=OG_IMAGE_URL,
layout=gated_layout(metadata.endpoint, metadata.name, layout),
category=metadata.category,
icon=metadata.icon,
diff --git a/pyproject.toml b/pyproject.toml
index ad1e4f9..cedc2dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dash-leaflet2"
-version = "0.2.0"
+version = "0.2.1"
description = "Leaflet 2-native Dash components — a from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4."
readme = "README.md"
# 3.9, not 3.8. Dash 4.4.1 itself requires >=3.9, so a 3.8 user would be
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..7ec8cdf
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,7 @@
+[pytest]
+testpaths = tests
+# tests/ is on sys.path so `from conftest import ...` works in every module.
+pythonpath = . tests
+addopts = -q --strict-markers
+filterwarnings =
+ ignore::DeprecationWarning
diff --git a/requirements.txt b/requirements.txt
index b14b622..0e27a45 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -28,13 +28,34 @@ dash[fastapi]==4.4.1 # COMPAT-MATRIX: dash
# site needs Python >= 3.10. The dash_leaflet2 PACKAGE needs only >= 3.9.
dash_mantine_components==2.7.0
dash-iconify==0.1.2
-markdown2dash
+# markdown2dash 0.1.2 declares `gunicorn>=21.2.0,<22.0.0` — a markdown parser
+# pinning a WSGI server, and directly against the CVE-driven gunicorn>=23 floor
+# below. pip cannot resolve both, so markdown2dash is NOT on this list and is
+# installed on its own line WITHOUT its dependency graph:
+#
+# pip install -r requirements.txt
+# pip install --no-deps markdown2dash==0.1.2
+#
+# The Dockerfile and CI both do exactly that pair. Its real runtime
+# dependencies are listed here instead (dash, dash-iconify and
+# dash-mantine-components are already pinned above), carrying markdown2dash's
+# OWN declared ranges rather than bare names — with --no-deps there is no
+# resolver left to catch an incompatible upgrade, so a future `mistune` 4.0
+# would otherwise install silently and break the parser at import time.
+docutils!=0.21
+jsonpath>=0.82,<0.83
+mistune>=3.0.1,<4.0.0
python-frontmatter
-# 2.3.3, not 2.0: the upgrade alone fixes the assign-vs-merge home-page stub
-# (2.2.0 merge semantics), directive leakage into agent markdown (2.3.3), and
-# the OAI/Anthropic robots taxonomy (2.3.2/2.3.3). The [flask] extra is just
+# 2.3.4 is the 2plot network standard floor, not a nice-to-have: it adds
+# `resolve_site_title`, which is what stops a generic home-page name ("Home")
+# or the Dash default becoming this site's published identity in the /llms.txt
+# H1 and the llms viewer's brand chip. Below it, both fall back silently.
+#
+# Earlier steps on the same line: 2.2.0's merge semantics fixed the assign-vs-
+# merge home-page stub, 2.3.3 stopped directive leakage into agent markdown and
+# settled the OAI/Anthropic robots taxonomy. The [flask] extra is just
# flask>=2.0, already present via Dash.
-dash-improve-my-llms[flask]>=2.3.3
+dash-improve-my-llms[flask]>=2.3.4
python-dotenv>=1.0
pydantic>=2.0
requests>=2.31
@@ -83,7 +104,14 @@ clerk-backend-api>=5.0.0,<6
# --- Deployment ------------------------------------------------------------
# gunicorn serves the Flask (WSGI) backend in the Dockerfile. The FastAPI
# backend is ASGI and needs uvicorn instead — already pulled in by dash[fastapi].
-gunicorn>=21.2,<22
+#
+# gunicorn fronts every Flask deployment in this network, so the floor is the
+# network's security baseline rather than a convenience: 21.x carried two HTTP
+# request-smuggling CVEs (CVE-2024-6827, CVE-2024-1135), both fixed in 23.0.
+# This is why markdown2dash is installed with --no-deps above — CI asserts the
+# resolved version inside the built image, so a transitive pin can never drag
+# the production server back under the floor without failing the build.
+gunicorn>=23.0.0
# --- Development / testing (optional, not installed by default) -------------
# build — `python -m build --wheel` to produce the PyPI artifact
diff --git a/run.py b/run.py
index b07389d..9821533 100644
--- a/run.py
+++ b/run.py
@@ -51,7 +51,13 @@
from lib import auth, network_directory, satellite_analytics
from lib.backend import get_backend_info, resolve_backend
-from lib.constants import APP_VERSION, BASE_URL, LEAFLET_VERSION, SITE_TITLE
+from lib.constants import (
+ APP_VERSION,
+ BASE_URL,
+ LEAFLET_VERSION,
+ SITE_BRAND,
+ SITE_DESCRIPTION,
+)
# ----------------------------------------------------------------------------
# Pluggable backend (Dash 4.1+). FastAPI default so WebSocket / background
@@ -110,7 +116,7 @@
suppress_callback_exceptions=True,
prevent_initial_callbacks=True,
update_title=None,
- title=SITE_TITLE,
+ title=SITE_BRAND,
index_string=open("templates/index.html").read(),
)
@@ -162,13 +168,18 @@
disallowed_paths=["/admin/"],
)
+# The home page's registered `name` is this site's published identity, not a
+# nav label: dash-improve-my-llms 2.3.4 resolves it through `resolve_site_title`
+# into the /llms.txt H1, og:title and the llms viewer's brand chip. It must be
+# SITE_BRAND and nothing else — docs/home/home.md registers the page as "Home",
+# which `resolve_site_title` SKIPS as generic, so without this call the site
+# would fall through to `app.title` and, on a pre-2.3.4 artifact, to a bare
+# "Dash". `register_page_metadata` MERGES (2.2.0+), so this refines the entry
+# the markdown loader created without touching the prose it registered.
register_page_metadata(
path="/",
- name="dash-leaflet2",
- description=(
- "Leaflet 2 (alpha) on Dash 4 — a generation-ahead mapping component "
- "library that wraps Leaflet 2 core directly, without react-leaflet."
- ),
+ name=SITE_BRAND,
+ description=SITE_DESCRIPTION,
)
# ----------------------------------------------------------------------------
@@ -217,6 +228,19 @@
# https://2plot.ai/api/satellite/traffic. Dormant without
# CROSS_APP_WEBHOOK_SECRET — /healthz is served either way, which is what
# render.yaml's healthCheckPath points at.
+#
+# THIS STAYS AFTER add_llms_routes, and the reason is worth keeping: on the
+# FastAPI backend the tracker is a Starlette http middleware, and Starlette
+# makes the LAST-added middleware the outermost one. Registered first, it
+# would sit inside `_bot_middleware` — which answers every crawler with
+# prerendered HTML — and no crawler request would ever be counted.
+#
+# Flask and Quart have the opposite rule (`before_request` handlers run in
+# registration order, and the first to return a response wins), so no single
+# ordering can be correct for all three. They are therefore wrapped at the
+# WSGI/ASGI boundary instead and do not depend on this line's position at all
+# — see `_wsgi_tracker` in lib/satellite_analytics.py, which is what fixed the
+# structural `bot_hits: 0` this satellite had been reporting.
# ----------------------------------------------------------------------------
satellite_analytics.register(app, BACKEND)
diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py
index 117fb3d..ec93750 100644
--- a/scripts/compat_matrix.py
+++ b/scripts/compat_matrix.py
@@ -44,6 +44,9 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT))
+
+from lib.constants import internal_ua # noqa: E402 (needs PROJECT_ROOT on the path)
WORK_DIR = PROJECT_ROOT / ".compat"
# The support claim is `dash>=4.1`. These are the rungs we actually test:
@@ -220,7 +223,7 @@ def smoke(py: Path, version: str, backend: str) -> dict:
def browser_leg(py: Path, version: str, backend: str, port: int) -> dict:
"""Optional: boot run.py for real and collect browser console errors."""
try:
- from playwright.sync_api import sync_playwright # noqa: F401
+ from playwright.sync_api import sync_playwright
except ImportError:
return {"skipped": "playwright not installed in the driving interpreter"}
@@ -233,17 +236,21 @@ def browser_leg(py: Path, version: str, backend: str, port: int) -> dict:
import urllib.request
base = f"http://127.0.0.1:{port}"
+ # The readiness probe carries the internal-traffic token like every
+ # other 2plot battery: the app under test boots with
+ # SATELLITE_ANALYTICS_DRY_RUN=1, so without it this poll would append
+ # up to sixty phantom visits to its ledger before a page is measured.
+ probe = urllib.request.Request(
+ base, headers={"User-Agent": internal_ua("compat-matrix")})
for _ in range(60): # wait for the server to answer
try:
- urllib.request.urlopen(base, timeout=1)
+ urllib.request.urlopen(probe, timeout=1)
break
except Exception:
time.sleep(1)
else:
return {"error": "server never came up"}
- from playwright.sync_api import sync_playwright
-
pages_json = json.loads(
subprocess.run(
[str(py), "-c",
diff --git a/scripts/network_smoke.py b/scripts/network_smoke.py
new file mode 100644
index 0000000..96881a8
--- /dev/null
+++ b/scripts/network_smoke.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+"""Smoke battery for a 2plot satellite — CI container and production alike.
+
+One script, two seats, the SAME named checks either way, so a failure in CI
+and a failure against production read identically:
+
+ CI container python scripts/network_smoke.py --base-url http://localhost:8050
+ Production python scripts/network_smoke.py --base-url https://leaflet.2plot.dev
+
+Stdlib-only on purpose: CI runs it from the host against the booted container
+with a bare `python3`, before anything is pip-installed.
+
+Copied from dash-documentation-boilerplate (the network template); only the
+block marked "per-site" below differs. If a check outside that block is wrong,
+it is wrong on twenty hosts — fix it there and re-sync.
+
+What a satellite is to the network is what the battery proves: that it states
+its identity, that its agent-facing document surfaces are real, that it runs
+the intended dash-improve-my-llms artifact, and that no owner-only surface
+leaks. A satellite holds no key material, so unlike the hub's copy of this
+script there is no agent-key API to fail closed — the corresponding check
+here is that this host's llms.txt points *back* at the hub that does.
+
+Every UA this script sends carries the internal-traffic token (the analytics
+point of truth — https://2plot.ai/docs/satellite-analytics, "Internal
+traffic"): a battery must never register as a visitor or a "bot" in any
+network ledger. Even the deliberately crawler-shaped probe appends the token
+— the target still exercises its bot path, but its analytics know the caller
+is machinery.
+
+Exit code: 1 if any check fails, else 0.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+import urllib.error
+import urllib.request
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+TIMEOUT = 30
+try:
+ from lib.constants import INTERNAL_UA as _INTERNAL_UA
+except Exception: # running outside a repo checkout — keep the token intact
+ _INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)"
+UA = _INTERNAL_UA + " network-smoke"
+CRAWLER_UA = "Mozilla/5.0 (compatible; Googlebot/2.1) " + _INTERNAL_UA
+
+# The body dash-improve-my-llms serves when a page has no prose registered.
+# Matched in full, deliberately: this app's own
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..727fae7
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,246 @@
+"""Shared fixtures — boot the real app once, then interrogate it.
+
+The suite deliberately exercises `run.py` itself rather than a stripped-down
+app assembled for testing. Nearly everything worth catching here lives in the
+wiring: registration order, which middleware runs first, whether a page's
+prose survived to the response. A test app that re-implements that wiring
+tests the re-implementation.
+
+Backend selection follows `DASH_BACKEND`, so the same suite runs against
+Flask, FastAPI and Quart in CI. `client` normalises the three test clients
+behind `.get(path, user_agent=...) -> Response`.
+
+SECRETLESS, AND ORDER MATTERS. The suite runs against the app exactly as CI's
+zero-secret container does: no Clerk keys (auth falls open, non-public tiers
+still deny), no `CROSS_APP_WEBHOOK_SECRET` (nothing is ever POSTed to the
+hub), and the analytics ledger in a temp dir. The zero-secret boot is itself
+the first invariant.
+
+The env block below therefore has to run BEFORE anything imports `run.py`,
+because run.py calls `load_dotenv()` at import time and a developer's local
+`.env` would otherwise flip the app into a configured posture. `load_dotenv()`
+never overrides an existing key, so pinning each secret to `""` here (falsy to
+every `os.getenv(...) or None` reader in `lib/`) neutralises the file without
+deleting it. In CI there is no `.env` at all and this is belt-and-braces. Same
+pattern as 2plotai, pip-docs+ and dash-documentation-boilerplate.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import os
+import sys
+import tempfile
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
+# --- 1. Neutralise every secret (must precede any import of run.py) ---------
+SECRET_ENV_KEYS = (
+ "CLERK_SECRET_KEY", "CLERK_PUBLISHABLE_KEY", "CLERK_SIGN_IN_URL",
+ "CLERK_SIGN_UP_URL", "CLERK_FRONTEND_API", "CLERK_WEBHOOK_SECRET",
+ "CLERK_IS_SATELLITE", "CLERK_SATELLITE_DOMAIN", "SESSION_SECRET",
+ "FLASK_SECRET_KEY", "CROSS_APP_WEBHOOK_SECRET", "NETWORK_BULLETIN_URL",
+ "ADMIN_EMAILS", "MUI_PRO_API_KEY", "DATABASE_URL", "AD_DATABASE_URL",
+)
+for _key in SECRET_ENV_KEYS:
+ os.environ[_key] = ""
+
+# --- 2. Keep app state out of the repo --------------------------------------
+# Without this the suite appends its own hits to the checked-out
+# satellite_traffic.jsonl, which then shows up in `git status` and, worse, in
+# the next rollup a developer's local run happens to send.
+_TMP_STATE = tempfile.mkdtemp(prefix="leaflet-tests-")
+os.environ["SATELLITE_ANALYTICS_FILE"] = os.path.join(_TMP_STATE, "satellite_traffic.jsonl")
+os.environ["PAGE_VISIBILITY_FILE"] = os.path.join(_TMP_STATE, "page_visibility.json")
+
+# DRY RUN, NOT "dormant". `lib.satellite_analytics.enabled()` is
+# `bool(secret) or DRY_RUN`, and with no secret (above) the tracker would
+# short-circuit on the very first line of `track()` — every internal-traffic
+# assertion would then pass vacuously, proving nothing. Dry-run keeps the whole
+# write path live while guaranteeing the reporter never POSTs anywhere.
+os.environ["SATELLITE_ANALYTICS_DRY_RUN"] = "1"
+# The reporter thread would otherwise start on import and wake up mid-suite.
+os.environ["SATELLITE_REPORT_INTERVAL_S"] = "86400"
+os.environ.setdefault("APP_ENV", "test")
+
+BROWSER_UA = (
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+)
+CRAWLER_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
+
+# What a real browser sends. `//llms.txt` negotiates on this header —
+# not on the User-Agent — so it is what separates "a person opened the URL"
+# from "an agent fetched it".
+BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
+
+# The body dash-improve-my-llms serves when a page has no prose registered.
+# Its presence on any page is the failure this whole network cares most about.
+STUB_MARKER = "This page contains interactive content that requires JavaScript"
+
+# A real documentation page, used wherever a test needs one that is not the
+# home page. Mirrors scripts/network_smoke.SAMPLE_PAGE.
+SAMPLE_PAGE = "/pointer-events"
+
+
+def backend() -> str:
+ """Whichever backend the app will actually boot on.
+
+ Not `os.environ["DASH_BACKEND"]` directly: run.py calls `load_dotenv()`,
+ so a local .env can select a backend the bare environment knows nothing
+ about. Reading the env here instead would hand out a Werkzeug test client
+ for a FastAPI app, and every test would fail on the client rather than on
+ the code.
+ """
+ from lib.backend import resolve_backend
+
+ return resolve_backend()
+
+
+@pytest.fixture(scope="session")
+def app_module():
+ """Import run.py as a module, from the repo root.
+
+ run.py opens 'templates/index.html' by relative path and pages/markdown.py
+ globs 'docs/**/*.md', so the process CWD has to be the repo root regardless
+ of where pytest was invoked from.
+ """
+ os.chdir(REPO_ROOT)
+ spec = importlib.util.spec_from_file_location("runmod", REPO_ROOT / "run.py")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["runmod"] = module
+ try:
+ spec.loader.exec_module(module)
+ except SystemExit: # pragma: no cover - run.py doesn't call sys.exit today
+ pass
+ return module
+
+
+@pytest.fixture(scope="session")
+def app(app_module):
+ return app_module.app
+
+
+class Response:
+ __slots__ = ("status", "text", "headers")
+
+ def __init__(self, status: int, text: str, headers=None) -> None:
+ self.status = status
+ self.text = text
+ # Headers matter from 2.2.0 on: `//llms.txt` content-negotiates,
+ # so the *type* of the response is part of the contract and `Vary` is
+ # what stops a CDN serving cached HTML to the next agent.
+ #
+ # Keys are lowercased because the three backends disagree on casing —
+ # Werkzeug hands back `Content-Type`, httpx `content-type`. A plain
+ # `headers.get("Content-Type")` therefore passes on Flask and fails on
+ # FastAPI and Quart, which reads like a backend bug and isn't one.
+ self.headers = {k.lower(): v for k, v in (headers or {}).items()}
+
+ @property
+ def ok(self) -> bool:
+ return self.status == 200
+
+ def header(self, name: str, default: str = "") -> str:
+ return self.headers.get(name.lower(), default)
+
+ @property
+ def content_type(self) -> str:
+ return self.header("Content-Type")
+
+ def __repr__(self) -> str: # pragma: no cover - assertion output only
+ return f""
+
+
+class Client:
+ """One synchronous `.get()` across all three backends.
+
+ Quart's test client is async all the way down — both the request and
+ `get_data()` return coroutines — so it gets driven from a loop owned by
+ the fixture rather than being awaited by every test.
+ """
+
+ def __init__(self, raw, kind: str, loop=None) -> None:
+ self._raw = raw
+ self._kind = kind
+ self._loop = loop
+
+ def get(self, path: str, user_agent: str = BROWSER_UA, accept: str = None) -> Response:
+ headers = {"User-Agent": user_agent}
+ if accept is not None:
+ headers["Accept"] = accept
+
+ if self._kind == "werkzeug":
+ r = self._raw.get(path, headers=headers)
+ # errors="replace", not `as_text=True`: the latter decodes strictly
+ # and raises UnicodeDecodeError on any binary response, so a test
+ # that merely checks a favicon or a manifest icon RESOLVES would
+ # blow up on the PNG's first byte. httpx (the FastAPI branch) is
+ # already lenient; this matches it.
+ return Response(r.status_code, r.get_data().decode("utf-8", "replace"),
+ dict(r.headers))
+
+ if self._kind == "quart":
+ async def fetch():
+ r = await self._raw.get(path, headers=headers)
+ return r.status_code, await r.get_data(as_text=True), dict(r.headers)
+
+ return Response(*self._loop.run_until_complete(fetch()))
+
+ r = self._raw.get(path, headers=headers)
+ return Response(r.status_code, r.text, dict(r.headers))
+
+
+@pytest.fixture(scope="session")
+def client(app):
+ """A test client for whichever backend is under test.
+
+ FastAPI/Quart need the ASGI lifespan to have run: Dash registers its page
+ catch-all from the startup event, so a client used outside the lifespan
+ context 404s every non-root URL for reasons that have nothing to do with
+ the code under test.
+ """
+ kind = backend()
+ if kind == "flask":
+ yield Client(app.server.test_client(), "werkzeug")
+ elif kind == "quart":
+ import asyncio
+
+ loop = asyncio.new_event_loop()
+ try:
+ yield Client(app.server.test_client(), "quart", loop=loop)
+ finally:
+ loop.close()
+ elif kind == "fastapi":
+ from starlette.testclient import TestClient
+
+ with TestClient(app.server) as raw:
+ yield Client(raw, "httpx")
+ else: # pragma: no cover - resolve_backend() rejects anything else
+ raise RuntimeError(f"unsupported DASH_BACKEND={kind!r}")
+
+
+@pytest.fixture(scope="session")
+def tmp_state_dir():
+ """Where the app's ledger and claim files live for this run."""
+ return _TMP_STATE
+
+
+@pytest.fixture(scope="session")
+def pages(app_module):
+ """Every registered page as (path, name, entry), sorted by path."""
+ import dash
+
+ return sorted(
+ ((entry["path"], entry.get("name", ""), entry) for entry in dash.page_registry.values()),
+ key=lambda item: item[0],
+ )
+
+
+@pytest.fixture(scope="session")
+def page_paths(pages):
+ return [path for path, _name, _entry in pages]
diff --git a/tests/test_internal_traffic.py b/tests/test_internal_traffic.py
new file mode 100644
index 0000000..957a520
--- /dev/null
+++ b/tests/test_internal_traffic.py
@@ -0,0 +1,304 @@
+"""The network's internal-traffic contract — the analytics point of truth.
+
+The rule (https://2plot.ai/docs/satellite-analytics, "Internal traffic"): a
+request whose User-Agent contains `2plot-internal` is 2plot machinery talking
+to itself — the hub's hourly health sweep, CI smoke batteries, the 4x-daily
+heartbeat, cross-app calls — and is counted NOWHERE. Dropped at write time,
+before bot classification. `/healthz` is never a visit either.
+
+Both halves are tested here, because a contract kept on only one side is not
+kept at all:
+
+*inbound* token-carrying requests never reach the ledger, and therefore
+ never reach `human_hits` / `bot_hits` in the rollup this app POSTs
+ to 2plot.ai;
+*outbound* every call this host makes to another network host sends
+ `INTERNAL_UA`, so the far side can apply the same rule. That half
+ was missing: the ad client fetched a campaign from 2plot.dev on
+ every single docs page view, arriving as `python-requests/2.x`,
+ and the hub counted this satellite's readers as its own bots.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+
+import pytest
+
+from conftest import BROWSER_UA, CRAWLER_UA, SAMPLE_PAGE
+from lib import satellite_analytics
+from lib.constants import INTERNAL_UA, INTERNAL_UA_TOKEN, internal_ua
+
+# A real page. `should_skip` drops infrastructure paths (`/llms.txt`,
+# `/robots.txt`, `/healthz`, `/api/`, ...), so an assertion made against one of
+# those would pass no matter what the tracker did.
+PAGE = SAMPLE_PAGE
+
+
+def _ledger_visits() -> list[dict]:
+ """Every hit on disk."""
+ try:
+ with open(satellite_analytics.LEDGER) as fh:
+ return [json.loads(ln) for ln in fh if ln.strip()]
+ except FileNotFoundError:
+ return []
+
+
+def _rollup() -> dict:
+ """Today's rollup as the hub would receive it, or an all-zero stand-in."""
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ return satellite_analytics.rollup(today) or {"human_hits": 0, "bot_hits": 0}
+
+
+def _distinct_ua(label: str, base: str = BROWSER_UA) -> str:
+ """A UA that is unique per call site.
+
+ `track()` dedupes the same (visitor, path) inside DEDUPE_S, and the visitor
+ key is `ip | md5(user-agent)[:8]` — so two hits from the test client with
+ the same UA collapse into one and a delta assertion silently measures
+ nothing. Varying the UA gives each assertion its own visitor.
+ """
+ return f"{base} {label}"
+
+
+# --------------------------------------------------------------- the token --
+
+
+def test_token_is_the_network_wide_string():
+ """The contract only works if every host agrees on the byte sequence."""
+ assert INTERNAL_UA_TOKEN == "2plot-internal"
+ assert INTERNAL_UA_TOKEN in INTERNAL_UA
+ assert INTERNAL_UA.startswith(INTERNAL_UA_TOKEN)
+
+
+def test_caller_suffix_never_breaks_the_token():
+ ua = internal_ua("ad-client")
+ assert INTERNAL_UA_TOKEN in ua
+ assert ua.endswith("ad-client")
+ assert internal_ua() == INTERNAL_UA
+ assert internal_ua(" ") == INTERNAL_UA
+
+
+# ------------------------------------------------------------------ inbound --
+
+
+def test_the_tests_can_see_the_ledger_at_all(client, tmp_state_dir):
+ """Guard for every delta assertion below.
+
+ If the ledger path were wrong (or the suite were writing into the repo's
+ own satellite_traffic.jsonl), every "count did not change" test would pass
+ vacuously. Prove a write lands first — and that tracking is enabled at all,
+ which for this app means SATELLITE_ANALYTICS_DRY_RUN rather than a secret.
+ """
+ assert satellite_analytics.enabled(), (
+ "tracking is dormant — every exclusion test below would pass vacuously"
+ )
+ assert str(satellite_analytics.LEDGER).startswith(tmp_state_dir), (
+ satellite_analytics.LEDGER
+ )
+ before = len(_ledger_visits())
+ client.get(PAGE, user_agent=_distinct_ua("ledger-guard"))
+ assert len(_ledger_visits()) == before + 1
+
+
+def test_internal_ua_is_counted_nowhere(client):
+ before = len(_ledger_visits())
+ client.get(PAGE, user_agent=internal_ua("network-smoke"))
+ client.get("/", user_agent=INTERNAL_UA)
+ assert len(_ledger_visits()) == before
+
+
+def test_a_crawler_shaped_probe_carrying_the_token_stays_internal(client):
+ """The battery's crawler probe exercises the bot path deliberately.
+
+ It must still not be counted. This is precisely why the drop happens
+ before `is_bot` — classification would file it under `bot`.
+ """
+ before = len(_ledger_visits())
+ client.get(PAGE, user_agent=f"{CRAWLER_UA} {INTERNAL_UA}")
+ assert len(_ledger_visits()) == before
+
+
+def test_the_token_is_matched_case_insensitively(client):
+ before = len(_ledger_visits())
+ client.get(PAGE, user_agent="2PLOT-INTERNAL/1.0 Health-Sweep")
+ assert len(_ledger_visits()) == before
+
+
+def test_healthz_is_never_a_visit(client):
+ before = len(_ledger_visits())
+ client.get("/healthz", user_agent="Render/1.0 health-check")
+ client.get("/healthz", user_agent=_distinct_ua("healthz-browser"))
+ assert len(_ledger_visits()) == before
+
+
+def test_the_pageview_beacon_also_drops_internal_traffic(client):
+ """The SPA beacon is a second write path into the same ledger.
+
+ `/api/pageview` is how every client-side route change is counted, so a
+ drop applied only to the request middleware would leak everything after
+ the entry page. Both go through `track()`, and this is what pins that.
+ """
+ assert satellite_analytics.is_internal(internal_ua("network-smoke"))
+ assert satellite_analytics.is_internal(f"{CRAWLER_UA} {INTERNAL_UA}")
+ assert not satellite_analytics.is_internal(BROWSER_UA)
+ assert not satellite_analytics.is_internal(None)
+
+
+# ----------------------------------------------- the reported numbers -------
+#
+# The exclusion that actually matters. Everything above is about the ledger;
+# this is about what 2plot.ai charts.
+
+
+def test_internal_traffic_is_absent_from_human_hits_and_bot_hits(client):
+ before = _rollup()
+
+ # Four calls that are all machinery, in the two shapes the network sends:
+ # a plain internal UA, and a crawler-shaped probe carrying the token.
+ for n in range(2):
+ client.get(PAGE, user_agent=internal_ua(f"network-smoke-{n}"))
+ client.get(PAGE, user_agent=f"{CRAWLER_UA} {INTERNAL_UA} {n}")
+
+ after = _rollup()
+ assert after["human_hits"] == before["human_hits"], (
+ "internal traffic reached human_hits — the hub would chart the health "
+ "sweep as readers of these docs"
+ )
+ assert after["bot_hits"] == before["bot_hits"], (
+ "internal traffic reached bot_hits — the hub would chart CI as crawler "
+ "interest"
+ )
+
+
+def test_the_tracker_sits_outside_the_handler_chain(app):
+ """The structural fix behind `test_real_traffic_is_still_counted`.
+
+ On Flask, `before_request` handlers stop running the moment one returns a
+ response — and `add_llms_routes` installs `_bot_middleware`, which returns
+ prerendered HTML for every crawler. While the tracker was a
+ `before_request`, not one crawler request ever reached it, and this
+ satellite reported `bot_hits: 0` to 2plot.ai every day it was live.
+
+ Registration order cannot fix that: Flask wants the tracker registered
+ FIRST, Starlette's `add_middleware` wants it LAST, and one call site
+ cannot be both. So the WSGI app is wrapped instead. That is what this
+ pins — the behavioural test catches the symptom, this names the cause.
+ """
+ if backend_name() != "flask":
+ pytest.skip("WSGI wrapping is a Flask-backend concern")
+
+ handlers = [
+ f"{fn.__module__}.{fn.__qualname__}"
+ for fn in app.server.before_request_funcs.get(None, [])
+ ]
+ assert not any("_satellite_track" in name for name in handlers), (
+ "the tracker is back to being a before_request handler — the llms bot "
+ f"middleware will short-circuit every crawler past it. Handlers: {handlers}"
+ )
+ assert "_wsgi_tracker" in repr(app.server.wsgi_app) or callable(app.server.wsgi_app)
+
+
+def backend_name() -> str:
+ from conftest import backend
+
+ return backend()
+
+
+def test_real_traffic_is_still_counted(client):
+ """The exclusions must not have lobotomised the tracker.
+
+ A rule that drops everything also satisfies every assertion above, so the
+ positive case is load-bearing: one browser hit is one human, one Googlebot
+ hit is one bot.
+ """
+ before = _rollup()
+ client.get(PAGE, user_agent=_distinct_ua("real-human"))
+ client.get(PAGE, user_agent=_distinct_ua("real-bot", CRAWLER_UA))
+ after = _rollup()
+
+ assert after["human_hits"] == before["human_hits"] + 1
+ assert after["bot_hits"] == before["bot_hits"] + 1
+
+
+# ----------------------------------------------------------------- outbound --
+
+
+class _Captured(Exception):
+ """Abort the request once the headers have been seen."""
+
+
+def _capture_headers(monkeypatch, module, attr):
+ """Record the headers of the next outbound call, then abort it."""
+ seen = {}
+
+ def fake(*args, **kwargs):
+ seen.update(kwargs.get("headers") or {})
+ raise _Captured
+
+ monkeypatch.setattr(module, attr, fake)
+ return seen
+
+
+def test_the_traffic_rollup_post_sends_the_token(monkeypatch):
+ """The signed hourly rollup to 2plot.ai."""
+ import requests
+
+ monkeypatch.setenv("CROSS_APP_WEBHOOK_SECRET", "test-secret")
+ seen = _capture_headers(monkeypatch, requests, "post")
+ with pytest.raises(_Captured):
+ satellite_analytics.post_signed(
+ "/api/satellite/traffic", {"app": "leaflet", "date": "2026-07-31"}
+ )
+ assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "")
+
+
+def test_the_ad_fetch_sends_the_token(monkeypatch):
+ """One call per docs page view — the loudest of the two."""
+ from lib import ad_client
+
+ seen = _capture_headers(monkeypatch, ad_client._session, "get")
+ monkeypatch.setattr(ad_client, "_last_failure", 0.0)
+ assert ad_client.fetch_ad(SAMPLE_PAGE) is None
+ assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "")
+
+
+def test_the_ad_fetch_still_fails_soft(monkeypatch):
+ """The header must not have cost the module its fail-silent contract.
+
+ `fetch_ad` swallowing everything is what keeps an ad-server outage from
+ breaking a page view; a `from lib.constants import ...` placed outside the
+ try block would have moved an ImportError out of that guarantee.
+ """
+ from lib import ad_client
+
+ monkeypatch.setattr(ad_client, "_last_failure", 0.0)
+ monkeypatch.setattr(
+ ad_client._session, "get",
+ lambda *a, **k: (_ for _ in ()).throw(RuntimeError("ad server down")),
+ )
+ assert ad_client.fetch_ad(SAMPLE_PAGE) is None
+
+
+@pytest.mark.parametrize("script", ["smoke_live", "network_smoke"])
+def test_every_battery_script_sends_the_token(script):
+ """A post-deploy battery sweeps every peer; it must not register anywhere."""
+ import importlib.util
+
+ from conftest import REPO_ROOT
+
+ spec = importlib.util.spec_from_file_location(
+ f"_ua_{script}", REPO_ROOT / "scripts" / f"{script}.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ agents = [
+ value
+ for name, value in vars(module).items()
+ if (name == "UA" or name.endswith("_UA")) and isinstance(value, str)
+ ]
+ assert agents, f"scripts/{script}.py declares no User-Agent constant"
+ missing = [ua for ua in agents if INTERNAL_UA_TOKEN not in ua]
+ assert missing == [], f"scripts/{script}.py sends untokened UAs: {missing}"
diff --git a/tests/test_network_smoke.py b/tests/test_network_smoke.py
new file mode 100644
index 0000000..0e6dd68
--- /dev/null
+++ b/tests/test_network_smoke.py
@@ -0,0 +1,150 @@
+"""Run the network battery against the in-process app.
+
+`scripts/network_smoke.py` only ever executes in two places a developer never
+watches: against the container CI just booted, and against production after a
+deploy. That is exactly the code that rots — a typo in a check turns it into a
+silent pass and the battery keeps reporting green over a broken host.
+
+So it runs here too, with its `fetch` pointed at the test client. Three
+distinct things get proven, and it is worth being explicit about which:
+
+1. the battery's own logic still works (the checks fire, and they can fail);
+2. this app satisfies every check the network standard makes of a satellite;
+3. the per-site block at the top of the script — the expected H1, the hidden
+ paths, the sample page — still matches the app it describes.
+
+What it cannot prove is the deployed artifact, which is the whole reason the
+container run and the post-deploy run exist as well.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+
+import pytest
+
+from conftest import REPO_ROOT
+from lib.constants import BASE_URL, INTERNAL_UA_TOKEN, SITE_BRAND
+
+BASE = BASE_URL
+
+
+@pytest.fixture(scope="module")
+def battery():
+ spec = importlib.util.spec_from_file_location(
+ "network_smoke", REPO_ROOT / "scripts" / "network_smoke.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["network_smoke"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture
+def wired(battery, client, monkeypatch):
+ """Point the battery's `fetch` at the test client.
+
+ The signature is `fetch(url, ua=..., method=..., body=..., headers=...)`
+ and it returns `(status, lowercased_headers, text)`. Only GET is used by
+ the satellite battery, so a non-GET here is a bug in the script rather
+ than something to emulate.
+ """
+ seen_agents = []
+
+ def fetch(url, ua=battery.UA, method="GET", body=None, headers=None,
+ timeout=None, retries=1):
+ assert method == "GET", f"the satellite battery issued a {method}"
+ seen_agents.append(ua)
+ accept = (headers or {}).get("Accept")
+
+ # Off-host URLs — today just the CDN-hosted social card — resolve to a
+ # stub. Reaching the real CDN from a unit test would make the suite
+ # depend on another service being up; that the asset genuinely
+ # resolves is the DEPLOYED battery's job, which is where the check
+ # earns its keep.
+ if not url.startswith(BASE) and "://" in url:
+ return 200, {"content-type": "image/png"}, ""
+
+ path = url[len(BASE):] if url.startswith(BASE) else url
+ response = client.get(path or "/", user_agent=ua, accept=accept)
+ return response.status, dict(response.headers), response.text
+
+ monkeypatch.setattr(battery, "fetch", fetch)
+ monkeypatch.setattr(battery, "_RESULTS", [])
+ battery.seen_agents = seen_agents
+ return battery
+
+
+def test_the_battery_passes_against_this_app(wired, capsys):
+ wired.satellite_checks(BASE)
+ output = capsys.readouterr().out
+
+ failed = [(name, detail) for name, verdict, detail in wired._RESULTS
+ if verdict == wired.FAIL]
+ assert failed == [], f"battery failures against the in-process app:\n{output}"
+ assert len(wired._RESULTS) >= 9, "checks silently stopped running"
+
+
+def test_every_request_the_battery_makes_is_internal(wired):
+ """A battery that pollutes the ledger it is auditing is worse than none."""
+ wired.satellite_checks(BASE)
+ untokened = [ua for ua in wired.seen_agents if INTERNAL_UA_TOKEN not in ua]
+ assert untokened == [], f"battery sent untokened User-Agents: {untokened}"
+
+
+def test_the_expected_h1_tracks_the_brand_constant(battery):
+ """The per-site block is a copy of `SITE_BRAND`; copies drift."""
+ assert battery.SITE_H1 == f"# {SITE_BRAND}"
+
+
+def test_the_expected_og_image_tracks_the_constant(battery):
+ """Same reason: the battery hard-codes the URL so it can run standalone."""
+ from lib.constants import OG_IMAGE_URL
+
+ assert battery.OG_IMAGE_URL == OG_IMAGE_URL
+
+
+def test_the_sample_page_is_a_real_page(battery, page_paths):
+ """The battery probes one named page; a rename would make it 404 forever."""
+ assert battery.SAMPLE_PAGE in page_paths
+
+
+def test_the_hidden_paths_are_the_ones_run_py_marks_hidden(battery):
+ """A hidden page nobody listed here is a leak the battery cannot see."""
+ run_py = (REPO_ROOT / "run.py").read_text()
+ listed = {p.rsplit("/llms.txt", 1)[0] for p in battery.HIDDEN_DOC_PATHS}
+ for path in listed:
+ if path == "/admin":
+ continue # the canary, deliberately not a registered page
+ assert f'mark_hidden("{path}")' in run_py, (
+ f"{path} is in the battery's hidden list but run.py does not mark "
+ "it hidden — the check would pass for the wrong reason"
+ )
+
+
+def test_the_battery_reports_a_failure_rather_than_swallowing_it(wired):
+ """The check that keeps every other assertion here honest.
+
+ If `check()` ever caught too broadly, the battery would print `pass` for a
+ host that is on fire. Break one expectation on purpose and require it to
+ be reported.
+ """
+ wired.SITE_H1 = "# not this site"
+ try:
+ wired.satellite_checks(BASE)
+ finally:
+ wired.SITE_H1 = f"# {SITE_BRAND}"
+
+ verdicts = {name: verdict for name, verdict, _ in wired._RESULTS}
+ assert verdicts.get("llms_txt_identity") == wired.FAIL
+
+
+def test_the_default_base_url_matches_the_container_port(battery):
+ """CI boots the image and runs the battery with no --base-url."""
+ dockerfile = (REPO_ROOT / "Dockerfile").read_text()
+ port = battery.DEFAULT_BASE_URL.rsplit(":", 1)[1]
+ assert f"EXPOSE {port}" in dockerfile, (
+ f"the battery defaults to port {port}; the image exposes something else"
+ )
+ assert f"PORT={port}" in dockerfile, "the image defaults to a different port"
diff --git a/tests/test_network_surfaces.py b/tests/test_network_surfaces.py
new file mode 100644
index 0000000..58451b2
--- /dev/null
+++ b/tests/test_network_surfaces.py
@@ -0,0 +1,164 @@
+"""The agent- and crawler-facing surfaces, in-process.
+
+Same contract as scripts/network_smoke.py, one layer down: the battery proves
+the DEPLOYED artifact serves these, this proves the code does. The overlap is
+deliberate — a pull request that breaks a surface fails here in seconds
+without waiting for a container, and a deploy that breaks one fails there even
+though the code is fine.
+
+Everything asserted here is a silent failure in production: a sitemap on the
+wrong host does not error, it deindexes; an owner-only page that answers
+llms.txt does not error, it leaks; a page serving the JavaScript stub does not
+error, it just indexes nothing.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+
+from conftest import BROWSER_ACCEPT, CRAWLER_UA, SAMPLE_PAGE, STUB_MARKER
+
+# run.py marks exactly one page hidden. `/admin` is the canary for the next.
+HIDDEN_PATHS = ("/admin/control-board", "/admin")
+
+
+def test_healthz_is_json_and_names_this_app(client):
+ """render.yaml's healthCheckPath, and the hub's hourly pulse target."""
+ response = client.get("/healthz")
+ assert response.ok
+ body = json.loads(response.text)
+ assert body["ok"] is True
+ # The 2plot network-directory key — the hub labels this app's series from
+ # it, so a rename here silently orphans the traffic chart.
+ assert body["app"] == "leaflet"
+
+
+def test_llms_index_publishes_the_page_directory(client):
+ body = client.get("/llms.txt").text
+ assert "## Pages" in body
+ assert "## Network" in body
+
+
+def test_llms_index_names_the_hub(client):
+ """What lets an agent walk from this leaf back up to the network."""
+ assert "https://2plot.dev" in client.get("/llms.txt").text
+
+
+def test_a_page_document_is_not_a_dead_end(client):
+ response = client.get(f"{SAMPLE_PAGE}/llms.txt")
+ assert response.ok
+ assert "/llms.txt" in response.text
+
+
+def test_hidden_pages_404_their_llms_txt(client):
+ for path in HIDDEN_PATHS:
+ response = client.get(f"{path}/llms.txt")
+ assert response.status == 404, f"{path}/llms.txt leaked ({response.status})"
+
+
+def test_the_sitemap_stays_on_this_host_and_leaks_nothing(client):
+ from lib.constants import BASE_URL
+
+ response = client.get("/sitemap.xml")
+ assert response.ok
+ locs = re.findall(r"([^<]+)", response.text)
+ assert locs, "sitemap lists no pages"
+ foreign = [u for u in locs if not u.startswith(BASE_URL)]
+ assert foreign == [], f"sitemap points off-host: {foreign[:3]}"
+ for path in HIDDEN_PATHS:
+ assert path not in response.text, f"hidden path {path} leaked into the sitemap"
+
+
+def test_robots_carries_the_ai_search_allowlist(client):
+ """The 2.3.2 / 2.3.3 artifact fingerprint, visible from outside.
+
+ pip metadata is invisible to a live probe, so these stanzas are how a
+ deployed host is proven to run the intended package.
+ """
+ lines = [ln.strip() for ln in client.get("/robots.txt").text.splitlines()]
+
+ def rule(agent: str) -> str:
+ marker = f"User-agent: {agent}"
+ assert marker in lines, f"{marker} stanza missing"
+ return lines[lines.index(marker) + 1]
+
+ for agent in ("OAI-SearchBot", "ChatGPT-User", "PerplexityBot",
+ "Claude-User", "Claude-SearchBot"):
+ assert rule(agent) == "Allow: /", f"{agent} is not allowed"
+
+ assert any(ln.startswith("Sitemap:") for ln in lines), "no Sitemap line"
+
+
+def test_robots_keeps_this_sites_deliberate_open_training_posture(client):
+ """`block_ai_training=False` in run.py is a decision, not drift.
+
+ For MIT-licensed component documentation, being in the training corpus is
+ how a model recommends this library to somebody who never visits the site.
+ Under that config dash-improve-my-llms emits no ClaudeBot stanza at all —
+ training crawlers fall under `User-agent: *`. If someone flips the flag,
+ this is the test that makes them say so out loud.
+ """
+ lines = [ln.strip() for ln in client.get("/robots.txt").text.splitlines()]
+ assert "User-agent: ClaudeBot" not in lines
+
+
+def test_robots_keeps_the_admin_surface_out_of_the_index(client):
+ assert "Disallow: /admin/" in client.get("/robots.txt").text
+
+
+def test_a_crawler_gets_prose_not_the_javascript_stub(client, page_paths):
+ """The prerender — the failure this whole network cares most about.
+
+ A crawler that receives the stub indexes nothing, and the page looks
+ perfect in a browser the entire time.
+ """
+ checked = [p for p in page_paths if not p.startswith("/admin")][:8]
+ assert checked, "no public pages registered"
+ for path in checked:
+ html = client.get(path, user_agent=CRAWLER_UA).text
+ assert STUB_MARKER not in html, f"{path} served the JavaScript stub"
+
+
+def test_a_crawler_gets_a_canonical_on_this_host(client):
+ from lib.constants import BASE_URL
+
+ html = client.get("/", user_agent=CRAWLER_UA).text
+ found = re.findall(r'rel="canonical"\s+href="([^"]*)"', html)
+ assert len(found) == 1, f"expected one canonical, got {found}"
+ assert found[0].startswith(BASE_URL), found[0]
+
+
+def test_agents_and_browsers_get_different_types(client):
+ """One URL, two audiences, and a `Vary` that stops a CDN mixing them."""
+ md = client.get(f"{SAMPLE_PAGE}/llms.txt")
+ assert md.content_type.startswith("text/markdown"), md.content_type
+ assert "" not in md.text, "viewer chrome reached an agent"
+
+ html = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT)
+ assert "text/html" in html.content_type, html.content_type
+ assert "mk-wordmark" in html.text, "the network wordmark is missing"
+
+ for label, response in (("markdown", md), ("html", html)):
+ assert "accept" in response.header("Vary").lower(), (
+ f"no Vary: Accept on the {label} variant — a shared cache may "
+ "serve it to everyone"
+ )
+
+
+def test_the_llms_viewer_is_noindex(client):
+ """The rendered view must not compete with the page it documents."""
+ html = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT).text
+ assert re.search(r']+name="robots"[^>]+noindex', html)
+
+
+def test_every_public_page_has_llms_prose(client, page_paths):
+ """`warn_missing_llms_doc=True` in run.py should have nothing to say."""
+ missing = []
+ for path in page_paths:
+ if path.startswith("/admin") or path == "/":
+ continue
+ response = client.get(f"{path.rstrip('/')}/llms.txt")
+ if not response.ok or STUB_MARKER in response.text:
+ missing.append(path)
+ assert missing == [], f"pages with no agent-facing prose: {missing}"
diff --git a/tests/test_site_identity.py b/tests/test_site_identity.py
new file mode 100644
index 0000000..2d548ab
--- /dev/null
+++ b/tests/test_site_identity.py
@@ -0,0 +1,148 @@
+"""Site identity: one brand, every surface, verbatim.
+
+The network standard says a site states what it is in the same words
+everywhere an agent or a reader can reach. The failure this pins is silent,
+which is why it needs tests rather than a code review: nothing errors when a
+surface falls back to a default.
+
+dash-improve-my-llms 2.3.4's `resolve_site_title` is what makes the fix
+possible: it takes the home page's registered `name` first, `app.title`
+second, and *skips* generic candidates ("Home", "Index", "Dash") rather than
+publishing them. This site is one candidate away from that failure — its home
+page is registered under the nav label "Home", which is on the generic list —
+so the explicit `register_page_metadata(path="/", name=SITE_BRAND)` call in
+run.py is load-bearing, not decorative.
+"""
+
+from __future__ import annotations
+
+from conftest import BROWSER_ACCEPT, REPO_ROOT, SAMPLE_PAGE
+from lib.constants import SITE_BRAND, SITE_DESCRIPTION
+
+# Spelled out rather than imported, so that renaming the constant cannot
+# silently rename the site. Changing the brand should require changing this
+# line, deliberately.
+EXPECTED_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash"
+
+
+def test_brand_constant_is_the_agreed_identity():
+ assert SITE_BRAND == EXPECTED_BRAND
+
+
+def test_app_title_is_the_brand(app):
+ """`Dash(title=...)` — the and `resolve_site_title`'s fallback."""
+ assert app.title == EXPECTED_BRAND
+
+
+def test_home_prose_opens_with_the_brand():
+ """The home markdown's own H1, below the frontmatter."""
+ body = (REPO_ROOT / "docs" / "home" / "home.md").read_text()
+ headings = [ln for ln in body.splitlines() if ln.startswith("# ")]
+ assert headings, "docs/home/home.md has no H1"
+ assert headings[0] == f"# {EXPECTED_BRAND}"
+
+
+def test_llms_index_h1_is_the_brand(client):
+ """The single most-read line of this site, and the one nobody looks at."""
+ response = client.get("/llms.txt")
+ assert response.ok
+ assert response.text.splitlines()[0] == f"# {EXPECTED_BRAND}"
+
+
+def test_llms_index_tagline_is_the_description(client):
+ body = client.get("/llms.txt").text
+ assert f"> {SITE_DESCRIPTION}" in body
+
+
+def test_the_viewer_brand_chip_is_not_a_framework_default(client):
+ """The chip that reads a bare "Dash" on a pre-2.3.4 artifact.
+
+ It is rendered from the same `resolve_site_title` call as the H1, so
+ asserting the brand is present catches both a stale package and a
+ regressed constant.
+ """
+ import html as html_module
+
+ page = client.get(f"{SAMPLE_PAGE}/llms.txt", accept=BROWSER_ACCEPT).text
+ assert html_module.escape(EXPECTED_BRAND) in page or EXPECTED_BRAND in page, (
+ "the viewer banner does not name this site"
+ )
+
+
+def test_a_control_board_toggle_cannot_rename_the_site():
+ """The regression `lib.page_visibility.published_name` exists to stop.
+
+ `apply_llms_state` re-registers a page's metadata every time a verdict
+ changes, from the name the markdown loader recorded — "Home" for this
+ site's root. Without the substitution, one flip of the home page's
+ llms.txt switch would overwrite SITE_BRAND at runtime and the /llms.txt H1
+ would quietly become "Home", with nothing logged.
+ """
+ from lib.page_visibility import published_name
+
+ assert published_name("/", "Home") == EXPECTED_BRAND
+ assert published_name(SAMPLE_PAGE, "Pointer Events") == "Pointer Events"
+
+
+def test_the_home_page_name_really_is_generic():
+ """The premise of the test above, pinned.
+
+ If the home page were ever renamed to something specific, the substitution
+ would be redundant rather than load-bearing — and this test failing is how
+ you would find out, instead of discovering that two mechanisms now fight
+ over the same string.
+ """
+ from dash_improve_my_llms.handlers import _GENERIC_SITE_TITLES
+
+ import frontmatter
+
+ meta, _ = frontmatter.parse((REPO_ROOT / "docs" / "home" / "home.md").read_text())
+ assert meta["name"].strip().lower() in _GENERIC_SITE_TITLES
+
+
+def test_no_surface_falls_back_to_a_generic_title():
+ """The values `resolve_site_title` is designed to skip.
+
+ If the brand were ever set to one of these, the package would silently
+ fall through to the next candidate and this repo would have no idea which
+ string it was publishing.
+ """
+ from dash_improve_my_llms.handlers import _GENERIC_SITE_TITLES
+
+ assert SITE_BRAND.strip().lower() not in _GENERIC_SITE_TITLES
+
+
+def test_the_package_name_is_in_the_description_not_the_brand():
+ """Naming rules from the standard, both directions.
+
+ The brand says what the site *is*; the byline belongs in the description.
+ A brand of "Pip Install Python" would make every satellite in the network
+ share one name. (`dash-leaflet2` is this project's actual name, so it
+ legitimately appears in both.)
+ """
+ assert "dash-leaflet2" in SITE_DESCRIPTION
+ assert "Pip Install Python" in SITE_DESCRIPTION
+ assert "Pip Install Python" not in SITE_BRAND
+
+
+def test_readme_agrees_with_the_brand():
+ """A README that names the site differently is the next drift."""
+ readme = (REPO_ROOT / "README.md").read_text()
+ assert EXPECTED_BRAND in readme, "README.md does not state the site brand"
+
+
+def test_llms_package_floor_is_the_network_standard():
+ """Identity resolution lives in the package; the floor is what delivers it."""
+ import dash_improve_my_llms as pkg
+
+ parts = tuple(int(p) for p in pkg.__version__.split(".")[:3] if p.isdigit())
+ assert parts >= (2, 3, 4), (
+ f"dash-improve-my-llms {pkg.__version__} predates resolve_site_title; "
+ "the viewer chip and the /llms.txt H1 would fall back to app.title"
+ )
+
+
+def test_the_home_markdown_is_no_longer_a_scaffold():
+ """`This page demonstrates Home.` was the generated stub's prose."""
+ body = (REPO_ROOT / "docs" / "home" / "home.md").read_text()
+ assert "This page demonstrates Home." not in body
diff --git a/tests/test_smoke_live.py b/tests/test_smoke_live.py
new file mode 100644
index 0000000..10cb741
--- /dev/null
+++ b/tests/test_smoke_live.py
@@ -0,0 +1,290 @@
+"""Exercise scripts/smoke_live.py against the app itself.
+
+The script only ever runs in CD, against a host that already exists, which is
+exactly the kind of code that rots unnoticed — a typo in a regex turns every
+check into a silent pass and CD keeps reporting green over a broken deploy.
+So it gets run here too, with its `fetch` pointed at the in-process app
+instead of the network.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+
+import pytest
+
+from conftest import REPO_ROOT, backend
+from lib.constants import BASE_URL
+
+# The app's real origin, because the script checks that canonical tags and
+# sitemap URLs match the host being requested. Pointing it at a made-up
+# hostname would fail those checks for the wrong reason.
+BASE = BASE_URL
+
+
+@pytest.fixture(scope="module")
+def smoke():
+ spec = importlib.util.spec_from_file_location(
+ "smoke_live", REPO_ROOT / "scripts" / "smoke_live.py"
+ )
+ module = importlib.util.module_from_spec(spec)
+ sys.modules["smoke_live"] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture
+def wired(smoke, client, monkeypatch):
+ """Point the script's fetch at the test client.
+
+ Off-host URLs (the peers' llms.txt) resolve to a stub 200 — reaching over
+ the network from a unit test would make the suite depend on eleven other
+ deployments being up.
+ """
+ def fetch(url, user_agent=smoke.BROWSER_UA, accept=None):
+ if url.startswith(BASE):
+ path = url[len(BASE):] or "/"
+ response = client.get(path, user_agent=user_agent, accept=accept)
+ return response.status, response.text, response.headers
+ return 200, "# peer\n", {"Content-Type": "text/markdown"}
+
+ monkeypatch.setattr(smoke, "fetch", fetch)
+ monkeypatch.setattr(smoke, "failures", [])
+ monkeypatch.setattr(smoke, "warnings", [])
+ monkeypatch.setattr(smoke, "checks_run", 0)
+ return smoke
+
+
+def test_smoke_script_passes_against_this_app(wired, capsys):
+ exit_code = wired.main(BASE)
+ output = capsys.readouterr().out
+ assert exit_code == 0, f"smoke_live reported failures:\n{output}"
+ assert "checks passed" in output
+
+
+def test_smoke_script_detects_a_stub_body(wired, smoke, monkeypatch, capsys):
+ """The check that matters most must actually fire when it should."""
+ original = smoke.fetch
+
+ def stubbed(url, user_agent=smoke.BROWSER_UA, accept=None):
+ status, body, headers = original(url, user_agent, accept)
+ if user_agent == smoke.CRAWLER_UA:
+ body = f"
{smoke.STUB_MARKER}
"
+ return status, body, headers
+
+ monkeypatch.setattr(smoke, "fetch", stubbed)
+ assert wired.main(BASE) > 0
+ assert "served the JavaScript stub" in capsys.readouterr().out
+
+
+def test_smoke_script_detects_a_foreign_canonical(wired, smoke, monkeypatch, capsys):
+ original = smoke.fetch
+
+ def rehosted(url, user_agent=smoke.BROWSER_UA, accept=None):
+ status, body, headers = original(url, user_agent, accept)
+ return status, body.replace(
+ f'rel="canonical" href="{BASE}',
+ 'rel="canonical" href="https://someone-elses-host.example.com',
+ ), headers
+
+ monkeypatch.setattr(smoke, "fetch", rehosted)
+ assert wired.main(BASE) > 0
+ assert "canonical on" in capsys.readouterr().out
+
+
+def test_smoke_script_detects_viewer_chrome_leaking_to_agents(
+ wired, smoke, monkeypatch, capsys
+):
+ """The other check ROLLOUT.md calls out as silent and expensive.
+
+ If the viewer's HTML ever reaches a plain fetch, every agent in the
+ network pays tokens for decoration and nothing anywhere reports it.
+ """
+ original = smoke.fetch
+
+ def leaky(url, user_agent=smoke.BROWSER_UA, accept=None):
+ status, body, headers = original(url, user_agent, accept)
+ if url.endswith("/llms.txt") and accept is None:
+ body = '
chrome
' + body
+ return status, body, headers
+
+ monkeypatch.setattr(smoke, "fetch", leaky)
+ assert wired.main(BASE) > 0
+ assert "viewer chrome" in capsys.readouterr().out
+
+
+def test_peer_urls_survive_markdown_link_syntax(wired, smoke, capsys):
+ """The 2.2.0 nav block writes `[https://host/llms.txt](https://host/llms.txt)`.
+
+ A URL pattern that stops only at whitespace and `)` swallows the label and
+ the opening paren into one malformed URL, which then 404s and fails a
+ perfectly good deploy. Every extracted URL must be fetchable as-is.
+ """
+ assert wired.main(BASE) == 0
+ # Either label: a peer that answers is reported as "serves a document",
+ # one that doesn't as "reachable".
+ reported = [
+ line.split(": ", 1)[1].strip()
+ for line in capsys.readouterr().out.splitlines()
+ if "peer reachable: " in line or "peer serves a document: " in line
+ ]
+ assert reported, "no peer URLs were extracted at all"
+ malformed = [u for u in reported if any(ch in u for ch in "()[]")]
+ assert malformed == [], f"markdown syntax leaked into peer URLs: {malformed}"
+
+
+def test_smoke_script_detects_a_missing_vary_header(wired, smoke, monkeypatch, capsys):
+ """A CDN that never sees `Vary: Accept` will serve one cached variant to
+ everyone — the one failure that only appears in front of a real cache."""
+ original = smoke.fetch
+
+ def unvaried(url, user_agent=smoke.BROWSER_UA, accept=None):
+ status, body, headers = original(url, user_agent, accept)
+ return status, body, {k: v for k, v in headers.items() if k.lower() != "vary"}
+
+ monkeypatch.setattr(smoke, "fetch", unvaried)
+ assert wired.main(BASE) > 0
+ assert "Vary: Accept" in capsys.readouterr().out
+
+
+def test_smoke_script_rejects_a_peer_serving_its_spa_shell(
+ wired, smoke, monkeypatch, capsys
+):
+ """A 200 alone does not mean a host serves the document.
+
+ A Dash app answers its catch-all with the SPA shell for any unmatched
+ path, so a peer that publishes no llms.txt still returns 200 text/html.
+ Verified against 2plot.dev, where `/api/this-endpoint-cannot-exist` also
+ returns 200 text/html — a status-only check passes on every such host and
+ the directory looks healthy while pointing at nothing.
+ """
+ original = smoke.fetch
+
+ def spa_shell(url, user_agent=smoke.BROWSER_UA, accept=None):
+ if not url.startswith(BASE):
+ return 200, "app", {
+ "Content-Type": "text/html; charset=utf-8"
+ }
+ return original(url, user_agent, accept)
+
+ monkeypatch.setattr(smoke, "fetch", spa_shell)
+ # Reported, but NOT fatal: this is somebody else's host. See `check()`.
+ assert wired.main(BASE) == 0
+ output = capsys.readouterr().out
+ assert "that host's catch-all" in output
+ assert "warn peer serves a document" in output
+ assert wired.warnings, "the peer problem was detected but not recorded"
+
+
+@pytest.mark.skipif(backend() != "flask", reason="one backend is enough for this")
+def test_a_dead_peer_is_reported_but_does_not_fail_the_deploy(
+ wired, smoke, monkeypatch, capsys
+):
+ """Every peer in the network down at once, and this deploy still ships.
+
+ The policy this pins: a check about THIS host is fatal, a check about
+ somebody else's host is a warning. Gating on peers is shared fate — one
+ expired certificate anywhere in the network would stop every satellite
+ from deploying, which is both wrong and the fastest way to teach people
+ that a red CD means nothing.
+ """
+ original = smoke.fetch
+
+ def dead_peers(url, user_agent=smoke.BROWSER_UA, accept=None):
+ if not url.startswith(BASE):
+ return 404, "", {}
+ return original(url, user_agent, accept)
+
+ monkeypatch.setattr(smoke, "fetch", dead_peers)
+ assert wired.main(BASE) == 0
+ output = capsys.readouterr().out
+ assert "warn peer reachable" in output
+ assert "warnings (peers — not this deployment)" in output
+
+
+def test_a_broken_local_surface_still_fails_the_deploy(
+ wired, smoke, monkeypatch, capsys
+):
+ """The other half of the policy, and the one worth guarding.
+
+ Demoting peers to warnings is only safe if everything about this host
+ stayed fatal. Break a local surface while every peer is healthy and the
+ exit code must still be non-zero.
+ """
+ original = smoke.fetch
+
+ def no_sitemap(url, user_agent=smoke.BROWSER_UA, accept=None):
+ if url.startswith(BASE) and url.endswith("/sitemap.xml"):
+ return 500, "", {}
+ return original(url, user_agent, accept)
+
+ monkeypatch.setattr(smoke, "fetch", no_sitemap)
+ assert wired.main(BASE) > 0
+ assert "FAIL /sitemap.xml responds 200" in capsys.readouterr().out
+
+
+def test_fetch_survives_an_unreadable_error_body(smoke, monkeypatch):
+ """A peer that 502s mid-body must not take the whole run down.
+
+ `fetch` returns the status and reads the body as a bonus, but
+ `HTTPError.read()` can itself raise — a truncated error response raises
+ `IncompleteRead` — and an exception escaping `fetch` crashes the script
+ before it can report anything. Observed against a live peer during the
+ 2plot network rollout: one sick host ended a CD run with a traceback
+ instead of a warning, which is precisely what the fatal/warn split in
+ `check()` exists to prevent.
+ """
+ import http.client
+ import urllib.error
+
+ class _Truncated(urllib.error.HTTPError):
+ def __init__(self):
+ super().__init__("https://peer.example/llms.txt", 502, "Bad Gateway", {}, None)
+
+ def read(self, *_args, **_kwargs):
+ raise http.client.IncompleteRead(b"partial", 20205)
+
+ def boom(*_args, **_kwargs):
+ raise _Truncated()
+
+ monkeypatch.setattr(smoke.urllib.request, "urlopen", boom)
+
+ status, body, _headers = smoke.fetch("https://peer.example/llms.txt")
+ assert status == 502
+ assert body == ""
+
+
+def test_fetch_retries_network_errors_but_not_http_statuses(smoke, monkeypatch):
+ """The distinction that keeps a cold start from reading as a regression.
+
+ This site is on Render's free tier and sleeps after ~15 minutes idle, so a
+ burst of ~17 requests reliably meets one cold start. Without a retry that
+ surfaced as `FAIL canonical on /` — a check that never ran — sending
+ you to inspect canonical tags that were correct all along. A real 404 must
+ still be reported on the first response, with no added latency.
+ """
+ import urllib.error
+
+ attempts = {"n": 0}
+
+ def flaky(*_args, **_kwargs):
+ attempts["n"] += 1
+ raise TimeoutError("connection timed out")
+
+ monkeypatch.setattr(smoke.urllib.request, "urlopen", flaky)
+ monkeypatch.setattr(smoke.time, "sleep", lambda _s: None)
+ status, body, _ = smoke.fetch("https://leaflet.2plot.dev/")
+ assert status == 0
+ assert "TimeoutError" in body
+ assert attempts["n"] == smoke.RETRIES, "network errors are not being retried"
+
+ attempts["n"] = 0
+
+ def not_found(*_args, **_kwargs):
+ raise urllib.error.HTTPError("https://leaflet.2plot.dev/nope", 404, "NF", {}, None)
+
+ monkeypatch.setattr(smoke.urllib.request, "urlopen", not_found)
+ status, _body, _ = smoke.fetch("https://leaflet.2plot.dev/nope")
+ assert status == 404
+ assert attempts["n"] == 0
diff --git a/tests/test_social_card.py b/tests/test_social_card.py
new file mode 100644
index 0000000..76d4a4a
--- /dev/null
+++ b/tests/test_social_card.py
@@ -0,0 +1,271 @@
+"""The social card and the installable-app surfaces.
+
+Both fail silently and both fail *outside* this app, which is why they need
+tests rather than a look at the page:
+
+* a link preview is built by Facebook, Twitter/X, Slack, Discord and LinkedIn
+ from tags nobody on the team ever sees rendered — and an EMPTY `og:image`
+ is worse than none, because a scraper treats the empty value as the declared
+ image and renders a blank card. Every page on this site shipped exactly that
+ until `image_url=` was passed to `register_page`;
+* the web app manifest decides whether a browser offers "install". Its
+ `name`/`short_name` were empty strings and its icon paths pointed at
+ `/android-chrome-192x192.png` at the site root, where nothing is served, so
+ no browser could ever have made the offer. Nothing about that is visible on
+ the page.
+
+Note on where the tags come from — the split matters when one of these fails:
+`og:image`, `twitter:image` and the whole `twitter:*` set are emitted by DASH
+(`dash/_pages.py`, per page, from `register_page`), while `og:site_name`,
+the `og:image:*` auxiliaries and the icon links come from
+`templates/index.html`. dash-improve-my-llms adds a third set, but only on the
+prerender path, which social scrapers do not take — its bot list has
+`facebookbot` (Meta's AI training crawler) and not `facebookexternalhit` (the
+link-preview fetcher). That is why deleting index.html would silently kill
+every unfurl.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+
+from conftest import REPO_ROOT, SAMPLE_PAGE
+from lib.constants import (
+ OG_IMAGE_ALT,
+ OG_IMAGE_HEIGHT,
+ OG_IMAGE_URL,
+ OG_IMAGE_WIDTH,
+ SITE_BRAND,
+)
+
+MANIFEST = REPO_ROOT / "assets" / "favicon_io" / "site.webmanifest"
+
+
+def _visible(html: str) -> str:
+ """The document with HTML comments removed.
+
+ templates/index.html documents itself, and a regex cannot tell an example
+ tag inside a comment from a live one — this file's first draft reported
+ phantom `rel="icon"` links that were pure prose.
+ """
+ return re.sub(r"", "", html, flags=re.S)
+
+
+def _meta(html: str, key: str, value: str) -> list[str]:
+ """Every `content` for a given property/name — a list, to catch duplicates.
+
+ Tags carrying `data-dimll-prerender` are excluded. dash-improve-my-llms
+ injects its own description and OpenGraph block and marks each one exactly
+ so it can be told apart; counting those would hide what these tests are
+ for, which is duplication between templates/index.html and the tags Dash
+ generates from `register_page`.
+ """
+ pattern = (
+ rf']*(?:property|name)="{re.escape(value)}"[^>]*content="([^"]*)"'
+ rf'|]*content="([^"]*)"[^>]*(?:property|name)="{re.escape(value)}"'
+ )
+ body = re.sub(r']*data-dimll-prerender[^>]*>', "", _visible(html))
+ return ["".join(m) for m in re.findall(pattern, body)]
+
+
+# ------------------------------------------------------------- the og image --
+
+
+def test_the_og_image_is_never_empty(client, page_paths):
+ """The regression this file exists for.
+
+ Dash emits `og:image` unconditionally and leaves it empty when it can find
+ no image. An empty tag is a blank preview card on every platform.
+ """
+ for path in [p for p in page_paths if not p.startswith("/admin")][:8]:
+ html = client.get(path).text
+ images = _meta(html, "property", "og:image")
+ assert images, f"{path} declares no og:image at all"
+ assert all(src.strip() for src in images), (
+ f"{path} serves an EMPTY og:image {images} — the card renders blank"
+ )
+
+
+def test_the_og_image_is_absolute(client):
+ """A relative og:image is unusable: the scraper has no base to resolve it."""
+ for prop in ("og:image", "twitter:image"):
+ values = _meta(client.get("/").text, "property", prop)
+ assert values, f"no {prop} on the home page"
+ for src in values:
+ assert src.startswith("https://"), f"{prop}={src!r} is not absolute"
+
+
+def test_the_image_is_the_one_the_constants_declare(client):
+ assert OG_IMAGE_URL in client.get("/").text
+
+
+def test_the_image_is_declared_exactly_once(client):
+ """Two og:image tags let the scraper pick, and it will pick the wrong one.
+
+ templates/index.html deliberately ships only the AUXILIARY image tags
+ (dimensions, type, alt) precisely so it cannot duplicate the URL Dash
+ already emits. This is what keeps that rule honest.
+ """
+ html = client.get(SAMPLE_PAGE).text
+ assert len(_meta(html, "property", "og:image")) == 1
+ assert len(_meta(html, "property", "twitter:image")) == 1
+
+
+def test_the_auxiliary_image_tags_match_the_constants(client):
+ """index.html hard-codes the dimensions; lib/constants.py is the source."""
+ html = client.get("/").text
+ assert _meta(html, "property", "og:image:width") == [str(OG_IMAGE_WIDTH)]
+ assert _meta(html, "property", "og:image:height") == [str(OG_IMAGE_HEIGHT)]
+ assert _meta(html, "property", "og:image:alt") == [OG_IMAGE_ALT]
+ assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL]
+
+
+def test_the_twitter_card_is_a_large_image(client):
+ assert _meta(client.get("/").text, "property", "twitter:card") == [
+ "summary_large_image"
+ ]
+
+
+def test_no_meta_tag_dash_emits_is_also_declared_statically(client):
+ """The rule templates/index.html's OG block is built on.
+
+ Dash emits all of these per page from `register_page`. A static copy in the
+ template makes two of each, and the static one describes the SITE where
+ Dash's describes the PAGE — redundant and less accurate at once. Which tag
+ a scraper honours is undefined; in practice the later wins.
+ """
+ html = client.get(SAMPLE_PAGE).text
+ for tag in ("description", "og:type", "og:title", "og:description",
+ "og:image", "twitter:card", "twitter:url", "twitter:title",
+ "twitter:description", "twitter:image"):
+ found = _meta(html, "property", tag)
+ assert len(found) <= 1, f"{tag} is declared {len(found)} times: {found}"
+
+
+def test_the_tags_dash_omits_are_declared_here(client):
+ """The other half of the rule — these are not covered by Dash."""
+ html = client.get("/").text
+ for tag in ("og:site_name", "og:url", "og:image:alt", "twitter:image:alt"):
+ assert _meta(html, "property", tag), (
+ f"{tag} is missing and Dash does not emit it"
+ )
+
+
+def test_og_site_name_is_present_because_dash_omits_it(client):
+ """The one image-adjacent tag Dash genuinely does not emit."""
+ assert _meta(client.get("/").text, "property", "og:site_name")
+
+
+# ------------------------------------------------------------- the manifest --
+
+
+def test_the_manifest_is_linked_from_the_document(client):
+ """Without the link element the manifest may as well not exist."""
+ html = client.get("/").text
+ assert 'rel="manifest"' in html, "no manifest link — no install prompt"
+ assert "/assets/favicon_io/site.webmanifest" in html
+
+
+def test_the_manifest_is_served(client):
+ response = client.get("/assets/favicon_io/site.webmanifest")
+ assert response.ok, f"the manifest 404s ({response.status})"
+
+
+def test_the_manifest_is_installable():
+ """The fields a browser requires before it will offer to install.
+
+ An empty `name` disqualifies it, and that is exactly how it shipped.
+ """
+ manifest = json.loads(MANIFEST.read_text())
+ assert manifest["name"].strip(), "empty name — no browser will offer install"
+ assert manifest["short_name"].strip(), "empty short_name"
+ assert manifest["start_url"] == "/"
+ assert manifest["display"] == "standalone"
+ assert manifest["name"] == SITE_BRAND, "the manifest name is not the site brand"
+
+
+def test_every_manifest_icon_actually_resolves(client):
+ """The failure that made the manifest inert.
+
+ Icons were declared at `/android-chrome-192x192.png` — the site root —
+ while the files live under `/assets/favicon_io/`. A manifest whose icons
+ 404 is not installable, and nothing reports it.
+ """
+ manifest = json.loads(MANIFEST.read_text())
+ icons = manifest.get("icons") or []
+ assert icons, "the manifest declares no icons"
+ for icon in icons:
+ src = icon["src"]
+ assert src.startswith("/assets/"), f"{src} is not under /assets/"
+ assert client.get(src).ok, f"manifest icon {src} does not resolve"
+ # A 192px icon is the documented floor for an install prompt.
+ assert any(i.get("sizes") == "192x192" for i in icons)
+ assert any(i.get("sizes") == "512x512" for i in icons)
+
+
+def test_the_apple_touch_icon_is_declared_and_resolves(client):
+ """iOS ignores the manifest and uses this for Add to Home Screen."""
+ html = client.get("/").text
+ match = re.search(r']*rel="apple-touch-icon"[^>]*href="([^"]+)"', html)
+ assert match, "no apple-touch-icon — iOS home-screen icon falls back to a screenshot"
+ assert client.get(match.group(1)).ok
+
+
+def test_the_favicon_resolves(client):
+ """Dash walks assets recursively, so the favicon_io subfolder is found."""
+ html = client.get("/").text
+ hrefs = re.findall(r']*rel="icon"[^>]*href="([^"]+)"', html)
+ assert hrefs, "no favicon link"
+ for href in hrefs:
+ assert client.get(href.split("?")[0]).ok, f"favicon {href} does not resolve"
+
+
+def test_the_theme_colour_agrees_with_the_manifest(client):
+ """A mismatch shows as one colour in the browser chrome and another in the
+ installed app's splash screen."""
+ manifest = json.loads(MANIFEST.read_text())
+ assert _meta(client.get("/").text, "name", "theme-color") == [
+ manifest["theme_color"]
+ ]
+
+
+# --------------------------------------------------- the template itself ----
+
+
+def test_the_index_template_is_still_wired_in(app_module):
+ """A guard on the whole file.
+
+ `templates/index.html` looks removable — dash-improve-my-llms appears to
+ cover OG — but its injection runs only on the prerender path, which social
+ scrapers do not take. Deleting the template silently kills every unfurl,
+ the icons and the manifest at once, and nothing else in this suite would
+ notice on its own.
+ """
+ index = (REPO_ROOT / "templates" / "index.html").read_text()
+ for placeholder in ("{%metas%}", "{%favicon%}", "{%css%}", "{%app_entry%}",
+ "{%config%}", "{%scripts%}", "{%renderer%}"):
+ assert placeholder in index, f"{placeholder} missing from the template"
+ assert app_module.app.index_string.startswith("")
+
+
+def test_the_structured_data_points_at_a_network_host():
+ """`pip-install-python.com` was this site's published Organization URL.
+
+ It is not a 2plot network host, and it appeared three times in the
+ structured data every crawler reads: the Organization `url`, the
+ SoftwareSourceCode `author.url`, and a link in the noscript footer.
+
+ Scoped to the template on purpose. `lib/network_directory.py` also lists
+ the domain, but that is a deliberate directory of AFFILIATED (explicitly
+ non-network) sites — a separate editorial decision, not template drift.
+ """
+ index = (REPO_ROOT / "templates" / "index.html").read_text()
+ assert "pip-install-python.com" not in index
+
+ for block in re.findall(
+ r'', index, re.S
+ ):
+ data = json.loads(block)
+ for url in re.findall(r'"url":\s*"([^"]+)"', block):
+ assert "pip-install-python.com" not in url, data.get("@type")