A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own key, about each item of their subscribed feeds from the last 30 days and every new one: one call an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit. Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is passed over until its owner saves another, so a bad key is not sent the whole queue. A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort and read state are an ordinary list's, and reading an item in a Signal reads it in its feed. The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key, which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
7.7 KiB
JavaScript
127 lines
7.7 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)}`;
|
|
|
|
/// 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('__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'],
|
|
['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 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 }));
|
|
}
|
|
}
|