Files
datatools-dev/landing/deploy.py
Michael e7ec79b9b5 demo: retarget landing pages to the accounting audience
Reorients the whole sales surface to accounting so it matches the rebuilt
demos. Replaces the Shopify and RevOps persona pages with accounts-payable
(1099) and accounts-receivable pages, refreshes the bookkeeper page, and
rewires the hub + deploy tooling:

- landing/bookkeeper/  — refreshed to the validated bank-rec demo
  (26 -> 20, six phantom duplicates), iframe ?p=bookkeeper.
- landing/ap-1099/     — NEW (replaces shopify-pet/): 1099 vendor prep,
  "24 records -> 8 vendors, 7 missing EINs recovered", iframe ?p=ap-1099,
  amber accent.
- landing/ar-aging/    — NEW (replaces revops/): AR open invoices,
  "26 -> 21, five double-entered invoices removed", iframe ?p=ar-aging,
  green accent.
- landing/index.html   — hub rewritten with the three accounting cards.
- deploy.py / deploy.config.example.json / README.md / _shared/styles.css
  — persona list, sitemap defaults, 404 links, cross-links, docs updated.

All demo iframes now point at the renamed app_demo personas; deploy.py
builds the dist bundle cleanly (verified) and the Gumroad ?from= tags match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:59:50 +00:00

236 lines
7.8 KiB
Python

