Tests, tooling and the served scripts are TypeScript (#392)
The page and the service worker were strict TypeScript; the tests, the build, the home page's generator, the load tests, boot.js and the directory's site.js were plain JavaScript that nothing checked. All of it is TypeScript now, in five programs, each with its own globals, all checked by npm run typecheck: the page (tsconfig.json, with boot.ts and the directory's site.ts), the worker, Node's (tsconfig.tools.json: the Node tests, the browser suite, the build, the site generator, the load harness), and k6's (tsconfig.k6.json, with @types/k6). Node 24 runs the .ts files by stripping their types, so they hold only TypeScript that erases (erasableSyntaxOnly) and import each other by .ts name; package.json says "type": "module" for it, and Dockerfile.web moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types itself. The browser suite's page.evaluate calls reach the page's globals; the page's tsc run now also writes its declarations to target/page-types, which the suite's program includes, so those calls are typed by the page's own code rather than by hand-written copies. boot.ts is compiled by build.mts with swc, as the worker is, and named by the hash of what is served. The directory's build has no Node, so site.js is compiled from site.ts and committed (node web/build.mts --directory-script), and tests/directory-script.ts fails if they drift. Checked: the site generator's 139 pages are byte for byte what the JavaScript made; the page build's app.js is identical and only boot.js (now minified) and its hash differ; contrast, page-smoke, page-unit, native-bridge, the browser suite (96 passed, 2 skipped) and the directory's and ipx's Rust tests pass; k6 parses each load script. The load tests themselves did not run: they build a release binary outside the capped builder. .agents/skills/security-audit is an installed skill's, not ours, and is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ http://{$WEB_BIND::8080} {
|
||||
@compress not path /api/events /media/*
|
||||
encode @compress zstd gzip
|
||||
|
||||
# The copies web/build.mjs compressed are served in place of their file, never asked for by
|
||||
# The copies web/build.mts compressed are served in place of their file, never asked for by
|
||||
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
||||
# admins (#253).
|
||||
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
||||
@@ -137,7 +137,7 @@ http://{$WEB_BIND::8080} {
|
||||
}
|
||||
}
|
||||
|
||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mts), so a browser
|
||||
# keeps them a year; the icons a day, under names that do not change.
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
/* The edge of a text box or a switch: the theme's own text mixed into its ground, as far as it
|
||||
takes to clear 3:1 on every surface one sits on (#305, #308). --line is for rules between
|
||||
things, which need not, and a box drawn in it all but vanished. A theme whose grounds sit
|
||||
further apart takes more; tests/contrast.js holds every palette to it. */
|
||||
further apart takes more; tests/contrast.ts holds every palette to it. */
|
||||
--fieldmix:60%;
|
||||
--field:color-mix(in oklab,var(--fg) var(--fieldmix),var(--bg));
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Runs in <head>, before the first frame (#203): this browser's theme and font (#359), from their cookies, and
|
||||
// whether the last visit here was an admin's, so the link to Admin is there from the start. The
|
||||
// server wrote both into the page while it served it; the page is a static file now.
|
||||
(()=>{const r=document.documentElement,c=/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/.exec(document.cookie);
|
||||
if(c){r.dataset.theme=c[1];r.dataset.choice=c[2];if(c[2]!=='auto')r.dataset.mode=c[2];}
|
||||
const f=/(?:^|; )ipx_font=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(f)r.dataset.font=f[1];
|
||||
const g=/(?:^|; )ipx_rfont=([a-z]{1,10})(?:;|$)/.exec(document.cookie);if(g)r.dataset.rfont=g[1];
|
||||
try{if(localStorage.getItem('ipx.admin')==='1')r.dataset.admin='';}catch{}})();
|
||||
19
web/boot.ts
Normal file
19
web/boot.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Runs in <head>, before the first frame (#203): this browser's theme and font (#359), from their
|
||||
// cookies, and whether the last visit here was an admin's, so the link to Admin is there from the
|
||||
// start. The server wrote both into the page while it served it; the page is a static file now.
|
||||
// A script of its own, not part of app.js, which loads after the markup (web/build.mts).
|
||||
(() => {
|
||||
const r = document.documentElement;
|
||||
const cookie = (re: RegExp) => re.exec(document.cookie);
|
||||
const theme = cookie(/(?:^|; )ipx_theme=([a-z]{1,20})\.(light|dark|auto)(?:;|$)/);
|
||||
if (theme) {
|
||||
r.dataset.theme = theme[1];
|
||||
r.dataset.choice = theme[2];
|
||||
if (theme[2] !== 'auto') r.dataset.mode = theme[2];
|
||||
}
|
||||
const font = cookie(/(?:^|; )ipx_font=([a-z]{1,10})(?:;|$)/);
|
||||
if (font) r.dataset.font = font[1];
|
||||
const rfont = cookie(/(?:^|; )ipx_rfont=([a-z]{1,10})(?:;|$)/);
|
||||
if (rfont) r.dataset.rfont = rfont[1];
|
||||
try { if (localStorage.getItem('ipx.admin') === '1') r.dataset.admin = ''; } catch { /* storage refused */ }
|
||||
})();
|
||||
@@ -8,28 +8,27 @@
|
||||
// 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
|
||||
// node web/build.mts [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 { createRequire } from 'node:module';
|
||||
import swc from '@swc/core';
|
||||
import html from '@swc/html';
|
||||
import { concatMap } from './concat-map.mts';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
// A build for measuring what the browser tests run (#390), IPX_COVERAGE=1: the script not minified
|
||||
// and with a source map back to each file of web/src, which concat-map.mts makes. Loaded only then:
|
||||
// it is TypeScript, which the Node of Dockerfile.web does not run.
|
||||
// Required, not imported with a top-level await, which would stop the tests require()ing this file.
|
||||
const coverageMap = process.env.IPX_COVERAGE ? createRequire(import.meta.url)('./concat-map.mts').concatMap : null;
|
||||
const coverageMap = process.env.IPX_COVERAGE ? concatMap : null;
|
||||
// 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 = {
|
||||
const PAGES: Record<string, { script: string, src: string[] }> = {
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'offline', 'events', 'native'] },
|
||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||
'login.html': { script: 'login.js', src: ['login'] },
|
||||
@@ -37,7 +36,7 @@ const PAGES = {
|
||||
// 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);
|
||||
const hash = (s: string | Buffer) => 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__.
|
||||
@@ -79,20 +78,32 @@ export function buildStyle({ minify = true } = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The offline service worker (#389), web/sw/sw.ts made JavaScript, at the top of the site.
|
||||
export function buildWorker({ minify = true } = {}) {
|
||||
const src = fs.readFileSync(path.join(here, 'sw', 'sw.ts'), 'utf8');
|
||||
/// A script served on its own, not one of the pages' (#392): TypeScript made JavaScript.
|
||||
function compile(file: string, { minify = true } = {}): string {
|
||||
const src = fs.readFileSync(path.resolve(here, file), 'utf8');
|
||||
return swc.transformSync(src, {
|
||||
filename: 'sw.ts',
|
||||
filename: path.basename(file),
|
||||
jsc: { parser: { syntax: 'typescript' }, target: 'es2022', minify: minify ? { compress: true, mangle: true } : undefined },
|
||||
isModule: false,
|
||||
minify,
|
||||
}).code;
|
||||
}
|
||||
|
||||
/// The offline service worker (#389), at the top of the site.
|
||||
export const buildWorker = (o?: { minify?: boolean }) => compile('sw/sw.ts', o);
|
||||
/// The directory's one script (#392), as crates/directory/src/site.js holds it: readable, since it
|
||||
/// is committed, and saying where it comes from.
|
||||
export const buildDirectoryScript = () =>
|
||||
'// Made from site.ts by `node web/build.mts --directory-script`: edit that, not this.\n' +
|
||||
compile('../crates/directory/src/site.ts', { minify: false });
|
||||
/// What runs in <head> before the first frame (boot.ts), named by a hash of what is served.
|
||||
const BOOT = compile('boot.ts');
|
||||
|
||||
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||
export function buildPage(name, { minify = !coverageMap } = {}) {
|
||||
const { script, src: files } = PAGES[name];
|
||||
export function buildPage(name: string, { minify = !coverageMap } = {}) {
|
||||
const spec = PAGES[name];
|
||||
if (!spec) throw new Error(`${name} is not one of the pages`);
|
||||
const { script, src: files } = spec;
|
||||
const parts = files.map(f => ({ name: `web/src/${f}.ts`, text: fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8') }));
|
||||
const src = parts.map(p => p.text).join('\n')
|
||||
.replaceAll('__HLS_JS__', HLS_URL)
|
||||
@@ -123,7 +134,7 @@ export function buildPage(name, { minify = !coverageMap } = {}) {
|
||||
// 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)))}"`);
|
||||
(_, attr, file) => `${attr}="/${file}?v=${hash(file === 'boot.js' ? BOOT : 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');
|
||||
@@ -131,11 +142,14 @@ export function buildPage(name, { minify = !coverageMap } = {}) {
|
||||
return { html: r.code, js, script };
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === '--directory-script') {
|
||||
fs.writeFileSync(path.join(here, '..', 'crates', 'directory', 'src', 'site.js'), buildDirectoryScript());
|
||||
} else 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, 'sw.js'), buildWorker());
|
||||
fs.writeFileSync(path.join(out, 'boot.js'), BOOT);
|
||||
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
|
||||
@@ -146,7 +160,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
...['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'],
|
||||
['ipodderx-icon.png', 'icon.png'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||
// The fonts Settings offers besides Inter (#359), with their licences.
|
||||
fs.cpSync(path.join(here, 'fonts'), path.join(out, 'fonts'), { recursive: true });
|
||||
@@ -1,4 +1,4 @@
|
||||
// A source map for the page's script as web/build.mjs makes it, files joined one after another
|
||||
// A source map for the page's script as web/build.mts makes it, files joined one after another
|
||||
// (#390): each line of the joined script maps to its line in the file it came from. Given to swc
|
||||
// as the input map, the built script then maps to web/src/*.ts file by file, which is what the
|
||||
// coverage report needs. Only a coverage build uses it.
|
||||
@@ -26,7 +26,7 @@ export function vlq(n: number): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The map of `files` joined with '\n' between them, as build.mjs joins them.
|
||||
/// The map of `files` joined with '\n' between them, as build.mts joins them.
|
||||
export function concatMap(files: { name: string; text: string }[]): SourceMap {
|
||||
const lines: string[] = [];
|
||||
// Each segment's fields are relative to the one before: source, line in it, column.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// What ipx's API sends the page, as types for the type-check (#235), each named after the Rust
|
||||
// struct it mirrors in crates/ipx/src. Types only: swc strips this file to nothing, so it is not
|
||||
// in web/build.mjs's list; tsconfig.json's glob reads it. A field added there goes here too.
|
||||
// in web/build.mts's list; tsconfig.json's glob reads it. A field added there goes here too.
|
||||
// An Option is `T | null`, as serde writes None.
|
||||
|
||||
/// A feed in your list, `/api/feeds` and the live `feed_row` (web.rs FeedRow).
|
||||
|
||||
Reference in New Issue
Block a user