#!/usr/bin/env bun /** * Minify first-party public assets and copied vendor assets that do not * already ship minified. * Uses terser (JS), clean-css (CSS), html-minifier-terser (HTML). * * Usage: bun run scripts/minify-public.ts [--dir public] */ import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { minify as terserMinify } from "terser"; import CleanCSS from "clean-css"; import { minify as htmlMinify } from "html-minifier-terser"; const args = process.argv.slice(2); let publicDir = path.resolve("public"); const dirIdx = args.indexOf("--dir"); if (dirIdx !== -1 && args[dirIdx + 1]) { publicDir = path.resolve(args[dirIdx + 1]); } const cleanCss = new CleanCSS({ level: 2 }); const htmlOpts = { collapseWhitespace: true, removeComments: true, removeRedundantAttributes: true, removeEmptyAttributes: true, minifyCSS: true, minifyJS: true, sortAttributes: true, sortClassName: true, }; type Stats = { js: number; css: number; html: number; savedBytes: number }; const stats: Stats = { js: 0, css: 0, html: 0, savedBytes: 0 }; let failures = 0; const requestedConcurrency = Number.parseInt(process.env.MINIFY_CONCURRENCY || "", 10); const minifyConcurrency = Number.isFinite(requestedConcurrency) && requestedConcurrency > 0 ? requestedConcurrency : 4; async function collectFiles(dir: string, exts: Set): Promise { const results: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const e of entries) { const full = path.join(dir, e.name); if (e.isDirectory()) { // Skip vendor — already distributed minified if (e.name === "vendor") continue; results.push(...(await collectFiles(full, exts))); } else if (exts.has(path.extname(e.name).toLowerCase())) { results.push(full); } } return results; } async function minifyJS(filePath: string, isModule = true) { const src = await Bun.file(filePath).text(); const result = await terserMinify(src, { compress: { passes: 2, drop_console: false, ecma: 2020 }, mangle: { toplevel: false }, format: { ecma: 2020 }, module: isModule, }); if (result.code && result.code.length < src.length) { stats.savedBytes += src.length - result.code.length; await Bun.write(filePath, result.code); } stats.js++; } async function minifyCSS(filePath: string) { const src = await Bun.file(filePath).text(); const result = cleanCss.minify(src); if (result.styles && result.styles.length < src.length) { stats.savedBytes += src.length - result.styles.length; await Bun.write(filePath, result.styles); } stats.css++; } async function minifyHTML(filePath: string) { const src = await Bun.file(filePath).text(); const result = await htmlMinify(src, htmlOpts); if (result.length < src.length) { stats.savedBytes += src.length - result.length; await Bun.write(filePath, result); } stats.html++; } async function runPool( items: T[], concurrency: number, worker: (item: T) => Promise, ): Promise { let nextIndex = 0; const workers = Array.from( { length: Math.min(concurrency, Math.max(1, items.length)) }, async () => { while (nextIndex < items.length) { const item = items[nextIndex++]; await worker(item); } }, ); await Promise.all(workers); } const vendorDir = path.join(publicDir, "vendor"); const vendorRoots = [ "msgpackr", "codemirror", "chart.js", "gridstack", "hotwired", "xterm", "inter", "jetbrains-mono", ]; async function collectVendorFiles(exts: Set): Promise { const results: string[] = []; for (const root of vendorRoots) { const dir = path.join(vendorDir, root); try { results.push(...(await collectFiles(dir, exts))); } catch (error: any) { if (error?.code !== "ENOENT") throw error; } } return results; } function isVendorModule(filePath: string): boolean { const normalized = filePath.replaceAll("\\", "/"); return normalized.endsWith(".mjs") || normalized.includes("/hotwired/") || normalized.endsWith("/chart.esm.js"); } // Collect all files. const jsFiles = await collectFiles(path.join(publicDir, "assets"), new Set([".js"])); const cssFiles = await collectFiles(path.join(publicDir, "assets"), new Set([".css"])); const htmlFiles = await collectFiles(publicDir, new Set([".html"])); const vendorJsFiles = await collectVendorFiles(new Set([".js", ".mjs"])); const vendorCssFiles = await collectVendorFiles(new Set([".css"])); console.log( `Minifying ${jsFiles.length + vendorJsFiles.length} JS, ` + `${cssFiles.length + vendorCssFiles.length} CSS, ${htmlFiles.length} HTML files ` + `(concurrency=${minifyConcurrency})...`, ); const jobs = [ ...jsFiles.map((file) => ({ kind: "JS", file, minify: minifyJS })), ...vendorJsFiles.map((file) => ({ kind: "JS", file, minify: (filePath: string) => minifyJS(filePath, isVendorModule(filePath)), })), ...cssFiles.map((file) => ({ kind: "CSS", file, minify: minifyCSS })), ...vendorCssFiles.map((file) => ({ kind: "CSS", file, minify: minifyCSS })), ...htmlFiles.map((file) => ({ kind: "HTML", file, minify: minifyHTML })), ]; await runPool(jobs, minifyConcurrency, async ({ kind, file, minify }) => { try { await minify(file); } catch (e) { failures++; const message = e instanceof Error ? e.message : String(e); console.error(`${kind} error ${file}: ${message}`); } }); console.log( `Done: ${stats.js} JS, ${stats.css} CSS, ${stats.html} HTML — saved ${(stats.savedBytes / 1024).toFixed(1)} KB`, ); if (failures > 0) { console.error(`Minification failed for ${failures} file(s).`); process.exit(1); }