234 lines
8.7 KiB
JavaScript
234 lines
8.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* bundle.js — Flatten an HTML app and all its local deps into a single
|
|
* self-contained .html artifact, ready to open offline.
|
|
*
|
|
* What it does:
|
|
* - Inlines every local <script src> and <link rel="stylesheet" href>.
|
|
* - Pre-compiles JSX (type="text/babel") to plain JS using @babel/core,
|
|
* then removes the Babel CDN <script> tag — it's no longer needed.
|
|
* - Replaces React/ReactDOM dev CDN builds with production minified builds
|
|
* (much smaller; fetched once per run).
|
|
* - Skips anything inside HTML comments — commented-out blocks stay inert.
|
|
* - Leaves other remote URLs (fonts, etc.) untouched.
|
|
*
|
|
* Usage:
|
|
* node bundle.js <input.html> [output.html]
|
|
*
|
|
* Example:
|
|
* node bundle.js index.html ../webapp/index-style-v2.html
|
|
*
|
|
* Requirements:
|
|
* Node 18+ (uses global fetch to pull prod React builds).
|
|
* Run `npm install` once to get @babel/core + @babel/preset-react.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// JSX compiler — graceful no-op if not installed.
|
|
// Require the preset as a module reference so Babel doesn't try to resolve it
|
|
// by name from cwd (which may differ from __dirname when invoked from another dir).
|
|
let babel, babelPresetReact;
|
|
try {
|
|
babel = require('@babel/core');
|
|
babelPresetReact = require('@babel/preset-react');
|
|
} catch { /* npm install to enable */ }
|
|
|
|
// ---- CDN substitution table -------------------------------------------------
|
|
// key: substring present in the CDN src URL
|
|
// value: prod URL to fetch & inline, or null to remove the tag entirely
|
|
const CDN_SWAP = [
|
|
{ match: 'unpkg.com/react-dom@', prod: 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js' },
|
|
{ match: 'unpkg.com/react@', prod: 'https://unpkg.com/react@18.3.1/umd/react.production.min.js' },
|
|
{ match: 'unpkg.com/@babel/standalone', prod: null }, // removed — we pre-compile
|
|
];
|
|
// react-dom must come before react in the list so substring matching works correctly
|
|
// (react-dom URL also contains "react@")
|
|
|
|
// ---- Helpers -----------------------------------------------------------------
|
|
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
|
|
|
if (args.length < 1) {
|
|
console.error('Usage: node bundle.js <input.html> [output.html]');
|
|
process.exit(1);
|
|
}
|
|
|
|
const inputPath = args[0];
|
|
const inputDir = path.dirname(path.resolve(inputPath));
|
|
const inputBase = path.basename(inputPath, '.html');
|
|
const outputPath = args[1] || path.join(inputDir, `${inputBase} (single file).html`);
|
|
|
|
function isRemote(url) { return /^(https?:)?\/\//i.test(url) || url.startsWith('data:'); }
|
|
|
|
function readLocal(ref) {
|
|
const full = path.resolve(inputDir, ref);
|
|
if (!fs.existsSync(full)) { console.warn(` ⚠ missing: ${ref}`); return null; }
|
|
return fs.readFileSync(full, 'utf8');
|
|
}
|
|
|
|
async function fetchRemote(url) {
|
|
console.log(` ⤓ fetching ${url}`);
|
|
const r = await fetch(url);
|
|
if (!r.ok) { console.warn(` ⚠ ${r.status} ${url}`); return null; }
|
|
return r.text();
|
|
}
|
|
|
|
// Returns [start, end] pairs for every <!-- ... --> span in html.
|
|
function commentSpans(html) {
|
|
const spans = [];
|
|
const re = /<!--[\s\S]*?-->/g;
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) spans.push([m.index, m.index + m[0].length]);
|
|
return spans;
|
|
}
|
|
|
|
function insideComment(pos, spans) {
|
|
return spans.some(([s, e]) => pos >= s && pos < e);
|
|
}
|
|
|
|
// Escape </script inside a <script> block so the browser doesn't close it early.
|
|
function escForScript(src) { return src.replace(/<\/script/gi, '<\\/script'); }
|
|
function escForStyle(src) { return src.replace(/<\/style/gi, '<\\/style'); }
|
|
|
|
// Pre-compile JSX → JS. Falls back to raw source if babel isn't installed.
|
|
function compileJSX(src, filename) {
|
|
if (!babel || !babelPresetReact) return { code: src, compiled: false };
|
|
try {
|
|
const result = babel.transformSync(src, {
|
|
presets: [babelPresetReact],
|
|
filename,
|
|
sourceMaps: false,
|
|
compact: false,
|
|
});
|
|
return { code: result.code, compiled: true };
|
|
} catch (e) {
|
|
console.warn(` ⚠ JSX compile error in ${filename}: ${e.message}`);
|
|
return { code: src, compiled: false };
|
|
}
|
|
}
|
|
|
|
// ---- Inline local stylesheets -----------------------------------------------
|
|
async function inlineStylesheets(html) {
|
|
const comments = commentSpans(html);
|
|
const re = /<link\b([^>]*?)\brel\s*=\s*["']stylesheet["']([^>]*?)>/gi;
|
|
const matches = [...html.matchAll(re)];
|
|
let out = '', cursor = 0;
|
|
for (const m of matches) {
|
|
if (insideComment(m.index, comments)) {
|
|
out += html.slice(cursor, m.index + m[0].length);
|
|
cursor = m.index + m[0].length;
|
|
continue;
|
|
}
|
|
out += html.slice(cursor, m.index);
|
|
const hrefM = (m[1] + ' ' + m[2]).match(/\bhref\s*=\s*["']([^"']+)["']/i);
|
|
const href = hrefM?.[1];
|
|
const body = href && !isRemote(href) ? readLocal(href) : null;
|
|
if (body) { console.log(` ✓ stylesheet ${href}`); out += `<style>\n${escForStyle(body)}\n</style>`; }
|
|
else out += m[0];
|
|
cursor = m.index + m[0].length;
|
|
}
|
|
return out + html.slice(cursor);
|
|
}
|
|
|
|
// ---- Inline / swap / remove <script src="..."> tags -------------------------
|
|
async function inlineScripts(html) {
|
|
const comments = commentSpans(html);
|
|
const re = /<script\b([^>]*)\bsrc\s*=\s*["']([^"']+)["']([^>]*)>\s*<\/script>/gi;
|
|
const matches = [...html.matchAll(re)];
|
|
let out = '', cursor = 0;
|
|
|
|
for (const m of matches) {
|
|
// Keep everything inside HTML comments verbatim.
|
|
if (insideComment(m.index, comments)) {
|
|
out += html.slice(cursor, m.index + m[0].length);
|
|
cursor = m.index + m[0].length;
|
|
continue;
|
|
}
|
|
|
|
out += html.slice(cursor, m.index);
|
|
const [full, attrsBefore, src, attrsAfter] = m;
|
|
const allAttrs = (attrsBefore + ' ' + attrsAfter).trim();
|
|
const isBabel = /type\s*=\s*["']text\/babel["']/i.test(allAttrs);
|
|
|
|
if (isRemote(src)) {
|
|
// Look for a CDN substitution rule.
|
|
const rule = CDN_SWAP.find(r => src.includes(r.match));
|
|
if (rule === undefined) {
|
|
out += full; // unknown CDN — keep as-is
|
|
} else if (rule.prod === null) {
|
|
console.log(` ✗ removed ${src}`);
|
|
// tag dropped
|
|
} else {
|
|
const body = await fetchRemote(rule.prod);
|
|
if (body) { console.log(` ✓ inlined prod build`); out += `<script>\n${escForScript(body)}\n</script>`; }
|
|
else out += full;
|
|
}
|
|
} else {
|
|
// Local file.
|
|
let body = readLocal(src);
|
|
if (body == null) { out += full; }
|
|
else {
|
|
let label = src;
|
|
if (isBabel) {
|
|
const r = compileJSX(body, src);
|
|
body = r.code;
|
|
label = r.compiled ? `${src} (JSX→JS)` : src;
|
|
}
|
|
console.log(` ✓ script ${label}`);
|
|
// Strip type="text/babel", integrity, crossorigin — content has changed.
|
|
const attrs = allAttrs
|
|
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
|
|
.replace(/\bintegrity\s*=\s*["'][^"']*["']/gi, '')
|
|
.replace(/\bcrossorigin\s*=\s*["'][^"']*["']/gi, '')
|
|
.replace(/\s+/g, ' ').trim();
|
|
out += `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(body)}\n</script>`;
|
|
}
|
|
}
|
|
cursor = m.index + full.length;
|
|
}
|
|
return out + html.slice(cursor);
|
|
}
|
|
|
|
// ---- Compile remaining inline <script type="text/babel">…</script> blocks ---
|
|
function compileInlineBabel(html) {
|
|
const comments = commentSpans(html);
|
|
const re = /(<script\b[^>]*\btype\s*=\s*["']text\/babel["'][^>]*>)([\s\S]*?)(<\/script>)/gi;
|
|
return html.replace(re, (full, open, body, close, offset) => {
|
|
if (insideComment(offset, comments)) return full;
|
|
const { code, compiled } = compileJSX(body, 'inline.jsx');
|
|
if (!compiled) return full;
|
|
const attrs = open
|
|
.replace(/^<script\b/i, '')
|
|
.replace(/>$/, '')
|
|
.replace(/\btype\s*=\s*["']text\/babel["']/gi, '')
|
|
.replace(/\s+/g, ' ').trim();
|
|
console.log(' ✓ inline babel block compiled');
|
|
return `<script${attrs ? ' ' + attrs : ''}>\n${escForScript(code)}\n${close}`;
|
|
});
|
|
}
|
|
|
|
// ---- Main -------------------------------------------------------------------
|
|
(async () => {
|
|
if (babel && babelPresetReact) console.log('JSX: pre-compile enabled (@babel/core)');
|
|
else console.log('JSX: no @babel/core — run npm install to enable');
|
|
|
|
console.log(`\nReading ${inputPath}…`);
|
|
let html = fs.readFileSync(inputPath, 'utf8');
|
|
|
|
console.log('\nStylesheets:');
|
|
html = await inlineStylesheets(html);
|
|
|
|
console.log('\nScripts:');
|
|
html = await inlineScripts(html);
|
|
|
|
if (babel) {
|
|
console.log('\nInline Babel blocks:');
|
|
html = compileInlineBabel(html);
|
|
}
|
|
|
|
fs.writeFileSync(outputPath, html);
|
|
const kb = (fs.statSync(outputPath).size / 1024).toFixed(0);
|
|
console.log(`\n✔ wrote ${outputPath} (${kb} KB)`);
|
|
})();
|