201 lines
7.2 KiB
JavaScript
201 lines
7.2 KiB
JavaScript
#!/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 */ }
|
|
}
|
|
}
|
|
|
|
// 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(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, '');
|
|
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, '');
|
|
// Rewrite standalone about.css reference to versioned minified file.
|
|
html = html.replace(
|
|
/(<link\s[^>]*href=["'][^"']*assets\/)about\.css(["'][^>]*>)/gi,
|
|
'$1about.min.css?v=' + version + '$2'
|
|
);
|
|
const css = ' <link rel="stylesheet" href="./assets/bundle.min.css?v=' + version + '">';
|
|
const js = ' <script defer src="./assets/bundle.min.js?v=' + version + '"></script>';
|
|
html = html.replace('</head>', css + '\n' + js + '\n</head>');
|
|
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
|
|
console.log(' Bundling JS...');
|
|
await esbuild.build({
|
|
entryPoints: [path.join(ROOT, 'assets/js/main.js')],
|
|
bundle: true,
|
|
minify: true,
|
|
format: 'iife',
|
|
globalName: 'SunScope',
|
|
outfile: path.join(ASSETS, 'bundle.min.js'),
|
|
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 static files
|
|
console.log(' Copying static files...');
|
|
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png']) {
|
|
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);
|
|
});
|