#!/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(/';
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']) {
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);
});