authorclover caruso <git@paperclover.net> 2026-10-16 01:09:35-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 19:37:07-07:00
log71fe97b0da367a55122a8d3a4d5db9243db592f6
treebf31cc746d3567120a6d7915ca22d7867b2e4bb5
parent62d00c88cc019087d79d6ac2fbb0d4ea4b56ad0e
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs (~clover)

WIP: feat: opt-in client-side router (#8)

a page that exports `router = true`, or whose layout does, gets a binary twin beside its document: "<3", a version byte, four u32le lengths, then head, inline css, body, and scripts as utf-8. the router fetches it from the page's own url with `Accept: application/x-clover-twin` while the pointer rests on a link, and the click swaps it in with innerHTML. anything without a twin is left to the browser. - build: `assemblePage` writes the document or the twin from the same steps, and `Asset.twin` holds the twin's hash. views get `renderTwin`, a second lazy step over the same render. - serving: assets and views answer that `Accept` with the twin, and both representations say `Vary: Accept`. the file viewer answers 204. - router: the history api rather than the navigation api, so safari 18 swaps too. each history entry saves its scroll, which also brings a reload back. - `joinScripts` wraps a lone script too, so the router's names stay off `window`, and a page shown again can declare its names again. Assisted-by: claude-opus-5.5

17 files changed, 308 insertions(+), 42 deletions(-)

framework/lib/assets.ts+13-4
...@@ -21,7 +21,9 @@ let current: { assets: Output["assets"]; bytes: Map<string, Encodings> } | null...@@ -21,7 +21,9 @@ let current: { assets: Output["assets"]; bytes: Map<string, Encodings> } | null
21 */21 */
22export async function load(assets: Output["assets"], dir: string) {22export async function load(assets: Output["assets"], dir: string) {
23 const known = current?.bytes;23 const known = current?.bytes;
24 const hashes = new Set(Object.values(assets).map((asset) => asset.hash));24 const hashes = new Set(
25 Object.values(assets).flatMap((asset) => asset.twin ? [asset.hash, asset.twin] : [asset.hash]),
26 );
25 const read = async (hash: string): Promise<Encodings> => {27 const read = async (hash: string): Promise<Encodings> => {
26 const [raw, gzip, zstd] = await Promise.all(28 const [raw, gzip, zstd] = await Promise.all(
27 ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))),29 ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))),
...@@ -79,7 +81,13 @@ function respond(...@@ -79,7 +81,13 @@ function respond(
79 status: StatusCode,81 status: StatusCode,
80 cache: CacheMode = "auto",82 cache: CacheMode = "auto",
81) {83) {
82 const etag = UNWRAP(asset.headers.etag);84 // a router page's twin is served at its url, to whoever accepts only twins
85 const twin = c.req.header("Accept") === router.type ? asset.twin : undefined;
86 const vary: Record<string, string> = asset.twin === undefined ? {} : { vary: "Accept" };
87 const headers: Record<string, string> = twin === undefined
88 ? { ...asset.headers, ...vary }
89 : { "content-type": router.type, etag: JSON.stringify(twin), "cache-control": "no-cache", ...vary };
90 const etag = UNWRAP(headers.etag);
83 const ifNoneMatch = c.req.header("If-None-Match");91 const ifNoneMatch = c.req.header("If-None-Match");
84 if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) {92 if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) {
85 return (c.res = new Response(null, {93 return (c.res = new Response(null, {
...@@ -87,14 +95,14 @@ function respond(...@@ -87,14 +95,14 @@ function respond(
87 statusText: "Not Modified",95 statusText: "Not Modified",
88 headers: {96 headers: {
89 ETag: etag,97 ETag: etag,
98 ...vary,
90 },99 },
91 }));100 }));
92 }101 }
93102
94 const encodings = UNWRAP(loaded().bytes.get(asset.hash));103 const encodings = UNWRAP(loaded().bytes.get(twin ?? asset.hash));
95 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";104 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
96 const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw";105 const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw";
97 const headers = { ...asset.headers };
98 if (encoding !== "raw") headers["Content-Encoding"] = encoding;106 if (encoding !== "raw") headers["Content-Encoding"] = encoding;
99 console.info(`${key} encoding=${encoding} status=${status}`);107 console.info(`${key} encoding=${encoding} status=${status}`);
100108
...@@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer";...@@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer";
128import * as fs from "node:fs/promises";136import * as fs from "node:fs/promises";
129import * as path from "node:path";137import * as path from "node:path";
130import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts";138import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts";
139import * as router from "./router.ts";
framework/lib/router.client.ts created+143
...@@ -0,0 +1,143 @@
1// Swaps a page's twin into this one when a link to it is clicked. The twin is
2// fetched while the pointer rests on the link, so the click shows it at once.
3// The browser itself follows a link to anything without a twin.
4import * as router from "./router.ts";
5
6interface Twin {
7 at: number;
8 parts: Promise<router.Parts | null>;
9 /** what `parts` resolved to, so a click can decide at once */
10 known?: router.Parts | null;
11}
12
13/** what a history entry keeps, since the browser can't restore scroll across a swap */
14interface Saved {
15 scroll?: number;
16}
17
18const decoder = new TextDecoder();
19const twins = new Map<string, Twin>();
20// what a page puts in the head lies between these two
21const charset = document.querySelector("meta[charset]")!;
22const style = document.querySelector("head > style")!;
23/** the page shown, which a traversal to another of its fragments keeps */
24let shown = location.pathname + location.search;
25/** counts clicks and traversals, so only the last one swaps */
26let navigations = 0;
27
28history.scrollRestoration = "manual";
29const save = () => history.replaceState({ scroll: scrollY } satisfies Saved, "");
30let saving: ReturnType<typeof setTimeout> | undefined;
31addEventListener("scroll", () => {
32 clearTimeout(saving);
33 saving = setTimeout(save, 150);
34}, { passive: true });
35// a reload, which the browser no longer scrolls back
36if (history.state?.scroll) scrollTo(0, history.state.scroll);
37
38function load(href: string): Twin {
39 const url = new URL(href);
40 url.hash = "";
41 const cached = twins.get(url.href);
42 if (cached && performance.now() - cached.at < 300_000) return cached;
43 const parts = fetch(url, { headers: { accept: router.type }, redirect: "manual" }).then(decode, () => null);
44 const twin: Twin = { at: performance.now(), parts };
45 void parts.then((known) => twin.known = known);
46 twins.set(url.href, twin);
47 return twin;
48}
49
50async function decode(response: Response): Promise<router.Parts | null> {
51 if (response.headers.get("content-type") !== router.type) {
52 void response.body?.cancel();
53 return null;
54 }
55 const bytes = new Uint8Array(await response.arrayBuffer());
56 if (bytes[0] !== 0x3c || bytes[1] !== 0x33 || bytes[2] !== router.version) return null;
57 const lengths = new DataView(bytes.buffer, 3, 16);
58 let at = 19;
59 const [head, inlineCss, body, scripts] = [0, 4, 8, 12].map((offset) =>
60 decoder.decode(bytes.subarray(at, at += lengths.getUint32(offset, true)))
61 ) as [string, string, string, string];
62 // fonts this page has not used yet load before the click, so its text does not swap in late
63 for (const [, font] of inlineCss.matchAll(/url\(([^)]+\.woff2)\)/g)) void fetch(font!);
64 return { head, inlineCss, body, scripts };
65}
66
67/** show `href`, after a click, or after a traversal to an entry with `state` */
68async function go(href: string, twin: Twin, state?: Saved | null) {
69 const navigation = ++navigations;
70 const parts = await twin.parts;
71 if (navigation !== navigations) return;
72 const traversal = state !== undefined;
73 if (!parts) return traversal ? location.reload() : location.assign(href);
74 if (!traversal) {
75 save();
76 history.pushState(null, "", href);
77 }
78 swap(parts);
79 restore(state ?? null);
80}
81
82function swap({ head, inlineCss, body, scripts }: router.Parts) {
83 while (charset.nextSibling !== style) charset.nextSibling!.remove();
84 const nodes = document.createElement("template");
85 nodes.innerHTML = head;
86 style.before(nodes.content);
87 if (style.textContent !== inlineCss) style.textContent = inlineCss;
88 document.body.innerHTML = body;
89 shown = location.pathname + location.search;
90 // run as the parser would have run the page's own
91 const script = document.createElement("script");
92 script.textContent = scripts;
93 document.body.append(script);
94}
95
96/** where an entry was scrolled, or else its fragment, or else the top */
97function restore(state: Saved | null) {
98 if (state?.scroll !== undefined) return scrollTo(0, state.scroll);
99 const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
100 if (target) target.scrollIntoView();
101 else scrollTo(0, 0);
102}
103
104addEventListener("click", (event) => {
105 if (event.defaultPrevented || event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
106 return;
107 }
108 const link = (event.target as Element).closest?.("a[href]");
109 if (
110 !(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self") || link.hasAttribute("download")
111 ) {
112 return;
113 }
114 const url = new URL(link.href);
115 // the browser scrolls to another fragment of this page itself
116 if (url.origin !== location.origin || (url.pathname + url.search === shown && url.hash)) return;
117 const twin = load(url.href);
118 if (twin.known === null) return;
119 event.preventDefault();
120 void go(url.href, twin);
121});
122
123addEventListener("popstate", (event) => {
124 // the entry gone to is scrolled from what it saved, not saved over
125 clearTimeout(saving);
126 const state = (event.state as Saved | null) ?? null;
127 if (location.pathname + location.search === shown) return restore(state);
128 void go(location.href, load(location.href), state);
129});
130
131const prefetch = (event: Event) => {
132 const link = (event.target as Element).closest?.("a[href]");
133 if (link instanceof HTMLAnchorElement && link.origin === location.origin) load(link.href);
134};
135let resting: ReturnType<typeof setTimeout> | undefined;
136addEventListener("mouseover", (event) => {
137 clearTimeout(resting);
138 resting = setTimeout(prefetch, 65, event);
139});
140addEventListener("touchstart", prefetch, { passive: true });
141addEventListener("focusin", prefetch);
142// a traversal back to the first page swaps it in like any other
143setTimeout(load, 1000, location.href);
framework/lib/router.ts created+35
...@@ -0,0 +1,35 @@
1// A page that exports `router = true` gets a twin: its head, style, body, and
2// scripts in one binary file, which `router.client.ts` swaps into the page
3// where a link to it was clicked. The twin is served at the page's own url,
4// to a request whose `Accept` is `type`.
5
6/** what a twin is served as, and what the router accepts */
7export const type = "application/x-clover-twin";
8/** the format of a twin. a router that reads another version lets the browser navigate */
9export const version = 1;
10/** the router's script id, a path within `src` like every other script's */
11export const script = "../framework/lib/router.client.ts";
12
13/** what a page is made of, which the router swaps into another page */
14export interface Parts {
15 head: string;
16 inlineCss: string;
17 body: string;
18 scripts: string;
19}
20
21/** `"<3"`, the version, the byte lengths of the four parts as u32le, then the parts as utf-8 */
22export function encode({ head, inlineCss, body, scripts }: Parts): Uint8Array<ArrayBuffer> {
23 const encoder = new TextEncoder();
24 const parts = [head, inlineCss, body, scripts].map((text) => encoder.encode(text));
25 const twin = new Uint8Array(19 + parts.reduce((total, bytes) => total + bytes.length, 0));
26 const lengths = new DataView(twin.buffer, 3, 16);
27 twin.set([0x3c, 0x33, version]);
28 let at = 19;
29 parts.forEach((bytes, i) => {
30 lengths.setUint32(4 * i, bytes.length, true);
31 twin.set(bytes, at);
32 at += bytes.length;
33 });
34 return twin;
35}
framework/lib/sitegen.ts+9-4
...@@ -12,6 +12,13 @@ export interface ViewExports {...@@ -12,6 +12,13 @@ export interface ViewExports {
12 default: Marko.Component;12 default: Marko.Component;
13 meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta);13 meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta);
14 theme?: css.Theme;14 theme?: css.Theme;
15 /**
16 * links between pages that export this swap them in place of a document
17 * load. a page's scripts then run each time it is shown, so they bind to
18 * its elements, not to `window` or `document`. a layout can export it for
19 * its pages.
20 */
21 router?: boolean;
15}22}
1623
17/**24/**
...@@ -71,17 +78,15 @@ export function wrapDocument({...@@ -71,17 +78,15 @@ export function wrapDocument({
71 }${analytics}</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body></html>`;78 }${analytics}</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body></html>`;
72}79}
7380
81/** each script in a function of its own, so its names stay off `window` and a page shown again can declare them again */
74export function joinScripts(scriptSources: string[]) {82export function joinScripts(scriptSources: string[]) {
75 const { length } = scriptSources;83 if (scriptSources.length === 0) return "";
76 if (scriptSources[0] == null) return "";
77 if (length === 1) return UNWRAP(scriptSources[0]);
78 return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",")84 return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",")
79 + "].map(e=>e())";85 + "].map(e=>e())";
80}86}
8187
82export { default as CloverScriptInclude } from "./add-script.marko";88export { default as CloverScriptInclude } from "./add-script.marko";
8389
84import { UNWRAP } from "@clo/lib/assert";
85import type * as incremental from "@clo/lib/incremental";90import type * as incremental from "@clo/lib/incremental";
86import type { Meta } from "@clo/lib/meta";91import type { Meta } from "@clo/lib/meta";
87import type * as css from "../css.ts";92import type * as css from "../css.ts";
framework/lib/view.ts+42-15
...@@ -33,7 +33,16 @@ export function serveCached<K extends Key>(...@@ -33,7 +33,16 @@ export function serveCached<K extends Key>(
33 id: K,33 id: K,
34 props: Record<string, incremental.Serializable>,34 props: Record<string, incremental.Serializable>,
35) {35) {
36 return serveStep(context, cached(id, props), { "Content-Type": "text/html;charset=utf8" });36 const { output } = loaded();
37 const view = UNWRAP(output.views[id], `Missing view ${id}`);
38 const twin = view.router && context.req.header("Accept") === router.type;
39 const style = UNWRAP(output.styles[view.styleKey]);
40 const step = twin ? sg.graph.lazy(renderTwin, view, props, output.meta, style, output.scripts) : cached(id, props);
41 return serveStep(context, step, {
42 "Content-Type": twin ? router.type : "text/html;charset=utf8",
43 // a router view and its twin share a url, which `Accept` tells apart
44 ...view.router ? { Vary: "Accept" } : {},
45 });
37}46}
3847
39/** the step that {@linkcode serveCached} serves, which another step can read to follow the page */48/** the step that {@linkcode serveCached} serves, which another step can read to follow the page */
...@@ -66,34 +75,54 @@ export async function serveUncached<K extends Key>(...@@ -66,34 +75,54 @@ export async function serveUncached<K extends Key>(
66) {75) {
67 const { output, modules } = loaded();76 const { output, modules } = loaded();
68 const view = UNWRAP(output.views[id], `Missing view ${id}`);77 const view = UNWRAP(output.views[id], `Missing view ${id}`);
69 const html = await render(78 const twin = view.router && context.req.header("Accept") === router.type;
79 const parts = await render(
70 UNWRAP(modules[id]),80 UNWRAP(modules[id]),
71 { context, ...props },81 { context, ...props },
72 output.meta,82 output.meta,
73 await UNWRAP(output.styles[view.styleKey]).read(),83 await UNWRAP(output.styles[view.styleKey]).read(),
74 (await output.scripts.read()).scripts,84 (await output.scripts.read()).scripts,
85 view.router,
86 twin,
75 );87 );
76 return context.html(html, { headers: { "Content-Type": "text/html;charset=utf8" } });88 const vary: Record<string, string> = view.router ? { Vary: "Accept" } : {};
89 if (twin) {
90 return context.body(router.encode(parts), { headers: { "Content-Type": router.type, ...vary } });
91 }
92 return context.html(sg.wrapDocument(parts), { headers: { "Content-Type": "text/html;charset=utf8", ...vary } });
77}93}
7894
79async function renderView(95type Inputs = [
80 b: incremental.Build,
81 view: Output["views"][string],96 view: Output["views"][string],
82 props: Record<string, incremental.Serializable>,97 props: Record<string, incremental.Serializable>,
83 template: Output["meta"],98 template: Output["meta"],
84 style: incremental.Step<string>,99 style: incremental.Step<string>,
85 scripts: incremental.Step<{ scripts: Record<string, string> }>,100 scripts: incremental.Step<{ scripts: Record<string, string> }>,
86) {101];
102
103async function renderView(b: incremental.Build, ...inputs: Inputs) {
104 return sg.wrapDocument(await renderParts(b, inputs, false));
105}
106
107async function renderTwin(b: incremental.Build, ...inputs: Inputs) {
108 return router.encode(await renderParts(b, inputs, true));
109}
110
111async function renderParts(b: incremental.Build, [view, props, template, style, scripts]: Inputs, twin: boolean) {
87 const module = await b.import<ViewModule>(view.file);112 const module = await b.import<ViewModule>(view.file);
88 return render(module, props, template, await b.readStep(style), (await b.readStep(scripts)).scripts);113 const { scripts: scriptMap } = await b.readStep(scripts);
114 return render(module, props, template, await b.readStep(style), scriptMap, view.router, twin);
89}115}
90116
117/** what a view's document or twin is made of */
91async function render(118async function render(
92 { default: Page, meta: metadata }: ViewModule,119 { default: Page, meta: metadata }: ViewModule,
93 attrs: Record<string, unknown>,120 attrs: Record<string, unknown>,
94 template: Output["meta"],121 template: Output["meta"],
95 inlineCss: string,122 inlineCss: string,
96 scripts: Readonly<Record<string, string>>,123 scripts: Readonly<Record<string, string>>,
124 routed: boolean,
125 twin: boolean,
97) {126) {
98 // -- metadata --127 // -- metadata --
99 const renderedMetaPromise = Promise.resolve(128 const renderedMetaPromise = Promise.resolve(
...@@ -111,17 +140,14 @@ async function render(...@@ -111,17 +140,14 @@ async function render(
111 });140 });
112141
113 // -- join document and send --142 // -- join document and send --
114 return sg.wrapDocument({143 // the router goes first on a page, and a twin is shown by one already running
144 const refs = routed && !twin ? [router.script, ...cloverScripts] : [...cloverScripts];
145 return {
115 body: text,146 body: text,
116 head: await renderedMetaPromise,147 head: await renderedMetaPromise,
117 inlineCss,148 inlineCss,
118 scripts: sg.joinScripts(149 scripts: sg.joinScripts(refs.map((id) => UNWRAP(scripts[id], `Missing script ${id}`))),
119 Array.from(150 };
120 cloverScripts,
121 (id) => UNWRAP(scripts[id], `Missing script ${id}`),
122 ),
123 ),
124 });
125}151}
126152
127function loaded() {153function loaded() {
...@@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta";...@@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta";
136import type * as hono from "hono";162import type * as hono from "hono";
137import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";163import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";
138import SitegenWrap from "../sitegen-wrap.marko";164import SitegenWrap from "../sitegen-wrap.marko";
165import * as router from "./router.ts";
139import * as sg from "./sitegen.ts";166import * as sg from "./sitegen.ts";
src/blog/pages/community-translations.mdo+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1---1---
2router: true
2meta:3meta:
3 title: clover's community translations4 title: clover's community translations
4---5---
src/blog/tags/blog-layout.marko+1
...@@ -4,6 +4,7 @@ export const theme = {...@@ -4,6 +4,7 @@ export const theme = {
4 fg: "#ffffff",4 fg: "#ffffff",
5 primary: "#91ffc6",5 primary: "#91ffc6",
6};6};
7export const router = true;
7export const components = {8export const components = {
8 heading: Heading,9 heading: Heading,
9 codeBlock: CodeBlock,10 codeBlock: CodeBlock,
src/file-viewer/backend.ts+3
...@@ -10,6 +10,8 @@ app.post("/file/cotyledon", async (c) => {...@@ -10,6 +10,8 @@ app.post("/file/cotyledon", async (c) => {
10});10});
1111
12app.get("/file/*", async (c, next) => {12app.get("/file/*", async (c, next) => {
13 // the file viewer has its own router, and a twin request must not start streaming a file
14 if (c.req.header("Accept") === router.type) return c.body(null, 204);
13 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";15 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
14 const lofi = ua.includes("msie") || ua.startsWith("w3m/") || false;16 const lofi = ua.includes("msie") || ua.startsWith("w3m/") || false;
1517
...@@ -282,6 +284,7 @@ import { type Context, Hono } from "hono";...@@ -282,6 +284,7 @@ import { type Context, Hono } from "hono";
282284
283import * as sg from "#sitegen";285import * as sg from "#sitegen";
284import { etagMatches, serveAsset } from "#sitegen/assets";286import { etagMatches, serveAsset } from "#sitegen/assets";
287import * as router from "#sitegen/router";
285import * as view from "#sitegen/view";288import * as view from "#sitegen/view";
286import * as http from "@clo/lib/http";289import * as http from "@clo/lib/http";
287import type * as incremental from "@clo/lib/incremental";290import type * as incremental from "@clo/lib/incremental";
src/pages/dream.mdo+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1---1---
2router: true
2meta:3meta:
3 title: clo's dream4 title: clo's dream
4---5---
src/pages/index.marko+1
...@@ -2,6 +2,7 @@ import type { Meta } from "#meta";...@@ -2,6 +2,7 @@ import type { Meta } from "#meta";
2import "./index.css";2import "./index.css";
33
4export const theme = { bg: "#fff", fg: "#666" };4export const theme = { bg: "#fff", fg: "#666" };
5export const router = true;
5export const meta = {6export const meta = {
6 title: "paper clover",7 title: "paper clover",
7 description:8 description:
src/pages/license.mdo+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1---1---
2router: true
2meta:3meta:
3 title: paper clover's media license4 title: paper clover's media license
4theme: 5theme:
src/pages/log.mdo+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1---1---
2router: true
2meta:3meta:
3 title: clover's log4 title: clover's log
4---5---
src/pages/resume.marko+1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import "./resume.css";1import "./resume.css";
22
3export const meta = { title: "clover's resume" };3export const meta = { title: "clover's resume" };
4export const router = true;
45
5<main>6<main>
6 <h1>clover's resume</h1>7 <h1>clover's resume</h1>
src/pages/subscribe.marko+1
...@@ -3,6 +3,7 @@ export const meta = {...@@ -3,6 +3,7 @@ export const meta = {
3 title: "paper clover mailing list",3 title: "paper clover mailing list",
4 description: "subscribe to the mailing list",4 description: "subscribe to the mailing list",
5};5};
6export const router = true;
67
7<main>8<main>
8 <h1>mailing list</h1>9 <h1>mailing list</h1>
src/paperclover.net.ts+53-19
...@@ -64,6 +64,8 @@ export interface Output {...@@ -64,6 +64,8 @@ export interface Output {
64export interface Asset {64export interface Asset {
65 hash: string;65 hash: string;
66 headers: Record<string, string>;66 headers: Record<string, string>;
67 /** a router page's twin, by hash, served at the same pathname */
68 twin?: string;
67}69}
6870
69export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> {71export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> {
...@@ -110,9 +112,14 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>...@@ -110,9 +112,14 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>
110 }112 }
111113
112 // -- assemble page assets --114 // -- assemble page assets --
113 const documents = pages.map((page, i) =>115 const assembled = pages.map((page, i) => {
114 incr.step(assemblePage, page, UNWRAP(styles.get(prepared[i]!.styleKey)), bundleScripts)116 const { styleKey, router: routed } = prepared[i]!;
115 );117 const style = UNWRAP(styles.get(styleKey));
118 return {
119 document: incr.step(assemblePage, page, style, bundleScripts, false),
120 twin: routed ? incr.step(assemblePage, page, style, bundleScripts, true) : null,
121 };
122 });
116123
117 // -- write assets, once for all the pathnames with the same bytes --124 // -- write assets, once for all the pathnames with the same bytes --
118 // each asset's bytes are a step of their own, so an asset is hashed and125 // each asset's bytes are a step of their own, so an asset is hashed and
...@@ -121,7 +128,12 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>...@@ -121,7 +128,12 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>
121 const { assets: scriptAssets } = await bundleScripts.read();128 const { assets: scriptAssets } = await bundleScripts.read();
122 const fontAssets = await Promise.all(fontFiles.map((font) => font.read()));129 const fontAssets = await Promise.all(fontFiles.map((font) => font.read()));
123 const html = { "content-type": "text/html;charset=utf8" };130 const html = { "content-type": "text/html;charset=utf8" };
124 const sources: Array<{ pathname: string; bytes: incremental.Step<string | Uint8Array>; headers?: typeof html }> = [131 const sources: Array<{
132 pathname: string;
133 bytes: incremental.Step<string | Uint8Array>;
134 headers?: typeof html;
135 twin?: true;
136 }> = [
125 ...Object.keys(scriptAssets).map((pathname) => ({137 ...Object.keys(scriptAssets).map((pathname) => ({
126 pathname,138 pathname,
127 bytes: incr.step(scriptAsset, bundleScripts, pathname),139 bytes: incr.step(scriptAsset, bundleScripts, pathname),
...@@ -130,18 +142,27 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>...@@ -130,18 +142,27 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>
130 Object.keys(fontAssets[i]!).map((pathname) => ({ pathname, bytes: incr.step(fontAsset, font, pathname) }))142 Object.keys(fontAssets[i]!).map((pathname) => ({ pathname, bytes: incr.step(fontAsset, font, pathname) }))
131 ),143 ),
132 ...files.staticFiles.map((item, i) => ({ pathname: item.id, bytes: staticFiles[i]! })),144 ...files.staticFiles.map((item, i) => ({ pathname: item.id, bytes: staticFiles[i]! })),
133 ...files.pages.map((item, i) => ({ pathname: item.id, bytes: documents[i]!, headers: html })),145 ...files.pages.map((item, i) => ({ pathname: item.id, bytes: assembled[i]!.document, headers: html })),
146 // after every page, whose asset a twin belongs to
147 ...files.pages.flatMap((item, i) => {
148 const { twin } = assembled[i]!;
149 return twin ? [{ pathname: item.id, bytes: twin, twin: true as const }] : [];
150 }),
134 ];151 ];
135 const hashes = await Promise.all(sources.map(({ bytes }) => incr.step(hashAsset, bytes).read()));152 const hashes = await Promise.all(sources.map(({ bytes }) => incr.step(hashAsset, bytes).read()));
136 const assets: Output["assets"] = {};153 const assets: Output["assets"] = {};
137 const writes = new Map<string, Promise<unknown>>();154 const writes = new Map<string, Promise<unknown>>();
138 sources.forEach(({ pathname, bytes, headers }, i) => {155 sources.forEach(({ pathname, bytes, headers, twin }, i) => {
139 ASSERT(pathname.startsWith("/") && !(pathname in assets), () => `asset ${pathname} is written twice`);
140 const hash = hashes[i]!;156 const hash = hashes[i]!;
141 assets[pathname] = {157 if (twin) {
142 hash,158 UNWRAP(assets[pathname]).twin = hash;
143 headers: { "content-type": mime.for(pathname), ...headers, etag: JSON.stringify(hash) },159 } else {
144 };160 ASSERT(pathname.startsWith("/") && !(pathname in assets), () => `asset ${pathname} is written twice`);
161 assets[pathname] = {
162 hash,
163 headers: { "content-type": mime.for(pathname), ...headers, etag: JSON.stringify(hash) },
164 };
165 }
145 if (!writes.has(hash)) writes.set(hash, incr.step(writeAsset, hash, bytes).read());166 if (!writes.has(hash)) writes.set(hash, incr.step(writeAsset, hash, bytes).read());
146 });167 });
147 await Promise.all(writes.values());168 await Promise.all(writes.values());
...@@ -276,7 +297,7 @@ export async function scanSiteSection(b: incremental.Build, section: Readonly<sg...@@ -276,7 +297,7 @@ export async function scanSiteSection(b: incremental.Build, section: Readonly<sg
276 return { staticFiles, pages, views, scripts };297 return { staticFiles, pages, views, scripts };
277}298}
278299
279type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme } };300type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme; router?: boolean } };
280// what a step can hold of a template: no url, and no function301// what a step can hold of a template: no url, and no function
281type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string };302type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string };
282303
...@@ -284,11 +305,15 @@ type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string }...@@ -284,11 +305,15 @@ type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string }
284export async function preparePage(b: incremental.Build, item: Readonly<sg.FileItem>, template: SiteMeta) {305export async function preparePage(b: incremental.Build, item: Readonly<sg.FileItem>, template: SiteMeta) {
285 using _ = b.start(item.file);306 using _ = b.start(item.file);
286 try {307 try {
287 const { default: Page, meta: metadata, theme: pageTheme, layout } = await b.import<Route>(item.file);308 const { default: Page, meta: metadata, theme: pageTheme, layout, router: pageRouter } = await b.import<Route>(
309 item.file,
310 );
288 if (!Page) throw new Error("Page is missing a 'default' export.");311 if (!Page) throw new Error("Page is missing a 'default' export.");
289 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");312 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
290313
291 const theme: css.Theme = { ...css.defaultTheme, ...layout?.theme ?? pageTheme };314 const theme: css.Theme = { ...css.defaultTheme, ...layout?.theme ?? pageTheme };
315 // unlike its theme, a page can leave its layout's router
316 const routed = pageRouter ?? layout?.router ?? false;
292 const cssImports = await findCssImports(b, item.file);317 const cssImports = await findCssImports(b, item.file);
293318
294 const renderedMeta = meta.toHtml({ ...template, base: new URL(template.base) }, {319 const renderedMeta = meta.toHtml({ ...template, base: new URL(template.base) }, {
...@@ -313,7 +338,9 @@ export async function preparePage(b: incremental.Build, item: Readonly<sg.FileIt...@@ -313,7 +338,9 @@ export async function preparePage(b: incremental.Build, item: Readonly<sg.FileIt
313 cssImports,338 cssImports,
314 theme,339 theme,
315 styleKey: css.styleKey(cssImports, theme),340 styleKey: css.styleKey(cssImports, theme),
316 clientRefs: Array.from(cloverScripts),341 // first, so that a page script which throws can't stop it
342 clientRefs: routed ? [router.script, ...cloverScripts] : Array.from(cloverScripts),
343 router: routed,
317 };344 };
318 } catch (e) {345 } catch (e) {
319 exception.rethrowWithMetadata(e, { page: item.id });346 exception.rethrowWithMetadata(e, { page: item.id });
...@@ -332,14 +359,16 @@ export async function prepareView(b: incremental.Build, item: Readonly<sg.FileIt...@@ -332,14 +359,16 @@ export async function prepareView(b: incremental.Build, item: Readonly<sg.FileIt
332 const clientRefs = (await b.trackModule(item.file)).flatMap((module) =>359 const clientRefs = (await b.trackModule(item.file)).flatMap((module) =>
333 (module.data as { scannedClientRefs?: string[] } | null)?.scannedClientRefs ?? []360 (module.data as { scannedClientRefs?: string[] } | null)?.scannedClientRefs ?? []
334 );361 );
362 const routed = module.router ?? module.layout?.router ?? false;
335 return {363 return {
336 id: item.id,364 id: item.id,
337 file: item.file,365 file: item.file,
338 cssImports,366 cssImports,
339 theme,367 theme,
340 clientRefs: Array.from(new Set(clientRefs)),368 clientRefs: Array.from(new Set(routed ? [router.script, ...clientRefs] : clientRefs)),
341 hasLayout: !!module.layout?.default,369 hasLayout: !!module.layout?.default,
342 styleKey: css.styleKey(cssImports, theme),370 styleKey: css.styleKey(cssImports, theme),
371 router: routed,
343 };372 };
344}373}
345export type PreparedView = Awaited<ReturnType<typeof prepareView>>;374export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
...@@ -360,21 +389,25 @@ async function findCssImports(b: incremental.Build, file: string): Promise<strin...@@ -360,21 +389,25 @@ async function findCssImports(b: incremental.Build, file: string): Promise<strin
360 return Array.from(found);389 return Array.from(found);
361}390}
362391
363/** the whole html document of a page, with its styles and scripts inline */392/** the whole html document of a page, with its styles and scripts inline, or else its twin */
364export async function assemblePage(393export async function assemblePage(
365 b: incremental.Build,394 b: incremental.Build,
366 page: incremental.Step<Awaited<ReturnType<typeof preparePage>>>,395 page: incremental.Step<Awaited<ReturnType<typeof preparePage>>>,
367 style: incremental.Step<string>,396 style: incremental.Step<string>,
368 scripts: incremental.Step<{ scripts: Record<string, string> }>,397 scripts: incremental.Step<{ scripts: Record<string, string> }>,
398 twin: boolean,
369) {399) {
370 const { html, meta, clientRefs } = await b.readStep(page);400 const { html, meta, clientRefs } = await b.readStep(page);
371 const scriptMap = (await b.readStep(scripts)).scripts;401 const scriptMap = (await b.readStep(scripts)).scripts;
372 return sg.wrapDocument({402 // a twin is shown by a router that is already running
403 const refs = twin ? clientRefs.filter((ref) => ref !== router.script) : clientRefs;
404 const parts = {
373 body: html,405 body: html,
374 head: meta,406 head: meta,
375 inlineCss: await b.readStep(style),407 inlineCss: await b.readStep(style),
376 scripts: sg.joinScripts(clientRefs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))),408 scripts: sg.joinScripts(refs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))),
377 });409 };
410 return twin ? router.encode(parts) : sg.wrapDocument(parts);
378}411}
379412
380/** one of the scripts the bundle made, so writing it follows that script alone */413/** one of the scripts the bundle made, so writing it follows that script alone */
...@@ -411,6 +444,7 @@ const gzip = util.promisify(zlib.gzip);...@@ -411,6 +444,7 @@ const gzip = util.promisify(zlib.gzip);
411const zstd = util.promisify(zlib.zstdCompress);444const zstd = util.promisify(zlib.zstdCompress);
412445
413import * as sg from "#sitegen";446import * as sg from "#sitegen";
447import * as router from "#sitegen/router";
414import { ASSERT, UNWRAP } from "@clo/lib/assert";448import { ASSERT, UNWRAP } from "@clo/lib/assert";
415import * as exception from "@clo/lib/exception";449import * as exception from "@clo/lib/exception";
416import * as incremental from "@clo/lib/incremental";450import * as incremental from "@clo/lib/incremental";
src/q+a/pages/q+a.404.marko+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export * as layout from "../tags/question-layout.marko";1export * as layout from "../tags/question-layout.marko";
2export const router = true;
2export const meta: Metadata = {3export const meta: Metadata = {
3 title: "paper clover q+a",4 title: "paper clover q+a",
4};5};
src/q+a/views/permalink.marko+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export { theme } from "../tags/question-layout.marko";1export { theme } from "../tags/question-layout.marko";
2export const router = true;
2export interface Input {3export interface Input {
3 question: Question;4 question: Question;
4 isDiscord: boolean;5 isDiscord: boolean;