From 7942cf2ee0943c92908aa6a7d69ff3a9ba82e3ec Mon Sep 17 00:00:00 2001 From: Chris Czub Date: Wed, 22 Jul 2026 00:52:45 -0400 Subject: [PATCH] Generate the Pages demo from web/ at deploy time demo/ no longer vendors copies of the viewer's index.html/app.css/app.js. The Pages workflow now runs demo/build_demo.py, which builds the site from web/ (the single source of truth) plus demo/summary.json, applying the demo deltas mechanically: static ./summary.json data source, relative asset paths, demo title/heading, synthetic-data banner and footer. Every substitution asserts its anchor matched exactly once, so viewer drift fails the deploy instead of silently publishing a stale or broken demo. The deploy also triggers on web/** changes now. Closes #19 --- .github/workflows/pages.yml | 5 +- .gitignore | 1 + demo/app.css | 93 ------------------------------------- demo/app.js | 77 ------------------------------ demo/build_demo.py | 93 +++++++++++++++++++++++++++++++++++++ demo/index.html | 63 ------------------------- 6 files changed, 98 insertions(+), 234 deletions(-) delete mode 100644 demo/app.css delete mode 100644 demo/app.js create mode 100644 demo/build_demo.py delete mode 100644 demo/index.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 3d3e4e7..01907ae 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - "demo/**" + - "web/**" - ".github/workflows/pages.yml" workflow_dispatch: @@ -26,8 +27,10 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/configure-pages@v5 + - name: Build demo from web/ assets + run: python3 demo/build_demo.py _site - uses: actions/upload-pages-artifact@v3 with: - path: demo + path: _site - id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 883c823..a64310c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ tokencache.json stats.db* credentials.json *.swp +_site/ diff --git a/demo/app.css b/demo/app.css deleted file mode 100644 index d073558..0000000 --- a/demo/app.css +++ /dev/null @@ -1,93 +0,0 @@ -:root { - --bg: #fafafa; - --fg: #1a1a1a; - --muted: #6b6b6b; - --card: #ffffff; - --border: #e2e2e2; - --accent: #2563eb; -} -@media (prefers-color-scheme: dark) { - :root { - --bg: #16181d; - --fg: #e8e8e8; - --muted: #9a9a9a; - --card: #1f2229; - --border: #33363e; - --accent: #7aa2f7; - } -} -* { box-sizing: border-box; } -body { - margin: 0; - background: var(--bg); - color: var(--fg); - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - line-height: 1.5; -} -main { max-width: 60rem; margin: 0 auto; padding: 1.5rem 1rem 3rem; } -h1 { font-size: 1.4rem; margin: 0 0 1rem; } -h1 span { color: var(--muted); font-weight: normal; font-size: 1rem; } -.hidden { display: none; } -.muted { color: var(--muted); } - -.tiles { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 1.5rem; } -.tile { - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.75rem 1rem; - min-width: 10rem; - flex: 1 1 10rem; -} -.tile .label { font-size: 0.8rem; color: var(--muted); } -.tile .value { - font-size: 1.5rem; - font-weight: 600; - overflow-wrap: anywhere; -} -.tile .value.small { font-size: 1.05rem; } - -table { width: 100%; border-collapse: collapse; background: var(--card); - border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } -th, td { text-align: left; padding: 0.45rem 0.75rem; border-top: 1px solid var(--border); } -thead th { border-top: none; background: var(--card); font-size: 0.8rem; - text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); } -td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } -td.sender { overflow-wrap: anywhere; } - -.notice { - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1.25rem; -} -.notice h2 { margin-top: 0; font-size: 1.1rem; } -pre { - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.75rem; - overflow-x: auto; - font-size: 0.85rem; -} -button { - font: inherit; - color: var(--fg); - background: var(--card); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.35rem 0.9rem; - cursor: pointer; -} -button:hover { border-color: var(--accent); color: var(--accent); } - -/* Demo-only additions (not in the local viewer's stylesheet) */ -.demo-banner { - background: color-mix(in srgb, var(--accent) 12%, var(--card)); - border: 1px solid var(--accent); - border-radius: 8px; - padding: 0.6rem 0.9rem; - margin: 0 0 1rem; - font-size: 0.9rem; -} -a { color: var(--accent); } diff --git a/demo/app.js b/demo/app.js deleted file mode 100644 index 1df69b1..0000000 --- a/demo/app.js +++ /dev/null @@ -1,77 +0,0 @@ -"use strict"; - -// Vendored from the local viewer (web/app.js on the issue-11 branch), with -// the server-specific parts removed: data is a static ./summary.json, so -// there is no "missing DB" setup flow and no server error envelope. -// Sender strings are still only ever rendered via textContent — never -// innerHTML — to keep parity with the viewer's handling of untrusted input. - -const views = ["loading", "error", "stats"]; - -function showView(name) { - for (const id of views) { - document.getElementById(id).classList.toggle("hidden", id !== name); - } -} - -function showError(message) { - document.getElementById("error-message").textContent = message; - showView("error"); -} - -function render(data) { - const senders = Array.isArray(data.senders) ? data.senders : []; - - document.getElementById("total-messages").textContent = - Number(data.total_messages || 0).toLocaleString(); - document.getElementById("distinct-senders").textContent = - senders.length.toLocaleString(); - document.getElementById("top-sender").textContent = - senders.length > 0 ? senders[0].sender : "—"; - - const tbody = document.getElementById("sender-rows"); - tbody.replaceChildren(); - for (const row of senders) { - const tr = document.createElement("tr"); - const senderCell = document.createElement("td"); - senderCell.className = "sender"; - senderCell.textContent = row.sender; - const countCell = document.createElement("td"); - countCell.className = "num"; - countCell.textContent = Number(row.mails_sent || 0).toLocaleString(); - tr.append(senderCell, countCell); - tbody.appendChild(tr); - } - - const generated = document.getElementById("generated-at"); - if (data.generated_at_unix) { - generated.textContent = - "Synthetic data generated " + - new Date(data.generated_at_unix * 1000).toLocaleDateString(); - } else { - generated.textContent = ""; - } - - showView("stats"); -} - -async function load() { - showView("loading"); - let response; - let body; - try { - response = await fetch("./summary.json"); - body = await response.json(); - } catch (err) { - showError("Could not load the demo data."); - return; - } - if (!response.ok) { - showError("Could not load the demo data."); - return; - } - render(body); -} - -document.getElementById("retry-error").addEventListener("click", load); -load(); diff --git a/demo/build_demo.py b/demo/build_demo.py new file mode 100644 index 0000000..74430d1 --- /dev/null +++ b/demo/build_demo.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Build the static GitHub Pages demo from the local viewer's assets. + +web/ is the single source of truth for the UI. This script copies the +viewer's index.html/app.css/app.js into an output directory and applies the +demo deltas mechanically: data comes from a committed ./summary.json instead +of the live /api/summary, asset paths become relative (Pages serves under a +subpath), and the page is labeled as a synthetic-data demo. + +Every substitution asserts that its anchor text matched exactly once, so if +web/ drifts in a way this transform doesn't understand, the Pages deploy +fails instead of silently publishing a broken or stale demo. + +Run: python3 demo/build_demo.py [output-dir] (default: _site) +""" + +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +WEB = ROOT / "web" +DEMO = ROOT / "demo" + +DEMO_BANNER = ( + '
\n' + " Demo — all data on this page is synthetic, for a fictional Gmail account.\n" + " No real inbox was scanned.\n" + "
\n\n " +) + +DEMO_FOOTER = ( + '