"""Build a deploy-ready ``landing/dist/`` from the source HTML.
Run from the repo root after copying ``landing/deploy.config.example.json``
to ``landing/deploy.config.json`` and filling in the real URLs:
python3 landing/deploy.py
Output:
landing/dist/index.html
landing/dist/bookkeeper/index.html
landing/dist/ap-1099/index.html
landing/dist/ar-aging/index.html
landing/dist/_shared/styles.css
landing/dist/robots.txt
landing/dist/sitemap.xml
landing/dist/404.html
landing/dist/favicon.svg
Upload ``landing/dist/`` to Cloudflare Pages (drag-and-drop in the
dashboard, or ``wrangler pages deploy landing/dist``).
Why this script exists:
The source HTML carries placeholder URLs (``{{demo_base_url}}``,
``{{gumroad_url}}``, ``{{support_email}}``, ``{{site_origin}}``)
so the operator's actual demo / Gumroad / domain URLs aren't
committed to the repo. This script reads the operator's config
and produces a ready-to-upload bundle.
It also stamps a sitemap.xml + robots.txt + 404.html and copies
the shared CSS so the output directory is fully self-contained.
"""
from __future__ import annotations
import json
import re
import shutil
import sys
from datetime import date
from pathlib import Path
LANDING = Path(__file__).resolve().parent
REPO = LANDING.parent
DIST = LANDING / "dist"
CONFIG_PATH = LANDING / "deploy.config.json"
EXAMPLE_PATH = LANDING / "deploy.config.example.json"
# Files to substitute and copy. Order matters only for readability.
HTML_PAGES = [
LANDING / "index.html",
LANDING / "bookkeeper" / "index.html",
LANDING / "ap-1099" / "index.html",
LANDING / "ar-aging" / "index.html",
]
SHARED = LANDING / "_shared" / "styles.css"
def _load_config() -> dict:
if not CONFIG_PATH.exists():
sys.stderr.write(
f"\nERROR: {CONFIG_PATH.name} not found.\n"
f" cp {EXAMPLE_PATH.name} {CONFIG_PATH.name}\n"
f" edit {CONFIG_PATH.name} with your real URLs\n"
f" re-run: python3 landing/deploy.py\n\n"
)
sys.exit(2)
cfg = json.loads(CONFIG_PATH.read_text())
required = ("site_origin", "demo_base_url", "gumroad_listing", "support_email")
missing = [k for k in required if not cfg.get(k)]
if missing:
sys.stderr.write(
f"\nERROR: {CONFIG_PATH.name} is missing required fields: {missing}\n"
f" See {EXAMPLE_PATH.name} for the full template.\n\n"
)
sys.exit(2)
return cfg
def _substitute(text: str, cfg: dict) -> str:
"""Replace placeholders + the demo / Gumroad URL patterns the source HTML uses today."""
site_origin = cfg["site_origin"].rstrip("/")
demo_base = cfg["demo_base_url"].rstrip("/")
gumroad_base = cfg["gumroad_listing"]
support_email = cfg["support_email"]
# Direct placeholder tokens (clean approach — used by future copy).
text = text.replace("{{site_origin}}", site_origin)
text = text.replace("{{demo_base_url}}", demo_base)
text = text.replace("{{gumroad_url}}", gumroad_base)
text = text.replace("{{support_email}}", support_email)
# Backwards-compatible patterns: the source HTML in this repo carries
# literal ``https://datatools.app`` and ``https://demo.datatools.app``
# so this script swaps those too. Once new pages adopt the
# ``{{placeholder}}`` style above, this layer can be retired.
text = re.sub(
r"https://demo\.datatools\.app",
demo_base,
text,
)
# Replace ``https://datatools.app/...`` for canonical / OG URLs but
# do NOT swap ``https://datatools.app`` when it is followed by an
# at-sign as part of an email address (no such case today; defensive).
text = re.sub(
r"https://datatools\.app",
site_origin,
text,
)
# Gumroad URL family — preserve the ``?from=<persona>`` query.
text = re.sub(
r"https://gumroad\.com/l/datatools",
gumroad_base.rstrip("/").replace("/l/datatools", "/l/datatools"),
text,
)
# Support email shows up only as ``mailto:hello@datatools.app``.
text = text.replace("mailto:hello@datatools.app", f"mailto:{support_email}")
text = text.replace("hello@datatools.app", support_email)
return text
def _stamp_sitemap(cfg: dict) -> str:
site = cfg["site_origin"].rstrip("/")
today = date.today().isoformat()
urls = [site + "/"] + [
f"{site}/{p}/" for p in cfg.get("personas", ["bookkeeper", "ap-1099", "ar-aging"])
]
items = "\n".join(
f" <url><loc>{u}</loc><lastmod>{today}</lastmod></url>"
for u in urls
)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
f"{items}\n"
"</urlset>\n"
)
def _robots_txt(cfg: dict) -> str:
return (
"# Allow everything; we want every persona page indexable.\n"
"User-agent: *\n"
"Allow: /\n"
f"Sitemap: {cfg['site_origin'].rstrip('/')}/sitemap.xml\n"
)
def _favicon_svg() -> str:
"""Tiny self-contained SVG favicon — broom emoji-style mark."""
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">\n'
' <rect width="64" height="64" rx="14" fill="#0f1115"/>\n'
' <circle cx="32" cy="32" r="9" fill="#6ee7b7"/>\n'
"</svg>\n"
)
def _build_404_html(cfg: dict) -> str:
"""Cloudflare Pages serves 404.html when a path doesn't match."""
site_origin = cfg["site_origin"].rstrip("/")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Not found · DataTools</title>
<link rel="stylesheet" href="/_shared/styles.css" />
</head>
<body>
<section class="hero" style="text-align: center;">
<div class="container">
<div class="eyebrow">404</div>
<h1>That page isn't here.</h1>
<p class="lead" style="margin: 0 auto 28px;">Pick a workflow below to land somewhere useful.</p>
<p>
<a class="btn" href="{site_origin}/bookkeeper/">For bookkeepers</a>
&nbsp;
<a class="btn" href="{site_origin}/ap-1099/">For AP / 1099</a>
&nbsp;
<a class="btn" href="{site_origin}/ar-aging/">For AR</a>
</p>
</div>
</section>
</body>
</html>
"""
def main() -> int:
cfg = _load_config()
if DIST.exists():
shutil.rmtree(DIST)
DIST.mkdir(parents=True)
# Shared CSS (same path the source HTML expects: ``../_shared/styles.css``)
(DIST / "_shared").mkdir()
shutil.copy(SHARED, DIST / "_shared" / "styles.css")
# Per-page substitutions
page_count = 0
for src in HTML_PAGES:
rel = src.relative_to(LANDING)
dest = DIST / rel
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(_substitute(src.read_text(), cfg))
page_count += 1
# Stamped supporting files
(DIST / "robots.txt").write_text(_robots_txt(cfg))
(DIST / "sitemap.xml").write_text(_stamp_sitemap(cfg))
(DIST / "404.html").write_text(_build_404_html(cfg))
(DIST / "favicon.svg").write_text(_favicon_svg())
# Final report
print(f"\n✓ Built {page_count} HTML pages + sitemap + robots + 404 + favicon")
print(f" Output: {DIST.relative_to(REPO)}/")
print()
print("Next steps:")
print(" 1) wrangler pages deploy landing/dist # if you use Wrangler")
print(" OR drag-and-drop landing/dist/ in the Cloudflare Pages dashboard")
print(" 2) Configure custom domain on Cloudflare Pages → "
f"{cfg['site_origin']}")
print(" 3) Verify: open the deployed apex URL, click each persona "
"card, click each demo iframe, click each buy button → Gumroad listing")
print()
return 0
if __name__ == "__main__":
sys.exit(main())