| |
| """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" |
| |
| 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]+)"\)') |
|
|
|
|
| |
| |
| |
| 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) |
| |
| |
| 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() |
|
|
|
|
| |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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) …") |
| |
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|