\n' + " This is a static demo of the\n" + ' gmail-stats local web\n' + " viewer, populated with synthetic data. All sender addresses use reserved\n" + " example domains; no real email data is involved.\n" + "

\n" +) + +DEMO_EXTRA_CSS = """ +/* Demo-only additions, appended by demo/build_demo.py */ +.demo-banner { + background: color-mix(in srgb, var(--accent) 12%, var(--card)); + border: 1px solid var(--accent); + border-radius: 8px; + padding: 0.6rem 0.9rem; + margin: 0 0 1rem; + font-size: 0.9rem; +} +a { color: var(--accent); } +""" + + +def replace_exactly(text, old, new, what): + count = text.count(old) + assert count == 1, ( + f"demo build: expected exactly one occurrence of {what!r} in the viewer " + f"assets, found {count}. web/ has drifted — update demo/build_demo.py." + ) + return text.replace(old, new) + + +def main(): + out = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "_site" + out.mkdir(parents=True, exist_ok=True) + + js = (WEB / "app.js").read_text() + js = replace_exactly(js, 'fetch("/api/summary")', 'fetch("./summary.json")', + "the /api/summary fetch") + (out / "app.js").write_text(js) + + (out / "app.css").write_text((WEB / "app.css").read_text() + DEMO_EXTRA_CSS) + + html = (WEB / "index.html").read_text() + html = replace_exactly(html, '', + '', "the stylesheet link") + html = replace_exactly(html, '', + '', "the script tag") + html = replace_exactly(html, "gmail-stats", + "gmail-stats · demo", "the page title") + html = replace_exactly(html, "

gmail-stats · local viewer

", + "

gmail-stats · public demo

", "the heading") + html = replace_exactly(html, "
\n ", "
\n " + DEMO_BANNER, "the main open tag") + html = replace_exactly(html, "
", DEMO_FOOTER + "
", "the main close tag") + (out / "index.html").write_text(html) + + shutil.copy(DEMO / "summary.json", out / "summary.json") + print(f"demo built into {out}: " + ", ".join(sorted(p.name for p in out.iterdir()))) + + +if __name__ == "__main__": + main() diff --git a/demo/index.html b/demo/index.html deleted file mode 100644 index adebe53..0000000 --- a/demo/index.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - -gmail-stats · demo - - - - - -
-
- Demo — all data on this page is synthetic, for a fictional Gmail account. - No real inbox was scanned. -
- -

gmail-stats · public demo

- -
-

Loading…

-
- - - - -
- -