Files
hanzo-dev 2f4fc01e0c scripts: the demo pipeline can rebuild what it shipped, and says what it built
Three native templates were live only because someone built them by hand, and
the pipeline that is supposed to own them would have broken all three on its
next run. Each failure hides behind a 200, which is why none of them showed up
as a failure.

framework() — the artifact's own filenames name it, so prep.py reports unity /
godot / unreal / static and deploy-templates.sh stops hardcoding "static".
That string is what clients/projects/sites.go turns into COOP/COEP: a Unity or
Godot export served without it loses SharedArrayBuffer and its multithreaded
wasm hangs forever on a page that answers 200. The deploy also PATCHes the
framework, because create is a no-op after the first run and every project born
before this keeps a stale one.

roots() — a page whose own same-origin scripts are not on disk cannot run,
whatever built it. Flutter's SOURCE web/index.html loads flutter_bootstrap.js,
a file that exists only in build/web, and web/ sits SHALLOWER so it outranked
the real build: the next batch would have packed a blank page over six working
Flutter demos. This is the general form of the %PUBLIC_URL% rule already here,
not a third framework special case.

build() — expo joins the static-build set. Its absence is the whole reason
android-expo-nativewind has no demo: prep.py returned before building, roots()
found nothing, and the slug 404s while its hand-built siblings are live. Flutter
gets the lane it needs too, gated on an SDK that Google publishes for Linux x64
only, so on arm64 it skips and the batch logs NO-SITE instead of shipping the
scaffold.

prep_test.py pins all of it — 12 checks, including that a wasm app which is not
a game stays un-isolated and that a CDN script tag is not ours to resolve.
__pycache__ stops being tracked; a committed .pyc conflicts on every run.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 09:28:47 -07:00

