// KidLisp Bundler Library // Shared bundle generation logic for CLI (ac-keep) and Netlify function // Creates self-contained .lisp.html files for KidLisp pieces import { promises as fs } from "fs"; import fsSync from "fs"; import path from "path"; import swc from "@swc/core"; import { execSync } from "child_process"; import { gzipSync } from "zlib"; // ============================================================================ // CONFIGURATION // ============================================================================ const MINIFY_JS = true; // Files to explicitly skip (saves significant space) // Only skip files that are dynamically imported or truly optional const SKIP_FILES = [ // WasmBoy GameBoy emulator - 386 KB source, ~163 KB minified 'dep/wasmboy/', // UDP/networking - 9 KB, only for multiplayer (dynamically imported) 'lib/udp.mjs', 'dep/geckos.io-client', // World system - 9.5 KB, 3D features 'systems/world.mjs', // Graphics optimizer - runtime optimization (dynamically imported) 'lib/graphics-optimizer.mjs', ]; // ULTRA-MINIMAL file set - only what's absolutely required for basic KidLisp visuals const ESSENTIAL_FILES = [ // Core system (required) 'boot.mjs', 'bios.mjs', // Core loop and disk 'lib/loop.mjs', 'lib/disk.mjs', 'lib/parse.mjs', // KidLisp interpreter 'lib/kidlisp.mjs', // Graphics essentials (2D only, NO 3D) 'lib/graph.mjs', 'lib/geo.mjs', 'lib/2d.mjs', 'lib/pen.mjs', 'lib/num.mjs', 'lib/gl.mjs', // System essentials 'lib/helpers.mjs', 'lib/logs.mjs', 'lib/store.mjs', 'lib/platform.mjs', 'lib/pack-mode.mjs', 'lib/l5.mjs', // ๐ง Wasmoon/Lua runtime required by disk.mjs // BIOS dependencies (required by imports) 'lib/keyboard.mjs', 'lib/gamepad.mjs', 'lib/motion.mjs', 'lib/speech.mjs', 'lib/help.mjs', 'lib/midi.mjs', 'lib/usb.mjs', 'lib/headers.mjs', 'lib/glaze.mjs', 'lib/ui.mjs', // Disk dependencies 'disks/common/tape-player.mjs', // Sound dependencies (tiny files) 'lib/sound/sound-whitelist.mjs', // gl-matrix dependencies (used by geo, graph, disk, 2d) 'dep/gl-matrix/common.mjs', 'dep/gl-matrix/vec2.mjs', 'dep/gl-matrix/vec3.mjs', 'dep/gl-matrix/vec4.mjs', 'dep/gl-matrix/mat3.mjs', 'dep/gl-matrix/mat4.mjs', 'dep/gl-matrix/quat.mjs', // Glaze dependencies 'lib/glazes/uniforms.js', ]; // ============================================================================ // UTILITY FUNCTIONS // ============================================================================ function resolvePath(base, relative) { if (!relative.startsWith('.')) return relative; let dir = path.dirname(base); const parts = dir === '.' ? [] : dir.split('/').filter(p => p); const relParts = relative.split('/'); for (const part of relParts) { if (part === '..') parts.pop(); else if (part !== '.' && part !== '') parts.push(part); } return parts.join('/'); } function rewriteImports(code, filepath) { // Rewrite aesthetic.computer imports code = code.replace(/from\s*['"]aesthetic\.computer\/disks\/([^'"]+)['"]/g, (match, p) => { return 'from \'ac/disks/' + p + '\''; }); code = code.replace(/import\s*\((['"]aesthetic\.computer\/disks\/([^'"]+)['")])\)/g, (match, fullPath, p) => { return 'import(\'ac/disks/' + p + '\')'; }); // Rewrite relative imports. Drop the query suffix entirely โ in bundled // output the import map maps bare `lib/foo.mjs` specifiers to blob: URLs // that are already unique per bundle, so cache-busting query params like // `?v=20260330-runtime-support` are not only unneeded but actively break // resolution (the map has no versioned entry, browser rejects the import). // This was the cause of the $rip / l5.mjs "Failed to resolve module // specifier" error that killed in-browser piece execution and made the // oven's thumbnail capture hang for its full 150s timeout. code = code.replace(/from\s*['"](\.\.\/[^'"?]+|\.\/[^'"?]+)(\?[^'"]*)?['"]/g, (match, p) => { const resolved = resolvePath(filepath, p); return 'from"' + resolved + '"'; }); // Rewrite dynamic imports (same query-drop logic) code = code.replace(/import\s*\((['"](\.\.\/[^'"?]+|\.\/[^'"?]+)(\?[^'"]*)?)['"](\))\)/g, (match, fullPath, p) => { const resolved = resolvePath(filepath, p); return 'import("' + resolved + '")'; }); code = code.replace(/import\s*\(\`(\.\.\/[^\`?]+)(\?[^\`]*)?\`\)/g, (match, p) => { const resolved = resolvePath(filepath, p); return 'import("' + resolved + '")'; }); return code; } async function minifyJS(content, relativePath) { const ext = path.extname(relativePath); if (ext !== ".mjs" && ext !== ".js") { return content; } let processedContent = content; if (!MINIFY_JS) { return rewriteImports(processedContent, relativePath); } // Rewrite imports BEFORE minification processedContent = rewriteImports(processedContent, relativePath); try { // Use SWC for faster, better minification const result = await swc.minify(processedContent, { compress: { dead_code: true, unused: true, passes: 3, pure_getters: true, unsafe_math: true, join_vars: true, sequences: true, evaluate: true, conditionals: true, booleans: true, loops: true, side_effects: true, collapse_vars: true, reduce_vars: true, inline: 3, drop_console: true, drop_debugger: true }, mangle: { toplevel: true, keep_classnames: false, keep_fnames: false, safari10: false }, format: { comments: false, ascii_only: false }, module: true, sourceMap: false }); if (result.code) { return result.code; } } catch (error) { console.warn(` โ ๏ธ Failed to minify ${relativePath}:`, error.message); } return processedContent; } // ============================================================================ // DEPENDENCY DISCOVERY // ============================================================================ async function discoverDependencies(acDir, essentialFiles, skipFiles) { const discovered = new Set(essentialFiles); const toProcess = [...essentialFiles]; while (toProcess.length > 0) { const file = toProcess.shift(); const fullPath = path.join(acDir, file); if (!fsSync.existsSync(fullPath)) continue; try { const content = await fs.readFile(fullPath, 'utf8'); // Find all relative imports (strip query params for path resolution) const importRegex = /from\s+["'](\.\.[^"'?]+|\.\/[^"'?]+)(\?[^"']*)?["']/g; const dynamicImportRegex = /import\s*\(\s*["'](\.\.[^"'?]+|\.\/[^"'?]+)(\?[^"']*)?["']\s*\)/g; let match; while ((match = importRegex.exec(content)) !== null) { const importPath = match[1]; // just the path, not the query const resolved = resolvePath(file, importPath); if (skipFiles.some(skip => resolved.includes(skip))) { continue; } if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } } while ((match = dynamicImportRegex.exec(content)) !== null) { const importPath = match[1]; const resolved = resolvePath(file, importPath); if (skipFiles.some(skip => resolved.includes(skip))) { continue; } if (!discovered.has(resolved)) { discovered.add(resolved); toProcess.push(resolved); } } } catch (error) { // Ignore errors reading files } } return Array.from(discovered); } // ============================================================================ // KIDLISP SOURCE FETCHING // ============================================================================ async function fetchKidLispFromAPI(pieceName, baseUrl = 'https://aesthetic.computer') { const cleanName = pieceName.replace('$', ''); const url = `${baseUrl}/api/store-kidlisp?code=${cleanName}`; try { const response = await fetch(url); const data = await response.json(); if (data.error || !data.source) { throw new Error(`Piece '$${cleanName}' not found`); } return data.source; } catch (error) { throw new Error(`Failed to fetch $${cleanName}: ${error.message}`); } } function extractKidLispRefs(source) { const refs = []; const regex = /\$[a-z0-9_-]+/gi; const matches = source.matchAll(regex); for (const match of matches) { const ref = match[0].toLowerCase(); if (!refs.includes(ref)) { refs.push(ref); } } return refs; } export async function getKidLispSourceWithDeps(pieceName, baseUrl = 'https://aesthetic.computer') { const allSources = {}; const toProcess = [pieceName]; const processed = new Set(); while (toProcess.length > 0) { const current = toProcess.shift(); const cleanName = current.replace('$', ''); if (processed.has(cleanName)) continue; processed.add(cleanName); const source = await fetchKidLispFromAPI(cleanName, baseUrl); allSources[cleanName] = source; const refs = extractKidLispRefs(source); for (const ref of refs) { const refName = ref.replace('$', ''); if (!processed.has(refName)) { toProcess.push(refName); } } } return allSources; } // ============================================================================ // BUNDLE GENERATION // ============================================================================ export async function createBundle(options) { const { pieceName, kidlispSources, acDir, authorHandle = '@jeffrey', verbose = false } = options; const PIECE_NAME_NO_DOLLAR = pieceName.replace('$', ''); const PIECE_NAME = '$' + PIECE_NAME_NO_DOLLAR; const mainSource = kidlispSources[PIECE_NAME_NO_DOLLAR]; // Get git info let gitHash = 'unknown'; let gitDirty = ''; let gitVersion = 'unknown'; try { gitHash = execSync("git rev-parse --short HEAD", { encoding: "utf8", cwd: acDir }).trim(); gitDirty = execSync("git diff --quiet || echo dirty", { encoding: "utf8", cwd: acDir }).trim(); gitVersion = gitHash + (gitDirty ? " (dirty)" : ""); } catch (e) { // Git might not be available } const packTime = Date.now(); const packDate = new Date().toLocaleString("en-US", { timeZone: "America/Los_Angeles", year: "numeric", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, }); const files = {}; // Auto-discover all dependencies from essential files if (verbose) console.log("๐ Auto-discovering dependencies..."); const allFiles = await discoverDependencies(acDir, ESSENTIAL_FILES, SKIP_FILES); if (verbose) console.log(`โ Found ${allFiles.length} total files`); // Load all discovered files if (verbose) console.log("\n๐ Loading files..."); for (const relativePath of allFiles) { const fullPath = path.join(acDir, relativePath); try { if (!fsSync.existsSync(fullPath)) { continue; } let content = await fs.readFile(fullPath, "utf8"); content = await minifyJS(content, relativePath); files[relativePath] = { content: content, binary: false, type: path.extname(relativePath).slice(1) }; if (verbose) console.log(` โ Loaded: ${relativePath}`); } catch (error) { if (verbose) console.warn(` โ ๏ธ Could not load ${relativePath}: ${error.message}`); } } // Create synthetic .lisp files for KidLisp pieces for (const [name, source] of Object.entries(kidlispSources)) { const pieceLispPath = `disks/${name}.lisp`; files[pieceLispPath] = { content: source, binary: false, type: 'lisp' }; } // Generate filename for this bundle const now = new Date(); const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`; const timeStr = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; const filename = `${PIECE_NAME_NO_DOLLAR}-${authorHandle}-${dateStr}-${timeStr}.lisp.html`; // Generate HTML const htmlContent = generateHTML({ PIECE_NAME, PIECE_NAME_NO_DOLLAR, mainSource, kidlispSources, files, packDate, packTime, gitVersion, gitHash, gitDirty, authorHandle }); // Create gzip-compressed self-extracting bundle const gzipCompressed = gzipSync(htmlContent, { level: 9 }); const gzipBase64 = gzipCompressed.toString('base64'); const selfExtractingHTML = `