#!/usr/bin/env node // build.js -- SunScope production bundler // // Usage: node build.js OR npm run build // // What it does: // 1. Bundles + minifies JS (entry: assets/js/main.js) -> dist/assets/bundle.min.js // 2. Resolves CSS @imports + minifies -> dist/assets/bundle.min.css // 3. Copies index.html / about.html / faq.html to dist/, rewriting script+link tags // 4. Copies static files (images, robots.txt, sitemap.xml) // // Dev files are NEVER touched. Deploy from dist/ for production. import * as esbuild from 'esbuild'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = __dirname; const DIST = path.join(ROOT, 'dist'); const ASSETS = path.join(DIST, 'assets'); // ---- Helpers --------------------------------------------------------------- function copyFile(src, dest) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); } // Delete specific output files without trying to rmdir the folders // (rmdir on Windows-mounted paths can fail inside Linux sandboxes) function cleanDist() { const targets = [ path.join(ASSETS, 'bundle.min.js'), path.join(ASSETS, 'bundle.min.css'), path.join(DIST, 'index.html'), path.join(DIST, 'about.html'), path.join(DIST, 'faq.html'), path.join(ASSETS, '_tmp_flat.css'), path.join(ASSETS, 'about.min.css'), ]; for (const f of targets) { try { fs.unlinkSync(f); } catch (_) { /* ok if missing */ } } // Clean up any previously-generated ESM chunks const chunksDir = path.join(ASSETS, 'chunks'); if (fs.existsSync(chunksDir)) { for (const f of fs.readdirSync(chunksDir)) { try { fs.unlinkSync(path.join(chunksDir, f)); } catch (_) {} } } } // Recursively resolve CSS @import statements into one flat string function resolveCSSImports(filePath, visited = new Set()) { const resolved = path.resolve(filePath); if (visited.has(resolved)) return ''; visited.add(resolved); const dir = path.dirname(resolved); const content = fs.readFileSync(resolved, 'utf8'); return content.replace(/@import\s+["']([^"']+)["'];?/g, (_, importPath) => { const importFile = path.join(dir, importPath); if (!fs.existsSync(importFile)) { console.warn(' WARNING: CSS @import not found: ' + importFile); return ''; } return resolveCSSImports(importFile, visited); }); } // Short cache-buster string function bumpVersion() { return Math.floor(Date.now() / 1000).toString(36); } // Swap dev asset tags in HTML for bundle tags. // Strips the main sunscope.css link, rewrites standalone about.css to // about.min.css with a version param, and injects the JS bundle. function rewriteHTML(html, version) { html = html.replace(/]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, ''); html = html.replace(/]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, ''); // Rewrite standalone about.css reference to versioned minified file. html = html.replace( /(]*href=["'][^"']*assets\/)about\.css(["'][^>]*>)/gi, '$1about.min.css?v=' + version + '$2' ); const css = ' '; // ESM module — browser loads chunks automatically on demand (e.g. xlsx-js-style) const js = ' '; html = html.replace('', css + '\n' + js + '\n'); return html; } // esbuild plugin: strips JSX block comments {/* ... */} that may contain // backticks or other characters that trip up esbuild's JS parser. // Safe to remove -- these are only developer notes, never runtime code. const stripJSXCommentsPlugin = { name: 'strip-jsx-comments', setup(build) { build.onLoad({ filter: /\.js$/ }, async (args) => { let source = await fs.promises.readFile(args.path, 'utf8'); // Remove {/* ... */} comment blocks (non-greedy, dotAll) source = source.replace(/\{\/\*[\s\S]*?\*\/\}/g, ''); return { contents: source, loader: 'js' }; }); }, }; // ---- Main ------------------------------------------------------------------ async function build() { const t0 = Date.now(); console.log('SunScope build starting...'); cleanDist(); fs.mkdirSync(ASSETS, { recursive: true }); console.log(' Output dir ready: dist/'); // 2. Bundle + minify JavaScript (ESM with code splitting for lazy chunks) console.log(' Bundling JS...'); await esbuild.build({ entryPoints: [path.join(ROOT, 'assets/js/main.js')], bundle: true, splitting: true, minify: true, format: 'esm', entryNames: 'bundle.min', chunkNames: 'chunks/[name]-[hash]', outdir: ASSETS, logLevel: 'warning', plugins: [stripJSXCommentsPlugin], }); const jsSize = fs.statSync(path.join(ASSETS, 'bundle.min.js')).size; console.log(' JS bundle: ' + (jsSize / 1024).toFixed(1) + ' KB'); // 3. Resolve CSS @imports then minify console.log(' Bundling CSS...'); const flatCSS = resolveCSSImports(path.join(ROOT, 'assets/sunscope.css')); const tmpCSS = path.join(os.tmpdir(), '_sunscope_flat.css'); fs.writeFileSync(tmpCSS, flatCSS, 'utf8'); await esbuild.build({ entryPoints: [tmpCSS], bundle: false, minify: true, outfile: path.join(ASSETS, 'bundle.min.css'), logLevel: 'warning', }); fs.unlinkSync(tmpCSS); const cssSize = fs.statSync(path.join(ASSETS, 'bundle.min.css')).size; console.log(' CSS bundle: ' + (cssSize / 1024).toFixed(1) + ' KB'); // 4. Process HTML files console.log(' Processing HTML...'); const version = bumpVersion(); for (const htmlFile of ['index.html', 'about.html', 'faq.html']) { const src = path.join(ROOT, htmlFile); if (!fs.existsSync(src)) continue; const updated = rewriteHTML(fs.readFileSync(src, 'utf8'), version); fs.writeFileSync(path.join(DIST, htmlFile), updated, 'utf8'); console.log(' -> ' + htmlFile); } // 5. Minify standalone CSS files that are not part of the main bundle // src name -> output name const standaloneCSS = [['about.css', 'about.min.css']]; for (const [srcName, outName] of standaloneCSS) { const src = path.join(ROOT, 'assets', srcName); if (fs.existsSync(src)) { fs.mkdirSync(ASSETS, { recursive: true }); await esbuild.build({ entryPoints: [src], bundle: false, minify: true, outfile: path.join(ASSETS, outName), logLevel: 'warning', }); const sz = fs.statSync(path.join(ASSETS, outName)).size; console.log(' -> assets/' + outName + ' (' + (sz / 1024).toFixed(1) + ' KB minified)'); } } // 6. Copy fonts - explicit allowlist keeps only what is referenced in CSS. // All variants remain in assets/fonts/ as source files. const FONT_ALLOWLIST = new Set([ 'manrope-variable.woff2', 'fraunces-300-italic.woff2', 'fraunces-700-normal.woff2', 'fraunces-700-italic.woff2', 'fraunces-900-normal.woff2', 'jetbrains-mono-400.woff2', 'jetbrains-mono-600.woff2', ]); console.log(' Copying fonts...'); const fontsDir = path.join(ROOT, 'assets', 'fonts'); if (fs.existsSync(fontsDir)) { let copied = 0; for (const f of fs.readdirSync(fontsDir)) { if (!FONT_ALLOWLIST.has(f)) continue; copyFile(path.join(fontsDir, f), path.join(DIST, 'assets', 'fonts', f)); copied++; } console.log(' -> assets/fonts/ (' + copied + ' of ' + fs.readdirSync(fontsDir).length + ' files)'); } // 7. Copy images console.log(' Copying images...'); const imagesDir = path.join(ROOT, 'assets', 'images'); if (fs.existsSync(imagesDir)) { const copyDirRecursive = (src, dest) => { fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) copyDirRecursive(srcPath, destPath); else fs.copyFileSync(srcPath, destPath); } }; copyDirRecursive(imagesDir, path.join(DIST, 'assets', 'images')); const imageCount = fs.readdirSync(path.join(imagesDir, 'profiles')).length; console.log(' -> assets/images/ (' + imageCount + ' profile images)'); } // 8. Copy static files console.log(' Copying static files...'); for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php']) { const src = path.join(ROOT, f); if (fs.existsSync(src)) { copyFile(src, path.join(DIST, f)); console.log(' -> ' + f); } } const elapsed = ((Date.now() - t0) / 1000).toFixed(2); console.log(''); console.log('========================================'); console.log(' Build complete in ' + elapsed + 's'); console.log(' Output: dist/'); console.log(' JS: ' + (jsSize / 1024).toFixed(1) + ' KB'); console.log(' CSS: ' + (cssSize / 1024).toFixed(1) + ' KB'); console.log('========================================'); console.log(' Deploy the dist/ folder to production.'); console.log(' Dev files in assets/ are untouched.'); console.log('========================================'); } build().catch(err => { console.error('Build failed: ' + err.message); process.exit(1); });