// Builds the frontend (#203): each page's TypeScript from web/src, types stripped and minified
// by swc into a script of its own (app.js, login.js), the page minified, its `);
out = out.replace(//,
() => ``);
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg|boot\.js)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
const bad = (r.errors || []).filter(e => e.level === 'Error');
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
return { html: r.code, js, script };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const out = process.argv[2] || path.join(here, 'dist');
fs.mkdirSync(out, { recursive: true });
fs.writeFileSync(path.join(out, STYLE), buildStyle());
fs.writeFileSync(path.join(out, 'hls.js'), HLS);
// Served as they are, under the names the pages and the stylesheet use. /favicon.ico is the
// PNG, for a browser that asks for that on its own; iOS asks for the precomposed touch icon
// first when the site is added to a home screen (#109). /icon.png is the 2004 icon, which
// nothing here shows any more but the classic theme.
for (const [from, to] of [
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
for (const style of FA_STYLES) {
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
}
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
for (const name of Object.keys(PAGES)) {
const { html, js, script } = buildPage(name);
fs.writeFileSync(path.join(out, name), html);
fs.writeFileSync(path.join(out, script), js);
}
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
const body = fs.readFileSync(path.join(out, f));
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
}));
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
}
}