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.` 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 @@
-# 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
[](https://discord.gg/WEnZR35mrK)
[](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)
@@ -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
+# , 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: ```` tags in ````, 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 , 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 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 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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 ```` 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 //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 → .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
+ # 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: 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' \n'
+ f' \n'
+ f' \n'
+ f' \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' \n' + extra
+ return html.replace("", extra + "", 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, //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 meta, so we strip the Dash placeholders → a static string.
+# them templates/index.html's 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/.png`, which is gitignored. The
+card is NOT served by the app — publish it to the CDN:
+
+ https://cdn.2plot.ai/github_assets/.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/.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