617 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""prep.py <repo-dir> <out.tgz> — turn one hanzo-templates checkout into a
servable static artifact with the Hanzo runtime injected.
ONE artifact shape (tar.gz of the built site, index.html at the root) so the
whole batch rides the SAME deploy route as the console one-click upload
(POST /v1/projects/:slug/deploy). Binary assets survive because the artifact is
a tarball, not a JSON string manifest.
"""
import json, os, re, shutil, subprocess, sys, tarfile, zipfile
# The Hanzo runtime: ONE shared snippet served from the existing widget CDN
# (hanzoai/a). Injecting the tags instead of vendoring the scripts means the
# runtime is updated once, at a.hanzo.ai, never across 71 template repos.
SNIPPET = (
'<script src="https://a.hanzo.ai/analytics.js" data-org="hanzo" defer></script>\n'
'<script src="https://a.hanzo.ai/chat.js" data-org="hanzo" data-mode="site" defer></script>\n'
)
# `previews/` is a template repo's VENDORED copy of OTHER templates' builds
# (hanzo-templates/gallery ships two). It holds an index.html, so root detection
# picked it whenever the repo's own build had not landed yet — that is why the
# gallery demo served a byte-identical copy of the beta demo. Skipping it drops
# the wrong root and the dead weight in one move.
SKIP_DIRS = {".git", "node_modules", ".next", ".cache", "__MACOSX", "previews"}
# Build output dirs, best first: a built site beats the sources it came from.
PREF = ["out", "dist", "build", "_site", "public", "html", "site", "www", "."]
def walk(d):
for dp, dns, fs in os.walk(d):
dns[:] = [x for x in dns if x not in SKIP_DIRS]
yield dp, fs
def roots(d):
"""Every directory holding an index.html, ranked: build-output name first,
then shallowest, then most files — the layout a template ships its demo in."""
out = []
for dp, fs in walk(d):
if "index.html" not in fs:
continue
# A create-react-app `public/index.html` is the SOURCE template: it still
# holds %PUBLIC_URL% placeholders and no bundle tag, so serving it gives a
# blank page that answers 200 — worse than an honest failure. It is a root
# only once a build has rewritten it.
# BOTH halves are required. Checking %PUBLIC_URL% alone rejects BUILT pages
# that merely mention it inside og:image/twitter:image meta — prism ships
# five real /_next/static/chunks/*.js tags and would be thrown away.
# The bundle must be a SAME-ORIGIN one. Any absolute-URL script counts a
# third party's file as proof the page was built, and the third party
# this hits is us: inject() writes an https://a.hanzo.ai/analytics.js tag
# into every html it touches, so one earlier run made cipher-react's and
# construct's untouched CRA source template permanently pass the test.
try:
html = open(os.path.join(dp, "index.html"),
encoding="utf-8", errors="ignore").read()
if "%PUBLIC_URL%" in html and not re.search(
r"<script[^>]+src=[\"'](?!\w+:|//)[^\"']*\.js", html, re.I):
continue
# The same failure the CRA clause above catches, in the shape no
# placeholder betrays: Flutter's SOURCE web/index.html is ordinary
# HTML that loads flutter_bootstrap.js — a file that exists only in
# build/web. Serving it gives a blank page under a 200, and because
# web/ sits SHALLOWER than build/web it outranks the real build. A
# page whose own scripts are not on disk cannot run, whatever it is
# built with, so that is the rule rather than a third framework
# special case. Only same-origin srcs are checked: a CDN tag is not
# ours to resolve.
if any(not os.path.isfile(os.path.join(dp, s.split("?")[0].split("#")[0].lstrip("/")))
for s in re.findall(r"<script[^>]+src=[\"'](?!\w+:|//)([^\"']+)", html, re.I)):
continue
except OSError:
continue
rel = os.path.relpath(dp, d)
base = os.path.basename(dp) if rel != "." else "."
rank = PREF.index(base) if base in PREF else len(PREF)
out.append((rank, rel.count(os.sep), -len(fs), dp))
out.sort()
return [x[3] for x in out]
def unzip(d):
"""Templates that ship their demo as a zip (restoq, blocks/react.zip) are
unpacked in place so root detection sees the same shape as every other one."""
for dp, fs in walk(d):
for f in fs:
if not f.lower().endswith(".zip"):
continue
p = os.path.join(dp, f)
try:
if os.path.getsize(p) > 200 << 20:
continue
with zipfile.ZipFile(p) as z:
if not any(n.endswith("index.html") for n in z.namelist()):
continue
z.extractall(os.path.join(dp, f[:-4] + ".unzipped"))
except Exception:
pass
# Next.js static export. The config's default export is wrapped (never replaced)
# so plugin-built configs — fumadocs' createMDX, next-mdx — keep working; only
# the export/image knobs are forced.
# Lint and typecheck are deliberately NOT suppressed. Forcing
# eslint.ignoreDuringBuilds + typescript.ignoreBuildErrors made every template
# "build", which is not the same as every template WORKING: a type error that
# breaks a page at runtime shipped as a green build. A template that cannot
# typecheck is a broken template and has to be fixed or reported, not hidden.
FORCE = "{output:'export',images:{unoptimized:true},trailingSlash:true}"
# A plain merge, no wrapper lambda: a rest parameter is an implicit `any`, so
# the old wrapper was itself the first thing a typechecked next.config.ts
# refused to compile — the suppression above was hiding prep.py's own bug.
MERGE = "Object.assign({}, __hanzoStatic, %s)" % FORCE
def patch_next(cfg):
src = open(cfg, encoding="utf-8", errors="ignore").read()
if "__hanzoStatic" in src:
return
if re.search(r"^\s*export\s+default\s", src, re.M):
src = re.sub(r"^(\s*)export\s+default\s", r"\1const __hanzoStatic = ", src, count=1, flags=re.M)
src += "\nexport default %s;\n" % MERGE
else:
src += "\nconst __hanzoStatic = module.exports; module.exports = %s;\n" % MERGE
open(cfg, "w", encoding="utf-8").write(src)
def run(cmd, cwd, timeout):
try:
r = subprocess.run(cmd, cwd=cwd, shell=True, timeout=timeout, capture_output=True,
env={**os.environ, "CI": "1", "NEXT_TELEMETRY_DISABLED": "1",
"npm_config_audit": "false", "npm_config_fund": "false"})
return r.returncode == 0, (r.stdout + r.stderr).decode("utf-8", "replace")
except Exception as e:
return False, str(e)
# Several templates were scaffolded against shadcn/ui but never vendored the
# primitives they import, so `next build` dies on "Can't resolve
# '@/components/ui/textarea'". The components are public registry values, so the
# missing ones are FETCHED rather than hand-written — one heal for every repo
# with the same hole, and none of them is a template edit we have to maintain.
MISSING_UI = re.compile(r"Can't resolve '@/components/ui/([\w-]+)'")
# A dependency the template imports but never declared, and a version pin npm
# cannot satisfy (@next/font@15.3.5 does not exist) — two more ways a template
# repo is simply incomplete, each with one mechanical repair.
MISSING_DEP = re.compile(r"Can't resolve '(@[\w.-]+/[\w.-]+|[a-z][\w.-]*)'")
BAD_PIN = re.compile(r"No matching version found for (@?[\w.\-/]+)@")
# The repo pinned a typescript older than its own tsconfig asks for
# ("moduleResolution": "bundler" needs >= 5.0), so tsc rejects the project
# before a single page is built — nextjs-template's whole failure.
STALE_TS = re.compile(r"error TS(?:5023|5095|6046)\b")
# npm resolves optionalDependencies from the LOCKFILE, so a package-lock.json
# committed on x64 never names this host's native binary and npm installs
# nothing for it (npm/cli#4828). lightningcss and @tailwindcss/oxide both then
# die at require time — the failure that made most of these templates NOROOT.
# The package that needs its sibling is the first frame of the require stack;
# the sibling's name is that package plus THIS host's triple, so the repair is
# arch-derived rather than hardcoded to whatever machine ran the build.
NATIVE_MISS = re.compile(r"Cannot find module '[^']*?[/.]([\w-]+)\.linux-[\w-]+\.node'")
# The SAME miss, reported the other way. A napi-rs loader swallows the resolve
# error into `new Error(..., {cause})`, and next build prints only the outer
# message — so the text above never appears and the repair silently never fired
# (blog: @tailwindcss/oxide, NOROOT). Here the package is the frame that threw.
NATIVE_THROW = re.compile(
r"Failed to load native binding[\s\S]{0,240}?node_modules/((?:@[\w.-]+/)?[\w.-]+)/index\.js")
TRIPLE = "linux-%s-gnu" % {"aarch64": "arm64", "x86_64": "x64"}.get(os.uname().machine,
os.uname().machine)
def heal_native(d, out):
pkgs = {"%s-%s" % (p, TRIPLE) for p in NATIVE_THROW.findall(out)}
for base in NATIVE_MISS.findall(out):
# Scoped packages (@tailwindcss/oxide) need the scope back: the require
# stack carries the full path, the module name in the error does not.
m = re.search(r"node_modules/((?:@[\w.-]+/)?%s)\b" % re.escape(base), out)
pkgs.add("%s-%s" % (m.group(1) if m else base, TRIPLE))
if pkgs:
run("npm install --no-audit --no-fund " + " ".join(sorted(pkgs)), d, 900)
return bool(pkgs)
def heal_deps(d, out):
if STALE_TS.search(out):
run("npm install --legacy-peer-deps --no-audit --no-fund typescript@latest", d, 900)
return True
pins = set(BAD_PIN.findall(out))
if pins:
pj = os.path.join(d, "package.json")
j = json.load(open(pj))
for k in ("dependencies", "devDependencies"):
for p in pins & set(j.get(k) or {}):
j[k][p] = "latest"
json.dump(j, open(pj, "w"), indent=2)
run("npm install --legacy-peer-deps --no-audit --no-fund", d, 1800)
return True
miss = {m for m in MISSING_DEP.findall(out) if not m.startswith(".")}
if miss:
run("npm install --legacy-peer-deps --no-audit --no-fund " + " ".join(sorted(miss)), d, 1200)
return bool(miss)
# `output: export` renders everything at BUILD time, and two kinds of app-dir
# file argue with that. Each has a mechanical repair that KEEPS the file.
META_IMG = ("opengraph-image", "twitter-image", "icon.", "apple-icon")
CODE = (".ts", ".tsx", ".js", ".jsx", ".mjs")
EDGE = re.compile(r"^[ \t]*export[ \t]+const[ \t]+runtime[ \t]*=.*$\n?", re.M)
def force_static(p):
"""Pin one app-dir route to build-time rendering — drop the edge pin (the
edge runtime has no build-time render) and force-static, which makes
headers()/cookies() return empty values instead of bailing the export out.
Inside a dynamic segment that is not enough: the export also needs the
segment's params, and the sibling page already computes them, so they are
re-exported. True when the file changed, False when nothing was left to add."""
s = open(p, encoding="utf-8", errors="ignore").read()
if re.match(r"\s*[\"']use client[\"']", s):
return False # a client component may export neither of these — writing
# them there trades one build error for a worse one
add = "" if "force-static" in s else "\nexport const dynamic = 'force-static';\n"
dp = os.path.dirname(p)
if "generateStaticParams" not in s and os.path.basename(dp).startswith("["):
# The page of the segment is the one file with nothing to inherit from.
# An un-enumerable segment exports as zero pages, which ships the rest
# of the site — the alternative on offer is no site at all.
add += ("export { generateStaticParams } from './page';\n"
if not p.startswith(os.path.join(dp, "page."))
and any(os.path.isfile(os.path.join(dp, "page" + e)) for e in CODE)
else "export async function generateStaticParams(){return []}\n")
if not add:
return False
open(p, "w", encoding="utf-8").write(EDGE.sub("", s) + add)
return True
def heal_export(d, out):
"""A generated metadata image — opengraph, twitter, icon, apple-icon — DOES
static-export once it is pinned to build time, so it is pinned, never
deleted: deleting it (what this did before) shipped every demo with no
social card and no favicon, and said nothing. A route handler is only
exportable as a force-static GET, so it is pinned first and dropped only if
the next build still refuses it — a static site cannot answer a POST.
`is missing "generateStaticParams()"` is the THIRD way the export refuses a
file and was missing from this gate, so the loop exited on the first repair
it made and blog/gallery2/quantum all came out NOROOT: the fix had been
applied and was never given the retry that would have proved it."""
if not any(k in out for k in ("Failed to collect page data", "dynamic = ",
'is missing "generateStaticParams()"',
"couldn't be rendered statically")):
return False
hit = False
for dp, dns, fs in os.walk(d):
dns[:] = [x for x in dns if x not in SKIP_DIRS]
if os.sep + "app" + os.sep not in dp + os.sep:
continue
dyn = os.path.basename(dp).startswith("[")
for f in fs:
if not f.endswith(CODE):
continue # icon.png and friends are already static assets
p = os.path.join(dp, f)
# A layout is pinned for the same reason a metadata image is: it is
# the one file every route inherits, so deploy's headers() call in
# generateViewport made even /_not-found un-exportable.
if f.startswith(META_IMG) or f.startswith("layout.") or (dyn and f.startswith("page.")):
hit |= force_static(p)
elif f.startswith("route."):
force_static(p) or os.remove(p)
hit = True
return hit
def heal_ui(d, out):
import urllib.request
names = set(MISSING_UI.findall(out))
if not names:
return False
uidir = next((os.path.join(dp, "ui") for dp, _ in walk(d)
if os.path.basename(dp) == "components" and os.path.isdir(os.path.join(dp, "ui"))),
os.path.join(d, "components", "ui"))
os.makedirs(uidir, exist_ok=True)
deps = set()
for n in names:
try:
with urllib.request.urlopen(
"https://ui.shadcn.com/r/styles/default/%s.json" % n, timeout=30) as r:
item = json.load(r)
except Exception:
continue
for f in item.get("files") or []:
open(os.path.join(uidir, os.path.basename(f["path"])), "w").write(f["content"])
deps |= set(item.get("dependencies") or [])
if deps:
run("npm install --legacy-peer-deps --no-audit --no-fund " + " ".join(sorted(deps)), d, 900)
return True
def buildable(d):
"""Project dirs worth building: the repo root, then any single-level child
that is its own project (launch/'Teaser - SaaS Landing Page', soar/garuda)."""
yield d
for n in sorted(os.listdir(d)):
p = os.path.join(d, n)
if n not in SKIP_DIRS and os.path.isdir(p) and os.path.isfile(os.path.join(p, "package.json")):
yield p
# Flutter's toolchain is not node, so a Flutter template never reaches the npm
# path below and `flutter build web` is the ONE command that turns a pubspec
# into a servable site. The SDK is not vendored and not fetched: Google ships
# Linux x64 ONLY (every entry in releases_linux.json carries dart_sdk_arch=x64,
# none arm64), so this lane needs an x64 runner. Everywhere else it returns
# without building, roots() finds no built root, and the batch logs NO-SITE —
# an honest skip instead of packing the unbuilt web/ scaffold, which is a blank
# page that answers 200.
def build_flutter(d):
fl = shutil.which("flutter") or os.path.expanduser("~/.cache/flutter-sdk/flutter/bin/flutter")
if not os.access(fl, os.X_OK):
return
run("%r config --enable-web --no-analytics" % fl, d, 600)
run("%r pub get" % fl, d, 900)
run("%r build web --release" % fl, d, 2400)
def build(d):
if os.path.isfile(os.path.join(d, "pubspec.yaml")):
return build_flutter(d)
pj = os.path.join(d, "package.json")
if not os.path.isfile(pj):
return
try:
j = json.load(open(pj))
except Exception:
return
deps = {**(j.get("dependencies") or {}), **(j.get("devDependencies") or {})}
scripts = j.get("scripts") or {}
if "build" not in scripts:
return
if "next" in deps:
# `out/` is OURS — it exists only because FORCE asks for it — so the
# last run's copy is cleared. Left in place it makes a build that now
# FAILS still answer OK: roots() finds the stale export and packs it.
shutil.rmtree(os.path.join(d, "out"), ignore_errors=True)
for c in ("next.config.js", "next.config.mjs", "next.config.ts"):
if os.path.isfile(os.path.join(d, c)):
patch_next(os.path.join(d, c))
break
else:
open(os.path.join(d, "next.config.js"), "w").write(
"const __hanzoStatic = {}; module.exports = %s;\n" % MERGE)
elif not ({"vite", "react-scripts", "gulp", "parcel", "astro", "@11ty/eleventy",
# Expo's web target IS a static build (`expo export -p web` ->
# dist/), and leaving it out of this set is the whole reason
# android-expo-nativewind has no demo: prep.py returned before
# building, roots() then found nothing, and the slug 404s while
# its sibling Expo templates — built by hand — are live. The
# pipeline could not reproduce its own output.
"expo"} & set(deps)):
return # not a static web build (design kit, font pack, monorepo shell)
ok, out = run("npm install --legacy-peer-deps --no-audit --no-fund", d, 1800)
if not ok:
# `workspace:*` is a protocol npm cannot parse at all (EUNSUPPORTEDPROTOCOL),
# and papers ships those deps with NO pnpm-lock.yaml — so gating this
# fallback on the lockfile left it with no node_modules at all and a
# build whose only output was `sh: 1: next: not found`.
ok, out = run("pnpm install --no-frozen-lockfile", d, 1800)
if not ok:
heal_deps(d, out)
# Build, repairing what the failure names, until it builds or nothing is
# left to repair. Templates are incomplete in a handful of mechanical ways;
# each heal is one of them, and the loop is the ONE place they compose.
for _ in range(6):
ok, out = run("npm run build", d, 1800)
if ok or not (heal_native(d, out) or heal_ui(d, out) or heal_deps(d, out)
or heal_export(d, out)):
break
# Next < 13.3 has no `output: 'export'`; the static site is a second command.
if "next" in deps and not os.path.isfile(os.path.join(d, "out", "index.html")):
run("npx --no-install next export", d, 900)
# A multi-page template ships a page-LIST scaffold at index.html: a bare column
# of links to its sibling pages ("Page List", "Index Page"). That is the format
# VARIANT of the template, not a page of it — serving it as the demo shows a
# directory instead of the design, and it is why four live demos rendered a link
# dump. A scaffold is recognisable without trusting its title: many sibling
# .html links and almost no prose of its own (measured: 22-27 chars per link,
# versus 234+ for the thinnest real page).
LANDING = ("home", "index", "main", "landing", "demo", "overview", "dashboard", "start")
def pick_index(root):
"""Point index.html at the template's best real page when it is a page-list
scaffold, keeping the scaffold at pages.html so no page is lost."""
p = os.path.join(root, "index.html")
try:
h = open(p, encoding="utf-8", errors="ignore").read()
except OSError:
return None
# A scaffold links its pages in either of two shapes and prism proved the
# second one has to count: a plain export writes sibling `about.html`, a
# trailingSlash Next export writes the route folder `about/`, and matching
# only the first left prism's purple link dump live.
links = set()
for x in re.findall(r'href="([^"#?]+)"', h):
if re.match(r"\w+:|//", x):
continue
x = x.strip("/") + ("" if x.endswith((".html", ".htm")) else "/index.html")
if x != "index.html" and os.path.isfile(os.path.join(root, x)):
links.add(x)
body = re.sub(r"<[^>]+>", " ", re.sub(r"(?is)<(script|style).*?</\1>", " ", h))
if len(links) < 6 or len(" ".join(body.split())) >= 60 * len(links):
return None
# The landing page by name — matched on the filename's hyphen tokens, so
# `circle-overview.html` counts as an overview — else the heaviest page,
# since the one with the most markup is the fullest demo of the design.
def rank(x):
# The LEADING token names the page; a later one only qualifies it. Ranking
# on any token let prism's `profile/index-v2` outscore `dashboard/social`
# because "index" is a landing word wherever it appears.
head = re.split(r"[-/]", x[:-11] if x.endswith("/index.html") else x.rsplit(".", 1)[0])[0]
return (LANDING.index(head) if head in LANDING else len(LANDING),
-os.path.getsize(os.path.join(root, x)), x)
best = min(links, key=rank)
os.replace(p, os.path.join(root, "pages.html"))
# A redirect, not a copy of the page's markup: copying is only right while
# the page is a SIBLING, and a route folder's page resolves its assets and
# its client router from its own path, so the copy would land broken.
# A route folder is entered by its folder URL: its client router matches on
# the path it was exported at, not on the index.html underneath it.
dest = best[:-10] if best.endswith("/index.html") else best
open(p, "w", encoding="utf-8").write(
'<!doctype html><meta http-equiv="refresh" content="0;url=%s">'
'<title>Demo</title><script>location.replace(%s)</script>' % (dest, json.dumps(dest)))
return best
def inject(root):
n = 0
for dp, fs in walk(root):
for f in fs:
if not f.endswith((".html", ".htm")):
continue
p = os.path.join(dp, f)
try:
t = open(p, encoding="utf-8", errors="ignore").read()
except Exception:
continue
if "a.hanzo.ai/analytics.js" in t:
continue
if "</head>" in t:
t = t.replace("</head>", SNIPPET + "</head>", 1)
elif "</body>" in t:
t = t.replace("</body>", SNIPPET + "</body>", 1)
else:
t += SNIPPET
try:
open(p, "w", encoding="utf-8").write(t)
n += 1
except Exception:
pass
return n
# The serve-time isolation policy is keyed off the project's `framework`
# (clients/projects/sites.go: crossOriginIsolated), and the ONE thing that knows
# what an artifact IS is the code that just packed it. Saying "static" for a
# Unity or Godot web export costs the page COOP/COEP, so SharedArrayBuffer is
# gone and a multithreaded WASM build hangs forever behind a 200 — the exact
# failure a status check cannot see. Detect from the exporter's own filenames:
# they are fixed by the engine, not by us, so this needs no per-template config.
ENGINE = (("unity", ".loader.js"), # Build/<name>.loader.js beside <name>.wasm
("godot", ".pck"), # <name>.pck beside <name>.wasm
("unreal", ".utoc")) # <name>.utoc (IoStore container)
def framework(root):
names = {f for _, fs in walk(root) for f in fs}
if any(f.endswith(".wasm") for f in names):
for name, mark in ENGINE:
if any(f.endswith(mark) for f in names):
return name
return "static"
def pack(root, out):
n = size = 0
with tarfile.open(out, "w:gz") as tf:
for dp, fs in walk(root):
for f in fs:
p = os.path.join(dp, f)
# Source maps are a debugger's copy of code the demo already
# ships; nothing renders from them and they are routinely the
# single heaviest file in a build (kinetic: 1.5 MB of 4.5 MB).
if os.path.islink(p) or f.endswith(".map"):
continue
try:
size += os.path.getsize(p)
except OSError:
continue
tf.add(p, arcname=os.path.relpath(p, root))
n += 1
return n, size
# The live edge caps a request body at 4 MiB (fiber's default; cloud's
# GATEWAY_BODY_LIMIT is not taking effect in v1.801.266 — see LLM.md). A demo
# must therefore FIT, and a 40 MB template is a bad demo anyway: its weight is
# unoptimized hero imagery. fit() re-encodes the heavy media IN PLACE — same
# path, same container format, so every href/src in the template still resolves
# — and never deletes a file, so nothing renders broken.
PASSES = [(1920, 82), (1600, 74), (1280, 66), (1024, 58), (800, 50), (640, 44), (512, 40)]
def shrink_images(root, maxdim, q):
from PIL import Image, ImageSequence
for dp, fs in walk(root):
for f in fs:
if not f.lower().endswith((".jpg", ".jpeg", ".png", ".webp", ".gif")):
continue
p = os.path.join(dp, f)
try:
if os.path.getsize(p) < 20 << 10:
continue
im = Image.open(p)
fmt = im.format
scale = min(1.0, maxdim / max(im.size))
size = (max(1, int(im.width * scale)), max(1, int(im.height * scale)))
if fmt == "GIF" and getattr(im, "n_frames", 1) > 1:
# An animated GIF's weight is frames × pixels, so resizing
# alone barely moves a 7 MB loop. Later passes also drop
# frames (and stretch each one's duration to match), which
# keeps the animation playing at the same speed.
step = max(1, 6 - maxdim // 320)
frames = [fr.copy().convert("RGB").resize(size)
for i, fr in enumerate(ImageSequence.Iterator(im)) if i % step == 0]
frames[0].save(p, "GIF", save_all=True, append_images=frames[1:],
optimize=True, loop=0,
duration=im.info.get("duration", 80) * step)
continue
im.load()
if max(im.size) > maxdim:
im.thumbnail((maxdim, maxdim), Image.LANCZOS)
if fmt == "JPEG":
im.convert("RGB").save(p, "JPEG", quality=q, optimize=True, progressive=True)
elif fmt == "WEBP":
im.save(p, "WEBP", quality=q, method=4)
else: # PNG / GIF still-frame: palette-quantize, which is where the bytes are
a = im.convert("RGBA")
im = a.quantize(colors=256, method=Image.FASTOCTREE)
im.save(p, fmt, optimize=True)
except Exception:
pass
def shrink_videos(root, w, crf):
for dp, fs in walk(root):
for f in fs:
if not f.lower().endswith((".mp4", ".webm", ".mov", ".m4v")):
continue
p = os.path.join(dp, f)
try:
if os.path.getsize(p) < 512 << 10:
continue
tmp = p + ".sh.mp4"
if run("ffmpeg -y -loglevel error -i %r -vf scale=%d:-2 -c:v libx264 -crf %d "
"-preset veryfast -movflags +faststart -an %r" % (p, w, crf, tmp), dp, 900)[0] \
and os.path.getsize(tmp) < os.path.getsize(p):
os.replace(tmp, p)
else:
os.path.exists(tmp) and os.remove(tmp)
except Exception:
pass
def fit(root, out, cap):
files, size = pack(root, out)
for i, (maxdim, q) in enumerate(PASSES):
if os.path.getsize(out) <= cap:
break
shrink_images(root, maxdim, q)
if i == 0:
shrink_videos(root, 1280, 30)
files, size = pack(root, out)
return files, size
def main():
d, out = sys.argv[1], sys.argv[2]
cap = int(sys.argv[3]) if len(sys.argv) > 3 else 0
unzip(d)
# Build BEFORE looking for a root, always. A framework repo ships an
# index.html that is a SOURCE entry (vite) or an unrelated preview
# (gallery/public/previews) — serving either is a broken demo — and build()
# is a no-op for a repo carrying no web-build framework, so this is one path
# rather than a "prebuilt or built" fork that picks the wrong one.
for p in buildable(d):
build(p)
r = roots(d)
if not r:
print("NOROOT")
return 2
root = r[0]
landed = pick_index(root)
inject(root)
files, size = fit(root, out, cap) if cap else pack(root, out)
print("OK %s %d %d framework=%s%s" % (os.path.relpath(root, d), files, size,
framework(root),
" index=" + landed if landed else ""))
return 0
if __name__ == "__main__":
sys.exit(main())