diff --git a/.dockerignore b/.dockerignore index eaa2717..9921231 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,6 @@ flask_session clerk_events.jsonl .claude nohup.out +# CI/build artifacts — never needed inside the image +build +.github diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..16d136e --- /dev/null +++ b/.flake8 @@ -0,0 +1,26 @@ +[flake8] +# 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, + venv, + __pycache__, + node_modules, + vendor, + dist, + build, + .idea, + dash_mui_scheduler, + src, + docs/*/, +per-file-ignores = + # run.py's wiring is ordered on purpose: load_dotenv() and the backend + # resolution must precede first-party imports, and the reporter/bulletin + # imports sit after the app is fully wired. + run.py: E402 + usage.py: E402 + # pytest fixtures look like shadowed names to flake8 + tests/*: F811 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..97d4457 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,114 @@ +name: CD + +# Deploys muischeduler.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 — Render is auto-deploying from +# GitHub on its own, so absence of the secret is a working configuration, not +# a failure. +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://muischeduler.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://muischeduler.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 single 200 proves + # nothing; give the build time, then require SUSTAINED health. + 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: 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, the CDN card's real pixels, and every + # peer llms.txt in the directory actually resolving (peers WARN, this + # host FAILS). + - 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 fallen 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 404ing or reshaped social card, a missing network directory, and dead peer llms.txt links." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..24dc79b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,252 @@ +# CI for muischeduler.2plot.dev — the 2plot network baseline, adapted from +# dash-documentation-boilerplate (see 2plot_leaflet and dash-email for the +# same shape beside a wheel build). +# +# * least-privilege `permissions`, cancel-in-progress `concurrency`; +# * explicit `timeout-minutes` on every job (the default is six hours); +# * actionlint first — an invalid workflow file dies with ZERO jobs and no +# failure signal, which is invisible for days; +# * a secretless in-process pytest suite — no CLERK_*, no +# CROSS_APP_WEBHOOK_SECRET — because fail-closed behaviour is only +# provable when nothing is configured; +# * the real Docker image, built, fingerprint-asserted INSIDE, BOOTED, then +# probed by the same battery that runs against production (LESSONS §19: +# CI green without a container boot is not a deploy gate); +# * an advisory pip-audit. + +name: CI + +# Deliberately NOT `push: branches: [main]` — cd.yml owns main and its first +# job `uses:` this workflow, so a push trigger here would run everything twice +# and the runs would cancel each other in the concurrency group. +on: + pull_request: + workflow_dispatch: + # Called by cd.yml so a deploy can never ship something the matrix rejected. + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + FORCE_COLOR: "1" + # Never let a CI run inherit production behaviour: the base-URL guard keys + # off RENDER / APP_ENV, and the traffic reporter keys off the webhook + # secret. Both must stay inert here. + APP_ENV: ci + +jobs: + 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 + + # The workflows lint themselves. An invalid workflow file is the one + # defect CI structurally cannot report — the run dies before a job + # exists to fail. A double quote inside ${{ }} silently killed every CI + # and CD run on the boilerplate for four days. + - 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: + # SINGLE quotes inside ${{ }} — a double quote is a LEX error that + # invalidates the whole file with zero jobs scheduled. + name: py${{ matrix.python }} · ${{ matrix.backend }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + # Both deployment-relevant backends on the current Python... + python: ["3.12"] + backend: [flask, fastapi] + include: + # ...and the supported Python range on the default backend. + - python: "3.11" + 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 + # The component library itself — docs pages import dash_mui_scheduler. + pip install -e . + # httpx backs starlette's TestClient on the fastapi leg. + pip install pytest httpx + + - 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()) + + assert parts(dash.__version__)[:2] >= (4, 2), dash.__version__ + # 2.3.4 is the network standard: below it resolve_site_title does + # not exist and the 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; 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: | + DASH_BACKEND=flask gunicorn run:server -b 127.0.0.1:8598 --daemon --access-logfile - --error-logfile - + for _ in $(seq 1 30); do + curl -sf http://127.0.0.1:8598/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 CWD. + curl -sf http://127.0.0.1:8598/ > /dev/null + curl -sf http://127.0.0.1:8598/quickstart > /dev/null + # The battery, against the same server shape a satellite deploys. + python3 scripts/network_smoke.py --base-url http://127.0.0.1:8598 + + 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. A dependency-resolution failure surfaces + # here, at CI time — not at 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-mui-scheduler: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-mui-scheduler: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, 2), f'expected dash >=4.2, 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 proves the dodge kept working. + 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') + + # 0.9.0 ships a dead avatar chip on Clerk satellite domains. + v = version('dash-clerk-auth') + print('dash-clerk-auth', v) + assert parts(v) >= (0, 9, 1), f'expected dash-clerk-auth >=0.9.1, image has {v}' + + import dash_mui_scheduler + print('dash_mui_scheduler', dash_mui_scheduler.__version__) + " + + # Boot with no secrets: Clerk no-ops, 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 (LESSONS §19). + - name: Boot the container and wait for /healthz + run: | + docker run -d --name docs -p 8598:8598 -e PORT=8598 dash-mui-scheduler:ci + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:8598/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://muischeduler.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:8598 + + - name: Container logs (for the record) + if: always() + run: docker logs --tail 40 docs 2>/dev/null || true + + 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 the day it lands, and worth nobody's broken build at 2am. + 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/CHANGELOG.md b/CHANGELOG.md index ea7d41e..1ab1afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,51 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +## [0.1.1] - 2026-08-01 + ### Added +- **The docs site is now on the 2plot network standard**, the baseline proven on + 2plot.ai, 2plot.dev and the other satellite documentation sites: + - **A test suite and CI/CD pipeline, from zero.** Every pull request now runs a + secretless test suite on both the Flask and FastAPI builds, lints the code and + the workflows themselves, builds the real production image, checks the shipped + dependency versions inside it, boots it, and runs the same smoke battery that + later checks the live site. Merging to `main` deploys and then verifies the + live domain — waiting for *sustained* health before calling the deploy good. + - **One identity on every surface.** The site now states what it is — + *dash-mui-scheduler — MUI X scheduling for Dash* — identically in the browser + tab, in search results, in shared-link previews, in the machine-readable + `/llms.txt` index, in its app manifest and atop the README, and a test pins + each surface so none of them can silently drift. + - **A proper share card.** Links shared to Slack, Discord, X or LinkedIn will + unfurl with a purpose-drawn 1200×630 card served from the network CDN (so a + sleeping free-tier container never blanks a preview) instead of an upscaled + favicon. + - **The network bulletin.** The hub's tips and announcements now render in the + documentation's llms.txt viewer once `NETWORK_BULLETIN_URL` is set on the + service, so network-wide news reaches this site's readers without a deploy. + - **Honest analytics.** The network's own machinery — health sweeps, smoke + batteries, this site's calls to the hub — now identifies itself and is + dropped from visitor analytics before it is ever written down — however the + marker is capitalised — and every + outbound call this site makes carries the same marker for the far side. The + site reports to the hub under its one short id, `muischeduler`, everywhere. + - `/healthz` on every backend (previously FastAPI-only), answering the hub's + hourly health sweep and gating deploys. + - **The cross-host network directory** — `/llms.txt` now lists the sibling + documentation sites and the hub, so an agent landing here can discover the + rest of the network. + - **Walkthrough video** — a video tour of the calendar, resource timeline, and - radial charts now sits near the top of the Quickstart page, and the README - header carries a clickable thumbnail linking to the same walkthrough. + radial charts now sits near the top of the Quickstart page **and on the + documentation home page**, so a reader landing on the docs can watch it + without going to GitHub first. The README header carries a clickable + thumbnail linking to the same walkthrough. Both embeds use YouTube's + no-cookie player, so nothing is set until you press play. +- **Richer search-result data** — the site now describes itself to search + engines as what it is: an MIT-licensed Python source library with a + repository, a PyPI download page, a version number read straight from the + package, and the walkthrough video attached. - **The docs site now reports its traffic to 2plot.ai**, the analytics home for the whole 2plot network. Once an hour it sends a signed daily rollup — page hits split human/bot, unique visitors, sessions, median session length, top @@ -22,7 +63,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). network secret is configured; without it the site behaves exactly as before and makes no outbound calls. +### Changed +- **Fresher, safer dependencies.** The AI/SEO layer (`dash-improve-my-llms`) now + installs from PyPI at ≥ 2.3.4 instead of a vendored 2.0.0 snapshot; the + `gunicorn` web server is floored at ≥ 23 (clearing two request-smuggling + CVEs its old pin was stuck on); and the optional Clerk auth package moves to + 0.9.1, the release that fixes the account chip on satellite domains. +- **The documentation has its own home: [muischeduler.2plot.dev](https://muischeduler.2plot.dev).** + Everything the site publishes about itself — search-engine addresses, shared-link previews, + the sitemap, the machine-readable pages — now points there, and the README and the PyPI + listing send readers to the docs rather than back to the repository. The old + `onrender.com` address keeps working and forwards to the new one, so existing links and + bookmarks survive the move and search engines are told where the pages went. + ### Fixed +- **Search engines were being told this site is a copy of a site that does not + exist.** The page template still carried the URL it was built with + (`dash-mui-scheduler.onrender.com`) rather than the address the docs actually + live at, and it claimed that one address for all 17 pages at once — the + fastest way for a site to fall out of the index entirely. Every page now + declares its own correct address, kept in step as you navigate, and every + link the site publishes about itself is built from a single setting. +- **Every page was announcing itself as the home page.** The template carried + its own copy of the title, description and social tags, which overrode the + per-page ones — so a search result or shared link for, say, *Recurrence* + showed the site blurb instead of the page's. The per-page text now wins + everywhere, including for search-engine and link-preview crawlers, and shared + links unfurl with the project logo and the right page's title. - **Visitor counts and countries are now measured at the edge, not at the proxy.** Behind Render/Cloudflare every request looked like it came from the same address, which collapsed all visitors into one and mislabelled where diff --git a/CLAUDE.md b/CLAUDE.md index ad700de..ed806c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ The React sources live in `src/lib/components/`; the **built bundle + generated are COMMITTED** (`dash_mui_scheduler/*.min.js` + `*.py`), so `pip install -e .` works without npm. Changing anything under `src/` requires `npm install && npm run build` and committing the regenerated artifacts. `setup.py` reads `package.json` for the version — keep them in sync -(currently 0.1.0; PyPI publish is an owner step in `.claude/migration/OWNER-ACTIONS.md`). +(currently 0.1.1; PyPI publish is an owner step in `.claude/migration/OWNER-ACTIONS.md`). ## Layout - `dash_mui_scheduler/` — the built package (5 wrappers + bundles). `src/lib/` — React sources. @@ -50,8 +50,32 @@ regenerated artifacts. `setup.py` reads `package.json` for the version — keep - `components/` — `appshell.py`, `header.py`, `navbar.py` (Scheduler + Radial sections), `backend_badge.py`. - `pages/` — `home.py` (landing), `markdown.py` (docs loader), `not_found_404.py` (plain DMC). -- `run.py` — entrypoint (PORT env). `Dockerfile`/`render.yaml` — fastapi Render deploy on the - default `*.onrender.com` URL. +- `run.py` — entrypoint (PORT env). `Dockerfile`/`render.yaml` — fastapi Render deploy at + **`https://muischeduler.2plot.dev`** (custom domain; the service's own `*.onrender.com` URL + 301s there via `lib/canonical_host.py` once `CANONICAL_HOST_REDIRECT=1`). + +## 2plot network standard (retrofit 2026-08-01) +This repo follows the satellite standard +(`pip-docs+/.claude/support_files/subdomain_blueprint/STANDARD.md`): +- **Identity**: `lib/constants.SITE_BRAND` ("dash-mui-scheduler — MUI X scheduling for + Dash") reaches every surface — `Dash(title=)`, `register_page_metadata(path="/", + name=SITE_BRAND)`, index.html ``/`og:site_name`, manifest. + `tests/test_site_identity.py` pins them; don't restate the brand, derive it. +- **App id is `muischeduler` everywhere**: `lib/traffic_report.app_key()`, + `lib/ad_client.APP_ID`, `lib/bulletin.app_id()` — pinned together in tests. +- **Social card**: `scripts/make_social_card.py` → CDN + `cdn.2plot.ai/github_assets/muischeduler.2plot.dev.png` (1200×630). Upload is MANUAL + and gates deploy: og:image points at the CDN, so a 404 there fails + `social_card_real_pixels` in the live battery — deliberately. +- **Internal traffic**: UAs carrying `2plot-internal` are dropped at write time in + `lib/analytics_tracker`; every outbound network call sends `internal_ua(caller)`. +- **CI/CD**: `.github/workflows/ci.yml` (lint+actionlint, secretless pytest on + flask+fastapi, docker build→fingerprints→boot→battery, advisory pip-audit); + `cd.yml` owns main (sustained health, then `scripts/network_smoke.py` + + `scripts/smoke_live.py` against the live host). `markdown2dash` installs + `--no-deps` everywhere (its gunicorn<22 pin vs our >=23 floor). +- **Tests are secretless by design** — `tests/conftest.py` pins every secret empty + before run.py imports; run `DASH_BACKEND=flask python -m pytest tests -q`. ## Run + verify recipe ```bash @@ -70,6 +94,17 @@ In a sandbox that blocks sockets, render in-process instead: - The `.. kwargs::dash_mui_scheduler.<Component>` directive renders the prop table from the generated wrapper docstrings; `PROPS_TO_EXCLUDE` in `lib/constants.py` filters style props. - `MUI_X_LICENSE_KEY` flows to examples via `licenseKey` — never hard-code a license string. +- **Host moves:** change `APP_BASE_URL` only — never a literal host in code/template. Order: + attach the domain in Render → DNS CNAME verified → confirm it serves → flip `APP_BASE_URL` + → set `CANONICAL_HOST_REDIRECT=1`. Flipping the redirect early strands every visitor. +- **SEO/URLs:** `lib/constants.BASE_URL` (from `APP_BASE_URL`) is the ONLY source of absolute + URLs — canonical, `og:*`, sitemap, robots, llms, JSON-LD. `templates/index.html` uses + `__BASE_URL__` / `__PAGE_URL__` / `__VERSION__` tokens that `run.py` substitutes; never + hard-code a host there, and never add a static `description`/`og:*`/`twitter:*` tag (Dash + emits those per page from `register_page`). Dash replaces **every** occurrence of a + `{%…%}` placeholder — including inside HTML comments. Crawlers get + dash-improve-my-llms' own prerendered HTML, not the SPA shell; `run.py` patches canonical + and `og:image` into it. ## .claude/ scaffold - **`migration/`** — the 2plot network split packet (HANDOFF → MIGRATION-CHECKLIST → diff --git a/Dockerfile b/Dockerfile index 8bf1809..2e8a10e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,14 @@ # and committing the regenerated artifacts (the image no longer self-builds). FROM python:3.12-slim +# PYTHONUNBUFFERED is load-bearing: without it Python block-buffers stdout to +# the pipe and NONE of the boot diagnostics (bulletin wired/off, traffic +# reporter state, backend banner) ever reach Render's log stream. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + WORKDIR /app RUN pip install --no-cache-dir --upgrade pip @@ -17,8 +25,11 @@ COPY requirements.txt . COPY vendor/ ./vendor/ RUN pip install --no-cache-dir -r requirements.txt -# dash-improve-my-llms 2.0 is not on PyPI yet — install the vendored sdist. -RUN pip install --no-cache-dir "vendor/dash_improve_my_llms-2.0.0.tar.gz" +# markdown2dash 0.1.2 pins gunicorn>=21.2,<22 — stuck on two request-smuggling +# CVEs against our gunicorn>=23 floor. --no-deps dodges the pin; its real +# dependencies (mistune, frontmatter, pydantic) are in requirements.txt. CI +# asserts gunicorn>=23 INSIDE this image to keep the dodge honest. +RUN pip install --no-cache-dir --no-deps markdown2dash==0.1.2 COPY . . RUN pip install --no-cache-dir -e . diff --git a/README.md b/README.md index 261432d..3a811de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ <div align="center"> -# Dash MUI Scheduler +# dash-mui-scheduler — MUI X scheduling for Dash **Event calendar, resource timeline & radial chart components for [Plotly Dash](https://dash.plotly.com), wrapping the [MUI X Scheduler](https://mui.com/x/react-scheduler/).** @@ -13,7 +13,7 @@ Drag & drop scheduling · recurrence · resources · timezones · automatic dark [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/WEnZR35mrK) [![YouTube](https://img.shields.io/badge/YouTube-%402plotai-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/channel/UC6Bmo0t0ZUpU_xKBYW0bJuQ) -**[Documentation](https://pip-install-python.com)** · [Discord](https://discord.gg/WEnZR35mrK) · [YouTube](https://www.youtube.com/channel/UC6Bmo0t0ZUpU_xKBYW0bJuQ) · [GitHub](https://github.com/pip-install-python/dash-mui-scheduler) +**[Documentation](https://muischeduler.2plot.dev)** · [Discord](https://discord.gg/WEnZR35mrK) · [YouTube](https://www.youtube.com/channel/UC6Bmo0t0ZUpU_xKBYW0bJuQ) · [GitHub](https://github.com/pip-install-python/dash-mui-scheduler) <br/> @@ -94,15 +94,18 @@ if __name__ == "__main__": ## Documentation -Full documentation, with a **live, editable demo and source for every example**, lives at the -open-source documentation index maintained by Pip Install Python LLC: +Full documentation, with a **live, editable demo and source for every example**: -### 📚 **[pip-install-python.com](https://pip-install-python.com)** +### 📚 **[muischeduler.2plot.dev](https://muischeduler.2plot.dev)** + +Part of the open-source documentation index maintained by Pip Install Python LLC at +[pip-install-python.com](https://pip-install-python.com). You can also run the docs site locally — it is a markdown-driven Dash app served by `run.py`: ```bash pip install -r requirements.txt +pip install --no-deps markdown2dash==0.1.2 # its gunicorn<22 pin conflicts with our >=23 floor pip install -e . # install the built components python run.py # open http://localhost:8560 ``` @@ -229,6 +232,7 @@ The full, auto-generated prop tables are on each component's documentation page. # Install dependencies npm install # @mui/x-scheduler + build toolchain pip install -r requirements.txt +pip install --no-deps markdown2dash==0.1.2 # see requirements.txt for why # Build the JS bundle + regenerate the Python wrappers npm run build # webpack bundle + dash-generate-components → dash_mui_scheduler/*.py diff --git a/assets/favicon/site.webmanifest b/assets/favicon/site.webmanifest new file mode 100644 index 0000000..f803323 --- /dev/null +++ b/assets/favicon/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "dash-mui-scheduler — MUI X scheduling for Dash", + "short_name": "MUI Scheduler", + "description": "Event calendar, resource timeline & radial chart components for Plotly Dash, wrapping the MUI X Scheduler.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3399ff", + "icons": [ + { + "src": "/assets/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/components/appshell.py b/components/appshell.py index 73b36b6..3cbdc0e 100644 --- a/components/appshell.py +++ b/components/appshell.py @@ -56,7 +56,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 @@ -324,4 +324,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/docs/quickstart/video.py b/docs/quickstart/video.py index 79b65b6..27f22ec 100644 --- a/docs/quickstart/video.py +++ b/docs/quickstart/video.py @@ -5,7 +5,9 @@ component = html.Div( html.Div( html.Iframe( - src="https://www.youtube.com/embed/i-CZH7W5ZsA", + # youtube-nocookie, matching the landing-page embed (pages/home.py): + # no tracking cookies are set until the reader presses play. + src="https://www.youtube-nocookie.com/embed/i-CZH7W5ZsA", title="dash-mui-scheduler walkthrough", style={ "position": "absolute", diff --git a/lib/ad_client.py b/lib/ad_client.py index 97b146d..fba7266 100644 --- a/lib/ad_client.py +++ b/lib/ad_client.py @@ -41,7 +41,10 @@ logger = logging.getLogger(__name__) AD_SERVER_URL = os.environ.get("AD_SERVER_URL", "https://2plot.dev").rstrip("/") -APP_ID = os.environ.get("AD_APP_ID", "dash-mui-scheduler") +# ONE short app id, network-wide: the directory key (the subdomain slug). +# AD_APP_ID, SATELLITE_APP_KEY and bulletin app_id all converge on it — +# tests/test_internal_traffic.py pins the three together. +APP_ID = os.environ.get("AD_APP_ID", "muischeduler") _TIMEOUT = 2 # seconds per fetch — never stall a page view longer _COOLDOWN = 60 # seconds to skip fetches after a failure @@ -63,10 +66,16 @@ def fetch_ad(page: str) -> dict | None: if time.time() - _last_failure < _COOLDOWN: return None try: + from lib.constants import internal_ua + resp = _session.get( f"{AD_SERVER_URL}/api/ad-network/serve", params={"app": APP_ID, "page": page}, timeout=_TIMEOUT, + # Internal-traffic contract: a bare python-requests UA would be + # classified as a bot by the hub's tracker, inflating its numbers + # with one fake bot hit per page view here. + headers={"User-Agent": internal_ua("ad-client")}, ) if resp.status_code == 200 and resp.content: return resp.json() diff --git a/lib/analytics_tracker.py b/lib/analytics_tracker.py index c29d42b..3199c50 100644 --- a/lib/analytics_tracker.py +++ b/lib/analytics_tracker.py @@ -11,7 +11,6 @@ import os from pathlib import Path from datetime import datetime -import re import requests from functools import lru_cache @@ -149,6 +148,10 @@ def detect_bot_type(self, user_agent): @lru_cache(maxsize=1000) def get_geolocation(self, ip_address): """Get geolocation data from IP address using ip-api.com (free service).""" + # Tests and CI must never depend on a third-party geo API being up. + if os.getenv("ANALYTICS_GEO_LOOKUP", "1") == "0": + return None + # Skip local/private IPs if not ip_address or ip_address in ['127.0.0.1', 'localhost', '::1']: return None @@ -190,6 +193,19 @@ def track_visit(self, path, user_agent, ip_address=None, auth_name=None, """Track a visitor. auth_name (the verified Clerk display name, when the caller resolved one) stamps the hit as authenticated. country is the edge-supplied CF-IPCountry code, when the request carried one.""" + # The network's internal-traffic contract: hub health sweeps, CI smoke + # batteries and satellite-to-satellite calls identify themselves with + # INTERNAL_UA_TOKEN in the User-Agent. Dropped at WRITE time — before + # device detection and before bot classification — so machinery talking + # to itself never reaches the ledger the hourly rollup is built from. + from lib.constants import INTERNAL_UA_TOKEN + if user_agent and INTERNAL_UA_TOKEN.lower() in user_agent.lower(): + return + + # /healthz is a liveness probe, never a visit. + if path.startswith('/healthz'): + return + # Skip internal Dash paths and static assets skip_paths = [ '.css', '.js', '.png', '.jpg', '.ico', '.svg', '.woff', '.woff2', '.ttf', '.eot', @@ -241,7 +257,7 @@ def track_visit(self, path, user_agent, ip_address=None, auth_name=None, try: with open(self.data_file, 'r') as f: data = json.load(f) - except: + except Exception: data = {"visits": [], "stats": {"desktop": 0, "mobile": 0, "tablet": 0, "bot": 0, "total": 0}} # Add visit diff --git a/lib/asgi_middleware.py b/lib/asgi_middleware.py index c4d0d97..ba12b57 100644 --- a/lib/asgi_middleware.py +++ b/lib/asgi_middleware.py @@ -8,10 +8,12 @@ from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import Response +from starlette.responses import RedirectResponse, Response from lib.analytics_tracker import resolve_client_ip, resolve_country, tracker from lib.auth import identify_request_user +from lib.canonical_host import canonical_redirect +from lib.social_cards import is_social_card class AnalyticsMiddleware(BaseHTTPMiddleware): @@ -43,30 +45,59 @@ class SocialCardMiddleware(BaseHTTPMiddleware): """Serve the full og:image HTML to social-card scrapers (Twitter/FB/Discord/…), which ``add_llms_routes`` would otherwise hand its image-less SEO HTML. Mirrors the Flask wsgi wrap in ``run.py``. Added LAST → outermost → pre-empts the package. + + ``renderer`` is a ``lib.social_cards.SocialCardRenderer`` — a callable that + builds the card for the requested path, so each URL unfurls as itself. """ - def __init__(self, app, social_html: str = "", social_uas=()) -> None: + def __init__(self, app, renderer=None, social_uas=()) -> None: super().__init__(app) - self._html = social_html or "" + self._render = renderer self._uas = tuple(social_uas) async def dispatch(self, request: Request, call_next) -> Response: - ua = (request.headers.get("user-agent", "") or "").lower() path = request.url.path - # method-aware: scrapers only GET/HEAD — spoofed-UA POSTs (e.g. to the - # Svix-verified /webhooks/clerk) must reach the real handlers. - if (self._html and self._uas and request.method in ("GET", "HEAD") - and any(b in ua for b in self._uas) - and not path.startswith("/assets") and not path.startswith("/_") - and not path.startswith("/webhooks")): - return Response(self._html, media_type="text/html; charset=utf-8") + if self._render and self._uas and is_social_card( + request.headers.get("user-agent"), path, request.method + ): + return Response(self._render(path), media_type="text/html; charset=utf-8") + return await call_next(request) + + +class CanonicalHostMiddleware(BaseHTTPMiddleware): + """301 every non-canonical host to the canonical one. See lib/canonical_host.""" + + def __init__(self, app, canonical_host: str = "", enabled: bool = False) -> None: + super().__init__(app) + self._host = canonical_host + self._enabled = enabled + + async def dispatch(self, request: Request, call_next) -> Response: + target = canonical_redirect( + request.headers.get("host"), + request.url.path, + request.method, + request.url.query, + canonical_host=self._host, + enabled=self._enabled, + ) + if target: + return RedirectResponse(target, status_code=301) return await call_next(request) -def register_asgi_middleware(app, social_html: str = None, social_uas=()) -> None: +def register_asgi_middleware(app, social_renderer=None, social_uas=(), + canonical_host="", canonical_redirect_enabled=False) -> None: """Attach all ASGI middleware to ``app.server`` (a FastAPI instance). Starlette runs middleware in REVERSE add-order, so the LAST added is OUTERMOST: analytics → social - (the social-card shim sees the request first on the way in).""" + → canonical host. The host redirect must be outermost of all — a request on the + wrong host should be sent away before anything renders a page for it.""" app.server.add_middleware(AnalyticsMiddleware) - if social_html: - app.server.add_middleware(SocialCardMiddleware, social_html=social_html, social_uas=social_uas) + if social_renderer: + app.server.add_middleware( + SocialCardMiddleware, renderer=social_renderer, social_uas=social_uas + ) + if canonical_redirect_enabled: + app.server.add_middleware( + CanonicalHostMiddleware, canonical_host=canonical_host, enabled=True + ) diff --git a/lib/asgi_routes.py b/lib/asgi_routes.py index f54c763..70f374b 100644 --- a/lib/asgi_routes.py +++ b/lib/asgi_routes.py @@ -51,6 +51,7 @@ class PageListResponse(BaseModel): class HealthResponse(BaseModel): ok: bool = True + app: str = "" backend: str dash_version: str @@ -102,8 +103,11 @@ def build_health_router() -> APIRouter: @router.get("/healthz", response_model=HealthResponse, summary="Liveness probe") def healthz() -> HealthResponse: + from lib.traffic_report import app_key + return HealthResponse( ok=True, + app=app_key(), backend="fastapi", dash_version=dash.__version__, ) diff --git a/lib/auth.py b/lib/auth.py index 63cd516..a88bbb4 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -63,7 +63,7 @@ def clerk_enabled(): "https://cast.2plot.net", "https://2plot.dev", "https://2plot.me", "https://2plot.world", "https://2plot.shop", - # scheduler-docs origin added here once it has a domain + "https://muischeduler.2plot.dev", # these docs ) diff --git a/lib/bulletin.py b/lib/bulletin.py new file mode 100644 index 0000000..46b156a --- /dev/null +++ b/lib/bulletin.py @@ -0,0 +1,82 @@ +"""Network bulletin — hub-published tips and announcements. + +The hub (2plot.dev) serves one JSON document at ``/api/network/bulletin`` and +every satellite renders it in the header of its llms.txt viewer — the network +says "here is what changed" once, in one place, instead of in a dozen +repositories that immediately drift. + +The wiring is a function that returns whether it wired, ``run.py`` prints +that, and ``tests/test_bulletin.py`` exercises it directly — no commented-out +code, and a boot log line that says which of the two states you are in. (The +boilerplate learned this the hard way: four commented-out lines in run.py and +an env var set in production against code that never read it. Nothing failed; +the announcement just never appeared.) + +NOTE: ``NETWORK_BULLETIN_URL`` must be set on the Render SERVICE, not only in +render.yaml — blueprint ``envVars`` apply on Blueprint sync, not on git-push +autodeploys. Detection: this satellite showing ONE generic tip where the hub +publishes more is an unwired bulletin, not a styling difference. + +Env: + NETWORK_BULLETIN_URL the hub endpoint. Absent -> feature off, silently. + NETWORK_BULLETIN_TTL_S seconds a cached bulletin stays fresh (default 900) +""" + +from __future__ import annotations + +import os +from typing import Optional + +DEFAULT_TTL_S = 900.0 + +# The hub endpoint. Not a default — `configure()` requires the env var to be +# set, because a satellite that silently starts calling a hub it was never +# pointed at is a surprise. This constant is the one place render.yaml and +# .env docs copy from. +HUB_BULLETIN_URL = "https://2plot.dev/api/network/bulletin" + + +def url() -> Optional[str]: + return os.environ.get("NETWORK_BULLETIN_URL") or None + + +def _ttl() -> float: + try: + return max(60.0, float(os.environ.get("NETWORK_BULLETIN_TTL_S", + DEFAULT_TTL_S))) + except (TypeError, ValueError): + return DEFAULT_TTL_S + + +def app_id() -> str: + """This app's key in the hub's network directory. + + Reused from ``lib.traffic_report`` rather than hard-coded, so a deployment + that sets ``SATELLITE_APP_KEY`` for its traffic rollups is automatically + identified the same way here. tests/test_internal_traffic.py pins this, + ``ad_client.APP_ID`` and the reporter key to the one short id. + """ + from lib.traffic_report import app_key + + return app_key() + + +def configure() -> bool: + """Point the package at the hub's bulletin. Returns whether it did. + + Fail-open in both directions: with no URL the feature is off and the + viewer header renders the package's defaults; with an unreachable URL the + package's client degrades silently — a hub outage must not take the + documentation down with it. + """ + endpoint = url() + if not endpoint: + return False + + try: + from dash_improve_my_llms import configure_bulletin + except ImportError: # pragma: no cover - older releases lack the feature + return False + + configure_bulletin(url=endpoint, ttl=_ttl(), app_id=app_id()) + return True diff --git a/lib/canonical_host.py b/lib/canonical_host.py new file mode 100644 index 0000000..69d1c6b --- /dev/null +++ b/lib/canonical_host.py @@ -0,0 +1,55 @@ +"""Redirect every non-canonical host to the one canonical origin. + +The docs are reachable at more than one address — the Render service's own +``*.onrender.com`` URL never stops working once a custom domain is attached. Two +hosts serving byte-identical pages is duplicate content: search engines pick a +winner per URL, links split between the two, and the ``rel=canonical`` we emit +is only a *hint*. A 301 is the instruction. + +Off by default. ``CANONICAL_HOST_REDIRECT`` must be set to turn it on, because +enabling it before the custom domain's DNS actually resolves would bounce every +visitor to a dead host. Order of operations is in render.yaml. +""" +from __future__ import annotations + +# Never redirected: +# /healthz — Render's health check; a 3xx there fails the deploy. +# /assets, /_dash* — static + the renderer's own XHR; an extra hop per asset +# buys nothing, and a redirected POST would lose its body. +# /webhooks, /api — signed/programmatic callers that address a fixed URL. +_EXEMPT_PREFIXES = ("/healthz", "/assets", "/_dash", "/_reload", "/_favicon", + "/webhooks", "/api/") + +# Local development and in-process test clients are never "the wrong host". +_LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "[::1]", "testserver") + + +def canonical_redirect( + host: str | None, + path: str, + method: str = "GET", + query: str = "", + *, + canonical_host: str, + enabled: bool, +) -> str | None: + """Return the absolute URL to 301 to, or None to serve the request normally. + + ``host`` is the request's Host header (``example.com`` or ``example.com:443``). + """ + if not enabled or not canonical_host or not host: + return None + if method not in ("GET", "HEAD"): + return None + + hostname = host.split(":", 1)[0].strip().lower() + if not hostname or hostname == canonical_host.lower(): + return None + if hostname in _LOCAL_HOSTS or hostname.endswith(".local"): + return None + + path = path or "/" + if any(path.startswith(prefix) for prefix in _EXEMPT_PREFIXES): + return None + + return f"https://{canonical_host}{path}" + (f"?{query}" if query else "") diff --git a/lib/constants.py b/lib/constants.py index 4601464..7f6be03 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -1,8 +1,144 @@ -PAGE_TITLE_PREFIX = "dash-mui-scheduler | " +import os as _os + +# --------------------------------------------------------------------------- +# Site identity — one string, every surface (2plot network standard) +# --------------------------------------------------------------------------- +# The brand reaches: Dash(title=SITE_BRAND), register_page_metadata(path="/", +# name=SITE_BRAND) (→ the /llms.txt H1 and the viewer brand chip via +# dash-improve-my-llms ≥2.3.4 resolve_site_title), templates/index.html's +# <title>, and the home page prose. tests/test_site_identity.py pins them all +# to this constant — the failure mode is silent (a viewer chip reading a bare +# "Dash") so only a test catches it. +# +# Library rule: the PACKAGE NAME comes first in the brand (people install it); +# "Pip Install Python" is the byline, never part of the brand. +SITE_BRAND = "dash-mui-scheduler — MUI X scheduling for Dash" + +SITE_DESCRIPTION = ( + "Event calendar, resource timeline & radial chart components for Plotly " + "Dash, wrapping the MUI X Scheduler — EventCalendar, EventCalendarPremium, " + "EventTimeline, RadialLineChart and RadialBarChart, with recurrence, " + "drag & resize, resources, timezones and theming. By Pip Install Python." +) + +# The brand without its tagline — for surfaces that prefix something else and +# would otherwise run past platform truncation points. +SITE_SHORT_NAME = "dash-mui-scheduler" + +# Prefixed to every per-page title. Dash passes page titles straight into +# og:title / twitter:title, so this is the headline on every share card. +# Derived, not retyped, so the two can't drift (test_site_identity pins it). +PAGE_TITLE_PREFIX = f"{SITE_SHORT_NAME} | " # App accent: a blue palette ("brand") anchored on rgb(51,153,255) = #3399ff, # defined in components/appshell.py theme.colors. Set back to "teal" to revert. PRIMARY_COLOR = "brand" -APP_VERSION = "0.1.0" + +# Read from package.json — the same file setup.py takes the version from, so the +# site, the wheel, and the JSON-LD in templates/index.html cannot drift apart. +try: + import json as _json + from pathlib import Path as _Path + + APP_VERSION = _json.loads( + (_Path(__file__).resolve().parent.parent / "package.json").read_text() + ).get("version", "0.0.0") +except Exception: # pragma: no cover - never break startup over a version string + APP_VERSION = "0.0.0" + +# --------------------------------------------------------------------------- +# Canonical origin. ONE source of truth for every absolute URL the site emits: +# canonical links, og:url, og:image, sitemap.xml, robots.txt, llms.txt and the +# JSON-LD @ids. Env-driven (APP_BASE_URL, see render.yaml) so the host can move +# without a code change; the default below is the live public address. +# +# The Render service's own dash-mui-scheduler-docs.onrender.com URL keeps +# serving the same site forever — lib/canonical_host.py 301s it here so the two +# don't compete as duplicates. +# --------------------------------------------------------------------------- +BASE_URL = _os.getenv("APP_BASE_URL", "https://muischeduler.2plot.dev").rstrip("/") + +# Hostname only — what lib/canonical_host.py compares the Host header against. +CANONICAL_HOST = BASE_URL.split("//", 1)[-1].split("/", 1)[0] + +# Send every other host here with a 301. OFF unless CANONICAL_HOST_REDIRECT is +# set: switching it on before the custom domain's DNS resolves would bounce +# every visitor to a host that isn't answering yet. +CANONICAL_HOST_REDIRECT = _os.getenv("CANONICAL_HOST_REDIRECT", "").strip().lower() in ( + "1", "true", "yes", "on", +) + +# --------------------------------------------------------------------------- +# The social card (2plot network standard) +# --------------------------------------------------------------------------- +# The card lives on the CDN, NOT in assets/: a scraper fetching from a cold +# free-tier container times out once and the platform caches the miss forever. +# Rendered by scripts/make_social_card.py (1200x630 — the Open Graph ideal) +# and uploaded BY HAND to the Cloudflare bucket. HARD GATE: never deploy code +# whose og:image points at this URL until the object answers 200 with a +# 1200x630 IHDR — scripts/smoke_live.py checks the real pixels after every +# deploy, and fails while it 404s, deliberately. +# +# image_url=OG_IMAGE_URL and description= go at EVERY register_page: one +# missing and Dash emits content="" — and the empty tag, later in document +# order, is the one scrapers take. +OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/muischeduler.2plot.dev.png" +OG_IMAGE_WIDTH = 1200 +OG_IMAGE_HEIGHT = 630 +OG_IMAGE_TYPE = "image/png" +OG_IMAGE_ALT = SITE_BRAND + +# --------------------------------------------------------------------------- +# The network's internal-traffic contract +# --------------------------------------------------------------------------- +# Any request whose User-Agent contains INTERNAL_UA_TOKEN is 2plot machinery +# talking to itself (hub health sweeps, CI smoke batteries, this app's own +# calls to the hub) and is counted NOWHERE. Two halves, both required: +# inbound — lib/analytics_tracker drops token-carrying hits at WRITE time, +# before device detection and bot classification; +# outbound — every call this host makes to another network host sends +# internal_ua(...), so the far side can apply the same rule. +# The token must stay byte-identical across the network (mirrors +# pip-docs+/lib/constants.py and the boilerplate). +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"``) for the far + side's logs. Only the token matters to the contract.""" + caller = (caller or "").strip() + return f"{INTERNAL_UA} {caller}" if caller else INTERNAL_UA + + +def require_owned_base_url(base_url: str = BASE_URL) -> None: + """Fail fast in production when BASE_URL isn't this app's real origin. + + Only enforced when a hosting platform is detected (Render sets ``RENDER``; + ``APP_ENV=production`` works anywhere else) so local runs and the test + suite are unaffected. Catches APP_BASE_URL unset (the canonical would + advertise whatever the default says) and platform-generated hostnames + (``*.onrender.com`` still resolves after the custom domain attaches, and a + canonical pointing there splits link equity across two hosts). + """ + in_production = bool( + _os.environ.get("RENDER") or _os.environ.get("APP_ENV") == "production" + ) + if not in_production: + return + if not _os.environ.get("APP_BASE_URL"): + raise RuntimeError( + "APP_BASE_URL is not set. Canonical links, sitemap.xml and llms.txt " + f"would all claim {base_url!r}. Set APP_BASE_URL to this " + "deployment's real origin (e.g. https://muischeduler.2plot.dev)." + ) + for platform_host in ("onrender.com", "herokuapp.com", "railway.app", "fly.dev"): + if platform_host in base_url: + raise RuntimeError( + f"APP_BASE_URL={base_url!r} is a platform-generated hostname. " + "Set APP_BASE_URL to the public domain so canonicals point at " + "one host." + ) + # Populated by pages/markdown.py when loading documentation files (raw markdown # keyed by page name) — used by the "copy for LLM" button directive. diff --git a/lib/directives/source.py b/lib/directives/source.py index 76a06dd..a3d73bd 100644 --- a/lib/directives/source.py +++ b/lib/directives/source.py @@ -29,4 +29,4 @@ 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/health.py b/lib/health.py new file mode 100644 index 0000000..f5a5586 --- /dev/null +++ b/lib/health.py @@ -0,0 +1,52 @@ +"""``/healthz`` liveness probe for the Flask backend. + +The 2plot.ai hub sweeps every satellite's ``/healthz`` once an hour and +records up/down + latency — the "Satellite health & reach" panel on +``/traffic``. The battery (scripts/network_smoke.py) and the CD deploy gate +both assert the exact field ``ok: true``; a 200 with different JSON reads as +"unhealthy" to them, deliberately. + +The FastAPI build already declares a typed ``/healthz`` in +``lib/asgi_routes`` (it shows up in Swagger); this module gives the flask +backend the same endpoint so the probe result doesn't depend on which backend +a deployment happens to run. Keep it cheap: the hub measures the round trip. +""" +from __future__ import annotations + +import dash + + +def health_payload(backend: str) -> dict: + from lib.traffic_report import app_key + + return { + "ok": True, + "app": app_key(), + "backend": backend, + "dash_version": dash.__version__, + } + + +def register_health_route(app, backend: str) -> None: + """Mount ``/healthz`` on flask. No-op on FastAPI (already typed there).""" + if backend == "fastapi": + return + + server = app.server + payload = health_payload(backend) + + if backend == "quart": + from quart import jsonify + + @server.get("/healthz") + async def _healthz(): # pragma: no cover — quart runtime + return jsonify(payload) + else: + from flask import jsonify + + @server.get("/healthz") + def _healthz(): + return jsonify(payload) + + print(f"[dash-mui-scheduler] /healthz registered ({backend}) — " + "the 2plot.ai hourly health sweep probes this path.") diff --git a/lib/network_directory.py b/lib/network_directory.py new file mode 100644 index 0000000..701b46d --- /dev/null +++ b/lib/network_directory.py @@ -0,0 +1,221 @@ +"""Cross-host directory for the 2plot network — one definition, every satellite. + +Why this file exists +-------------------- +Search engines follow links between hosts weakly; agents don't follow them at +all. A model answering "what does this ecosystem provide?" fetches one or two +URLs and reasons from what came back. Landing on ``leaflet.2plot.dev`` it sees +one library, with nothing in the markup saying the other eleven hosts exist. +``sitemap.xml`` cannot fix that — a sitemap is scoped to its own origin by +design — so ``dash-improve-my-llms`` 2.1 emits an explicit machine-readable +directory instead: ``<link rel="related">`` tags in ``<head>``, a ``## Network`` +section in ``/llms.txt``, and followed links in the prerendered body. + +Keep the definition **here**, in the template, and import it. Twelve +hand-maintained copies of the same peer list will drift, and a directory that +disagrees with itself across hosts is worse than no directory at all. + +Three tiers, and the distinction is load-bearing: + +``PEERS`` + Same network, same operator. These build the cross-host graph you own. +``AFFILIATED`` + Yours, on unrelated domains. Findable when asked "what else did you + build?" without being swept into "what is the 2plot network?". +``EXTERNAL`` + Third-party docs you reference but don't own. Emitted ``rel="nofollow"`` + — references, not endorsements. + +Usage in a satellite's ``run.py``, before ``add_llms_routes(app)``:: + + from lib.constants import BASE_URL + from lib import network_directory + + app._base_url = BASE_URL + network_directory.apply(BASE_URL) +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +# Only list hosts that are actually live. A directory entry pointing at a +# subdomain with no site is a dead link an agent will follow once and then +# distrust the rest of the list for. muicharts.2plot.dev and +# flexlayout.2plot.dev have no docs site yet — add them in the same change +# that ships them, not before. +# +# --------------------------------------------------------------------------- +# DIVERGENCE FROM boilerplate.2plot.dev — deliberate, not drift. +# +# Verified by request on 2026-07-31, not by reading a status table: +# +# pannellum.2plot.dev NXDOMAIN <- listed in the boilerplate's copy +# emojimart.2plot.dev NXDOMAIN <- listed in the boilerplate's copy +# muischeduler.2plot.dev 200 (hub still says "shipping") +# flows.2plot.dev 200 (hub still says "shipping") +# leaflet.2plot.dev 200 +# boilerplate.2plot.dev 200 +# llms.2plot.dev 503 spin-up, live per the hub <- ABSENT upstream +# +# So the two dead entries are dropped here and llms.2plot.dev is added. The +# real fix belongs in the boilerplate, because that copy propagates to every +# satellite; restore this file to a straight copy once it lands there. +# --------------------------------------------------------------------------- +PEERS: List[Dict[str, str]] = [ + { + "name": "2plot.ai", + "url": "https://2plot.ai", + "description": "Network hub and account origin.", + }, + { + "name": "2plot.dev", + "url": "https://2plot.dev", + "description": "Package index for every open-source component in the network.", + }, + { + "name": "Documentation boilerplate", + "url": "https://boilerplate.2plot.dev", + "description": "The markdown-driven documentation template every satellite site is built from.", + }, + { + "name": "dash-leaflet2", + "url": "https://leaflet.2plot.dev", + "description": "Leaflet 2 maps as Dash components.", + }, + { + "name": "dash-mui-scheduler", + "url": "https://muischeduler.2plot.dev", + "description": "MUI X Scheduler — calendars and event scheduling for Dash.", + }, + { + "name": "dash-flows", + "url": "https://flows.2plot.dev", + "description": "Node-graph editors built on React Flow.", + }, + { + "name": "dash-improve-my-llms", + "url": "https://llms.2plot.dev", + "description": "The AI/LLM and SEO package every site in this network is built on.", + }, + { + "name": "dash-email", + "url": "https://email.2plot.dev", + "description": "Email composition and delivery components.", + }, + # dash-pannellum (pannellum.2plot.dev) and dash-emoji-mart + # (emojimart.2plot.dev) belong here the day their DNS resolves. Both are + # NXDOMAIN as of 2026-07-31 — see the note above. +] + +AFFILIATED: List[Dict[str, str]] = [ + { + "name": "Pip Install Python", + "url": "https://pip-install-python.com", + "description": "The original component documentation site.", + }, + { + "name": "Pirate's Bargain", + "url": "https://piratesbargain.com", + "description": "Deal aggregator built on the same Dash stack.", + }, + { + "name": "ai-agent.buzz", + "url": "https://ai-agent.buzz", + "description": "Agent tooling directory.", + }, +] + +EXTERNAL: List[Dict[str, Any]] = [ + { + "name": "Dash Mantine Components", + "url": "https://www.dash-mantine-components.com", + "description": "The UI component layer these docs are built with.", + "llms_txt": "https://www.dash-mantine-components.com/llms.txt", + }, + { + "name": "Plotly Dash documentation", + "url": "https://dash.plotly.com", + "description": "Upstream framework documentation.", + }, +] + +NETWORK_NAME = "The 2plot network" +NETWORK_DESCRIPTION = ( + "Open-source Dash component libraries by Pip Install Python. Each component " + "has its own documentation site and its own llms.txt; 2plot.dev indexes all " + "of them, and 2plot.ai is the hub." +) +HUB_URL = "https://2plot.dev" + +# The mark drawn in the header of the rendered llms.txt view: "2" + morse +# encoding of "plot" + "ai", as columns of dots and dashes. +# +# No period glyph between the halves — the morse block already separates them, +# and a literal "." next to it reads as punctuation dropped into a graphic. +# The renderer turns a suffix ending in "i" into an upward flourish, so "ai" +# draws as "a" plus that mark; `label` carries the real domain for screen +# readers and the SVG <title>, which is the only place the dot belongs. +# +# Defined here rather than per-app because this module is copied verbatim into +# every satellite — that is what keeps one mark across the network instead of +# twelve slightly different ones. +WORDMARK = { + "morse": "plot", + "prefix": "2", + "suffix": "ai", + "label": "2plot.ai", +} + + +def peers_for(app_url: str) -> List[Dict[str, str]]: + """`PEERS` with this app removed. + + A site listing itself as its own peer reads as generated rather than + curated, and it wastes a slot in a list an agent may only skim. + """ + own = app_url.rstrip("/") + return [p for p in PEERS if p["url"].rstrip("/") != own] + + +def apply(app_url: str) -> None: + """Publish the directory for the app served at ``app_url``. + + Degrades rather than fails on older releases of the package. A satellite + pinned behind this file should still boot: losing the directory, or losing + the wordmark, is a degradation — refusing to start is not. + + That matters during a staged rollout, when this module reaches satellites + before the new package does. ``register_network`` arrived in 2.1 and its + ``wordmark`` argument in 2.2, and Python raises ``TypeError`` on an unknown + keyword, so the argument is only passed when the installed signature + actually accepts it. + """ + try: + from dash_improve_my_llms import register_network + except ImportError: # pragma: no cover - only on <2.1 + import warnings + + warnings.warn( + "dash-improve-my-llms is older than 2.1, so the cross-host network " + "directory will not be published. Upgrade to publish it.", + RuntimeWarning, + stacklevel=2, + ) + return + + import inspect + + extra: Dict[str, Any] = {} + if "wordmark" in inspect.signature(register_network).parameters: + extra["wordmark"] = WORDMARK + + register_network( + name=NETWORK_NAME, + description=NETWORK_DESCRIPTION, + hub_url=HUB_URL, + peers=peers_for(app_url), + affiliated=AFFILIATED, + external=EXTERNAL, + **extra, + ) diff --git a/lib/social_cards.py b/lib/social_cards.py new file mode 100644 index 0000000..b1a8e3b --- /dev/null +++ b/lib/social_cards.py @@ -0,0 +1,129 @@ +"""Link-unfurl (social card) HTML for scrapers. + +Why this exists: ``add_llms_routes`` classifies Twitter/Facebook/Discord/Slack +crawlers as bots and hands them the package's prerendered SEO HTML, which has no +``og:image`` — so every shared link unfurled as a bare text row. These shims +serve those scrapers the site's own ``templates/index.html`` head instead. + +Scrapers never run JavaScript, so the Dash placeholders are stripped and the +per-page ``og``/``twitter`` block that Dash would have emitted at ``{%metas%}`` +is rendered here from ``dash.page_registry`` for the requested path. That keeps +the card per-page (right title, right description, right URL) rather than +describing the site on every link. +""" +from __future__ import annotations + +# Scrapers only ever GET/HEAD. Matched case-insensitively against the UA. +SOCIAL_UAS = ( + "twitterbot", "facebookexternalhit", "facebookcatalog", "discordbot", + "slackbot", "slack-imgproxy", "linkedinbot", "whatsapp", "telegrambot", + "pinterest", "redditbot", "skypeuripreview", "embedly", "iframely", +) + +# Placeholders Dash would fill. Scrapers read <head> meta only, so everything +# except {%metas%} (replaced with the card block) is simply dropped. +_DASH_PLACEHOLDERS = ( + "{%favicon%}", "{%css%}", "{%app_entry%}", "{%config%}", "{%scripts%}", + "{%renderer%}", "{%title%}", +) + +# Dimensions/type/alt come from lib.constants' OG block at render time so the +# scraper card can never disagree with what the SPA shell declares. 1200x630 → +# twitter:card=summary_large_image (the wide slot, no letterboxing). +_CARD = """ + <meta name="description" content="{description}"> + <meta property="og:type" content="website"> + <meta property="og:title" content="{title}"> + <meta property="og:description" content="{description}"> + <meta property="og:image" content="{image}"> + <meta property="og:image:secure_url" content="{image}"> + <meta property="og:image:type" content="{image_type}"> + <meta property="og:image:width" content="{image_width}"> + <meta property="og:image:height" content="{image_height}"> + <meta property="og:image:alt" content="{image_alt}"> + <meta property="twitter:card" content="summary_large_image"> + <meta property="twitter:title" content="{title}"> + <meta property="twitter:description" content="{description}"> + <meta property="twitter:image" content="{image}"> + <meta property="twitter:image:alt" content="{image_alt}"> +""" + + +def is_social_card(ua: str | None, path: str, method: str = "GET") -> bool: + """True when this request is a social-card scraper fetching a page. + + Method-aware on purpose: a spoofed-UA POST must fall through to the real + handlers (e.g. the Svix-verified ``/webhooks/clerk``). + """ + ua = (ua or "").lower() + return ( + method in ("GET", "HEAD") + and any(bot in ua for bot in SOCIAL_UAS) + and not path.startswith("/assets") + and not path.startswith("/_") + and not path.startswith("/webhooks") + ) + + +def _escape(text: str) -> str: + return ( + str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +class SocialCardRenderer: + """Render the scraper-facing ``<head>`` for a given request path.""" + + def __init__(self, template: str, base_url: str, image_url: str, + fallback_title: str, fallback_description: str) -> None: + self._base_url = base_url.rstrip("/") + self._image_url = image_url + self._fallback = (fallback_title, fallback_description) + + html = template + for placeholder in _DASH_PLACEHOLDERS: + html = html.replace(placeholder, "") + self._template = html # still holds {%metas%} and __PAGE_URL__ + + def _page_meta(self, path: str) -> tuple[str, str]: + try: + import dash + + for entry in (dash.page_registry or {}).values(): + if entry.get("path") == path: + title = entry.get("title") or self._fallback[0] + description = entry.get("description") or self._fallback[1] + return ( + title() if callable(title) else title, + description() if callable(description) else description, + ) + except Exception: + pass + return self._fallback + + def __call__(self, path: str) -> str: + from lib.constants import ( + OG_IMAGE_ALT, OG_IMAGE_HEIGHT, OG_IMAGE_TYPE, OG_IMAGE_WIDTH, + ) + + path = "/" + (path or "/").strip("/") + title, description = self._page_meta(path) + card = _CARD.format( + title=_escape(title), + description=_escape(description), + image=self._image_url, + image_type=OG_IMAGE_TYPE, + image_width=OG_IMAGE_WIDTH, + image_height=OG_IMAGE_HEIGHT, + image_alt=_escape(OG_IMAGE_ALT), + ) + page_url = self._base_url + (path if path != "/" else "/") + return ( + self._template + .replace("{%metas%}", card) + .replace("__PAGE_URL__", page_url) + ) diff --git a/lib/traffic_report.py b/lib/traffic_report.py index b1aefca..096f954 100644 --- a/lib/traffic_report.py +++ b/lib/traffic_report.py @@ -58,7 +58,16 @@ HUB_TRAFFIC_URL = os.environ.get( "HUB_TRAFFIC_URL", "https://2plot.ai/api/satellite/traffic") -APP_KEY = os.environ.get("SATELLITE_APP_KEY", "scheduler") + + +def app_key() -> str: + """This app's ONE short id on every hub surface: its network-directory + key, the subdomain slug. AD_APP_ID and bulletin app_id converge on the + same value (tests/test_internal_traffic.py pins them together).""" + return os.environ.get("SATELLITE_APP_KEY") or "muischeduler" + + +APP_KEY = app_key() SESSION_GAP_MIN = 30 # the hub's session rule — keep it identical _TIMEOUT = 10 # seconds per POST @@ -217,9 +226,14 @@ def post_rollup(rollup: dict) -> bool: sig = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest() try: + from lib.constants import internal_ua + resp = requests.post( HUB_TRAFFIC_URL, data=body, timeout=_TIMEOUT, headers={"Content-Type": "application/json", + # Internal-traffic contract: the hub must never count + # this machinery POST as a visit or a bot hit. + "User-Agent": internal_ua("traffic-report"), "X-AI-Canvas-Timestamp": ts, "X-AI-Canvas-Signature": sig}) except Exception as exc: diff --git a/package.json b/package.json index f9358b2..69dd709 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dash_mui_scheduler", - "version": "0.1.0", + "version": "0.1.1", "description": "Dash components wrapping MUI X Scheduler — Event Calendar (Community & Premium) and Event Timeline", "main": "build/index.js", "repository": { diff --git a/pages/home.py b/pages/home.py index 4b0f0d0..77e536e 100644 --- a/pages/home.py +++ b/pages/home.py @@ -3,19 +3,20 @@ from dash import register_page, html from dash_iconify import DashIconify +from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX, SITE_DESCRIPTION + register_page( __name__, path="/", - title="dash-mui-scheduler — MUI X Scheduler for Plotly Dash", - description=( - "A Plotly Dash wrapper for the MUI X Scheduler: EventCalendar, " - "EventCalendarPremium and EventTimeline plus the RadialLineChart and " - "RadialBarChart polar charts, " - "with recurrence, drag & resize, resources, timezones and theming." - ), + name="Home", + title=PAGE_TITLE_PREFIX + "Home", + description=SITE_DESCRIPTION, + # Pins og:image/twitter:image to the CDN card (see lib/constants). + image_url=OG_IMAGE_URL, ) ACCENT = "#3399ff" +VIDEO_ID = "i-CZH7W5ZsA" _FEATURES = [ ("tabler:calendar-event", "Event Calendar", @@ -33,6 +34,67 @@ ] +def _walkthrough(): + """The walkthrough video, embedded on the landing page. + + Same tour that the README links as a thumbnail and that Quickstart embeds — + here so a reader hitting the docs can watch it without leaving for GitHub. + Responsive 16:9: the outer box caps the width, the padding-bottom trick + holds the ratio at any screen size. youtube-nocookie keeps the landing page + from setting tracking cookies before anyone presses play. + """ + return dmc.Stack( + [ + dmc.Group( + [ + dmc.Title("Watch the walkthrough", order=3), + dmc.Anchor( + dmc.Group( + [DashIconify(icon="tabler:brand-youtube", width=18), + dmc.Text("Open on YouTube", size="sm")], + gap=6, align="center", + ), + href=f"https://youtu.be/{VIDEO_ID}", + target="_blank", underline="hover", + ), + ], + justify="space-between", align="center", wrap="nowrap", + ), + dmc.Text( + "A tour of the event calendar, the resource timeline, and the " + "radial charts.", + size="sm", c="dimmed", + ), + html.Div( + html.Iframe( + src=f"https://www.youtube-nocookie.com/embed/{VIDEO_ID}", + title="dash-mui-scheduler walkthrough", + style={ + "position": "absolute", + "top": 0, + "left": 0, + "width": "100%", + "height": "100%", + "border": 0, + "borderRadius": "8px", + }, + allow=( + "accelerometer; autoplay; clipboard-write; encrypted-media; " + "gyroscope; picture-in-picture; web-share; fullscreen" + ), + ), + style={ + "position": "relative", + "paddingBottom": "56.25%", # 16:9 + "height": 0, + "overflow": "hidden", + }, + ), + ], + gap="xs", mb="xl", style={"maxWidth": 820, "margin": "0 auto"}, + ) + + def _feature_card(icon, title, body, href): return dmc.Anchor( dmc.Card( @@ -80,6 +142,7 @@ def layout(**kwargs): ], gap="lg", py="xl", ), + _walkthrough(), dmc.SimpleGrid( [_feature_card(*f) for f in _FEATURES], cols={"base": 1, "sm": 2}, spacing="lg", mb="xl", diff --git a/pages/markdown.py b/pages/markdown.py index 3ff8315..fa225be 100644 --- a/pages/markdown.py +++ b/pages/markdown.py @@ -11,7 +11,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 PAGE_TITLE_PREFIX, NAME_CONTENT_MAP, OG_IMAGE_URL from lib.directives.kwargs import Kwargs from lib.directives.llms_copy import LlmsCopy from lib.directives.source import SC @@ -123,6 +123,9 @@ def _build_llms_doc(name: str, description: str, expanded_markdown: str, path: s layout=layout, category=metadata.category, icon=metadata.icon, + # Pins og:image/twitter:image to the canonical origin. Without it Dash + # infers assets/logo.svg — an SVG, which no social scraper renders. + image_url=OG_IMAGE_URL, ) # Feed the expanded markdown into dash-improve-my-llms so /<page>/llms.txt diff --git a/pages/not_found_404.py b/pages/not_found_404.py index 0755d58..0899ba4 100644 --- a/pages/not_found_404.py +++ b/pages/not_found_404.py @@ -6,7 +6,19 @@ import dash_mantine_components as dmc from dash import register_page -register_page(__name__, path="/404", title="Page not found · dash-mui-scheduler") +from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX + +register_page( + __name__, + path="/404", + name="Page not found", + title=PAGE_TITLE_PREFIX + "Page not found", + # Every register_page needs description= and image_url=: one missing and + # Dash emits an empty og tag — and the empty tag, later in document order, + # is the one scrapers take (network standard). + description="The page you were looking for isn't on the calendar.", + image_url=OG_IMAGE_URL, +) ACCENT = "#3399ff" 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/render.yaml b/render.yaml index 8925bd8..1b93eb2 100644 --- a/render.yaml +++ b/render.yaml @@ -1,7 +1,8 @@ # Render blueprint — dash-mui-scheduler docs (single web service, docker runtime). -# Auto-deploys on push to main. Serves the component documentation on the -# default *.onrender.com URL (no custom domain yet; add one later and update -# APP_BASE_URL). Clerk auth is OFF at launch — no CLERK_* env vars → the auth +# Auto-deploys on push to main. Serves the component documentation at +# https://muischeduler.2plot.dev (custom domain attached in the Render +# dashboard; the service's own *.onrender.com URL 301s there — see +# CANONICAL_HOST_REDIRECT). Clerk auth is OFF — no CLERK_* env vars → the auth # package cleanly no-ops (see lib/clerk_satellite.py for the later flip-on). # # FIRST DEPLOY CHECKLIST: @@ -9,8 +10,9 @@ # the dashboard (values live in the local .env — NEVER committed). # 2. DASH_BACKEND=fastapi is load-bearing: without it the app boots on flask, # /healthz 404s, and Render loops the health check forever. -# 3. APP_BASE_URL drives sitemap.xml/llms.txt absolute URLs — keep it in -# sync with the live URL. +# 3. APP_BASE_URL drives every absolute URL the site publishes about itself +# (canonical, og, sitemap, robots, llms) — keep it in sync with the live +# URL, and see the CANONICAL_HOST_REDIRECT block for the domain-move order. services: - type: web name: dash-mui-scheduler-docs @@ -30,10 +32,30 @@ services: - key: WEB_WORKERS value: "1" - # --- Public base URL (sitemap/llms absolute links + social og:url). - # Must match the live URL: the service name below → dash-mui-scheduler-docs.onrender.com. + # --- Public base URL. THE single source of every absolute URL the site + # emits: canonical links, og:url/og:image, sitemap.xml, robots.txt, + # llms.txt and the JSON-LD @ids (lib/constants.BASE_URL). Get this wrong + # and every page canonicalises to a host that isn't the live one. + # + # The service also keeps answering on dash-mui-scheduler-docs.onrender.com + # forever. Two hosts serving identical pages is duplicate content, so + # CANONICAL_HOST_REDIRECT below 301s that one here. - key: APP_BASE_URL - value: https://dash-mui-scheduler-docs.onrender.com + value: https://muischeduler.2plot.dev + + # --- Send every other host (the *.onrender.com URL) here with a 301. + # DO THIS IN ORDER — enabling it before DNS resolves bounces every + # visitor to a host that isn't answering: + # 1. Render → Settings → Custom Domains → add muischeduler.2plot.dev. + # 2. At the 2plot.dev DNS provider add the CNAME Render shows + # (muischeduler → <service>.onrender.com); wait for Verified + the + # TLS certificate to be issued. + # 3. Confirm https://muischeduler.2plot.dev serves the docs. + # 4. THEN set APP_BASE_URL above and this flag, and redeploy. + # /healthz, /assets, /_dash*, /api/* and /webhooks are never redirected + # (a 3xx on the health check would fail the deploy) — lib/canonical_host.py. + - key: CANONICAL_HOST_REDIRECT + value: "0" # --- MUI X license (perpetual LICENSE key, not metered — set it). # The docs examples read MUI_X_LICENSE_KEY (licenseKey=os.environ.get("MUI_X_LICENSE_KEY", "")). @@ -51,9 +73,18 @@ services: # reporter is a clean no-op (and the app never appears on /traffic). - key: CROSS_APP_WEBHOOK_SECRET sync: false - # Network-directory key for this app (lib/network_directory in the hub). + # ONE short app id, network-wide (the subdomain slug / hub directory + # key). AD_APP_ID and the bulletin app_id follow this value in code. - key: SATELLITE_APP_KEY - value: scheduler + value: muischeduler + + # --- Network bulletin (hub tips/announcements in the llms.txt viewer). + # ⚠️ Blueprint envVars apply on BLUEPRINT SYNC only, not on git-push + # autodeploys — set this on the SERVICE in the Render dashboard too, or + # the viewer keeps rendering one generic package tip and nothing looks + # broken. Boot log states "network bulletin: wired/off". + - key: NETWORK_BULLETIN_URL + value: https://2plot.dev/api/network/bulletin # --- Optional --- # Visitor analytics writes ./visitor_analytics.json. That path is diff --git a/requirements.txt b/requirements.txt index cc8aaf8..0da3f1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,26 +17,36 @@ dash-mantine-components>=2.7.0 # hooks no-op: unauthenticated, $0, no network, no session files). MUST be # installed WITH dependency resolution (never --no-deps: dash auto-imports the # package via its [dash_hooks] entry point, so a broken transitive dep crashes -# every Dash() construction app-wide). -vendor/dash_clerk_auth-0.9.0.tar.gz +# every Dash() construction app-wide). 0.9.1 floor: 0.9.0 ships a dead avatar +# chip on Clerk SATELLITE domains, and this host becomes one at flip-on. +vendor/dash_clerk_auth-0.9.1.tar.gz svix>=1.45.0 # Clerk webhook signature verification (POST /webhooks/clerk) -# 2plot.dev ad-network client (lib/ad_client.py) +# 2plot.dev ad-network client (lib/ad_client.py) + the hub traffic reporter requests>=2.27.1 -# Documentation rendering +# Documentation rendering. +# markdown2dash is NOT listed here: 0.1.2 pins gunicorn>=21.2.0,<22.0.0, +# which is unresolvable against the CVE-driven gunicorn>=23 floor below. It +# is installed `pip install --no-deps markdown2dash==0.1.2` at every install +# site (Dockerfile, ci.yml) and its real dependencies are pinned here +# instead — with ranges, because --no-deps removes the resolver: +mistune>=3.0.0 python-frontmatter>=1.0.0 -markdown2dash pydantic>=2.3.0 python-dotenv>=1.0.0 +# markdown2dash==0.1.2 <- installed --no-deps, see above -# AI/LLM integration & SEO. -# dash-improve-my-llms 2.0 is not yet on PyPI (PyPI publishes up to 1.x only), -# so it is vendored as an sdist in this repo. Install it with: -# pip install vendor/dash_improve_my_llms-2.0.0.tar.gz -# (the Dockerfile does this automatically). +# AI/LLM integration & SEO — from PyPI (the vendored 2.0.0 sdist is retired). +# 2.3.4 floor is the network standard: it brings resolve_site_title (the +# /llms.txt H1 + viewer brand chip); 2.3.3 fixed the Anthropic bot taxonomy +# and directive stripping. Both backend extras: prod runs fastapi +# (render.yaml), CI and local dev also run flask. +dash-improve-my-llms[flask,fastapi]>=2.3.4 -# Production servers. uvicorn >=0.49 is required for `--ws websockets-sansio` -# (the Dockerfile CMD depends on it). -gunicorn>=21.2.0 +# Production servers. gunicorn>=23: 21.x carried two request-smuggling CVEs +# (CVE-2024-6827, CVE-2024-1135) — that floor is why markdown2dash installs +# --no-deps. uvicorn >=0.49 is required for `--ws websockets-sansio` (the +# Dockerfile CMD depends on it). +gunicorn>=23.0.0 uvicorn[standard]>=0.49.0 diff --git a/run.py b/run.py index b23d3e7..d191bcc 100644 --- a/run.py +++ b/run.py @@ -1,8 +1,7 @@ import os import dash -from dash import Dash, _dash_renderer +from dash import Dash from components.appshell import create_appshell -import dash_mantine_components as dmc # AI/LLM Integration & SEO — dash-improve-my-llms 2.0 # 2.0 supports Flask, FastAPI, and Quart via a single backend-detecting @@ -114,6 +113,33 @@ def _strip_clerk_url(layout): except Exception: pass +# ---------------------------------------------------------------------------- +# Index template +# ---------------------------------------------------------------------------- +# templates/index.html carries __BASE_URL__/__PAGE_URL__/__VERSION__ tokens +# instead of hard-coded URLs. BASE_URL (lib/constants, from APP_BASE_URL) is the +# single source of truth for every absolute URL the site emits — the reason a +# host move is one env var, and the reason the template once spent a release +# telling search engines every page was a duplicate of a host that never existed. +from lib.canonical_host import canonical_redirect +from lib.constants import ( + APP_VERSION, BASE_URL, CANONICAL_HOST, CANONICAL_HOST_REDIRECT, OG_IMAGE_URL, + SITE_BRAND, SITE_DESCRIPTION, require_owned_base_url, +) + +# Refuse to boot in production with an unset or platform-generated base URL — +# every canonical/og/sitemap URL would advertise the wrong host, silently. +require_owned_base_url() + +_INDEX_TEMPLATE = open('templates/index.html').read() +_index_string = ( + _INDEX_TEMPLATE + .replace("__BASE_URL__", BASE_URL) + .replace("__VERSION__", APP_VERSION) + # __PAGE_URL__ is deliberately left in place — the index hook below fills it + # with the REQUESTED page's canonical URL on every response. +) + app = Dash( __name__, backend=BACKEND, @@ -122,7 +148,12 @@ def _strip_clerk_url(layout): external_scripts=scripts, update_title=None, prevent_initial_callbacks=True, - index_string=open('templates/index.html').read(), + index_string=_index_string, + # The site identity, stated the same way on every surface (network + # standard; tests/test_site_identity.py pins it). Feeds the <title> + # fallback and the "name" in the crawler HTML's JSON-LD; per-page titles + # come from register_page and are applied by the renderer on navigation. + title=SITE_BRAND, # Belt-and-suspenders: keep the GLOBAL websocket flag off too (see the # capability disable above for the full rationale). websocket_callbacks=False, @@ -132,13 +163,35 @@ def _strip_clerk_url(layout): # re-reading the env var (which could drift between processes/workers). app._backend_info = BACKEND_INFO + +# ---------------------------------------------------------------------------- +# Per-request canonical URL. +# ---------------------------------------------------------------------------- +# One HTML document serves all 17 routes, so a canonical baked into the template +# is right for exactly one of them. The inline script in templates/index.html +# keeps it right across CLIENT-side navigation; this hook makes the very first +# server response already correct, so a crawler that reads HTML without running +# JavaScript sees the real canonical instead of the home page's. +@dash.hooks.index() +def _resolve_page_url(index: str) -> str: + path = "/" + try: + request = app.backend.request_adapter() + if request is not None and getattr(request, "path", None): + path = "/" + request.path.strip("/") + except Exception: + pass # no request context (build check, tests) → fall back to the home URL + return index.replace("__PAGE_URL__", BASE_URL + (path if path != "/" else "/")) + + # ============================================================================ # AI/LLM & SEO Configuration # ============================================================================ # Base URL for SEO (sitemap.xml + llms.txt emit absolute URLs from this). # Env-driven so the Render service / custom domain sets it without a code change. -app._base_url = os.getenv("APP_BASE_URL", "https://dash-mui-scheduler-docs.onrender.com") + +app._base_url = BASE_URL # Configure bot management policies. See dash-improve-my-llms 2.0 SKILLS for # the full menu — balanced default = block training crawlers, allow AI search @@ -159,16 +212,13 @@ def _strip_clerk_url(layout): register_page_metadata( path="/", - name="dash-mui-scheduler", - description=( - "A Plotly Dash wrapper for the MUI X Scheduler — EventCalendar, " - "EventCalendarPremium and EventTimeline plus the RadialLineChart and " - "RadialBarChart polar charts — " - "with recurrence, drag & resize, resources, timezones and theming. " - "This site is the component documentation with live examples." - ), + # SITE_BRAND here is what dash-improve-my-llms ≥2.3.4 resolve_site_title + # publishes as the /llms.txt H1 and the viewer's brand chip — the display + # name "Home" is deliberately generic so this one is load-bearing. + name=SITE_BRAND, + description=SITE_DESCRIPTION, llms_doc=( - "# dash-mui-scheduler\n\n" + "# dash-mui-scheduler — MUI X scheduling for Dash\n\n" "A Plotly Dash component library wrapping the MUI X Scheduler.\n\n" "Install: `pip install dash-mui-scheduler`\n\n" "Components: EventCalendar (day/week/month/agenda views, drag & resize, " @@ -207,6 +257,92 @@ def _strip_clerk_url(layout): "[boilerplate] FastAPI showcase routers mounted: /healthz, " "/api/backend, /api/pages. Swagger UI at /docs, ReDoc at /redoc." ) +else: + # Flask/Quart get the same /healthz the FastAPI build declares — the + # 2plot.ai hourly health sweep, the CI battery and the CD deploy gate all + # probe it and assert the exact field `ok: true`. + from lib.health import register_health_route + register_health_route(app, BACKEND) + +# Cross-host directory for the 2plot network: <link rel="related"> tags, the +# "## Network" section in /llms.txt, and followed links in the prerendered +# body. Must run BEFORE add_llms_routes so the routes pick it up. +from lib import network_directory +network_directory.apply(BASE_URL) + +# ---------------------------------------------------------------------------- +# Crawler HTML: add the tags dash-improve-my-llms 2.0 does not emit. +# ---------------------------------------------------------------------------- +# Search engines are served the package's prerendered per-page document, NOT the +# SPA shell — so the canonical link, og:site_name and og:image have to be added +# there too, or they are missing from exactly the response Google indexes. +# Patched at the module attribute because handlers.py imports the generator +# lazily inside the request path. Best-effort: any signature drift falls back to +# the untouched HTML rather than breaking the crawler response. + + +def _augment_crawler_html() -> None: + from dash_improve_my_llms import html_generator as _gen + + _original = _gen.generate_static_page_html + + def _with_canonical(*args, **kwargs): + html = _original(*args, **kwargs) + try: + path = kwargs.get("page_path") or "/" + url = BASE_URL + (path if path != "/" else "/") + extra = ( + f' <meta property="og:site_name" content="{SITE_BRAND}">\n' + f' <meta property="og:image" content="{OG_IMAGE_URL}">\n' + f' <meta property="twitter:card" content="summary_large_image">\n' + f' <meta property="twitter:image" content="{OG_IMAGE_URL}">\n' + ) + # dimll ≥2.3.4 emits its own canonical in the prerender; adding a + # second identical tag fails the battery's exactly-one check (the + # same double-canonical dash-email shipped and then removed). Only + # inject ours if the artifact ever stops emitting it. + if 'rel="canonical"' not in html: + extra = f' <link rel="canonical" href="{url}">\n' + extra + return html.replace("</head>", extra + "</head>", 1) + except Exception: + return html + + _gen.generate_static_page_html = _with_canonical + + +try: + _augment_crawler_html() +except Exception as e: # pragma: no cover - never block startup on an SEO nicety + print(f"[seo] crawler-HTML canonical injection skipped: {e!r}") + +# ============================================================================ +# Analytics tracking (flask) — registered BEFORE add_llms_routes, deliberately. +# Flask runs before_request hooks in registration order, and the package's bot +# middleware ANSWERS recognized crawlers itself: registered after it, this +# tracker never sees a Googlebot hit and the ledger undercounts every crawler. +# (FastAPI is unaffected — its tracking lives in ASGI middleware, outermost.) +# ============================================================================ +if IS_FLASK: + from flask import request as _flask_request + + @app.server.before_request + def track_visitor(): + """Track visitor analytics before each request.""" + try: + from lib.auth import identify_request_user + from lib.analytics_tracker import resolve_client_ip, resolve_country + # Behind a proxy remote_addr is the PROXY — resolve the forwarded + # client address so visitor counts and countries mean something. + tracker.track_visit( + _flask_request.path, + _flask_request.headers.get('User-Agent', ''), + resolve_client_ip(_flask_request.headers, + _flask_request.remote_addr), + auth_name=identify_request_user(_flask_request.cookies), + country=resolve_country(_flask_request.headers), + ) + except Exception: + pass # Wire up the package: /llms.txt, /<page>/llms.txt, /robots.txt, /sitemap.xml, # bot-detection middleware, and (on Dash 4.3+) MCP resource registration. @@ -236,56 +372,27 @@ def _strip_clerk_url(layout): # ============================================================================ # Social-card scrapers (Twitter / Facebook / Discord / Slack / …) are treated as # bots by add_llms_routes and would get the SEO HTML (which has NO og:image). Serve -# them the full meta HTML (favicon + og:image/twitter:image from templates/index.html) -# so link unfurls show the card image. Registered OUTERMOST so it pre-empts the package. -# Scrapers only read <head> meta, so we strip the Dash placeholders → a static string. +# them templates/index.html's <head> with a per-page og/twitter card rendered in +# place of {%metas%}, so unfurls show the image AND the right page's title/URL. +# Registered OUTERMOST so it pre-empts the package. See lib/social_cards.py. # ============================================================================ -_SOCIAL_UAS = ('twitterbot', 'facebookexternalhit', 'facebookcatalog', 'discordbot', - 'slackbot', 'slack-imgproxy', 'linkedinbot', 'whatsapp', 'telegrambot', - 'pinterest', 'redditbot', 'skypeuripreview', 'embedly', 'iframely') -_SOCIAL_HTML = open('templates/index.html').read() -for _ph in ('{%metas%}', '{%favicon%}', '{%css%}', '{%app_entry%}', '{%config%}', - '{%scripts%}', '{%renderer%}', '{%title%}'): - _SOCIAL_HTML = _SOCIAL_HTML.replace(_ph, '') - - -def _is_social_card(ua, path, method='GET'): - # method-aware: social scrapers only ever GET/HEAD — a spoofed-UA POST must - # fall through to the real handlers (e.g. the Svix-verified /webhooks/clerk). - ua = (ua or '').lower() - return (method in ('GET', 'HEAD') - and any(b in ua for b in _SOCIAL_UAS) - and not path.startswith('/assets') and not path.startswith('/_') - and not path.startswith('/webhooks')) +from lib.social_cards import SOCIAL_UAS, SocialCardRenderer, is_social_card + +_social_card = SocialCardRenderer( + template=_INDEX_TEMPLATE.replace("__BASE_URL__", BASE_URL).replace("__VERSION__", APP_VERSION), + base_url=BASE_URL, + image_url=OG_IMAGE_URL, + fallback_title=SITE_BRAND, + fallback_description=SITE_DESCRIPTION, +) # ============================================================================ -# Analytics Tracking — backend-specific. -# Flask uses before_request; FastAPI uses ASGI middleware. +# Social-card / canonical-host WSGI wrap — backend-specific. +# (Flask visitor tracking registers ABOVE add_llms_routes — see that block.) # ============================================================================ if IS_FLASK: - from flask import request as _flask_request - - @server.before_request - def track_visitor(): - """Track visitor analytics before each request.""" - try: - from lib.auth import identify_request_user - from lib.analytics_tracker import resolve_client_ip, resolve_country - # Behind a proxy remote_addr is the PROXY — resolve the forwarded - # client address so visitor counts and countries mean something. - tracker.track_visit( - _flask_request.path, - _flask_request.headers.get('User-Agent', ''), - resolve_client_ip(_flask_request.headers, - _flask_request.remote_addr), - auth_name=identify_request_user(_flask_request.cookies), - country=resolve_country(_flask_request.headers), - ) - except Exception: - pass - # Wrap OUTERMOST (after add_llms_routes wrapped server.wsgi_app) so social-card # scrapers get the full og:image HTML instead of the package's image-less SEO HTML. _orig_wsgi = server.wsgi_app @@ -293,8 +400,21 @@ def track_visitor(): def _social_card_wsgi(environ, start_response): path = environ.get('PATH_INFO', '/') method = environ.get('REQUEST_METHOD', 'GET') - if _is_social_card(environ.get('HTTP_USER_AGENT'), path, method): - body = _SOCIAL_HTML.encode('utf-8') + + # Wrong host → 301 before anything renders. Outermost of all, so a + # scraper or crawler on the onrender URL is sent to the real domain + # rather than being served a duplicate of it. + target = canonical_redirect( + environ.get('HTTP_HOST'), path, method, environ.get('QUERY_STRING', ''), + canonical_host=CANONICAL_HOST, enabled=CANONICAL_HOST_REDIRECT, + ) + if target: + start_response('301 Moved Permanently', + [('Location', target), ('Content-Length', '0')]) + return [b''] + + if is_social_card(environ.get('HTTP_USER_AGENT'), path, method): + body = _social_card(path).encode('utf-8') start_response('200 OK', [('Content-Type', 'text/html; charset=utf-8'), ('Content-Length', str(len(body)))]) return [body] @@ -305,7 +425,11 @@ def _social_card_wsgi(environ, start_response): elif BACKEND == "fastapi": from lib.asgi_middleware import register_asgi_middleware - register_asgi_middleware(app, _SOCIAL_HTML, _SOCIAL_UAS) + register_asgi_middleware( + app, _social_card, SOCIAL_UAS, + canonical_host=CANONICAL_HOST, + canonical_redirect_enabled=CANONICAL_HOST_REDIRECT, + ) # ============================================================================ # Satellite traffic reporting — this app's hourly rollup POSTed to 2plot.ai, @@ -323,6 +447,23 @@ def _social_card_wsgi(environ, start_response): print("[traffic-report] disabled (no CROSS_APP_WEBHOOK_SECRET) — " "the app will not appear on 2plot.ai/traffic.") +# ============================================================================ +# Network bulletin — hub-published tips/announcements rendered in the llms.txt +# viewer header. The boot line states which of the two states the process is +# in; NETWORK_BULLETIN_URL must be set on the Render SERVICE (blueprint +# envVars only apply on Blueprint sync). See lib/bulletin.py. +# ============================================================================ + +from lib import bulletin as _bulletin + +if _bulletin.configure(): + print(f"[dash-mui-scheduler] network bulletin: {_bulletin.url()} " + f"(app='{_bulletin.app_id()}')") +else: + print("[dash-mui-scheduler] network bulletin: off — set " + f"NETWORK_BULLETIN_URL={_bulletin.HUB_BULLETIN_URL} to render the " + "hub's announcements") + # ============================================================================ # Optional: Dash 4.3+ MCP server. # When available, this exposes the app's layout, components, pages and diff --git a/scripts/make_social_card.py b/scripts/make_social_card.py new file mode 100644 index 0000000..b484487 --- /dev/null +++ b/scripts/make_social_card.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Render the 1200x630 social card for a 2plot satellite. + + python scripts/make_social_card.py # defaults, this site + python scripts/make_social_card.py --open # ...and preview it + python scripts/make_social_card.py \ + --artwork assets/logo.png --brand "dash-email" \ + --tagline "email components for Dash" --domain email.2plot.dev + +NETWORK FILE: copied from dash-documentation-boilerplate 1.2.4, with this +site's artwork, tagline and accent as the defaults. Every card in the network +is framed identically instead of being hand-made once per site and drifting. + +Output goes to `build/social-cards/<domain>.png`, which is gitignored. The +card is NOT served by the app — publish it to the CDN: + + https://cdn.2plot.ai/github_assets/<domain>.png + +That is deliberate and is the network rule. A card served by the app itself +is fetched by the scraper at unfurl time, and on a cold free-tier container +that request lands mid-wake and times out — the preview renders blank, once, +permanently, because platforms cache the miss. The CDN has no cold start. + +WHY 1200x630 and not leaflet's 1280x515 +--------------------------------------- +1200x630 is exactly 1.91:1, the Open Graph documented ideal, and it degrades +cleanly into Twitter's 2:1 `summary_large_image` slot. leaflet.2plot.dev's is +1280x515 = 2.49:1, which is wider than both and gets cropped on each — and +what sits at that URL today is the 2plot wordmark rather than a per-site card +at all. This is the shape to converge on, not that one. + +Pillow is a build-time dependency only. It is deliberately absent from +requirements.txt: nothing at runtime renders images, and a docs site should +not carry an image library into production to support a script run by hand +every few months. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: # pragma: no cover - the one dependency, named clearly + sys.exit("This script needs Pillow:\n pip install Pillow") + +# Card geometry. WIDTH/HEIGHT are the contract; everything else is derived so +# a fork can change the padding without recomputing a layout by hand. +WIDTH, HEIGHT = 1200, 630 +PAD = 72 +ART_BOX = 430 # the square the artwork is fitted inside, right-hand side +RULE_W = 6 # the accent bar under the brand + +# Palette. The backgrounds come from assets/favicon/site.webmanifest, so the +# card, the browser chrome and the install splash cannot disagree. The accent +# is this site's Mantine primary (lib/constants.PRIMARY_COLOR = "blue", shade +# 6) rather than the manifest's theme_color: the manifest carries the dark +# surface colour here, which would be invisible against the card's own +# background. +BG_TOP = (26, 27, 30) # #1a1b1e — manifest background_color +BG_BOTTOM = (17, 20, 26) # a shade deeper, for a gradient with a direction +ACCENT = (51, 153, 255) # #3399ff — this site's "brand" primary (appshell) +TEXT = (245, 246, 247) +MUTED = (150, 158, 168) + +# Font families in preference order. `truetype` is tried on each until one +# loads: macOS ships the first group, Debian/Ubuntu CI images the second. +# There is no bundled font on purpose — shipping a licensed TTF in a template +# every satellite forks is a licensing question nobody wants to answer. +FONT_CANDIDATES = { + "bold": [ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/HelveticaNeue.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + ], + "regular": [ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ], + "mono": [ + "/System/Library/Fonts/Menlo.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + ], +} + + +def load_font(kind: str, size: int): + for path in FONT_CANDIDATES[kind]: + if Path(path).exists(): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + # Pillow >= 10.1 scales its built-in font; older ones give a 10px bitmap + # and the card looks broken rather than merely plain. Say so. + print(f"[card] WARNING: no {kind} system font found — falling back to " + "Pillow's built-in, which will look wrong. Install DejaVu or " + "Liberation fonts.", file=sys.stderr) + try: + return ImageFont.load_default(size=size) + except TypeError: # pragma: no cover - Pillow < 10.1 + return ImageFont.load_default() + + +def vertical_gradient(size, top, bottom): + """A one-pixel-wide gradient stretched across the canvas. + + Cheaper and smoother than filling row by row on the full-width image, and + the resample keeps the banding invisible at this height. + """ + w, h = size + strip = Image.new("RGB", (1, h)) + for y in range(h): + t = y / max(1, h - 1) + strip.putpixel((0, y), tuple( + round(top[i] + (bottom[i] - top[i]) * t) for i in range(3) + )) + return strip.resize((w, h), Image.BILINEAR) + + +def wrap(draw, text, font, max_width): + """Greedy word wrap against measured pixel width, not a character count.""" + words, lines, current = text.split(), [], "" + for word in words: + trial = f"{current} {word}".strip() + if draw.textlength(trial, font=font) <= max_width or not current: + current = trial + else: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + +def build_card(artwork: Path, brand: str, tagline: str, domain: str) -> Image.Image: + card = vertical_gradient((WIDTH, HEIGHT), BG_TOP, BG_BOTTOM).convert("RGBA") + draw = ImageDraw.Draw(card) + + # --- artwork, right ---------------------------------------------------- + # `thumbnail` preserves aspect ratio, so a square-ish logo and a wide one + # both land inside the same box without being stretched. The alpha bbox is + # cropped first: assets/ddb.png carries ~66px of transparent margin, which + # would otherwise be centred as if it were part of the image. + art = Image.open(artwork).convert("RGBA") + bbox = art.getchannel("A").getbbox() + if bbox: + art = art.crop(bbox) + art.thumbnail((ART_BOX, ART_BOX), Image.LANCZOS) + art_x = WIDTH - PAD - ART_BOX + (ART_BOX - art.width) // 2 + art_y = (HEIGHT - art.height) // 2 + card.alpha_composite(art, (art_x, art_y)) + + # --- text, left -------------------------------------------------------- + text_width = WIDTH - (PAD * 2) - ART_BOX - 48 + + brand_font = load_font("bold", 62) + tagline_font = load_font("regular", 29) + domain_font = load_font("mono", 25) + + brand_lines = wrap(draw, brand, brand_font, text_width) + # Shrink once rather than overflow: a three-line brand at 62px collides + # with the domain strip below. + if len(brand_lines) > 2: + brand_font = load_font("bold", 50) + brand_lines = wrap(draw, brand, brand_font, text_width) + + tagline_lines = wrap(draw, tagline, tagline_font, text_width)[:3] + + brand_lh, tagline_lh = 74, 40 + block_h = (len(brand_lines) * brand_lh) + 26 + (len(tagline_lines) * tagline_lh) + y = (HEIGHT - block_h - 60) // 2 + + # Accent rule, aligned to the top of the brand block. + draw.rounded_rectangle( + [PAD, y + 6, PAD + RULE_W, y + block_h - 10], radius=RULE_W // 2, fill=ACCENT + ) + text_x = PAD + RULE_W + 28 + + for line in brand_lines: + draw.text((text_x, y), line, font=brand_font, fill=TEXT) + y += brand_lh + y += 26 + for line in tagline_lines: + draw.text((text_x, y), line, font=tagline_font, fill=MUTED) + y += tagline_lh + + # Domain, bottom left — the one string a reader uses to decide whether the + # link goes where they think it does. + draw.text((text_x, HEIGHT - PAD - 26), domain, font=domain_font, fill=ACCENT) + + return card.convert("RGB") + + +def main() -> int: + from lib.constants import BASE_URL, SITE_BRAND + + default_domain = BASE_URL.split("://", 1)[-1].rstrip("/") + + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + # This repo ships no standalone logo, so the 512px app icon is the artwork. + # It is already square, already transparent-cropped by `build_card`, and it + # is the same mark the install prompt and the browser tab show — which is + # the point of a card: recognisable before it is readable. + ap.add_argument("--artwork", default="assets/favicon/android-chrome-512x512.png", + help="source image, transparent PNG (default: %(default)s)") + ap.add_argument("--brand", default=SITE_BRAND.split(" — ")[0], + help="headline (default: the brand, minus its tagline)") + ap.add_argument("--tagline", + default="Event calendar, resource timeline & radial " + "chart components for Plotly Dash, wrapping the " + "MUI X Scheduler.") + ap.add_argument("--domain", default=default_domain) + ap.add_argument("--out", default=None, + help="default: build/social-cards/<domain>.png") + ap.add_argument("--open", action="store_true", help="preview when done (macOS)") + args = ap.parse_args() + + artwork = (REPO_ROOT / args.artwork) if not Path(args.artwork).is_absolute() \ + else Path(args.artwork) + if not artwork.exists(): + return print(f"artwork not found: {artwork}", file=sys.stderr) or 1 + + out = Path(args.out) if args.out else \ + REPO_ROOT / "build" / "social-cards" / f"{args.domain}.png" + out.parent.mkdir(parents=True, exist_ok=True) + + card = build_card(artwork, args.brand, args.tagline, args.domain) + # optimize=True typically halves the file; scrapers fetch this on every + # cold unfurl and some give up on slow responses. + card.save(out, "PNG", optimize=True) + + kb = out.stat().st_size // 1024 + print(f"[card] {out.relative_to(REPO_ROOT)} {card.width}x{card.height} {kb} KB") + print(f"[card] ratio {card.width / card.height:.2f}:1") + print(f"[card] publish to: https://cdn.2plot.ai/github_assets/{args.domain}.png") + print("[card] then update OG_IMAGE_URL / OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT " + "in lib/constants.py") + + if args.open and sys.platform == "darwin": + subprocess.run(["open", str(out)], check=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/network_smoke.py b/scripts/network_smoke.py new file mode 100644 index 0000000..66790ef --- /dev/null +++ b/scripts/network_smoke.py @@ -0,0 +1,430 @@ +#!/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:8598 + Production python scripts/network_smoke.py --base-url https://muischeduler.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 <noscript> block legitimately +# says "requires JavaScript", and a substring check on that phrase reports a +# perfectly healthy host as broken. +STUB_MARKER = "This page contains interactive content that requires JavaScript" + +# ---------------------------------------------------------------- per-site -- +# The values a fork changes. Everything below this block is the network +# standard and is copied verbatim. + +# This app's one identity (lib/constants.SITE_BRAND). tests/test_site_identity +# asserts every local surface carries it; this pins the DEPLOYED artifact to +# it, which is the half no unit test can reach. +SITE_H1 = "# dash-mui-scheduler — MUI X scheduling for Dash" + +# The container port. Matches the Dockerfile's EXPOSE / PORT and run.py. +DEFAULT_BASE_URL = "http://localhost:8598" + +# A real documentation page, used to prove `/<page>/llms.txt` works at all. +# `/quickstart` is this app's first docs page and is not going anywhere. +SAMPLE_PAGE = "/quickstart" + +# Owner-only surfaces that must 404 their llms.txt to an anonymous reader. +# `/404` is this app's one hidden page (run.py calls `mark_hidden` on it). +HIDDEN_DOC_PATHS = ( + "/404/llms.txt", +) + +# The hub one level up the chain. A satellite's llms.txt must name it — that +# is what lets an agent walk from any leaf to the network root. +HUB_URL = "https://2plot.dev" + +# The social card. Dash emits `og:image` on every page and leaves it EMPTY +# when it can find no image, which renders a blank preview on every platform +# and is invisible from inside the app — nobody sees their own unfurls. The +# URL is served from the 2plot CDN so a sleeping free-tier container never +# costs a preview. Keep in step with lib/constants.OG_IMAGE_URL. +OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/muischeduler.2plot.dev.png" +OG_IMAGE_WIDTH = 1200 +OG_IMAGE_HEIGHT = 630 + +# --- robots.txt fingerprint, and this site's DELIBERATE divergence ----------- +# pip metadata is invisible from outside a running host, so the robots.txt +# crawler split is how a live host is proven to run the intended package. +# +# Most satellites run `block_ai_training=True`, whose 2.3.3 fingerprint is +# `ClaudeBot -> Disallow: /`. THIS SITE RUNS `block_ai_training=False` ON +# PURPOSE (see run.py's RobotsConfig): 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 the package +# emits no ClaudeBot stanza at all — training crawlers fall under `*`. +# +# So the fingerprint checked here is the AI-search allowlist, which 2.3.2 +# (OAI-SearchBot) and 2.3.3 (Claude-User, Claude-SearchBot) introduced and +# which both configs emit — plus an explicit assertion that ClaudeBot carries +# no Disallow, which is what turns the divergence from drift into a decision. +ROBOTS_ALLOWED = ( + ("OAI-SearchBot", "2.3.2"), + ("Claude-User", "2.3.3"), + ("Claude-SearchBot", "2.3.3"), + ("ChatGPT-User", "2.3.2"), + ("PerplexityBot", "2.3.2"), +) +BLOCK_AI_TRAINING = False + +# --------------------------------------------------------------------------- + +PASS, FAIL, WARN, SKIP = "pass", "FAIL", "warn", "skip" +_RESULTS: list[tuple[str, str, str]] = [] # (name, verdict, detail) + + +class SmokeFailure(Exception): + pass + + +def fetch_raw(url: str, ua: str = UA, method: str = "GET", + body: bytes | None = None, headers: dict | None = None, + timeout: int = TIMEOUT, retries: int = 3): + """(status, headers, BYTES) — HTTP errors are results, not exceptions; + network errors raise AFTER retries. + + Bytes rather than text, because one caller needs them: the social card is a + PNG and its real dimensions live in the IHDR chunk at bytes 16..24. A + decode with `errors="replace"` substitutes U+FFFD for every invalid byte + and is one-way, so the header would be gone before it could be read. + + Response headers come back lower-cased: gunicorn sends `content-type`, + proxies often re-case it — callers must not care. + """ + last_exc: Exception | None = None + for attempt in range(retries): + if attempt: + time.sleep(2 * attempt) + req = urllib.request.Request(url, data=body, method=method) + req.add_header("User-Agent", ua) + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return (r.status, {k.lower(): v for k, v in r.headers.items()}, + r.read()) + except urllib.error.HTTPError as e: + return (e.code, {k.lower(): v for k, v in e.headers.items()}, + e.read()) + except Exception as exc: # timeout, reset, truncated read, … + last_exc = exc + raise last_exc + + +def fetch(url: str, ua: str = UA, method: str = "GET", + body: bytes | None = None, headers: dict | None = None, + timeout: int = TIMEOUT, retries: int = 3): + """(status, headers, text) — `fetch_raw` with the body decoded. + + A thin delegate ON PURPOSE. The in-process test patches ONE transport, and + if these were two independent implementations the card check would keep + reaching the real CDN from a unit test while everything else was stubbed. + """ + status, hdrs, raw = fetch_raw(url, ua, method, body, headers, timeout, retries) + return status, hdrs, raw.decode("utf-8", "replace") + + +def record(name: str, verdict: str, detail: str = "") -> None: + _RESULTS.append((name, verdict, detail)) + print(f"[{verdict:>4}] {name}" + (f" — {detail}" if detail else ""), flush=True) + if verdict == WARN and os.getenv("GITHUB_ACTIONS"): + print(f"::warning title=network-smoke {name}::{detail}", flush=True) + + +def check(name: str, fn) -> None: + try: + fn() + record(name, PASS) + except SmokeFailure as exc: + record(name, FAIL, str(exc)) + except Exception as exc: # network/parse error → still a failure + record(name, FAIL, f"{type(exc).__name__}: {exc}") + + +def expect(cond: bool, msg: str) -> None: + if not cond: + raise SmokeFailure(msg) + + +# ------------------------------------------------------------- the battery -- + +def satellite_checks(base: str) -> None: + get = lambda path, **kw: fetch(base + path, **kw) # noqa: E731 + + def healthz_ok(): + status, _, text = get("/healthz") + expect(status == 200, f"/healthz {status}") + expect(json.loads(text).get("ok") is True, f"unexpected body {text[:120]!r}") + + def llms_txt_identity(): + # The check this whole standard exists for. The H1 is what an agent + # fetching /llms.txt cold reads as the name of this site, and a + # pre-2.3.4 artifact publishes `app.title` (or a bare "Dash") there + # with nothing else looking wrong. + status, headers, text = get("/llms.txt") + expect(status == 200, f"/llms.txt {status}") + ct = headers.get("content-type", "") + expect(ct.startswith("text/markdown"), f"content-type {ct!r}") + first = text.splitlines()[0] if text else "" + expect(first == SITE_H1, f"H1 {first!r} — identity regression?") + expect("## Pages" in text, "page index section missing") + expect("## Network" in text, "cross-host directory missing") + + def llms_txt_names_the_hub(): + _status, _, text = get("/llms.txt") + expect(HUB_URL in text, f"the directory does not name {HUB_URL}") + + def page_llms_nav(): + status, _, text = get(f"{SAMPLE_PAGE}/llms.txt") + expect(status == 200, f"{SAMPLE_PAGE}/llms.txt {status}") + expect("/llms.txt" in text, "llms_nav header missing — page doc is a dead end") + + def hidden_pages_404(): + for path in HIDDEN_DOC_PATHS: + status, _, _ = get(path) + expect(status == 404, f"{path} {status} (owner surface leaked)") + + def robots_artifact_fingerprint(): + status, _, text = get("/robots.txt") + expect(status == 200, f"/robots.txt {status}") + lines = [ln.strip() for ln in text.splitlines()] + + def rule(agent): + marker = f"User-agent: {agent}" + expect(marker in lines, f"{marker} stanza missing") + return lines[lines.index(marker) + 1] + + for agent, since in ROBOTS_ALLOWED: + got = rule(agent) + expect(got == "Allow: /", + f"{agent} -> {got!r}, expected 'Allow: /': pre-{since} artifact") + + # The divergence, pinned. If someone flips `block_ai_training` in + # run.py without meaning to, this is the check that says so. + if not BLOCK_AI_TRAINING: + expect("User-agent: ClaudeBot" not in lines, + "a ClaudeBot stanza appeared — block_ai_training flipped to " + "True? This site allows AI training on purpose (run.py)") + else: # pragma: no cover - the other posture, kept so a flip is one line + expect(rule("ClaudeBot") == "Disallow: /", + "ClaudeBot is not blocked despite block_ai_training=True") + + expect(any(ln.startswith("Sitemap:") for ln in lines), "Sitemap line missing") + + def sitemap_absolute_and_on_this_host(): + status, _, text = get("/sitemap.xml") + expect(status == 200, f"/sitemap.xml {status}") + expect("<loc>https://" in text or "<loc>http://" in text, + "no absolute <loc> URLs") + for path in HIDDEN_DOC_PATHS: + leaked = path.rsplit("/llms.txt", 1)[0] + expect(leaked not in text, f"hidden path {leaked} leaked into sitemap") + + def crawler_gets_prose(): + # The prerender. A crawler that receives the JavaScript stub indexes + # nothing, and the page looks perfect in a browser the whole time. + status, _, text = get("/", ua=CRAWLER_UA) + expect(status == 200, f"/ {status}") + expect("<title>" in text, "crawler HTML has no <title>") + expect(STUB_MARKER not in text, + "the home page served the JavaScript stub to a crawler") + expect('rel="canonical"' in text, "no canonical tag for a crawler") + + def agents_and_browsers_get_different_types(): + # One URL, two audiences, and a `Vary` that stops a CDN mixing them. + status, md_headers, md = get(f"{SAMPLE_PAGE}/llms.txt") + expect(status == 200, f"{SAMPLE_PAGE}/llms.txt {status}") + expect(md_headers.get("content-type", "").startswith("text/markdown"), + f"agents got {md_headers.get('content-type')!r}") + expect("<!DOCTYPE html>" not in md, "viewer chrome reached an agent") + + _status, html_headers, html = get( + f"{SAMPLE_PAGE}/llms.txt", + headers={"Accept": "text/html,application/xhtml+xml,*/*;q=0.8"}) + expect("text/html" in html_headers.get("content-type", ""), + f"browsers got {html_headers.get('content-type')!r}") + expect("mk-wordmark" in html, "the network wordmark is missing") + + for label, headers in (("markdown", md_headers), ("html", html_headers)): + expect("accept" in headers.get("vary", "").lower(), + f"no Vary: Accept on the {label} variant — a shared cache " + "may serve it to everyone") + + def social_card_is_shareable(): + """The link preview, which nobody on the team ever sees. + + Checked against the deployed host because every part of it can break + without the app noticing: an empty `og:image` (Dash's default when it + finds no image) renders a blank card, and a CDN asset that starts + 404ing takes every preview with it while the site itself looks fine. + """ + status, _, html = get("/") + expect(status == 200, f"/ {status}") + + images = re.findall( + r'<meta[^>]+property="og:image"[^>]*content="([^"]*)"', html) + expect(bool(images), "no og:image tag at all") + expect(all(src.strip() for src in images), + "og:image is EMPTY — the link preview renders a blank card") + expect(len(images) == 1, f"{len(images)} og:image tags — scrapers pick one") + expect(images[0] == OG_IMAGE_URL, f"og:image is {images[0]!r}") + + twitter = re.findall( + r'<meta[^>]+(?:property|name)="twitter:image"[^>]*content="([^"]*)"', html) + expect(bool(twitter) and all(t.strip() for t in twitter), + "twitter:image is missing or empty") + + expect("/assets/" not in images[0], + "the app is serving its own card — a cold container blanks the " + "preview, and the platform caches the miss") + + # The file has to exist AND be the shape the tags promise. Read the + # BYTES, not the decoded text: PNG stores its dimensions in the IHDR + # chunk at bytes 16..24, which a lossy decode destroys. + # + # This is the check that catches a re-upload at a different size — + # every offline test stays green while the platform reserves the box + # the tags declare and crops the image into it. It is how the previous + # card (1280x515, 2.49:1, and the 2plot wordmark rather than a per-site + # card at all) went unnoticed. + img_status, img_headers, raw = fetch_raw(OG_IMAGE_URL) + expect(img_status == 200, f"the og:image URL returns {img_status}") + expect(img_headers.get("content-type", "").startswith("image/"), + f"og:image serves {img_headers.get('content-type')!r}") + # 24 bytes is exactly the signature plus the IHDR width/height, which + # is all this reads — `>` rather than `>=` would reject a perfectly + # readable header for being minimal. + expect(raw[1:4] == b"PNG" and len(raw) >= 24, + "og:image is not a PNG (or is truncated)") + actual_w = int.from_bytes(raw[16:20], "big") + actual_h = int.from_bytes(raw[20:24], "big") + expect((actual_w, actual_h) == (OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT), + f"the CDN file is {actual_w}x{actual_h}, the tags declare " + f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT}") + + def installable_as_an_app(): + """The manifest, and whether a browser could offer to install this. + + It shipped with empty `name`/`short_name` and icon paths pointing at + the site root, where nothing is served — so the install prompt was + never possible, and nothing anywhere said so. + """ + _status, _, html = get("/") + match = re.search(r'<link[^>]+rel="manifest"[^>]+href="([^"]+)"', html) + expect(bool(match), "no manifest link — the app cannot be installed") + + status, _, body = get(match.group(1)) + expect(status == 200, f"the manifest returns {status}") + manifest = json.loads(body) + expect(bool(manifest.get("name", "").strip()), "manifest name is empty") + expect(bool(manifest.get("short_name", "").strip()), "short_name is empty") + expect(bool(manifest.get("start_url")), "no start_url") + + icons = manifest.get("icons") or [] + expect(bool(icons), "the manifest declares no icons") + for icon in icons: + icon_status, _, _ = get(icon["src"]) + expect(icon_status == 200, + f"manifest icon {icon['src']} returns {icon_status}") + + for name, fn in ( + ("healthz_ok", healthz_ok), + ("llms_txt_identity", llms_txt_identity), + ("llms_txt_names_the_hub", llms_txt_names_the_hub), + ("page_llms_nav", page_llms_nav), + ("hidden_pages_404", hidden_pages_404), + ("robots_artifact_fingerprint", robots_artifact_fingerprint), + ("sitemap_absolute_and_on_this_host", sitemap_absolute_and_on_this_host), + ("crawler_gets_prose", crawler_gets_prose), + ("agents_and_browsers_get_different_types", + agents_and_browsers_get_different_types), + ("social_card_real_pixels", social_card_is_shareable), + ("installable_as_an_app", installable_as_an_app), + ): + check(name, fn) + + +# ------------------------------------------------------------------- main -- + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--base-url", default=DEFAULT_BASE_URL, + help="the satellite under test (default: the CI container)") + args = ap.parse_args() + + base = args.base_url.rstrip("/") + print(f"network-smoke → {base}\n") + + # Wake-up loop for live hosts: a free-tier container answers its first + # probe with the platform's loading page or a hang, and asserting into + # that reads as twelve failures. Poll /healthz until it's real, THEN run. + if base.startswith("https://"): + for attempt in range(12): + try: + status, _, text = fetch(base + "/healthz", timeout=10, retries=1) + if status == 200 and json.loads(text).get("ok") is True: + break + except Exception: + pass + time.sleep(5) + + satellite_checks(base) + + counts = {v: sum(1 for _, verdict, _ in _RESULTS if verdict == v) + for v in (PASS, FAIL, WARN, SKIP)} + print(f"\n{counts[PASS]} passed, {counts[FAIL]} failed, " + f"{counts[WARN]} warnings, {counts[SKIP]} skipped") + return 1 if counts[FAIL] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_live.py b/scripts/smoke_live.py new file mode 100644 index 0000000..14ae687 --- /dev/null +++ b/scripts/smoke_live.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +"""Post-deploy checks against a *live* satellite. + + python scripts/smoke_live.py https://muischeduler.2plot.dev + +NETWORK FILE: copied verbatim from dash-documentation-boilerplate 1.2.4. +Nothing in it is per-site — every value it checks is read from the host under +test — so a change here belongs upstream in the boilerplate first. + +Everything here fails silently in production if it isn't checked. A wrong +canonical host doesn't error, it deindexes; a stub body doesn't error, it +serves crawlers nothing; a dead peer link doesn't error, it just teaches an +agent that this network's directory isn't worth following. + +Run in CD after every deploy, and by hand against any satellite you're +upgrading. Exit code is the number of failed checks, capped at 125. + +Only the standard library, so it runs anywhere without an install step. +""" + +from __future__ import annotations + +import os +import re +import sys +import ssl +import urllib.error +import urllib.request +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Every UA below carries the network's internal-traffic token (the analytics +# point of truth — https://2plot.ai/docs/satellite-analytics, "Internal +# traffic"). A post-deploy battery runs on every push and sweeps every peer in +# the directory; without the token it registers as a burst of visitors, and +# the crawler-shaped probes register as crawler interest. The Googlebot and +# Chrome tokens are still there, so the target exercises exactly the path +# being tested — it just knows the caller is machinery. +try: + from lib.constants import INTERNAL_UA as _INTERNAL_UA +except Exception: # pragma: no cover — running outside a repo checkout + _INTERNAL_UA = "2plot-internal/1.0 (+https://2plot.ai/docs/satellite-analytics)" + +CRAWLER_UA = ( + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) " + + _INTERNAL_UA +) +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 " + _INTERNAL_UA +) +# `/<page>/llms.txt` negotiates on Accept, not on the User-Agent. +BROWSER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" +STUB_MARKER = "This page contains interactive content that requires JavaScript" +# Rendered chrome, not the bare class name — a Markdown page may legitimately +# discuss `dv-banner` (this network has one that does); it can never contain +# the element. +CHROME = re.compile(r'<[a-z]+ class="dv-banner') +TIMEOUT = 30 + + +def _ssl_context() -> ssl.SSLContext: + """Verify certificates via certifi when available. + + macOS Python ships without OS trust-store integration, so bare urllib + fails every https fetch with CERTIFICATE_VERIFY_FAILED — which reads as + "the whole site is down" (every check 0s). Same fix as audit_links.py. + Verification stays ON either way; certifi only supplies the CA bundle. + """ + try: + import certifi + + return ssl.create_default_context(cafile=certifi.where()) + except ImportError: + return ssl.create_default_context() + + +SSL_CONTEXT = _ssl_context() + +failures: List[str] = [] +warnings: List[str] = [] +checks_run = 0 + + +def fetch( + url: str, user_agent: str = BROWSER_UA, accept: Optional[str] = None +) -> Tuple[int, str, Dict[str, str]]: + """Returns (status, body, headers). + + Headers are part of the contract from 2.2.0 on: `/<page>/llms.txt` + content-negotiates, so which *type* came back is the thing being checked, + and `Vary` is what stops a CDN handing cached HTML to the next agent. + + `errors="surrogateescape"`, not `"replace"`: this function also fetches + the social card, and the card check reads the PNG's IHDR chunk for the + real pixel dimensions. `"replace"` substitutes U+FFFD for every invalid + byte and is one-way, so the header would be gone before it could be read. + surrogateescape round-trips exactly through + `body.encode("utf-8", "surrogateescape")`, and behaves identically to a + plain decode for text. + """ + headers = {"User-Agent": user_agent} + if accept is not None: + headers["Accept"] = accept + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen( + request, timeout=TIMEOUT, context=SSL_CONTEXT + ) as response: + body = response.read().decode("utf-8", "surrogateescape") + return response.status, body, dict(response.headers) + except urllib.error.HTTPError as exc: + return (exc.code, exc.read().decode("utf-8", "surrogateescape"), + dict(exc.headers or {})) + except Exception as exc: # noqa: BLE001 - DNS, TLS, timeouts all land here + return 0, f"{type(exc).__name__}: {exc}", {} + + +def header(headers: Dict[str, str], name: str) -> str: + """Case-insensitive header lookup — proxies rewrite the casing.""" + for key, value in headers.items(): + if key.lower() == name.lower(): + return value + return "" + + +def check(name: str, passed: bool, detail: str = "", fatal: bool = True) -> None: + """Record one check. ``fatal=False`` warns instead of failing the deploy. + + The distinction is a policy, not a convenience: **a check about THIS host + is fatal; a check about somebody else's host is a warning.** + + Peer reachability is the only thing in this script that fails on someone + else's infrastructure, and gating a deploy on it is shared fate — one peer + with an expired certificate turns every satellite in the network red, none + of them can ship, and the people who see it learn that red CD means + nothing. The information is still worth having (a directory of dead links + degrades silently and nothing else reports it), so it is surfaced as a + warning and, under Actions, as an annotation on the run summary. + """ + global checks_run + checks_run += 1 + if passed: + print(f" ok {name}") + elif fatal: + print(f" FAIL {name}" + (f" — {detail}" if detail else "")) + failures.append(name) + else: + print(f" warn {name}" + (f" — {detail}" if detail else "")) + warnings.append(f"{name}" + (f" — {detail}" if detail else "")) + if os.getenv("GITHUB_ACTIONS"): + print(f"::warning title=peer unreachable::{name} — {detail}") + + +def main(base: str) -> int: + base = base.rstrip("/") + host = urlparse(base).netloc + print(f"Smoke-testing {base}\n") + + # --- 1. The site is up, and llms.txt is the index it should be --------- + print("Core surfaces") + status, home, _ = fetch(f"{base}/") + check("home page responds 200", status == 200, f"got {status}") + + status, llms, llms_headers = fetch(f"{base}/llms.txt") + check("/llms.txt responds 200", status == 200, f"got {status}") + check("/llms.txt lists pages", "## Pages" in llms or "# " in llms) + check("/llms.txt publishes the network directory", "## Network" in llms) + + status, robots, _ = fetch(f"{base}/robots.txt") + check("/robots.txt responds 200", status == 200, f"got {status}") + check( + "/robots.txt points at this host's sitemap", + f"Sitemap: {base}/sitemap.xml" in robots, + "sitemap line missing or pointing elsewhere", + ) + # The artifact fingerprint. pip metadata is invisible from outside, so + # these robots.txt stanzas are how a live host is proven to run the + # intended dash-improve-my-llms: 2.3.2 introduced the OAI-SearchBot / + # ChatGPT-User / PerplexityBot allowlist, 2.3.3 added Claude-User and + # Claude-SearchBot. + # + # PER-SITE: most satellites also expect `ClaudeBot -> Disallow: /`, the + # 2.3.3 training-crawler split. This host runs `block_ai_training=False` + # ON PURPOSE (run.py's RobotsConfig — for MIT-licensed component docs, + # being in the training corpus is how a model recommends the library), and + # under that config the package emits no ClaudeBot stanza at all. The + # absence is asserted below so a silent flip of that flag is still caught. + robots_lines = robots.splitlines() + + def robots_rule(agent: str) -> str: + marker = f"User-agent: {agent}" + if marker not in robots_lines: + return "(missing)" + idx = robots_lines.index(marker) + following = robots_lines[idx + 1: idx + 2] + return following[0] if following else "(missing)" + + for agent, expected, since in ( + ("OAI-SearchBot", "Allow: /", "2.3.2"), + ("ChatGPT-User", "Allow: /", "2.3.2"), + ("PerplexityBot", "Allow: /", "2.3.2"), + ("Claude-User", "Allow: /", "2.3.3"), + ("Claude-SearchBot", "Allow: /", "2.3.3"), + ): + got = robots_rule(agent) + check( + f"/robots.txt {agent} -> {expected.split(':')[0]} ({since} artifact fingerprint)", + got == expected, + f"got {got}: this host runs a pre-{since} artifact", + ) + + check( + "/robots.txt keeps this site's deliberate open-training posture", + "User-agent: ClaudeBot" not in robots_lines, + "a ClaudeBot stanza appeared — block_ai_training flipped to True?", + ) + + status, sitemap, _ = fetch(f"{base}/sitemap.xml") + check("/sitemap.xml responds 200", status == 200, f"got {status}") + page_urls = re.findall(r"<loc>([^<]+)</loc>", sitemap) + check("/sitemap.xml lists pages", bool(page_urls), "no <loc> entries") + foreign = [u for u in page_urls if urlparse(u).netloc != host] + check("/sitemap.xml stays on this host", not foreign, f"foreign URLs: {foreign[:3]}") + + status, health, _ = fetch(f"{base}/healthz") + check("/healthz responds 200", status == 200, f"got {status}") + + # --- 2. Canonical host — the failure that deindexes a satellite -------- + print("\nCanonical tags") + for url in [f"{base}/"] + page_urls[:8]: + _status, html, _ = fetch(url, CRAWLER_UA) + found = re.findall(r'rel="canonical"\s+href="([^"]*)"', html) + check( + f"canonical on {urlparse(url).path or '/'}", + len(found) == 1 and urlparse(found[0]).netloc == host, + f"got {found}", + ) + + # --- 3. No page serves the JavaScript stub ---------------------------- + print("\nCrawler bodies") + for url in [f"{base}/"] + page_urls[:8]: + _status, html, _ = fetch(url, CRAWLER_UA) + check( + f"real content on {urlparse(url).path or '/'}", + STUB_MARKER not in html, + "served the JavaScript stub", + ) + + # --- 3b. The social card actually exists, and is the shape we claim ---- + # This is the ONLY check that can see either failure. The card is on the + # CDN, so no offline test can fetch it; and its dimensions are hard-coded + # in three places (lib/constants.py, index.html, the CDN object), so + # replacing the uploaded file with a different shape leaves every test + # green while the platform reserves the wrong box and crops into it. + # + # A blank preview is also self-inflicting: platforms cache a failed scrape, + # so the first share after a bad upload poisons the link for everyone. + print("\nSocial card") + card_urls = re.findall(r'<meta[^>]+property="og:image"[^>]+content="([^"]*)"', home) + check("og:image is declared exactly once", len(card_urls) == 1, f"got {card_urls}") + if card_urls and card_urls[0]: + card_url = card_urls[0] + check("og:image is not served by the app", "/assets/" not in card_url, + f"{card_url} — a cold container blanks the preview, cached") + status, body, headers = fetch(card_url) + check("og:image resolves", status == 200, f"got {status}") + ctype = header(headers, "Content-Type") + check("og:image is a real image", ctype.startswith("image/"), ctype or "none") + + declared = { + prop: re.findall( + rf'<meta[^>]+property="{prop}"[^>]+content="([^"]*)"', home) + for prop in ("og:image:width", "og:image:height") + } + # PNG stores its dimensions in the IHDR chunk: bytes 16..24 of the + # file. Read from the RESPONSE, so what is checked is what a scraper + # would actually receive rather than what the repo believes. + raw = body.encode("utf-8", "surrogateescape") + if raw[1:4] == b"PNG" and len(raw) > 24: + actual_w = int.from_bytes(raw[16:20], "big") + actual_h = int.from_bytes(raw[20:24], "big") + check( + "og:image dimensions match the declared width/height", + declared["og:image:width"] == [str(actual_w)] + and declared["og:image:height"] == [str(actual_h)], + f"file is {actual_w}x{actual_h}, tags say " + f"{declared['og:image:width']}x{declared['og:image:height']}", + ) + ratio = actual_w / actual_h if actual_h else 0 + check("og:image suits summary_large_image (~1.91:1)", + 1.7 <= ratio <= 2.05, f"{actual_w}x{actual_h} is {ratio:.2f}:1") + else: + check("og:image is not empty", False, + "an EMPTY og:image renders a blank card — worse than none") + + # --- 4. Content negotiation on llms.txt ------------------------------- + # Production is where this can break in ways development cannot show: a + # CDN sitting in front of the app is free to ignore `Vary` and serve one + # cached variant to everyone. Chrome leaking into the Markdown makes every + # agent in the network pay tokens for decoration and appears in no + # dashboard; the Markdown leaking into a browser just looks unfinished. + print("\nContent negotiation") + check( + "/llms.txt serves Markdown to a plain request", + not CHROME.search(llms) and "<!DOCTYPE html>" not in llms, + "the viewer chrome reached an agent", + ) + + page_doc = next( + (f"{u.rstrip('/')}/llms.txt" for u in page_urls if urlparse(u).path not in ("", "/")), + f"{base}/llms.txt", + ) + + status, doc, doc_headers = fetch(page_doc) + check(f"{urlparse(page_doc).path} responds 200", status == 200, f"got {status}") + check( + "agents get text/markdown", + "text/markdown" in header(doc_headers, "Content-Type"), + header(doc_headers, "Content-Type") or "no Content-Type", + ) + check( + "agents get no viewer chrome", + not CHROME.search(doc) and "<!DOCTYPE html>" not in doc, + "the viewer chrome reached an agent", + ) + check( + "page document is not a dead end", + f"{base}/llms.txt" in doc, + "no route back to the site index", + ) + + status, view, view_headers = fetch(page_doc, accept=BROWSER_ACCEPT) + check( + "browsers get text/html", + "text/html" in header(view_headers, "Content-Type"), + header(view_headers, "Content-Type") or "no Content-Type", + ) + check("the viewer renders the network wordmark", "mk-wordmark" in view) + + # The hub bulletin, which supplies BOTH banner panels — the "What's new" + # announcements and the "Tips for getting started" list (the package renders + # tips from `bulletin["tips"]`, falling back to one generic line). With + # NETWORK_BULLETIN_URL unset the panels still render, so nothing looks + # broken: you get one generic tip and "No announcements." That is exactly + # how this host went live unwired. + # + # WARN, not fail, and for a different reason than the peer checks below: a + # satellite may legitimately run with no bulletin, and a hub outage must + # never fail a deploy. This is the deploy telling you a panel is empty, + # which is the only place that fact is ever surfaced. + check( + "the network bulletin is wired (banner shows hub announcements)", + "No announcements." not in view, + "NETWORK_BULLETIN_URL is unset or unreachable — the viewer's " + "\"What's new\" panel is empty and its tips are the built-in fallback", + fatal=False, + ) + check( + "the viewer is noindex", + bool(re.search(r'<meta[^>]+name="robots"[^>]+noindex', view)), + "the rendered view would compete with the page it documents", + ) + + # Both variants, because a cache keys on the request that populated it. + for label, headers in (("markdown", doc_headers), ("html", view_headers)): + check( + f"Vary: Accept on the {label} variant", + "accept" in header(headers, "Vary").lower(), + f"Vary: {header(headers, 'Vary') or '(absent)'} — a shared cache " + "may serve this variant to everyone", + ) + + # --- 5. Every peer in the directory resolves -------------------------- + # A directory of dead links degrades quietly, and nothing else will tell + # you — so this is still worth checking on every deploy. But it is the ONE + # section that tests hosts this deployment does not control, so it warns + # rather than fails. See `check()` for why. That the directory is + # *published at all* is this host's job, so that check stays fatal. + print("\nNetwork directory") + # `[` `]` `(` are excluded, not just whitespace: the 2.2.0 nav block writes + # links as `[https://host/llms.txt](https://host/llms.txt)`, and a class + # that stops only at `)` swallows the label and the opening paren into one + # malformed URL — which then 404s and fails a perfectly good deploy. + peer_docs = sorted(set(re.findall(r"https://[^\s()\[\]\"'<>]+/llms\.txt", llms))) + check("directory lists peer llms.txt URLs", bool(peer_docs), "none found") + for url in peer_docs: + if url.startswith(base): + continue + status, body, headers = fetch(url) + # A 200 is not enough. A Dash app answers its catch-all with the SPA + # shell for *any* unmatched path, so a host that does not serve + # llms.txt at all still returns 200 text/html — and a status-only + # check passes on every one of them. Verified on 2plot.dev, where + # /api/this-endpoint-cannot-exist also returns 200 text/html. + is_html = "text/html" in header(headers, "Content-Type").lower() or ( + body.lstrip()[:15].lower().startswith("<!doctype html") + ) + if status != 200: + check(f"peer reachable: {url}", False, f"got {status}", fatal=False) + else: + check( + f"peer serves a document: {url}", + not is_html, + "200, but HTML — that host's catch-all, not an llms.txt", + fatal=False, + ) + + passed = checks_run - len(failures) - len(warnings) + summary = f"\n{passed}/{checks_run} checks passed" + if warnings: + summary += f", {len(warnings)} warnings (peers — not this deployment)" + print(summary) + + if warnings: + print("\nWarned:") + for name in warnings: + print(f" - {name}") + + if failures: + print("\nFailed:") + for name in failures: + print(f" - {name}") + return min(len(failures), 125) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/setup.py b/setup.py index 48a29c8..5e35f4f 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ project_urls={ 'Bug Reports': 'https://github.com/pip-install-python/dash-mui-scheduler/issues', 'Source': 'https://github.com/pip-install-python/dash-mui-scheduler', - 'Documentation': 'https://github.com/pip-install-python/dash-mui-scheduler#readme', + 'Documentation': 'https://muischeduler.2plot.dev', }, install_requires=['dash>=2.11.0'], classifiers=[ diff --git a/templates/index.html b/templates/index.html index 4138c3c..073fabf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,79 +1,196 @@ <!DOCTYPE html> <html lang="en"> <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <!-- FIRST, deliberately: Dash renders charset + viewport + X-UA-Compatible + here (it adds them automatically when the app config doesn't), and the + charset declaration has to land inside the document's first 1024 bytes. + Declaring them in this file as well produced a duplicate charset (an + HTML validity error), so they live only in Dash's block. This is also + where the per-page description/og/twitter tags land. --> + {%metas%} + + <!-- ======================================================================== + SEO NOTE — what belongs here and what MUST NOT. + + This one document serves EVERY route (Dash is an SPA), so anything + static in here is a claim about all 17 pages at once. + + * Per-page tags (description, og:title/type/description/image and the + whole twitter:* set) are emitted by Dash at the metas placeholder + above, from each register_page() call — see + dash/_pages.py:_page_meta_tags. Do NOT add static copies: they would + describe the SITE on every PAGE, and the duplicate would sort first + and win. (Never write the placeholder's literal name in a comment + either — Dash substitutes EVERY occurrence, comments included.) + * Only og:site_name and og:url are absent from Dash's set, so they + live here. + * Anything URL-shaped is written with the __BASE_URL__ token, which + run.py substitutes from APP_BASE_URL (lib/constants.BASE_URL). Never + hard-code a host here: this template shipped carrying a host that + did not exist, and later the site moved to its own domain — a + hard-coded value would have been wrong both times. + * canonical / og:url are filled per REQUEST by run.py's index hook, and + canonical / og:url / twitter:url are re-patched per ROUTE by the + script below — a static value would be right for exactly one page. + ===================================================================== --> - <!-- Primary meta tags --> - <title>dash-mui-scheduler — MUI X Scheduler for Plotly Dash - - + dash-mui-scheduler — MUI X scheduling for Dash - - - - - - - - - + + + + + - - - - - - - - - - - - - - + + + + + + + + + - - + - + - - {%metas%} + + + + {%favicon%} {%css%} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..13c566a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,231 @@ +"""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 wraps outermost (the social-card +shim and the canonical-host 301 both live at the WSGI boundary), 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` via `lib/backend.resolve_backend()`, +so the same suite runs against Flask and FastAPI in CI. `client` normalises +the 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 (the auth package cleanly no-ops), +no `CROSS_APP_WEBHOOK_SECRET` (the traffic reporter never starts a thread), +no `MUI_X_LICENSE_KEY` (Premium components degrade to a watermark — the docs +still render), and the analytics ledger in a temp dir. + +The env block below has to run BEFORE anything imports `run.py`, because +`lib/backend.py` calls `load_dotenv()` during that import and this repo ships +a real `.env` — a developer's local run 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 the boilerplate, +leaflet and dash-email. +""" + +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", + "MUI_X_LICENSE_KEY", "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 +# visitor_analytics.json, which then shows up in `git status` and, worse, in +# the next hourly rollup a developer's local run happens to send. +_TMP_STATE = tempfile.mkdtemp(prefix="muischeduler-tests-") +os.environ["TRAFFIC_ANALYTICS_FILE"] = os.path.join(_TMP_STATE, "visitor_analytics.json") +# Behind Cloudflare in production; in tests an outbound ip-api.com lookup per +# hit would make the suite depend on a third party being up. +os.environ["ANALYTICS_GEO_LOOKUP"] = "0" +# The base-URL guard and the reporter both key off these; keep them inert. +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 = "/quickstart" + + +def backend() -> str: + """Whichever backend the app will actually boot on. + + Not `os.environ["DASH_BACKEND"]` directly: lib/backend.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", "raw", "headers") + + def __init__(self, status: int, text: str, headers=None, raw: bytes = b"") -> None: + self.status = status + self.text = text + # The undecoded body. Only one caller needs it — the social-card + # checks read a PNG's IHDR chunk — but a lossy decode is one-way, so + # it has to be kept here rather than reconstructed. + self.raw = raw + # Headers matter from dimll 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 backends disagree on + # casing — Werkzeug hands back `Content-Type`, httpx `content-type`. + 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 the backends.""" + + def __init__(self, raw, kind: str) -> None: + self._raw = raw + self._kind = kind + + 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) + body = r.get_data() + # 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. + return Response(r.status_code, body.decode("utf-8", "replace"), + dict(r.headers), body) + + r = self._raw.get(path, headers=headers) + return Response(r.status_code, r.text, dict(r.headers), r.content) + + +@pytest.fixture(scope="session") +def client(app): + """A test client for whichever backend is under test. + + FastAPI needs 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 == "fastapi": + from starlette.testclient import TestClient + + with TestClient(app.server) as raw: + yield Client(raw, "httpx") + else: # pragma: no cover - quart is not a CI target for this repo + raise RuntimeError(f"unsupported DASH_BACKEND={kind!r} in tests") + + +@pytest.fixture(scope="session") +def tmp_state_dir(): + """Where the app's ledger 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] + + +def main_body(html: str) -> str: + """The prerendered
block, or '' when the document has none.""" + if "
" not in html: + return "" + return html.split("
", 1)[1].split("
", 1)[0] diff --git a/tests/test_bulletin.py b/tests/test_bulletin.py new file mode 100644 index 0000000..ddfb506 --- /dev/null +++ b/tests/test_bulletin.py @@ -0,0 +1,89 @@ +"""The network bulletin — wired, or off, and never a comment. + +NETWORK FILE: adapted from dash-email (itself from +dash-documentation-boilerplate 1.2.4, where this existed because the wiring +had sat COMMENTED OUT in run.py for weeks against a hub endpoint that was +already serving). Nothing failed. `configure_bulletin` is opt-in, so an +unwired app makes no request at all and the viewer header renders perfectly +well on the package's built-in tips and a "No announcements." empty state. +The only symptom was an announcement that never appeared — which nobody goes +looking for. + +The load-bearing test is the last one: commented-out wiring cannot define the +name it asserts on, so it fails the moment somebody comments it out again. +""" + +from __future__ import annotations + +import pytest + +from conftest import REPO_ROOT + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """conftest pins NETWORK_BULLETIN_URL to "" for the whole session; these + tests set it themselves and must not leak it into the others.""" + monkeypatch.delenv("NETWORK_BULLETIN_URL", raising=False) + monkeypatch.delenv("NETWORK_BULLETIN_TTL_S", raising=False) + + +def test_no_url_means_the_feature_is_simply_off(): + from lib import bulletin + + assert bulletin.url() is None + assert bulletin.configure() is False + + +def test_configure_reports_that_it_wired(monkeypatch): + from lib import bulletin + + monkeypatch.setenv("NETWORK_BULLETIN_URL", bulletin.HUB_BULLETIN_URL) + + seen = {} + + def fake_configure_bulletin(**kwargs): + seen.update(kwargs) + + import dash_improve_my_llms + + monkeypatch.setattr(dash_improve_my_llms, "configure_bulletin", + fake_configure_bulletin) + + assert bulletin.configure() is True + assert seen["url"] == bulletin.HUB_BULLETIN_URL + assert seen["ttl"] == bulletin._ttl() + assert seen["app_id"] == "muischeduler" + + +def test_the_app_id_is_the_directory_key_not_a_second_opinion(): + """One id on every hub surface. A satellite still announcing itself as + "boilerplate" (or "scheduler") would receive the wrong announcements.""" + from lib import bulletin + from lib.traffic_report import app_key + + assert bulletin.app_id() == app_key() == "muischeduler" + + +def test_a_bad_ttl_falls_back_rather_than_crashing_the_boot(monkeypatch): + from lib import bulletin + + monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "not-a-number") + assert bulletin._ttl() == bulletin.DEFAULT_TTL_S + monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "5") + assert bulletin._ttl() == 60.0, "a too-short TTL would hammer the hub" + + +def test_run_py_wires_it_rather_than_leaving_it_commented_out(): + """The regression this file exists for. + + Commented-out wiring cannot define the name it asserts on, so requiring a + real call here is what makes commenting it out fail loudly. + """ + source = (REPO_ROOT / "run.py").read_text() + live = "\n".join( + line for line in source.splitlines() if not line.strip().startswith("#") + ) + assert "bulletin.configure()" in live, ( + "run.py no longer calls bulletin.configure() outside a comment" + ) diff --git a/tests/test_internal_traffic.py b/tests/test_internal_traffic.py new file mode 100644 index 0000000..48498b1 --- /dev/null +++ b/tests/test_internal_traffic.py @@ -0,0 +1,267 @@ +"""The network's internal-traffic contract — the analytics point of truth. + +NETWORK FILE: adapted from dash-email (itself from +dash-documentation-boilerplate 1.2.4). This app has no `lib/hub_client.py` +(it holds no key material and asks the hub for nothing), so that repo's third +outbound test has no counterpart here. + +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 device detection and 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 hourly 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 — the + hourly rollup POST and the ad client's per-page-view fetch. +""" + +from __future__ import annotations + +import json + +import pytest + +from conftest import BROWSER_UA, CRAWLER_UA, SAMPLE_PAGE +from lib.analytics_tracker import tracker +from lib.constants import INTERNAL_UA, INTERNAL_UA_TOKEN, internal_ua + +# A real page. `lib/traffic_report` drops infrastructure paths (`/llms.txt`, +# `/robots.txt`, `/healthz`, ...) at read time, so a rollup assertion made +# against one of those would pass no matter what the tracker did. +PAGE = SAMPLE_PAGE + + +def _ledger_visits(): + """Every hit on disk. The tracker writes synchronously (whole-file + rewrite per hit), so there is no buffer to flush first.""" + try: + with open(tracker.data_file) as f: + return json.load(f).get("visits", []) + except FileNotFoundError: + return [] + + +def _rollup(): + """Today's rollup as the hub would receive it.""" + from lib.traffic_report import build_rollup + + return build_rollup() + + +# --------------------------------------------------------------- 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("traffic-report") + assert INTERNAL_UA_TOKEN in ua + assert ua.endswith("traffic-report") + 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 visitor_analytics.json), every "count did not change" test would pass + vacuously. Prove a write lands first. + """ + assert str(tracker.data_file).startswith(tmp_state_dir), tracker.data_file + before = len(_ledger_visits()) + client.get(PAGE, user_agent=BROWSER_UA) + 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 `detect_device_type` — 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=BROWSER_UA) + assert len(_ledger_visits()) == before + + +# ----------------------------------------------- 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 _ in range(2): + client.get(PAGE, user_agent=internal_ua("network-smoke")) + client.get(PAGE, user_agent=f"{CRAWLER_UA} {INTERNAL_UA}") + + 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_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 bot hit + is one bot. + + The bot probe is an AI-agent UA rather than Googlebot: on the flask + backend dash-improve-my-llms' bot middleware answers UAs on ITS bot list + (googlebot, generic 'bot'/'crawler'/'curl', ...) before run.py's tracking + hook runs — registered in the opposite order from dash-email — so a + Googlebot hit never reaches the ledger there at all (see the ordering + note in dash-email's run.py: tracking MUST precede add_llms_routes). + `ChatGPT/1.0` is classified a bot by this app's tracker but is not on the + package's list, so it exercises the full request path on both backends. + """ + bot_ua = "ChatGPT/1.0 (AI assistant)" + assert tracker.detect_device_type(bot_ua) == "bot" # keep the probe honest + + before = _rollup() + client.get(PAGE, user_agent=BROWSER_UA) + client.get(PAGE, user_agent=bot_ua) + after = _rollup() + + assert after["human_hits"] == before["human_hits"] + 1 + assert after["bot_hits"] == before["bot_hits"] + 1 + + +# ----------------------------------------------------------------- outbound -- + + +class _FakeResponse: + status_code = 200 + text = "" + + +def test_the_traffic_rollup_post_sends_the_token(monkeypatch): + """`post_rollup` is a clean no-op without the secret (the suite runs + secretless), so give it a dummy secret and capture the POST it makes.""" + from lib import traffic_report + + monkeypatch.setenv("CROSS_APP_WEBHOOK_SECRET", "test-secret") + + seen = {} + + def fake_post(*args, **kwargs): + seen.update(kwargs.get("headers") or {}) + return _FakeResponse() + + monkeypatch.setattr(traffic_report.requests, "post", fake_post) + + ok = traffic_report.post_rollup( + {"app": "muischeduler", "date": "2026-08-01", + "human_hits": 0, "bot_hits": 0} + ) + assert ok is True + ua = seen.get("User-Agent", "") + assert INTERNAL_UA_TOKEN in ua + assert ua.endswith("traffic-report") + + +def test_the_ad_fetch_sends_the_token(monkeypatch): + """One call per docs page view — the loudest outbound path.""" + from lib import ad_client + + seen = {} + + class _Captured(Exception): + """Abort the request once the headers have been seen.""" + + def fake_get(*args, **kwargs): + seen.update(kwargs.get("headers") or {}) + raise _Captured + + monkeypatch.setattr(ad_client._session, "get", fake_get) + # The 60s circuit breaker survives from any earlier failure in this + # process; reset it or fetch_ad returns None without calling anything. + monkeypatch.setattr(ad_client, "_last_failure", 0.0) + + assert ad_client.fetch_ad(SAMPLE_PAGE) is None # the fake raised + assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "") + + +def test_this_app_reports_under_its_short_directory_key(): + """One id on every hub surface: traffic, ads and the bulletin. + + The hub folds legacy spellings at ingest, so an old build keeps working — + but a satellite that never converges shows up as two rows on + /admin/ad-analytics and the network board, and nobody can tell they are + the same host. + """ + from lib import ad_client, bulletin, traffic_report + + assert traffic_report.app_key() == "muischeduler" + assert bulletin.app_id() == "muischeduler" + assert ad_client.APP_ID == "muischeduler" + + +@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..37ce927 --- /dev/null +++ b/tests/test_network_smoke.py @@ -0,0 +1,194 @@ +"""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 fetchers 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 sample + page, the hidden paths, the card URL — 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, + OG_IMAGE_HEIGHT, + OG_IMAGE_URL, + OG_IMAGE_WIDTH, + SITE_BRAND, +) + +BASE = BASE_URL + + +def _png_header(width: int, height: int) -> bytes: + """The first 24 bytes of a PNG: signature, chunk length, IHDR, w, h. + + Enough for the battery's dimension check, which reads bytes 16..24. A real + file is not needed and would only make this suite depend on Cloudflare. + """ + return (b"\x89PNG\r\n\x1a\n" + + (13).to_bytes(4, "big") + b"IHDR" + + width.to_bytes(4, "big") + height.to_bytes(4, "big")) + + +@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_raw` at the test client. + + `fetch` delegates to `fetch_raw`, so patching the one covers both. The + signature is `fetch_raw(url, ua=..., method=..., body=..., headers=...)` + returning `(status, lowercased_headers, bytes)`. 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_raw(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) + + # Off-host URLs — today just the CDN-hosted social card — resolve to a + # synthetic PNG header of the declared size. Reaching the real CDN from + # a unit test would make the suite depend on another service being up; + # that the asset genuinely resolves, and is genuinely that shape, 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"}, + _png_header(OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT)) + + path = url[len(BASE):] if url.startswith(BASE) else url + accept = (headers or {}).get("Accept") + response = client.get(path or "/", user_agent=ua, accept=accept) + return response.status, dict(response.headers), response.raw + + monkeypatch.setattr(battery, "fetch_raw", fetch_raw) + 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) >= 11, "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_card_constants_track_lib_constants(battery): + assert battery.OG_IMAGE_URL == OG_IMAGE_URL + assert battery.OG_IMAGE_WIDTH == OG_IMAGE_WIDTH + assert battery.OG_IMAGE_HEIGHT == OG_IMAGE_HEIGHT + + +def test_the_sample_page_is_a_page_this_app_actually_has(battery, page_paths): + assert battery.SAMPLE_PAGE in page_paths, ( + f"the battery probes {battery.SAMPLE_PAGE}, which is not registered" + ) + + +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} + assert listed, "the battery lists no hidden paths at all" + for path in listed: + 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_card_check_catches_a_resized_cdn_object(wired): + """The failure no offline test can see, simulated. + + Someone re-exports the card at 1280x640 and uploads it. Every unit test + stays green; the tags still say 1200x630; the platform reserves that box + and crops into the new file. Only a battery that reads the real bytes + notices. + """ + original = wired.fetch_raw + + def resized(url, **kw): + if not url.startswith(BASE) and "://" in url: + return 200, {"content-type": "image/png"}, _png_header(1280, 640) + return original(url, **kw) + + wired.fetch_raw = resized + try: + wired.satellite_checks(BASE) + finally: + wired.fetch_raw = original + + verdicts = {name: verdict for name, verdict, _ in wired._RESULTS} + assert verdicts.get("social_card_real_pixels") == 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 CMD binds a different port" diff --git a/tests/test_pages.py b/tests/test_pages.py new file mode 100644 index 0000000..f6f7cf9 --- /dev/null +++ b/tests/test_pages.py @@ -0,0 +1,70 @@ +"""Every registered page serves, and every registry entry can unfurl. + +Two cheap sweeps that catch the two ways a page breaks without anyone +noticing: + +1. A route that 500s (an import-time error in a doc page's `.. exec::` module + takes the whole page down, and `debug=False` hides the traceback). +2. A `register_page` call missing `description=` or `image_url=` — Dash then + emits `content=""` for the meta tag, and an EMPTY og:image is worse than a + missing one because scrapers treat it as the declared image and render a + blank card. lib/constants.py states the rule; this is its enforcement at + the registry level, so a new page cannot land without either. +""" + +from __future__ import annotations + + +def test_every_registered_page_serves_200(client, pages): + """A browser GET on every registered path. + + "/404" is skipped: it is the layout Dash serves *for* unknown paths, not a + destination anyone navigates to — its own contract is the test below. + """ + failures = [] + for path, name, _entry in pages: + if path == "/404": + continue + response = client.get(path) + if not response.ok: + failures.append(f"{path} ({name}) -> {response.status}") + assert failures == [], f"pages did not serve: {failures}" + + +def test_unknown_paths_serve_the_spa_shell_not_an_error(client): + """What Dash actually does with a bogus path, pinned. + + Dash's page routing is client-side: the server answers ANY path with the + SPA shell (HTTP 200) and the renderer swaps in the "/404" layout after + hydration. So the assertable server-side contract is: a 200, an HTML + document, and the real app shell (title and renderer entry point) rather + than a backend error page. If this test ever sees a 404/500 status, the + routing changed underneath us and the "/404" page is likely unreachable. + """ + response = client.get("/definitely-not-a-registered-page") + assert response.status == 200, ( + f"bogus path returned {response.status}; Dash serves the shell + " + "client-side 404 layout, so anything else is a routing regression" + ) + assert "text/html" in response.content_type + assert "_dash-renderer" in response.text or "react-entry-point" in response.text, ( + "the bogus-path response is not the Dash shell — the 404 layout can " + "never render" + ) + + +def test_every_registry_entry_declares_description_and_image(pages): + """The empty-og:image guard, at the source instead of the response. + + The response-side tests in test_social_card.py sample the first 8 pages; + this covers all of them, including "/404" — a 404 unfurled into a chat + still shows a card. + """ + missing = [] + for path, name, entry in pages: + if not (entry.get("description") or "").strip(): + missing.append(f"{path} ({name}): empty description") + if not (entry.get("image_url") or "").strip(): + missing.append(f"{path} ({name}): empty image_url — og:image will " + "be content=\"\" and the share card renders blank") + assert missing == [], f"register_page calls missing metadata: {missing}" diff --git a/tests/test_site_identity.py b/tests/test_site_identity.py new file mode 100644 index 0000000..8ca5c07 --- /dev/null +++ b/tests/test_site_identity.py @@ -0,0 +1,215 @@ +"""Site identity: one brand, every surface, verbatim. + +NETWORK FILE: adapted from dash-email. 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. + +The precedent, measured live on email.2plot.dev: the /llms.txt H1 read a bare +"# Dash Email" — not a framework default, and not anything a reader could act +on either, because no package by that name exists on PyPI. The brand an agent +publishes has to be the string that finds the thing, which for this repo is +`pip install dash-mui-scheduler`. + +dash-improve-my-llms 2.3.4's `resolve_site_title` is what makes the identity +resolvable: it takes the home page's registered `name` first, `app.title` +second, and *skips* generic candidates ("Home", "Index", "Dash") rather than +publishing them. These tests assert both ends of that — the inputs this repo +controls, and the H1 it produces. +""" + +from __future__ import annotations + +import re + +from conftest import REPO_ROOT +from lib.constants import ( + PAGE_TITLE_PREFIX, + SITE_BRAND, + SITE_DESCRIPTION, + SITE_SHORT_NAME, +) + +# 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-mui-scheduler — MUI X scheduling 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_llms_doc_opens_with_the_brand(): + """run.py's `register_page_metadata(path="/", llms_doc=...)` is the home + body that /llms.txt serves. + + This repo has no home markdown file — the prose lives inline in run.py — + so the H1 an agent reads for the home page is whatever that literal says. + Reading the source rather than importing keeps this test free of Dash's + page-registry side effects (conftest boots the app for the tests that + need it). + """ + source = (REPO_ROOT / "run.py").read_text() + match = re.search(r'llms_doc=\(\s*"((?:[^"\\]|\\.)*)"', source) + assert match, "run.py no longer registers an inline llms_doc for the home page" + assert match.group(1).startswith(f"# {EXPECTED_BRAND}"), ( + f"the home llms_doc opens {match.group(1)[:60]!r}, not the brand H1" + ) + + +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 "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. The banner is templated markup, so the brand may + arrive HTML-escaped — compare both spellings rather than failing for a + reason that has nothing to do with identity. + """ + import html as html_module + + from conftest import BROWSER_ACCEPT, SAMPLE_PAGE + + 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_the_byline_is_in_the_description_not_the_brand(): + """Naming rules from the standard. + + The brand says what the site *is*; the byline says who made it. A brand of + "Pip Install Python" would make every satellite in the network share one + name. + + The PACKAGE name deliberately leads the brand: for a component library the + package IS what a reader came to find — same rule as leaflet.2plot.dev's + "dash-leaflet2 — Leaflet 2 maps for Dash". Nobody installs a template; a + lot of people install this. + """ + assert SITE_SHORT_NAME in SITE_BRAND + assert "Pip Install Python" in SITE_DESCRIPTION + assert "Pip Install Python" not in SITE_BRAND + + +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_home_page_display_name_really_is_generic(app_module): + """Why `register_page_metadata(path="/", name=SITE_BRAND)` is load-bearing. + + `pages/home.py` registers the page as "Home" — the nav label, which is + correct there and is on `resolve_site_title`'s skip list. Without the + separate metadata registration in run.py the identity would fall through to + `app.title`, which happens to be right today and would silently stop being + right the moment somebody set a marketing title on the Dash constructor. + """ + import dash + + from dash_improve_my_llms.handlers import _GENERIC_SITE_TITLES + + home = next(e for e in dash.page_registry.values() if e["path"] == "/") + assert home["name"].strip().lower() in _GENERIC_SITE_TITLES + + +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" + ) + + +# --------------------------------------------------------------------------- +# The per-page title — a share-card surface, not just a browser tab +# +# Dash passes each page's `title` straight into `og:title` and `twitter:title` +# (dash/_pages.py `_page_meta_tags`). PAGE_TITLE_PREFIX therefore sets the +# headline of every unfurl this site produces. Nobody sees their own share +# cards, so only a test catches it. +# --------------------------------------------------------------------------- + + +def test_the_page_title_prefix_is_derived_from_the_short_name(): + assert PAGE_TITLE_PREFIX == f"{SITE_SHORT_NAME} | " + + +def test_the_short_name_cannot_drift_from_the_brand(): + """Two constants, one identity. Derived, so this should be automatic.""" + assert SITE_BRAND.startswith(SITE_SHORT_NAME) + + +def test_the_share_card_headline_names_this_site(client): + """og:title and twitter:title, as a scraper reads them.""" + html = client.get("/").text + for tag in ("og:title", "twitter:title"): + found = re.findall( + rf'<meta[^>]*property="{tag}"[^>]*content="([^"]*)"', html + ) + assert found, f"no {tag} on the home page" + for value in found: + assert SITE_SHORT_NAME in value, f"{tag}={value!r} does not name this site" + + +def test_no_surface_still_carries_the_old_display_name(): + """A sweep, because a rename never lands everywhere at once. + + This repo's pre-standard title was "MUI X Scheduler for Plotly Dash". + Comments and docstrings are stripped first: a file below is allowed to + document the old value while explaining the fix, and that is the one + legitimate mention. + + Scope is the files that PUBLISH an identity. `setup.py` and the package + metadata are out, because the library's PyPI description is a different + thing from the site's name. README and the docs pages are out too — they + legitimately describe "the MUI X Scheduler" (the upstream library) in + prose that a whole-repo sweep could not tell from a stale title. + """ + offenders = [] + for path in ("lib/constants.py", "templates/index.html", + "assets/favicon/site.webmanifest", "scripts/network_smoke.py"): + text = (REPO_ROOT / path).read_text() + if path.endswith(".py"): + text = re.sub(r'"""(?:.|\n)*?"""', "", text) + text = re.sub(r"#.*", "", text) + text = re.sub(r"<!--.*?-->", "", text, flags=re.S) + if "MUI X Scheduler for Plotly Dash" in text: + offenders.append(path) + assert offenders == [], f"the old display name survives in {offenders}" diff --git a/tests/test_smoke_live.py b/tests/test_smoke_live.py new file mode 100644 index 0000000..51604a8 --- /dev/null +++ b/tests/test_smoke_live.py @@ -0,0 +1,334 @@ +"""Exercise scripts/smoke_live.py against the app itself. + +NETWORK FILE: adapted from dash-documentation-boilerplate 1.2.4 (via +dash-email). The script itself is copied verbatim apart from the robots +posture comment — this host runs `block_ai_training=False` on purpose — so +these tests are too, minus the backend matrix, which this repo does not have. + +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 +from lib.constants import BASE_URL, OG_IMAGE_HEIGHT, OG_IMAGE_URL, OG_IMAGE_WIDTH + + +def _png_bytes(width: int, height: int) -> str: + """A minimal PNG whose IHDR declares `width` x `height`. + + Returned as a `surrogateescape`-decoded str because that is the shape + `smoke_live.fetch` hands back — the script re-encodes it the same way to + recover the bytes. Only the 8-byte signature and the IHDR matter here; the + card check reads nothing else. + """ + header = b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + body = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x06\x00\x00\x00" + return (header + body).decode("utf-8", "surrogateescape") + + +# 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 the 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 + if url == OG_IMAGE_URL: + # The social card lives on the CDN, so it is off-host like the + # peers — but answering it with "# peer\n" would make the card + # checks fail for the wrong reason and, worse, would mean the + # dimension check never ran against anything. A real PNG header + # at the declared size exercises it properly. + return 200, _png_bytes(OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT), { + "Content-Type": "image/png" + } + 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"<main><p>{smoke.STUB_MARKER}</p></main>" + 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 = '<!DOCTYPE html><div class="dv-banner">chrome</div>' + 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): + # The CDN-hosted card is off-host too, but it is not a peer. Leaving it + # to the stub would fail the (correctly fatal) card checks and this + # test would pass or fail for a reason unrelated to its name. + if not url.startswith(BASE) and url != OG_IMAGE_URL: + return 200, "<!DOCTYPE html><html><body>app</body></html>", { + "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" + + +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): + # Peers only — the card is off-host but is this deployment's own + # responsibility, and its checks are fatal on purpose. + if not url.startswith(BASE) and url != OG_IMAGE_URL: + 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_reshaped_card_on_the_cdn_fails_the_deploy(wired, smoke, monkeypatch, capsys): + """The failure only this check can see. + + The card's dimensions are declared in three places — lib/constants.py, + templates/index.html, and the CDN object itself. The first two are pinned + against each other offline, but nothing offline can look at the third. + Replace the uploaded file with a differently-shaped one and every test + stays green while the platform reserves the wrong box and crops into it. + """ + original = smoke.fetch + + def reshaped(url, user_agent=smoke.BROWSER_UA, accept=None): + if url == OG_IMAGE_URL: + return 200, _png_bytes(600, 600), {"Content-Type": "image/png"} + return original(url, user_agent, accept) + + monkeypatch.setattr(smoke, "fetch", reshaped) + assert wired.main(BASE) > 0 + output = capsys.readouterr().out + assert "dimensions match the declared" in output + assert "file is 600x600" in output + + +def test_an_empty_og_image_fails_the_deploy(wired, smoke, monkeypatch, capsys): + """An empty og:image renders a BLANK card, and platforms cache the miss. + + Dash emits `image_url or ""` when no image_url is passed, and its tag + comes last in document order, so the empty one wins. Worse than declaring + none, because with none most platforms fall back to an in-page image. + """ + original = smoke.fetch + + def blanked(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + if url.rstrip("/") == BASE.rstrip("/"): + body = body.replace(f'property="og:image" content="{OG_IMAGE_URL}"', + 'property="og:image" content=""') + return status, body, headers + + monkeypatch.setattr(smoke, "fetch", blanked) + assert wired.main(BASE) > 0 + assert "og:image is not empty" in capsys.readouterr().out + + +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_an_unwired_bulletin_warns_but_does_not_fail_the_deploy( + wired, smoke, capsys +): + """How a satellite ships with an empty "What's new" panel. + + The conftest pins `NETWORK_BULLETIN_URL` to "" for the whole suite, so the + in-process app IS the unwired case — no monkeypatching needed to reproduce + it. Both banner panels still render (the package falls back to one generic + tip and "No announcements."), which is why nothing looks broken and why + only a check can report it. + + Warn, not fail: a satellite may legitimately run without a bulletin, and a + hub outage must never fail a deploy. + """ + assert wired.main(BASE) == 0 + output = capsys.readouterr().out + assert "warn the network bulletin is wired" in output + assert any("bulletin" in w for w in wired.warnings) + + +def test_a_wired_bulletin_raises_no_warning(wired, smoke, monkeypatch, capsys): + """The positive case, so the check cannot pass by always warning.""" + original = smoke.fetch + + def announced(url, user_agent=smoke.BROWSER_UA, accept=None): + status, body, headers = original(url, user_agent, accept) + return status, body.replace("No announcements.", "Launched a Federated Network"), headers + + monkeypatch.setattr(smoke, "fetch", announced) + assert wired.main(BASE) == 0 + output = capsys.readouterr().out + assert "ok the network bulletin is wired" in output diff --git a/tests/test_social_card.py b/tests/test_social_card.py new file mode 100644 index 0000000..74da04b --- /dev/null +++ b/tests/test_social_card.py @@ -0,0 +1,373 @@ +"""The social card and the installable-app surfaces. + +NETWORK FILE: adapted from dash-email (itself from dash-documentation- +boilerplate 1.2.4). It changes only where this site legitimately differs — +see the theme-colour and origin-token tests. + +Both things tested here fail silently and fail OUTSIDE the app, which is why +they need tests rather than a look at the page — nobody sees their own unfurls, +and no browser explains why it declined to offer an install. + +The two failures the network has actually shipped, measured live in 2026: + +1. **TWO empty og:image tags on every page.** No `register_page` call passed + `image_url=`, so Dash emitted `og:image=""` (dash/_pages.py) — and an EMPTY + tag unfurls worse than a missing one, because scrapers treat the empty value + as the declared image and render a blank card. It was doubled because the + template spelled the metas placeholder out inside an HTML comment, and Dash + substitutes placeholders by plain string replacement over the whole + template, comments included. + +2. **A manifest naming another site** — the string an installed icon would + carry on somebody's home screen forever. + +Note where each tag comes from, because it decides which file to open when one +of these fails: `og:image`, `twitter:image` and the `twitter:*` set are DASH's +(per page, from `register_page`); `og:site_name`, `og:url`, the `og:image:*` +auxiliaries and the icon links are `templates/index.html`'s. +dash-improve-my-llms adds a third set, but only on the prerender path — for +actual social scrapers `lib/social_cards.py` renders the template's head with +a per-page card, which is a separate surface `scripts/network_smoke.py` +covers. That is why deleting the template would silently kill every unfurl. +""" + +from __future__ import annotations + +import json +import re + +from conftest import REPO_ROOT +from lib.constants import ( + OG_IMAGE_ALT, + OG_IMAGE_HEIGHT, + OG_IMAGE_TYPE, + OG_IMAGE_URL, + OG_IMAGE_WIDTH, + SITE_BRAND, +) + +MANIFEST = REPO_ROOT / "assets" / "favicon" / "site.webmanifest" + + +def _visible(html: str) -> str: + """The document with HTML comments removed. + + The template documents itself extensively, and a regex cannot tell a + commented-out example tag from a live one. + """ + return re.sub(r"<!--.*?-->", "", html, flags=re.S) + + +def _meta(html: str, value: str) -> list[str]: + """Every `content` for a property/name — a list, so duplicates show up. + + Tags carrying `data-dimll-prerender` are excluded. dash-improve-my-llms + injects its own description and OpenGraph block on the prerender path, and + marks each one precisely so it can be told apart. Counting those here would + make this test fail on a package behaviour nothing in this repo controls, + and it would hide what the test is actually for: duplication between + `templates/index.html` and the tags Dash generates from `register_page`. + """ + pattern = ( + rf'<meta[^>]*(?:property|name)="{re.escape(value)}"[^>]*content="([^"]*)"' + rf'|<meta[^>]*content="([^"]*)"[^>]*(?:property|name)="{re.escape(value)}"' + ) + body = re.sub(r'<meta[^>]*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): + for path in page_paths[:8]: + images = _meta(client.get(path).text, "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_image_is_declared_exactly_once(client, page_paths): + """The duplicate-tag regression, in both of the ways it has happened.""" + for path in page_paths[:8]: + html = client.get(path).text + assert len(_meta(html, "og:image")) == 1, ( + f"{path} has {_meta(html, 'og:image')} — a scraper picks one, and " + "it will not be the one you meant" + ) + assert len(_meta(html, "twitter:image")) == 1 + + +def test_no_dash_placeholder_is_named_inside_a_comment(): + """The bug behind the doubled tags, pinned at its source. + + Dash resolves `{%…%}` by plain string replacement over the whole template. + A placeholder named in a comment is therefore not documentation — it is a + second, hidden copy of whatever that placeholder emits. The template's + comments deliberately say "the metas placeholder" in words rather than + spelling it; this keeps it that way. + """ + template = (REPO_ROOT / "templates" / "index.html").read_text() + for comment in re.findall(r"<!--.*?-->", template, flags=re.S): + found = re.findall(r"\{%\s*\w+\s*%\}", comment) + assert not found, ( + f"a comment in templates/index.html names {found} — Dash will " + "substitute it there and emit the block twice" + ) + + +def test_the_image_is_not_an_svg(client): + """SVG is rejected by Facebook, Twitter/X, LinkedIn and Slack alike. + + Dash's asset inference reaches `logo.<ext>`, so this is one missing + `image_url=` and one added `assets/logo.svg` away from happening — and + this repo DOES ship an `assets/dms_logo.svg`. + """ + for prop in ("og:image", "twitter:image"): + for src in _meta(client.get("/").text, prop): + assert not src.lower().endswith(".svg"), f"{prop} is an SVG: {src}" + + +def test_the_image_is_absolute_and_matches_the_constant(client): + for prop in ("og:image", "twitter:image"): + values = _meta(client.get("/").text, prop) + assert values, f"no {prop} on the home page" + for src in values: + assert src.startswith("http"), f"{prop}={src!r} is not absolute" + assert src == OG_IMAGE_URL + + +def test_the_image_is_hosted_off_the_app(): + """The card must be on the CDN, not served by this app. + + Not a style rule. A card the app serves is fetched by the scraper at unfurl + time; on a cold free-tier container that request lands mid-wake and times + out, the preview renders blank ONCE, and the platform caches the miss — so + the first person to share the link poisons it for everyone. + + That the URL RESOLVES is deliberately not checked here. It is off-host now, + and reaching a third party would make this suite depend on Cloudflare being + up (the same reason conftest disables the geo lookup). + `scripts/network_smoke.py` and `scripts/smoke_live.py` fetch the real file + after every deploy and read its IHDR chunk — which also catches the CDN + object being replaced with something a different shape, something no + offline test can see. + """ + assert OG_IMAGE_URL.startswith("https://cdn.2plot.ai/github_assets/"), ( + f"{OG_IMAGE_URL} is not on the network CDN" + ) + assert "/assets/" not in OG_IMAGE_URL, "the app is serving its own card again" + + +def test_the_card_url_names_this_domain(): + """One card per host. A satellite pointing at another's card is a real + mistake and an easy one — the CDN path is a hand-typed filename.""" + assert OG_IMAGE_URL.endswith("/muischeduler.2plot.dev.png") + + +def test_the_auxiliary_image_tags_match_the_constants(client): + """index.html hard-codes the dimensions; lib/constants.py is the source. + + A declared width/height that disagrees with the file is worse than + declaring none — the platform reserves the wrong box and crops. + """ + html = client.get("/").text + assert _meta(html, "og:image:width") == [str(OG_IMAGE_WIDTH)] + assert _meta(html, "og:image:height") == [str(OG_IMAGE_HEIGHT)] + assert _meta(html, "og:image:alt") == [OG_IMAGE_ALT] + assert _meta(html, "og:image:type") == [OG_IMAGE_TYPE] + assert _meta(html, "og:image:secure_url") == [OG_IMAGE_URL], ( + "secure_url must be the same file as og:image, not a stale copy" + ) + + +def test_the_declared_ratio_suits_a_large_image_card(): + """`summary_large_image` wants roughly 1.91:1.""" + ratio = OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT + assert 1.7 <= ratio <= 2.05, f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT} is {ratio:.2f}:1" + + +def test_the_rendered_card_on_disk_is_the_declared_shape(): + """If `scripts/make_social_card.py` has been run, its output must agree. + + The build directory is gitignored, so this skips on a fresh checkout — it + is here for the machine that generated the card and is about to upload it, + which is the moment the dimensions can still be fixed cheaply. + """ + import pytest + + card = REPO_ROOT / "build" / "social-cards" / "muischeduler.2plot.dev.png" + if not card.exists(): + pytest.skip("no rendered card in build/social-cards (gitignored)") + + raw = card.read_bytes() + assert raw[1:4] == b"PNG" + assert int.from_bytes(raw[16:20], "big") == OG_IMAGE_WIDTH + assert int.from_bytes(raw[20:24], "big") == OG_IMAGE_HEIGHT + + +def test_the_twitter_card_is_a_large_image(client): + assert _meta(client.get("/").text, "twitter:card") == ["summary_large_image"] + + +def test_no_meta_tag_dash_emits_is_also_declared_statically(client): + """The rule the OG and Twitter blocks in index.html are built on. + + Dash emits all of these per page. A static copy in the template makes two + of each, and the static one describes the SITE where Dash's describes the + PAGE — so the duplicate is both redundant and the less accurate of the two. + """ + html = client.get("/").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, 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 — do not delete these thinking Dash covers them.""" + html = client.get("/").text + for tag in ("og:site_name", "og:url", "og:image:alt", "twitter:image:alt", + "og:image:secure_url", "og:image:type", + "og:image:width", "og:image:height"): + assert _meta(html, tag), f"{tag} is missing and Dash does not emit it" + + +def test_og_site_name_is_the_brand(client): + assert _meta(client.get("/").text, "og:site_name") == [SITE_BRAND] + + +# ------------------------------------------------------------- the manifest -- + + +def test_the_manifest_is_linked_and_served(client): + html = _visible(client.get("/").text) + assert 'rel="manifest"' in html, "no manifest link — no install prompt" + match = re.search(r'<link[^>]+rel="manifest"[^>]+href="([^"]+)"', html) + assert match + assert client.get(match.group(1)).ok, "the manifest link 404s" + + +def test_the_manifest_describes_THIS_site(): + """The string an installed icon carries on someone's home screen forever. + + On another satellite this file shipped still naming the hub it was copied + from — an installed app takes its label from `short_name`, so a wrong + string here becomes a permanent icon on someone's phone. + """ + manifest = json.loads(MANIFEST.read_text()) + assert manifest["name"] == SITE_BRAND + assert "2plot.dev" not in manifest["short_name"] + assert "2plot.dev" not in manifest["description"] + + +def test_the_manifest_is_installable(): + 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" + + +def test_every_manifest_icon_resolves(client): + manifest = json.loads(MANIFEST.read_text()) + icons = manifest.get("icons") or [] + assert icons, "the manifest declares no icons" + for icon in icons: + assert client.get(icon["src"]).ok, f"manifest icon {icon['src']} 404s" + 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 = _visible(client.get("/").text) + match = re.search(r'<link[^>]*rel="apple-touch-icon"[^>]*href="([^"]+)"', html) + assert match, "no apple-touch-icon link" + assert client.get(match.group(1)).ok, f"{match.group(1)} does not resolve" + + +def test_the_theme_colour_agrees_with_the_manifest(client): + """A mismatch is one colour in the browser chrome, another on the splash. + + THIS SITE DIVERGES FROM dash-email, deliberately: it declares ONE + theme-colour — the #3399ff brand accent — and the manifest carries the + same value, while `background_color` stays white for the install splash. + (dash-email declares two media-scoped colours and pins the manifest to the + dark one; this appshell paints its own surfaces, so a single accent is the + honest declaration here.) The assertion is membership so a future second, + media-scoped declaration does not break it. + """ + manifest = json.loads(MANIFEST.read_text()) + declared = [c.lower() for c in _meta(client.get("/").text, "theme-color")] + assert declared, "no theme-color" + assert manifest["theme_color"].lower() in declared, ( + f"manifest theme_color {manifest['theme_color']} matches none of the " + f"declared theme-colours {declared}" + ) + assert manifest.get("background_color", "").strip(), ( + "no background_color — the install splash paints black" + ) + + +def test_every_asset_the_template_references_resolves(client): + """The half-landed-commit guard. + + The boilerplate once shipped a template pointing at `/assets/favicon/…` + while the icon set sat UNTRACKED in git. The deploy builds from git, so + production 404'd the manifest, the apple-touch-icon and every PNG icon — + the whole installable-app surface — while every local boot looked perfect + because the files were on disk. `git status` was the only place it showed. + """ + html = _visible(client.get("/").text) + referenced = sorted(set(re.findall(r'(?:href|content|src)="(/assets/[^"]+)"', html))) + assert referenced, "no /assets/ references found — did the template change?" + + missing = [ref for ref in referenced if not client.get(ref).ok] + assert missing == [], ( + f"templates/index.html references assets that do not resolve: {missing}. " + "If they exist on disk, they are untracked — the deploy builds from git." + ) + + +def test_the_index_template_is_still_wired_in(app_module): + """`templates/index.html` looks removable and is not. + + 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 kills every unfurl, the icons and the manifest at once. + """ + 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("<!DOCTYPE html>") + + +def test_the_template_takes_its_origin_from_the_constants(app_module, client): + """No second copy of the canonical origin. + + The template is a static file and cannot import lib/constants, so run.py + substitutes `__BASE_URL__` and `__VERSION__` at startup; `__PAGE_URL__` is + deliberately left in `index_string` — the index hook fills it with the + REQUESTED page's canonical URL on every response. If a hand-typed origin + ever appears in the template, half the site can end up advertising one + hostname and half another — and nothing looks broken. + """ + from lib.constants import BASE_URL + + raw = (REPO_ROOT / "templates" / "index.html").read_text() + for token in ("__BASE_URL__", "__PAGE_URL__", "__VERSION__"): + assert token in raw, f"the template no longer uses the {token} token" + + index_string = app_module.app.index_string + assert "__BASE_URL__" not in index_string, "run.py did not substitute the origin token" + assert "__VERSION__" not in index_string, "run.py did not substitute the version token" + assert BASE_URL in index_string + + # The per-request half: a served page must have no token left in it. + assert "__PAGE_URL__" not in client.get("/").text, ( + "the index hook did not fill __PAGE_URL__ — canonical and og:url are broken" + ) diff --git a/vendor/dash_clerk_auth-0.9.0.tar.gz b/vendor/dash_clerk_auth-0.9.0.tar.gz deleted file mode 100644 index 290b84e..0000000 Binary files a/vendor/dash_clerk_auth-0.9.0.tar.gz and /dev/null differ diff --git a/vendor/dash_clerk_auth-0.9.1.tar.gz b/vendor/dash_clerk_auth-0.9.1.tar.gz new file mode 100644 index 0000000..adb8d6b Binary files /dev/null and b/vendor/dash_clerk_auth-0.9.1.tar.gz differ diff --git a/vendor/dash_improve_my_llms-2.0.0.tar.gz b/vendor/dash_improve_my_llms-2.0.0.tar.gz deleted file mode 100644 index d1bf193..0000000 Binary files a/vendor/dash_improve_my_llms-2.0.0.tar.gz and /dev/null differ