New big and small scope sunset and sunrise simulation New calculations for the day tabs Added missing icons to simple view New record popups Week ahead description popups Camping profile Tweak to some of the indoor tems
309 lines
12 KiB
JavaScript
309 lines
12 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(DIST, 'sunsoak.html'),
|
|
path.join(DIST, 'profiles.html'),
|
|
path.join(DIST, 'columns.html'),
|
|
path.join(DIST, 'temperatures.html'),
|
|
path.join(DIST, 'features.html'),
|
|
path.join(DIST, 'stress-bands.html'),
|
|
path.join(DIST, 'dashboard.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(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, '');
|
|
// Only strips the dev entry point (main.js) - other plain scripts these
|
|
// pages load directly, like nav-account.js, are not part of the Preact
|
|
// bundle and must survive untouched.
|
|
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/main\.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 + '">';
|
|
// ESM module — browser loads chunks automatically on demand (e.g. xlsx-js-style)
|
|
const js = ' <script type="module" 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 (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',
|
|
'sunsoak.html', 'profiles.html', 'columns.html',
|
|
'temperatures.html', 'features.html', 'stress-bands.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...');
|
|
// dashboard.html is copied as-is, not run through rewriteHTML - it is a
|
|
// standalone page (inline CSS/JS, no #root) and has no use for the
|
|
// Preact app bundle the other pages get.
|
|
// secrets.local.php is deliberately NOT copied here - it's gitignored
|
|
// per-environment config (DB creds, Stripe key). Copying the local dev
|
|
// copy into dist/ and syncing dist/ to the VPS would overwrite the
|
|
// live secrets file with local (127.0.0.1/root/blank) DB credentials.
|
|
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
|
|
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'dashboard.html',
|
|
'assets/js/nav-account.js', 'assets/about.css']) {
|
|
const src = path.join(ROOT, f);
|
|
if (fs.existsSync(src)) {
|
|
copyFile(src, path.join(DIST, f));
|
|
console.log(' -> ' + f);
|
|
}
|
|
}
|
|
|
|
// 8b. Copy the raw JS modules dashboard.html imports directly (it's not
|
|
// run through esbuild like the Preact pages are, so its import chain -
|
|
// compute.js -> physics.js/utils.js/config.js, plus the event calendars -
|
|
// has to exist as real files under dist/assets/js/).
|
|
console.log(' Copying dashboard JS modules...');
|
|
for (const f of ['assets/js/compute.js', 'assets/js/physics.js', 'assets/js/config.js', 'assets/js/utils.js',
|
|
'assets/js/events/cosmic-calendar.js', 'assets/js/events/almanac-calendar.js',
|
|
'assets/js/events/calendar/2026.js', 'assets/js/events/calendar/2027.js',
|
|
'assets/js/events/calendar/2028.js', 'assets/js/events/calendar/2029.js',
|
|
'assets/js/events/calendar/2030.js']) {
|
|
const src = path.join(ROOT, f);
|
|
if (fs.existsSync(src)) {
|
|
copyFile(src, path.join(DIST, f));
|
|
console.log(' -> ' + f);
|
|
}
|
|
}
|
|
|
|
// 9. Copy the dashboard backend (lib/ shared PHP + api/ endpoints)
|
|
console.log(' Copying dashboard backend...');
|
|
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);
|
|
}
|
|
};
|
|
for (const dir of ['lib', 'api']) {
|
|
const src = path.join(ROOT, dir);
|
|
if (fs.existsSync(src)) {
|
|
copyDirRecursive(src, path.join(DIST, dir));
|
|
console.log(' -> ' + dir + '/');
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|