// 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'], ['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 })); } }