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
2121 */
2222export async function load(assets: Output["assets"], dir: string) {
2323 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 );
2527 const read = async (hash: string): Promise<Encodings> => {
2628 const [raw, gzip, zstd] = await Promise.all(
2729 ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))),
......@@ -79,7 +81,13 @@ function respond(
7981 status: StatusCode,
8082 cache: CacheMode = "auto",
8183) {
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);
8391 const ifNoneMatch = c.req.header("If-None-Match");
8492 if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) {
8593 return (c.res = new Response(null, {
......@@ -87,14 +95,14 @@ function respond(
8795 statusText: "Not Modified",
8896 headers: {
8997 ETag: etag,
98 ...vary,
9099 },
91100 }));
92101 }
93102
94 const encodings = UNWRAP(loaded().bytes.get(asset.hash));
103 const encodings = UNWRAP(loaded().bytes.get(twin ?? asset.hash));
95104 const acceptEncoding = c.req.header("Accept-Encoding") ?? "";
96105 const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw";
97 const headers = { ...asset.headers };
98106 if (encoding !== "raw") headers["Content-Encoding"] = encoding;
99107 console.info(`${key} encoding=${encoding} status=${status}`);
100108
......@@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer";
128136import * as fs from "node:fs/promises";
129137import * as path from "node:path";
130138import 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 {
1212 default: Marko.Component;
1313 meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta);
1414 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;
1522}
1623
1724/**
......@@ -71,17 +78,15 @@ export function wrapDocument({
7178 }${analytics}</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body></html>`;
7279}
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 */
7482export function joinScripts(scriptSources: string[]) {
75 const { length } = scriptSources;
76 if (scriptSources[0] == null) return "";
77 if (length === 1) return UNWRAP(scriptSources[0]);
83 if (scriptSources.length === 0) return "";
7884 return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",")
7985 + "].map(e=>e())";
8086}
8187
8288export { default as CloverScriptInclude } from "./add-script.marko";
8389
84import { UNWRAP } from "@clo/lib/assert";
8590import type * as incremental from "@clo/lib/incremental";
8691import type { Meta } from "@clo/lib/meta";
8792import type * as css from "../css.ts";
framework/lib/view.ts+42-15
......@@ -33,7 +33,16 @@ export function serveCached<K extends Key>(
3333 id: K,
3434 props: Record<string, incremental.Serializable>,
3535) {
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 });
3746}
3847
3948/** 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>(
6675) {
6776 const { output, modules } = loaded();
6877 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(
7080 UNWRAP(modules[id]),
7181 { context, ...props },
7282 output.meta,
7383 await UNWRAP(output.styles[view.styleKey]).read(),
7484 (await output.scripts.read()).scripts,
85 view.router,
86 twin,
7587 );
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 } });
7793}
7894
79async function renderView(
80 b: incremental.Build,
95type Inputs = [
8196 view: Output["views"][string],
8297 props: Record<string, incremental.Serializable>,
8398 template: Output["meta"],
8499 style: incremental.Step<string>,
85100 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) {
87112 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);
89115}
90116
117/** what a view's document or twin is made of */
91118async function render(
92119 { default: Page, meta: metadata }: ViewModule,
93120 attrs: Record<string, unknown>,
94121 template: Output["meta"],
95122 inlineCss: string,
96123 scripts: Readonly<Record<string, string>>,
124 routed: boolean,
125 twin: boolean,
97126) {
98127 // -- metadata --
99128 const renderedMetaPromise = Promise.resolve(
......@@ -111,17 +140,14 @@ async function render(
111140 });
112141
113142 // -- 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 {
115146 body: text,
116147 head: await renderedMetaPromise,
117148 inlineCss,
118 scripts: sg.joinScripts(
119 Array.from(
120 cloverScripts,
121 (id) => UNWRAP(scripts[id], `Missing script ${id}`),
122 ),
123 ),
124 });
149 scripts: sg.joinScripts(refs.map((id) => UNWRAP(scripts[id], `Missing script ${id}`))),
150 };
125151}
126152
127153function loaded() {
......@@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta";
136162import type * as hono from "hono";
137163import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";
138164import SitegenWrap from "../sitegen-wrap.marko";
165import * as router from "./router.ts";
139166import * as sg from "./sitegen.ts";
src/blog/pages/community-translations.mdo+1
......@@ -1,4 +1,5 @@
11---
2router: true
23meta:
34 title: clover's community translations
45---
src/blog/tags/blog-layout.marko+1
......@@ -4,6 +4,7 @@ export const theme = {
44 fg: "#ffffff",
55 primary: "#91ffc6",
66};
7export const router = true;
78export const components = {
89 heading: Heading,
910 codeBlock: CodeBlock,
src/file-viewer/backend.ts+3
......@@ -10,6 +10,8 @@ app.post("/file/cotyledon", async (c) => {
1010});
1111
1212app.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);
1315 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
1416 const lofi = ua.includes("msie") || ua.startsWith("w3m/") || false;
1517
......@@ -282,6 +284,7 @@ import { type Context, Hono } from "hono";
282284
283285import * as sg from "#sitegen";
284286import { etagMatches, serveAsset } from "#sitegen/assets";
287import * as router from "#sitegen/router";
285288import * as view from "#sitegen/view";
286289import * as http from "@clo/lib/http";
287290import type * as incremental from "@clo/lib/incremental";
src/pages/dream.mdo+1
......@@ -1,4 +1,5 @@
11---
2router: true
23meta:
34 title: clo's dream
45---
src/pages/index.marko+1
......@@ -2,6 +2,7 @@ import type { Meta } from "#meta";
22import "./index.css";
33
44export const theme = { bg: "#fff", fg: "#666" };
5export const router = true;
56export const meta = {
67 title: "paper clover",
78 description:
src/pages/license.mdo+1
......@@ -1,4 +1,5 @@
11---
2router: true
23meta:
34 title: paper clover's media license
45theme:
src/pages/log.mdo+1
......@@ -1,4 +1,5 @@
11---
2router: true
23meta:
34 title: clover's log
45---
src/pages/resume.marko+1
......@@ -1,6 +1,7 @@
11import "./resume.css";
22
33export const meta = { title: "clover's resume" };
4export const router = true;
45
56<main>
67 <h1>clover's resume</h1>
src/pages/subscribe.marko+1
......@@ -3,6 +3,7 @@ export const meta = {
33 title: "paper clover mailing list",
44 description: "subscribe to the mailing list",
55};
6export const router = true;
67
78<main>
89 <h1>mailing list</h1>
src/paperclover.net.ts+53-19
......@@ -64,6 +64,8 @@ export interface Output {
6464export interface Asset {
6565 hash: string;
6666 headers: Record<string, string>;
67 /** a router page's twin, by hash, served at the same pathname */
68 twin?: string;
6769}
6870
6971export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> {
......@@ -110,9 +112,14 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>
110112 }
111113
112114 // -- assemble page assets --
113 const documents = pages.map((page, i) =>
114 incr.step(assemblePage, page, UNWRAP(styles.get(prepared[i]!.styleKey)), bundleScripts)
115 );
115 const assembled = pages.map((page, i) => {
116 const { styleKey, router: routed } = prepared[i]!;
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
117124 // -- write assets, once for all the pathnames with the same bytes --
118125 // 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>
121128 const { assets: scriptAssets } = await bundleScripts.read();
122129 const fontAssets = await Promise.all(fontFiles.map((font) => font.read()));
123130 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 }> = [
125137 ...Object.keys(scriptAssets).map((pathname) => ({
126138 pathname,
127139 bytes: incr.step(scriptAsset, bundleScripts, pathname),
......@@ -130,18 +142,27 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output>
130142 Object.keys(fontAssets[i]!).map((pathname) => ({ pathname, bytes: incr.step(fontAsset, font, pathname) }))
131143 ),
132144 ...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 }),
134151 ];
135152 const hashes = await Promise.all(sources.map(({ bytes }) => incr.step(hashAsset, bytes).read()));
136153 const assets: Output["assets"] = {};
137154 const writes = new Map<string, Promise<unknown>>();
138 sources.forEach(({ pathname, bytes, headers }, i) => {
139 ASSERT(pathname.startsWith("/") && !(pathname in assets), () => `asset ${pathname} is written twice`);
155 sources.forEach(({ pathname, bytes, headers, twin }, i) => {
140156 const hash = hashes[i]!;
141 assets[pathname] = {
142 hash,
143 headers: { "content-type": mime.for(pathname), ...headers, etag: JSON.stringify(hash) },
144 };
157 if (twin) {
158 UNWRAP(assets[pathname]).twin = hash;
159 } else {
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 }
145166 if (!writes.has(hash)) writes.set(hash, incr.step(writeAsset, hash, bytes).read());
146167 });
147168 await Promise.all(writes.values());
......@@ -276,7 +297,7 @@ export async function scanSiteSection(b: incremental.Build, section: Readonly<sg
276297 return { staticFiles, pages, views, scripts };
277298}
278299
279type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme } };
300type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme; router?: boolean } };
280301// what a step can hold of a template: no url, and no function
281302type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string };
282303
......@@ -284,11 +305,15 @@ type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string }
284305export async function preparePage(b: incremental.Build, item: Readonly<sg.FileItem>, template: SiteMeta) {
285306 using _ = b.start(item.file);
286307 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 );
288311 if (!Page) throw new Error("Page is missing a 'default' export.");
289312 if (!metadata) throw new Error("Page is missing 'meta' export with a title.");
290313
291314 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;
292317 const cssImports = await findCssImports(b, item.file);
293318
294319 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
313338 cssImports,
314339 theme,
315340 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,
317344 };
318345 } catch (e) {
319346 exception.rethrowWithMetadata(e, { page: item.id });
......@@ -332,14 +359,16 @@ export async function prepareView(b: incremental.Build, item: Readonly<sg.FileIt
332359 const clientRefs = (await b.trackModule(item.file)).flatMap((module) =>
333360 (module.data as { scannedClientRefs?: string[] } | null)?.scannedClientRefs ?? []
334361 );
362 const routed = module.router ?? module.layout?.router ?? false;
335363 return {
336364 id: item.id,
337365 file: item.file,
338366 cssImports,
339367 theme,
340 clientRefs: Array.from(new Set(clientRefs)),
368 clientRefs: Array.from(new Set(routed ? [router.script, ...clientRefs] : clientRefs)),
341369 hasLayout: !!module.layout?.default,
342370 styleKey: css.styleKey(cssImports, theme),
371 router: routed,
343372 };
344373}
345374export type PreparedView = Awaited<ReturnType<typeof prepareView>>;
......@@ -360,21 +389,25 @@ async function findCssImports(b: incremental.Build, file: string): Promise<strin
360389 return Array.from(found);
361390}
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 */
364393export async function assemblePage(
365394 b: incremental.Build,
366395 page: incremental.Step<Awaited<ReturnType<typeof preparePage>>>,
367396 style: incremental.Step<string>,
368397 scripts: incremental.Step<{ scripts: Record<string, string> }>,
398 twin: boolean,
369399) {
370400 const { html, meta, clientRefs } = await b.readStep(page);
371401 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 = {
373405 body: html,
374406 head: meta,
375407 inlineCss: await b.readStep(style),
376 scripts: sg.joinScripts(clientRefs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))),
377 });
408 scripts: sg.joinScripts(refs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))),
409 };
410 return twin ? router.encode(parts) : sg.wrapDocument(parts);
378411}
379412
380413/** one of the scripts the bundle made, so writing it follows that script alone */
......@@ -411,6 +444,7 @@ const gzip = util.promisify(zlib.gzip);
411444const zstd = util.promisify(zlib.zstdCompress);
412445
413446import * as sg from "#sitegen";
447import * as router from "#sitegen/router";
414448import { ASSERT, UNWRAP } from "@clo/lib/assert";
415449import * as exception from "@clo/lib/exception";
416450import * as incremental from "@clo/lib/incremental";
src/q+a/pages/q+a.404.marko+1
......@@ -1,4 +1,5 @@
11export * as layout from "../tags/question-layout.marko";
2export const router = true;
23export const meta: Metadata = {
34 title: "paper clover q+a",
45};
src/q+a/views/permalink.marko+1
......@@ -1,4 +1,5 @@
11export { theme } from "../tags/question-layout.marko";
2export const router = true;
23export interface Input {
34 question: Question;
45 isDiscord: boolean;