web/signals.svg and web/deadair-{on,off}.svg, each with a -dark twin,
follow logo.svg's rules: square and opaque, the logo's gradient ground,
flat layers in its scale blues, the needle's orange, nothing thin
enough to vanish at 32px. Signals is the page's own glyph, waves going
out from a point, the point in orange. DeadAir on is the waveform
closed up with the needle at the cut; off has the silence still in its
middle, an orange flat line, and its bars muted, so the state is in
the shape as the old glyphs had it.
The Signals folder row and its page header show the Signals tile in
place of the glyph on an accent tint; the player's DeadAir switch shows
the on or off tile. tile() puts both variants on the page, and the mode
rule that picked the logo's now picks any .onlight/.ondark. The three
glyphs nothing uses any more are gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
9.3 KiB
JavaScript
152 lines
9.3 KiB
JavaScript
// 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 <script data-src>
|
|
// pointing at that script, and the icons, logos and typeface beside them. The result, web/dist,
|
|
// is a static site: Caddy serves it (web/Caddyfile, Dockerfile.web) and passes the API to ipx.
|
|
//
|
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
|
// URL, and the page, which the browser checks on every visit, is what carries it. Caddy's file
|
|
// server ignores the query.
|
|
//
|
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import zlib from 'node:zlib';
|
|
import { fileURLToPath } from 'node:url';
|
|
import swc from '@swc/core';
|
|
import html from '@swc/html';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
// The page's own version (package.json), given to its script in place of __WEB_VERSION__.
|
|
const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
|
|
|
// The files are one script, concatenated in this order, not modules: they share one top-level
|
|
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
|
const PAGES = {
|
|
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
|
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
|
'login.html': { script: 'login.js', src: ['login'] },
|
|
};
|
|
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
|
const STYLE = 'app.css';
|
|
|
|
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
|
|
|
// hls.js, for browsers that cannot play DeadAir's playlists themselves (#181). Served as it comes,
|
|
// by a name with its hash in it, which the page's script is given in place of __HLS_JS__.
|
|
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
|
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
|
|
|
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
|
|
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
|
|
// colour, and an index of names and search terms the picker fetches when it opens. The page's
|
|
// script is given the index's address, named by its hash, in place of __ICONS__.
|
|
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
|
|
const FA_STYLES = ['solid', 'regular', 'brands'];
|
|
/// [`style/name`, label, search terms] for every icon, solid first.
|
|
export function iconIndex() {
|
|
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
|
|
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
|
|
const name = f.slice(0, -4), m = meta[name] || {};
|
|
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
|
|
}));
|
|
}
|
|
const ICONS = JSON.stringify(iconIndex());
|
|
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
|
|
|
|
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
|
/// one; the doctype only keeps it from complaining that a fragment has none.
|
|
export function buildStyle({ minify = true } = {}) {
|
|
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
|
|
if (!minify) return css;
|
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
|
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
|
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
|
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
|
// declaration away, so the element silently loses its padding. It reports no error and the
|
|
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
|
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
|
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
|
return out;
|
|
}
|
|
|
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
|
export function buildPage(name, { minify = true } = {}) {
|
|
const { script, src: files } = PAGES[name];
|
|
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
|
.replaceAll('__HLS_JS__', HLS_URL)
|
|
.replaceAll('__ICONS__', ICONS_URL)
|
|
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
|
const js = swc.transformSync(src, {
|
|
filename: name + '.ts',
|
|
jsc: {
|
|
parser: { syntax: 'typescript' },
|
|
target: 'es2022',
|
|
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
|
|
// and the browser tests reach others (player, savePos) through page.evaluate.
|
|
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
|
|
},
|
|
isModule: false,
|
|
minify,
|
|
}).code;
|
|
const page = fs.readFileSync(path.join(here, name), 'utf8');
|
|
const marker = /<script data-src="[^"]*"><\/script>/;
|
|
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
|
|
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
|
|
// after the markup it wires up, before anything else.
|
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
|
// 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 }));
|
|
}
|
|
}
|