From 1fb961a12ac82e0489e39737abd31fd6344c5c35 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:07:43 +0000 Subject: [PATCH] .mdeck (2/3): Whop static build with member and dashboard views, plus zip build script (#131) Co-authored-by: Time --- ventures/mdecks/.gitignore | 1 + ventures/mdecks/README.md | 15 ++++++ ventures/mdecks/tools/whop-build.sh | 74 ++++++++++++++++++++++++++ ventures/mdecks/tools/whop-check.sh | 80 +++++++++++++++++++++++++++++ ventures/mdecks/whop.js | 13 +++++ 5 files changed, 183 insertions(+) create mode 100644 ventures/mdecks/.gitignore create mode 100644 ventures/mdecks/tools/whop-build.sh create mode 100644 ventures/mdecks/tools/whop-check.sh create mode 100644 ventures/mdecks/whop.js diff --git a/ventures/mdecks/.gitignore b/ventures/mdecks/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/ventures/mdecks/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/ventures/mdecks/README.md b/ventures/mdecks/README.md index e92cc02..d9ad3d6 100644 --- a/ventures/mdecks/README.md +++ b/ventures/mdecks/README.md @@ -58,6 +58,21 @@ python3 -c 'import base64,sys,zlib; c=zlib.compressobj(9,zlib.DEFLATED,-15); d=c Without compression: `"https://mdecks.yolopreneur.lol/#md=" + encodeURIComponent(text)`. +## On Whop (static build) + +`sh tools/whop-build.sh` writes `dist/mdeck-whop.zip` and `dist/mdeck-whop.zip.sha256` (sha256sum format, next to the zip): a static site of the editor and presenter, without the public page's pricing and sign-up sections. `sh tools/whop-check.sh` builds it, checks the checksum, unzips it, serves it like a static host and requests the view paths. Nothing is uploaded; creating the app on Whop and uploading the zip is a separate step. + +Views to register on Whop, with the path Whop fills the ID into: + +| View type | Path | What it shows | +|---|---|---| +| Experience (member view) | `/experiences/[experienceId]` | the editor and presenter | +| Dashboard (creator's view) | `/dashboard/[companyId]` | the same editor, with **Copy prompt for your agent** | + +No other view is declared (there is no `openapi` view yet). + +A static host serves a dynamic segment only from a real directory or a rewrite rule. The zip ships `experiences/index.html`, `dashboard/index.html` and a `404.html`, all the same app with absolute asset paths, so `/experiences/` and `/dashboard/`, at any depth, load it; the app reads its path to tell the two views apart. This is the safe approach for any static host, chosen without a Whop build reference (docs.whop.com wasn't consulted). + ## Honest limits - Nothing is stored — close the tab without copying a link or the HTML and your text is gone. diff --git a/ventures/mdecks/tools/whop-build.sh b/ventures/mdecks/tools/whop-build.sh new file mode 100644 index 0000000..44d2780 --- /dev/null +++ b/ventures/mdecks/tools/whop-build.sh @@ -0,0 +1,74 @@ +#!/bin/sh +# Builds the Whop upload for .mdeck: one zip of a static site, and its sha256 next to it. +# +# ventures/mdecks/tools/whop-build.sh [outdir] (outdir defaults to ventures/mdecks/dist) +# +# Writes /mdeck-whop.zip and /mdeck-whop.zip.sha256 (sha256sum format). Nothing is uploaded. +# +# The site is the editor and presenter only (no pricing or marketing sections), served at: +# /experiences/ member view +# /dashboard/ creator's dashboard view +# A static host resolves a dynamic segment only with a real directory or a rewrite, so the zip ships +# experiences/index.html, dashboard/index.html and a 404.html that are all the same app, with absolute asset +# paths: any /experiences/ or /dashboard/ (however deep) lands on the app. +set -eu + +here=$(cd "$(dirname "$0")/.." && pwd) +out=${1:-$here/dist} +mkdir -p "$out" +out=$(cd "$out" && pwd) +zip="$out/mdeck-whop.zip" + +HERE="$here" ZIP="$zip" python3 - <<'PY' +import os, re, zipfile + +here, target = os.environ["HERE"], os.environ["ZIP"] + +def read(name): + with open(os.path.join(here, name), encoding="utf-8") as f: + return f.read() + +def sub1(pattern, repl, text, flags=0): + new, n = re.subn(pattern, repl, text, count=1, flags=flags) + if n != 1: + raise SystemExit("whop-build: index.html no longer matches " + repr(pattern)) + return new + +# The app page: index.html without the header's early-access note and button, and without everything after the +# editor (how it works, pricing, limits, footer): those belong to the public page. +page = read("index.html") +page = sub1(r'\s*

.*?

', "", page) +page = sub1(r'\s*).*?()', r"\1\n\n\2", page, re.S) +# Absolute paths, so the same file works at any depth; whop.js tells the two views apart. +page = sub1(r'', + '', page) +page = sub1(r"", "\n", page) + +app = read("app.js") +if 'fetch("SKILL.md")' not in app: + raise SystemExit("whop-build: app.js no longer fetches SKILL.md") +app = app.replace('fetch("SKILL.md")', 'fetch("/SKILL.md")') + +files = { + "index.html": page, + "experiences/index.html": page, + "dashboard/index.html": page, + "404.html": page, + "app.js": app, + "whop.js": read("whop.js"), + "SKILL.md": read("SKILL.md"), +} + +# Same bytes in, same zip out: fixed timestamps, sorted names. +with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as z: + for name in sorted(files): + info = zipfile.ZipInfo(name, (2020, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + z.writestr(info, files[name].encode("utf-8")) +PY + +(cd "$out" && sha256sum mdeck-whop.zip > mdeck-whop.zip.sha256) +echo "built $zip" +cat "$out/mdeck-whop.zip.sha256" diff --git a/ventures/mdecks/tools/whop-check.sh b/ventures/mdecks/tools/whop-check.sh new file mode 100644 index 0000000..68ed88b --- /dev/null +++ b/ventures/mdecks/tools/whop-check.sh @@ -0,0 +1,80 @@ +#!/bin/sh +# Checks the Whop build: builds it, verifies the checksum, unzips it, serves it the way a static host does +# (real files and a directory's index.html first, 404.html for anything else) and requests the view paths. +# +# sh ventures/mdecks/tools/whop-check.sh +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +sh "$here/whop-build.sh" "$work/out" +(cd "$work/out" && sha256sum -c mdeck-whop.zip.sha256) +python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/out/mdeck-whop.zip" "$work/site" + +SITE="$work/site" python3 - <<'PY' +import http.server, os, threading, urllib.request, urllib.error + +site = os.environ["SITE"] + +class Static(http.server.SimpleHTTPRequestHandler): + """A static host: a real file, or a directory's index.html; anything else gets 404.html with a 404.""" + def __init__(self, *a, **k): + super().__init__(*a, directory=site, **k) + def do_GET(self): + bare = self.path.split("?")[0] + path = self.translate_path(bare) + if os.path.isdir(path) and not bare.endswith("/"): + return super().do_GET() # redirect to the slash form + if os.path.isdir(path): + path = os.path.join(path, "index.html") + if os.path.isfile(path): + return super().do_GET() + with open(os.path.join(site, "404.html"), "rb") as f: + body = f.read() + self.send_response(404) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a): + pass + +srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Static) +threading.Thread(target=srv.serve_forever, daemon=True).start() +base = "http://127.0.0.1:%d" % srv.server_port + +def get(path): + try: + r = urllib.request.urlopen(base + path) + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + +failed = 0 +for path in ["/experiences/abc", "/experiences/abc/", "/experiences/a/b/c", "/dashboard/xyz", "/dashboard/xyz/", + "/experiences/", "/dashboard/", "/experiences", "/dashboard"]: + status, body = get(path) + ok = 'id="src"' in body and 'src="/app.js"' in body and 'src="/whop.js"' in body + print("%-22s %s serves the app: %s" % (path, status, "yes" if ok else "NO")) + failed += not ok + +for path, needle in [("/app.js", 'fetch("/SKILL.md")'), ("/whop.js", "data-view"), ("/SKILL.md", "mdeck")]: + status, body = get(path) + ok = status == 200 and needle in body + print("%-22s %s asset ok: %s" % (path, status, "yes" if ok else "NO")) + failed += not ok + +status, body = get("/experiences/abc") +for gone in ("Get early access", "Opens a public GitHub issue", "Pricing", "How it works"): + if gone in body: + print("public-page content left in the app: " + gone) + failed += 1 +if 'id="agent"' not in body: + print("agent button missing") + failed += 1 + +raise SystemExit(1 if failed else 0) +PY +echo "whop build ok" diff --git a/ventures/mdecks/whop.js b/ventures/mdecks/whop.js new file mode 100644 index 0000000..22748b1 --- /dev/null +++ b/ventures/mdecks/whop.js @@ -0,0 +1,13 @@ +/* Whop build only: which of the two human views this address is. The static build serves the same app at every + /experiences/ and /dashboard/ path, so the app reads the path to know where it is. + /dashboard/ the creator's view: the editor with "Copy prompt for your agent" + /experiences/ the member's view: the editor and presenter, no agent prompt + Anything else (the 404.html fallback on another path) is shown as the member view. */ +(function () { + "use strict"; + var view = /^\/dashboard(\/|$)/.test(location.pathname) ? "dashboard" : "member"; + document.documentElement.setAttribute("data-view", view); + var style = document.createElement("style"); + style.textContent = 'html[data-view="member"] #agent{display:none}'; + document.head.appendChild(style); +})();