#!/usr/bin/env node // The build hash is written back into public/index.html and public/sw.js, so a served shell can never name a build other than the one on disk. import { createHash } from "node:crypto"; import { copyFileSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; const root = fileURLToPath(new URL("./", import.meta.url)); export const paths = { compiled: `${root}build/src`, stylesheet: `${root}public/styles.css`, images: `${root}public/img`, fonts: `${root}public/fonts`, indexHtml: `${root}public/index.html`, worker: `${root}public/sw.js`, site: `${root}dist`, }; /** This is long enough to avoid a collision and short enough to read in a URL. */ export const HASH_CHARS = 16; export const ASSET_PATTERN = new RegExp(`/assets/[0-9a-f]{${HASH_CHARS}}/`, "g"); export const BUILD_PATTERN = /const BUILD = "[0-9a-f]+";/; /** The sourceMappingURL comment is removed because the maps are not copied and would 404 on every devtools open. */ export function served(source) { return source.replace(/\n?\/\/# sourceMappingURL=.*\n?$/, "\n"); } export function hashOf(files) { const digest = createHash("sha256"); for (const [name, content] of [...files].sort((a, b) => a[0].localeCompare(b[0]))) { digest.update(name); digest.update("\0"); digest.update(content); } return digest.digest("hex").slice(0, HASH_CHARS); } const IMAGE_TYPES = [".png", ".svg"]; const FONT_TYPES = [".woff2"]; /** Images and fonts sit flat beside styles.css so that a url(hero-light.png) or url(Manrope-Regular.woff2) reference in the stylesheet resolves. */ /** * This runs as a classic non-module script so that it executes before the first paint. * The CSP allows script-src 'self' but not inline, so it must be served as a file rather than inlined. */ const INIT_JS = `(function () { var r = document.documentElement; try { var raw = localStorage.getItem("sb.theme.v1"); var t = raw ? JSON.parse(raw) : "system"; if (t !== "light" && t !== "dark" && t !== "system") t = "system"; if (t === "system") r.removeAttribute("data-theme"); else r.setAttribute("data-theme", t); r.style.colorScheme = (t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches)) ? "dark" : "light"; } catch (e) {} try { var h = location.hash; var landing = (h === "" || h === "#" || h === "#/") && location.search.indexOf("order=") < 0; if (!landing) r.classList.add("sb-booting"); } catch (e) {} })(); `; export function assets(compiled = paths.compiled, stylesheet = paths.stylesheet, images = paths.images, fonts = paths.fonts) { const modules = readdirSync(compiled).filter((f) => f.endsWith(".js")).sort(); if (modules.length === 0) throw new Error("build: build/src holds no modules — run tsc first"); const pictures = readdirSync(images).filter((f) => IMAGE_TYPES.some((t) => f.endsWith(t))).sort(); const faces = readdirSync(fonts).filter((f) => FONT_TYPES.some((t) => f.endsWith(t))).sort(); return [ ...modules.map((name) => [name, served(readFileSync(`${compiled}/${name}`, "utf8"))]), ["styles.css", readFileSync(stylesheet, "utf8")], ["init.js", INIT_JS], ...pictures.map((name) => [name, readFileSync(`${images}/${name}`)]), ...faces.map((name) => [name, readFileSync(`${fonts}/${name}`)]), ]; } /** This throws rather than returning the text unchanged, because a silent no-op would ship a shell that still names the previous build. */ export function retarget(text, pattern, replacement, what) { if (!new RegExp(pattern.source).test(text)) throw new Error(`build: no ${what} to rewrite`); return text.replace(pattern, replacement); } export function withBuild(html, build) { return retarget(html, ASSET_PATTERN, `/assets/${build}/`, "asset path"); } export async function prerenderShell() { const dom = await import("./build/test/stub-dom.js"); const prevDoc = Object.getOwnPropertyDescriptor(globalThis, "document"); const prevNav = Object.getOwnPropertyDescriptor(globalThis, "navigator"); dom.installDocument(); try { const screens = await import("./build/src/screens.js"); const noop = () => {}; const chromeHtml = screens.chrome({ theme: "system", onNewPurchase: noop, onHistory: noop, onTheme: noop, onToggle: noop, onHome: noop, }).node.serialize(); const landingHtml = screens.landing({ onStart: noop }).serialize(); return { chromeHtml, appHtml: `