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>
236 lines
7.8 KiB
Python
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>
|
|
|
|
<a class="btn" href="{site_origin}/ap-1099/">For AP / 1099</a>
|
|
|
|
<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())
|