/admin, with Server, Accounts and Log sections chosen by the URL's hash. The server sends the page and /admin.js to admins only (anyone else asking for the page goes back to the app, and the script is 403), and removes the header's link to it from everyone else's page rather than hiding it. The API keeps refusing all of it to non-admins as before. Settings becomes personal: theme, OPML import and export, and the schedule and download folder to read. The server fields, the Users dialog and the Log dialog move out of dialogs.ts into admin.ts. The CSS moves out of index.html into web/app.css, which both pages load as /app.css?v=<hash>, served immutable like the scripts. The smoke test checks both pages. Closes #19. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
4.4 KiB
JavaScript
85 lines
4.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'] },
|
|
'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('; ')}`);
|
|
return r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|