Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy, its screen blue and amber EQ bars. It now takes the new logo's colours, the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the needle taken down to #c43e00 so white on it clears AA). The pending amber and the error red moved apart from the needle's orange, and the sign-in page's copy of the palette follows. The pages always showed logo.svg, the light variant, even in a dark theme; logo-dark.svg was never served. It is now, and the app and admin pages show whichever matches data-mode, dark until the script says light, as the palette is. The sign-in page, which has no account's theme, picks by the system's with <picture>. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 lines
5.4 KiB
JavaScript
97 lines
5.4 KiB
JavaScript
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
|
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
|
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
|
// include_str!s the results, so the binary still carries everything and nothing is served
|
|
// from disk.
|
|
//
|
|
// 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.
|
|
//
|
|
// 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 { fileURLToPath } from 'node:url';
|
|
import swc from '@swc/core';
|
|
import html from '@swc/html';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// 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', '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);
|
|
|
|
/// 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');
|
|
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)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/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());
|
|
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);
|
|
}
|
|
}
|