body is a grid of topbar / main / status / player, and nothing in the page accounted for a display's own intrusions. Mobile Safari hides that by insetting the layout viewport to the safe area, so the site was fine in a browser -- but a full-screen shell, the native app or the site added to an iOS home screen, hands the page the whole display, and the last row landed under the home indicator with its seek bar and times half cut off. viewport-fit=cover asks for the whole screen deliberately, and the bars along the edges now pay for the insets in padding: the bottom for the indicator, left and right for the notch in landscape. A browser with its own chrome reports nought and nothing moves. The padding has to be longhand, and there is a comment saying so, because 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 then throws the whole declaration away. The bars lost all their padding, which moved the item list far enough that the pull-to-refresh browser test stopped finding it; nothing reported an error, and the page still loaded. buildStyle now fails the build on a calc() run into its neighbour rather than trusting it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
4.9 KiB
JavaScript
92 lines
4.9 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 }))}">`);
|
|
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);
|
|
}
|
|
}
|