sozai / build_frontend.py
Shereen Lee
perf(boot): inline only the lucide icons we use, drop the full library
1189fab
Raw
History Blame Contribute Delete
10.7 kB
#!/usr/bin/env python3
"""Precompile the frontend so first boot doesn't ship compilers to the client.
`index.html` stays the editable source (it still works opened standalone via
the in-browser Babel + Tailwind fallbacks). This script reads it and writes
`index.build.html` with:
* the `<script type="text/babel">` block transpiled to plain JS (esbuild) and
the `@babel/standalone` CDN <script> removed — no ~0.7 MB Babel download and
no ~7.5k-line transpile on the client's main thread before first paint;
* the `<style type="text/tailwindcss">` block compiled to a static stylesheet
(Tailwind v4 CLI) and inlined, with the `@tailwindcss/browser` CDN <script>
removed — no Tailwind compiler download and no runtime CSS generation.
The server (app.py) serves `index.build.html` when present, else `index.html`.
Deploy flow:
python build_frontend.py # before `git push` to the Space
Requires `npx` + `npm` (esbuild and Tailwind are fetched on demand) or local
`esbuild`/`tailwindcss` binaries.
"""
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
BASE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(BASE, "index.html")
OUT = os.path.join(BASE, "index.build.html")
ESBUILD_VERSION = "0.21.5"
TAILWIND_VERSION = "4.3.1"
LUCIDE_VERSION = "1.21.0" # keep in sync with the lucide pin in index.html
# Persistent build sandbox so we only `npm install` Tailwind once per machine.
TW_BUILD_DIR = os.path.join(tempfile.gettempdir(), "sozai_tw_build")
BABEL_BLOCK_RE = re.compile(r'<script type="text/babel"[^>]*>(.*?)</script>', re.S)
BABEL_CDN_RE = re.compile(r'[ \t]*<script[^>]*@babel/standalone[^>]*></script>\s*\n', re.S)
TW_STYLE_RE = re.compile(r'<style type="text/tailwindcss">(.*?)</style>', re.S)
TW_CDN_RE = re.compile(r'[ \t]*<script[^>]*@tailwindcss/browser[^>]*></script>\s*\n', re.S)
LUCIDE_CDN_RE = re.compile(r'[ \t]*<script[^>]*/lucide@[^>]*></script>\s*\n', re.S)
MK_NAME_RE = re.compile(r'mk\("([A-Za-z0-9]+)"\)')
# --------------------------------------------------------------------------- #
# JSX -> JS (esbuild)
# --------------------------------------------------------------------------- #
def _esbuild_cmd():
if shutil.which("esbuild"):
return ["esbuild"]
if shutil.which("npx"):
return ["npx", "--yes", f"esbuild@{ESBUILD_VERSION}"]
sys.exit("error: need `esbuild` or `npx` on PATH to precompile the JSX.")
def transpile_jsx(jsx: str) -> str:
with tempfile.TemporaryDirectory() as td:
inp, outp = os.path.join(td, "app.jsx"), os.path.join(td, "app.js")
with open(inp, "w", encoding="utf-8") as fh:
fh.write(jsx)
# Classic JSX runtime against the global React UMD (no auto-import),
# matching the current `data-presets="react"` behaviour.
cmd = _esbuild_cmd() + [
inp, "--jsx-factory=React.createElement", "--jsx-fragment=React.Fragment",
"--target=es2020", "--charset=utf8", f"--outfile={outp}",
]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
sys.stderr.write(res.stderr)
sys.exit("error: esbuild failed to transpile the JSX (see above).")
with open(outp, "r", encoding="utf-8") as fh:
return fh.read()
# --------------------------------------------------------------------------- #
# Tailwind config-in-CSS -> static stylesheet (Tailwind v4 CLI)
# --------------------------------------------------------------------------- #
def compile_tailwind(tw_block: str) -> str:
"""Compile the in-page Tailwind block to a static, minified stylesheet.
`@source` points at index.html so the CLI scans the real className strings
(all Tailwind classes here are literal tokens — there is no runtime class
concatenation — so the static scan has full coverage)."""
if not shutil.which("npm"):
sys.exit("error: need `npm` on PATH to precompile Tailwind.")
os.makedirs(TW_BUILD_DIR, exist_ok=True)
if not os.path.isdir(os.path.join(TW_BUILD_DIR, "node_modules", "tailwindcss")):
print(f"[tailwind] installing tailwindcss@{TAILWIND_VERSION} (one-time) …")
res = subprocess.run(
["npm", "install", "--silent", "--no-audit", "--no-fund",
f"tailwindcss@{TAILWIND_VERSION}", f"@tailwindcss/cli@{TAILWIND_VERSION}"],
cwd=TW_BUILD_DIR, capture_output=True, text=True)
if res.returncode != 0:
sys.stderr.write(res.stderr)
sys.exit("error: failed to install Tailwind (see above).")
inp = os.path.join(TW_BUILD_DIR, "input.css")
outp = os.path.join(TW_BUILD_DIR, "app.css")
with open(inp, "w", encoding="utf-8") as fh:
fh.write(f'@import "tailwindcss";\n@source "{SRC}";\n' + tw_block)
res = subprocess.run(
[os.path.join(TW_BUILD_DIR, "node_modules", ".bin", "tailwindcss"),
"-i", inp, "-o", outp, "--minify"],
capture_output=True, text=True)
if res.returncode != 0:
sys.stderr.write(res.stderr)
sys.exit("error: Tailwind CLI failed (see above).")
with open(outp, "r", encoding="utf-8") as fh:
css = fh.read()
if len(css) < 5000:
sys.exit(f"error: compiled Tailwind CSS is suspiciously small ({len(css)} B) — aborting.")
return css
# --------------------------------------------------------------------------- #
# lucide: ship only the referenced icons instead of the full ~600 KB library
# --------------------------------------------------------------------------- #
def compile_lucide(names: list[str]) -> str:
"""Return an inline <script> that defines window.lucide with ONLY the icons
actually referenced (via mk("Name")). The data is pulled from the SAME pinned
UMD the CDN serves — evaluated exactly as the browser would — so what the
icon wrapper resolves (`_L[name] || _L.icons[name]`) is byte-for-byte the
same, just without downloading the whole library."""
if not shutil.which("node"):
sys.exit("error: need `node` on PATH to slim lucide.")
os.makedirs(TW_BUILD_DIR, exist_ok=True)
umd = os.path.join(TW_BUILD_DIR, f"lucide-{LUCIDE_VERSION}.umd.js")
if not os.path.exists(umd):
url = f"https://unpkg.com/lucide@{LUCIDE_VERSION}/dist/umd/lucide.js"
print(f"[lucide] fetching {url} (one-time) …")
# Prefer curl (uses the system trust store; Python's bundled certs are
# often missing on macOS); fall back to urllib.
if shutil.which("curl"):
res = subprocess.run(["curl", "-fsSL", url, "-o", umd], capture_output=True, text=True)
if res.returncode != 0:
sys.stderr.write(res.stderr)
sys.exit("error: failed to download lucide UMD via curl.")
else:
urllib.request.urlretrieve(url, umd)
if os.path.getsize(umd) < 10000:
os.remove(umd)
sys.exit("error: downloaded lucide UMD looks truncated — rerun.")
extract = os.path.join(TW_BUILD_DIR, "extract.js")
with open(extract, "w", encoding="utf-8") as fh:
fh.write(
"const fs=require('fs'),vm=require('vm');"
"const code=fs.readFileSync(process.argv[2],'utf8');"
"const ctx={console};ctx.window=ctx;ctx.self=ctx;ctx.globalThis=ctx;"
"vm.createContext(ctx);vm.runInContext(code,ctx);"
"const L=ctx.lucide||(ctx.window&&ctx.window.lucide)||{};"
"const names=JSON.parse(process.argv[3]);const out={},miss=[];"
"for(const n of names){const node=L[n]||(L.icons&&L.icons[n]);node?out[n]=node:miss.push(n);}"
"if(miss.length)process.stderr.write('MISSING: '+miss.join(',')+'\\n');"
"process.stdout.write(JSON.stringify(out));"
)
res = subprocess.run(["node", extract, umd, json.dumps(names)],
capture_output=True, text=True)
if res.stderr.strip():
sys.stderr.write(res.stderr)
sys.exit("error: some referenced lucide icons were not found (see MISSING above).")
if res.returncode != 0:
sys.exit("error: lucide extraction failed.")
icons = json.loads(res.stdout)
if len(icons) != len(names):
sys.exit(f"error: extracted {len(icons)} icons but expected {len(names)}.")
data = json.dumps(icons, separators=(",", ":"))
return ("<script>window.lucide={icons:" + data +
"};Object.assign(window.lucide,window.lucide.icons);</script>")
# --------------------------------------------------------------------------- #
def main() -> None:
with open(SRC, "r", encoding="utf-8") as fh:
html = fh.read()
# ---- JSX block -> plain JS ----
m = BABEL_BLOCK_RE.search(html)
if not m:
sys.exit('error: no <script type="text/babel"> block found in index.html')
js = transpile_jsx(m.group(1)).replace("</script", "<\\/script")
html = html[: m.start()] + "<script>\n" + js + "\n</script>" + html[m.end():]
html, n_babel = BABEL_CDN_RE.subn("", html)
# ---- Tailwind block -> inlined static CSS ----
tw = TW_STYLE_RE.search(html)
if not tw:
sys.exit('error: no <style type="text/tailwindcss"> block found in index.html')
css = compile_tailwind(tw.group(1)).replace("</style", "<\\/style")
html = html[: tw.start()] + "<style>\n" + css + "\n</style>" + html[tw.end():]
html, n_tw = TW_CDN_RE.subn("", html)
# ---- lucide: full library CDN -> inline subset of referenced icons ----
names = sorted(set(MK_NAME_RE.findall(html)))
if not names:
sys.exit("error: no mk(\"…\") icon references found in index.html")
inline_icons = compile_lucide(names)
html, n_lucide = LUCIDE_CDN_RE.subn(inline_icons + "\n", html)
if n_lucide != 1:
sys.exit(f"error: expected exactly 1 lucide CDN <script> to replace, found {n_lucide}.")
if n_babel == 0:
print("warning: @babel/standalone CDN <script> not found to remove", file=sys.stderr)
if n_tw == 0:
print("warning: @tailwindcss/browser CDN <script> not found to remove", file=sys.stderr)
header = "<!-- GENERATED by build_frontend.py from index.html — do not edit; edit index.html and rerun. -->\n"
with open(OUT, "w", encoding="utf-8") as fh:
fh.write(header + html)
print(f"wrote {os.path.relpath(OUT, BASE)} ({len(html) / 1024:.0f} KB total; "
f"JSX→{len(js) / 1024:.0f} KB JS, Tailwind→{len(css) / 1024:.0f} KB CSS, "
f"lucide→{len(names)} inlined icons; "
f"removed {n_babel} babel + {n_tw} tailwind + {n_lucide} lucide CDN tags)")
if __name__ == "__main__":
main()