import { existsSync, mkdirSync, writeFileSync } from "fs"; import path from "path"; import sharp from "sharp"; import { SiteData } from "../data/site-data"; import { AppData } from "../data/app-data"; import { HashedAssetPattern } from "../utils/is-hashed-asset-path"; import { PwaBadgePath, PwaBadgeSize, PwaBuildInfoPath, PwaIcons, PwaLoadingNamePlaceholder, PwaPrecacheCachePrefix, PwaPrecacheMarkerPath, PwaServiceWorkerPath, PwaManifestName, PwaManifestPath, PwaOfflinePath, PwaScope, PwaShortName, PwaShortcuts, PwaStartPath, PwaAppIconMarkScale, PwaMaskableMarkScale, } from "../data/pwa-data"; import renderAppIcon from "../functions/server/pwa/render-app-icon"; const ROOT = path.resolve(import.meta.dir, "../.."); const LOGO_SVG = path.join(ROOT, "public/logos/logo.svg"); const PUBLIC_DIR = path.join(ROOT, "public"); const ICONS_DIR = path.join(PUBLIC_DIR, "icons"); const MAIN_CSS = path.join(ROOT, "src/styles/main.css"); const ICO_SIZES = [16, 24, 32, 48] as const; const PNG_SIZES = [...ICO_SIZES, 180, 512] as const; function buildIco(pngs: Record): Buffer { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(ICO_SIZES.length, 4); const entries: Buffer[] = []; const blobs: Buffer[] = []; let offset = 6 + 16 * ICO_SIZES.length; for (const size of ICO_SIZES) { const blob = pngs[size]!; const entry = Buffer.alloc(16); entry.writeUInt8(size, 0); entry.writeUInt8(size, 1); entry.writeUInt8(0, 2); entry.writeUInt8(0, 3); entry.writeUInt16LE(1, 4); entry.writeUInt16LE(32, 6); entry.writeUInt32LE(blob.length, 8); entry.writeUInt32LE(offset, 12); offset += blob.length; entries.push(entry); blobs.push(blob); } return Buffer.concat([header, ...entries, ...blobs]); } /** * The app's font, loaded the way `src/pages/__root.tsx` loads it, so the * generated pages read like the app. Offline, the browser's cached copy is * used when it has one, else the fallbacks. */ const FontLinks = ` `; /** * The status-bar icon Android draws from a notification's `badge`. Only * the alpha channel counts, so it is the stone's silhouette alone, white * on transparent (the app icons sit on opaque paper and mask to a square). */ async function renderBadge({ svg, size }: { svg: Buffer; size: number }) { const silhouette = await sharp(svg) .resize(Math.round(size * 0.84), Math.round(size * 0.84), { fit: "contain" }) .ensureAlpha() .extractChannel("alpha") .toBuffer(); const mark = await sharp({ create: { width: Math.round(size * 0.84), height: Math.round(size * 0.84), channels: 3, background: "#ffffff", }, }) .joinChannel(silhouette) .png() .toBuffer(); return sharp({ create: { width: size, height: size, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, }) .composite([{ input: mark, gravity: "center" }]) .png() .toBuffer(); } function escapeHtml(text: string) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } type ThemeColors = { paper: string; ink: string; ink_secondary: string; hairline: string; dark_paper: string; dark_ink: string; dark_ink_secondary: string; dark_hairline: string; }; /** * The theme's colors come from `src/styles/main.css`, the only place they * are defined, so the manifest and the offline page never drift from it. */ async function grabThemeColors(): Promise { const css = await Bun.file(MAIN_CSS).text(); const root_block = css.match(/:root\s*\{([^}]*)\}/s)?.[1] || ""; const dark_block = css.match(/\.dark\s*\{([^}]*)\}/s)?.[1] || ""; const token = (block: string, name: string) => block.match(new RegExp(`--${name}:\\s*([^;]+);`))?.[1]?.trim(); const paper = token(root_block, "c-paper"); const ink = token(root_block, "c-ink"); const ink_secondary = token(root_block, "c-ink-secondary"); const hairline = token(root_block, "c-border"); const dark_paper = token(dark_block, "c-paper"); const dark_ink = token(dark_block, "c-ink"); const dark_ink_secondary = token(dark_block, "c-ink-secondary"); const dark_hairline = token(dark_block, "c-border"); if ( !paper || !ink || !ink_secondary || !hairline || !dark_paper || !dark_ink || !dark_ink_secondary || !dark_hairline ) { throw new Error("Cobble color tokens not found in src/styles/main.css"); } return { paper, ink, ink_secondary, hairline, dark_paper, dark_ink, dark_ink_secondary, dark_hairline, }; } function buildManifest({ colors }: { colors: ThemeColors }) { return { id: "/", name: PwaManifestName, short_name: PwaShortName, description: "Your crew of small AI worker bots.", lang: "en", dir: "ltr", start_url: PwaStartPath, scope: PwaScope, display: "standalone", display_override: ["standalone", "minimal-ui"], orientation: "any", background_color: colors.paper, theme_color: colors.paper, categories: ["productivity", "utilities"], prefer_related_applications: false, icons: PwaIcons, shortcuts: PwaShortcuts, }; } function buildOfflinePage({ colors, logo, }: { colors: ThemeColors; logo: string; }) { const name = escapeHtml(SiteData["SiteName"]); return ` Offline | ${name} ${FontLinks}
${logo}

${name}

You're offline.

Reconnect to keep working with your bots. Anything you sent before you lost the connection is still on its way.

`; } /** * The page a browser gets instead of the app while it downloads a build * it hasn't yet (`handlePrecacheGate`). The server fills in `{{build}}` * (`{ build_id, total }`). The page downloads every file the running * build lists at `/build-info.json` into that build's cache itself, so it * works whichever service worker version is in charge, then checks every * one is in the cache (downloading any that aren't again, up to * `PrecacheVerifyPasses` more times) before the marker goes in. It drops * the old build's cache and hands over to a new worker waiting behind an * older one (nothing of the app runs here). It then sets the build cookie and * reloads into the app. Without Cache Storage, after a failed download, * or once nothing has landed for `PrecacheStallMs`, it sets the cookie * anyway, so the app runs from the network instead of never opening. */ function buildLoadingPage({ colors, logo, }: { colors: ThemeColors; logo: string; }) { const name = escapeHtml(SiteData["SiteName"]); return ` ${PwaLoadingNamePlaceholder} ${FontLinks}
${logo}

Getting ${name} ready

Saving the app on this device, so it opens fast from now on.

`; } /** * The logo inline, so the offline page has no request to make while * offline. Its own width and height give way to ours. */ function inlineLogo(raw: string) { const cleaned = raw.replace(/<\?xml[^>]*\?>/, "").replace(/]*>/, ""); return cleaned.replace(/]*)>/, (match, attrs: string) => { const trimmed = attrs .replace(/\swidth="[^"]*"/, "") .replace(/\sheight="[^"]*"/, ""); return ``; }); } async function main() { if (!existsSync(LOGO_SVG)) { throw new Error(`Logo SVG not found at ${LOGO_SVG}`); } const svg = Buffer.from(await Bun.file(LOGO_SVG).arrayBuffer()); const colors = await grabThemeColors(); const pngs: Record = {}; for (const size of PNG_SIZES) { pngs[size] = await sharp(svg) .resize(size, size) .png() .toBuffer(); } writeFileSync(path.join(PUBLIC_DIR, "favicon.png"), pngs[32]!); writeFileSync(path.join(PUBLIC_DIR, "favicon.ico"), buildIco(pngs)); writeFileSync( path.join(PUBLIC_DIR, "apple-touch-icon.png"), await renderAppIcon({ svg, size: 180, scale: PwaAppIconMarkScale, background: colors.paper, }), ); mkdirSync(path.join(PUBLIC_DIR, "logos"), { recursive: true }); writeFileSync(path.join(PUBLIC_DIR, "logos/logo.png"), pngs[512]!); mkdirSync(ICONS_DIR, { recursive: true }); for (const icon of PwaIcons) { const size = Number(icon.sizes.split("x")[0]); const maskable = icon.purpose == "maskable"; writeFileSync( path.join(PUBLIC_DIR, icon.src.replace(/^\//, "")), await renderAppIcon({ svg, size, scale: maskable ? PwaMaskableMarkScale : PwaAppIconMarkScale, background: colors.paper, }), ); } writeFileSync( path.join(PUBLIC_DIR, PwaBadgePath.replace(/^\//, "")), await renderBadge({ svg, size: PwaBadgeSize }), ); writeFileSync( path.join(PUBLIC_DIR, "manifest.webmanifest"), `${JSON.stringify(buildManifest({ colors }), null, 4)}\n`, ); writeFileSync( path.join(PUBLIC_DIR, "offline.html"), buildOfflinePage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }), ); writeFileSync( path.join(PUBLIC_DIR, "loading.html"), buildLoadingPage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }), ); console.log("Generated brand and PWA assets from public/logos/logo.svg:"); console.log(" public/favicon.ico (16/24/32/48 multi-size)"); console.log(" public/favicon.png (32x32)"); console.log(" public/apple-touch-icon.png (180x180, opaque paper)"); console.log(" public/logos/logo.png (512x512)"); console.log(" public/icons/icon-192.png (app icon)"); console.log(" public/icons/icon-512.png (app icon)"); console.log(" public/icons/maskable-192.png (maskable, safe zone)"); console.log(" public/icons/maskable-512.png (maskable, safe zone)"); console.log(" public/icons/badge-96.png (notification badge, alpha only)"); console.log(" public/manifest.webmanifest (name, colors and icons)"); console.log(" public/offline.html (self-contained offline page)"); console.log(" public/loading.html (precache loading page)"); } main() .then(() => process.exit(0)) .catch((error) => { console.error("Brand asset generation failed:", error); process.exit(1); });