diff --git a/framework/lib/assets.ts b/framework/lib/assets.ts index 4aaa4674980f8c48af2dca86804c0579a1b674a5..24b9c6797271e71e9cb6d8e5fff8b01067a10962 100644 --- a/framework/lib/assets.ts +++ b/framework/lib/assets.ts @@ -21,7 +21,9 @@ let current: { assets: Output["assets"]; bytes: Map } | null */ export async function load(assets: Output["assets"], dir: string) { const known = current?.bytes; - const hashes = new Set(Object.values(assets).map((asset) => asset.hash)); + const hashes = new Set( + Object.values(assets).flatMap((asset) => asset.twin ? [asset.hash, asset.twin] : [asset.hash]), + ); const read = async (hash: string): Promise => { const [raw, gzip, zstd] = await Promise.all( ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))), @@ -79,7 +81,13 @@ function respond( status: StatusCode, cache: CacheMode = "auto", ) { - const etag = UNWRAP(asset.headers.etag); + // a router page's twin is served at its url, to whoever accepts only twins + const twin = c.req.header("Accept") === router.type ? asset.twin : undefined; + const vary: Record = asset.twin === undefined ? {} : { vary: "Accept" }; + const headers: Record = twin === undefined + ? { ...asset.headers, ...vary } + : { "content-type": router.type, etag: JSON.stringify(twin), "cache-control": "no-cache", ...vary }; + const etag = UNWRAP(headers.etag); const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) { return (c.res = new Response(null, { @@ -87,14 +95,14 @@ function respond( statusText: "Not Modified", headers: { ETag: etag, + ...vary, }, })); } - const encodings = UNWRAP(loaded().bytes.get(asset.hash)); + const encodings = UNWRAP(loaded().bytes.get(twin ?? asset.hash)); const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw"; - const headers = { ...asset.headers }; if (encoding !== "raw") headers["Content-Encoding"] = encoding; console.info(`${key} encoding=${encoding} status=${status}`); @@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts"; +import * as router from "./router.ts"; diff --git a/framework/lib/router.client.ts b/framework/lib/router.client.ts new file mode 100644 index 0000000000000000000000000000000000000000..1382f732caf9d5f55ad3a20c13b8db70fd273a03 --- /dev/null +++ b/framework/lib/router.client.ts @@ -0,0 +1,143 @@ +// Swaps a page's twin into this one when a link to it is clicked. The twin is +// fetched while the pointer rests on the link, so the click shows it at once. +// The browser itself follows a link to anything without a twin. +import * as router from "./router.ts"; + +interface Twin { + at: number; + parts: Promise; + /** what `parts` resolved to, so a click can decide at once */ + known?: router.Parts | null; +} + +/** what a history entry keeps, since the browser can't restore scroll across a swap */ +interface Saved { + scroll?: number; +} + +const decoder = new TextDecoder(); +const twins = new Map(); +// what a page puts in the head lies between these two +const charset = document.querySelector("meta[charset]")!; +const style = document.querySelector("head > style")!; +/** the page shown, which a traversal to another of its fragments keeps */ +let shown = location.pathname + location.search; +/** counts clicks and traversals, so only the last one swaps */ +let navigations = 0; + +history.scrollRestoration = "manual"; +const save = () => history.replaceState({ scroll: scrollY } satisfies Saved, ""); +let saving: ReturnType | undefined; +addEventListener("scroll", () => { + clearTimeout(saving); + saving = setTimeout(save, 150); +}, { passive: true }); +// a reload, which the browser no longer scrolls back +if (history.state?.scroll) scrollTo(0, history.state.scroll); + +function load(href: string): Twin { + const url = new URL(href); + url.hash = ""; + const cached = twins.get(url.href); + if (cached && performance.now() - cached.at < 300_000) return cached; + const parts = fetch(url, { headers: { accept: router.type }, redirect: "manual" }).then(decode, () => null); + const twin: Twin = { at: performance.now(), parts }; + void parts.then((known) => twin.known = known); + twins.set(url.href, twin); + return twin; +} + +async function decode(response: Response): Promise { + if (response.headers.get("content-type") !== router.type) { + void response.body?.cancel(); + return null; + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes[0] !== 0x3c || bytes[1] !== 0x33 || bytes[2] !== router.version) return null; + const lengths = new DataView(bytes.buffer, 3, 16); + let at = 19; + const [head, inlineCss, body, scripts] = [0, 4, 8, 12].map((offset) => + decoder.decode(bytes.subarray(at, at += lengths.getUint32(offset, true))) + ) as [string, string, string, string]; + // fonts this page has not used yet load before the click, so its text does not swap in late + for (const [, font] of inlineCss.matchAll(/url\(([^)]+\.woff2)\)/g)) void fetch(font!); + return { head, inlineCss, body, scripts }; +} + +/** show `href`, after a click, or after a traversal to an entry with `state` */ +async function go(href: string, twin: Twin, state?: Saved | null) { + const navigation = ++navigations; + const parts = await twin.parts; + if (navigation !== navigations) return; + const traversal = state !== undefined; + if (!parts) return traversal ? location.reload() : location.assign(href); + if (!traversal) { + save(); + history.pushState(null, "", href); + } + swap(parts); + restore(state ?? null); +} + +function swap({ head, inlineCss, body, scripts }: router.Parts) { + while (charset.nextSibling !== style) charset.nextSibling!.remove(); + const nodes = document.createElement("template"); + nodes.innerHTML = head; + style.before(nodes.content); + if (style.textContent !== inlineCss) style.textContent = inlineCss; + document.body.innerHTML = body; + shown = location.pathname + location.search; + // run as the parser would have run the page's own + const script = document.createElement("script"); + script.textContent = scripts; + document.body.append(script); +} + +/** where an entry was scrolled, or else its fragment, or else the top */ +function restore(state: Saved | null) { + if (state?.scroll !== undefined) return scrollTo(0, state.scroll); + const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1))); + if (target) target.scrollIntoView(); + else scrollTo(0, 0); +} + +addEventListener("click", (event) => { + if (event.defaultPrevented || event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + const link = (event.target as Element).closest?.("a[href]"); + if ( + !(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self") || link.hasAttribute("download") + ) { + return; + } + const url = new URL(link.href); + // the browser scrolls to another fragment of this page itself + if (url.origin !== location.origin || (url.pathname + url.search === shown && url.hash)) return; + const twin = load(url.href); + if (twin.known === null) return; + event.preventDefault(); + void go(url.href, twin); +}); + +addEventListener("popstate", (event) => { + // the entry gone to is scrolled from what it saved, not saved over + clearTimeout(saving); + const state = (event.state as Saved | null) ?? null; + if (location.pathname + location.search === shown) return restore(state); + void go(location.href, load(location.href), state); +}); + +const prefetch = (event: Event) => { + const link = (event.target as Element).closest?.("a[href]"); + if (link instanceof HTMLAnchorElement && link.origin === location.origin) load(link.href); +}; +let resting: ReturnType | undefined; +addEventListener("mouseover", (event) => { + clearTimeout(resting); + resting = setTimeout(prefetch, 65, event); +}); +addEventListener("touchstart", prefetch, { passive: true }); +addEventListener("focusin", prefetch); +// a traversal back to the first page swaps it in like any other +setTimeout(load, 1000, location.href); diff --git a/framework/lib/router.ts b/framework/lib/router.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f146d907090c844d88316969d7fbfad60f0fd3d --- /dev/null +++ b/framework/lib/router.ts @@ -0,0 +1,35 @@ +// A page that exports `router = true` gets a twin: its head, style, body, and +// scripts in one binary file, which `router.client.ts` swaps into the page +// where a link to it was clicked. The twin is served at the page's own url, +// to a request whose `Accept` is `type`. + +/** what a twin is served as, and what the router accepts */ +export const type = "application/x-clover-twin"; +/** the format of a twin. a router that reads another version lets the browser navigate */ +export const version = 1; +/** the router's script id, a path within `src` like every other script's */ +export const script = "../framework/lib/router.client.ts"; + +/** what a page is made of, which the router swaps into another page */ +export interface Parts { + head: string; + inlineCss: string; + body: string; + scripts: string; +} + +/** `"<3"`, the version, the byte lengths of the four parts as u32le, then the parts as utf-8 */ +export function encode({ head, inlineCss, body, scripts }: Parts): Uint8Array { + const encoder = new TextEncoder(); + const parts = [head, inlineCss, body, scripts].map((text) => encoder.encode(text)); + const twin = new Uint8Array(19 + parts.reduce((total, bytes) => total + bytes.length, 0)); + const lengths = new DataView(twin.buffer, 3, 16); + twin.set([0x3c, 0x33, version]); + let at = 19; + parts.forEach((bytes, i) => { + lengths.setUint32(4 * i, bytes.length, true); + twin.set(bytes, at); + at += bytes.length; + }); + return twin; +} diff --git a/framework/lib/sitegen.ts b/framework/lib/sitegen.ts index 69ded1d11a0b6eebc461608e7f14312a48e0d5f1..0bed318328edce2eda396708fbe76311cd270753 100644 --- a/framework/lib/sitegen.ts +++ b/framework/lib/sitegen.ts @@ -12,6 +12,13 @@ export interface ViewExports { default: Marko.Component; meta: Meta | ((props: { ssr: true }) => Promise | Meta); theme?: css.Theme; + /** + * links between pages that export this swap them in place of a document + * load. a page's scripts then run each time it is shown, so they bind to + * its elements, not to `window` or `document`. a layout can export it for + * its pages. + */ + router?: boolean; } /** @@ -71,17 +78,15 @@ export function wrapDocument({ }${analytics}${body}${scripts ? `` : ""}`; } +/** each script in a function of its own, so its names stay off `window` and a page shown again can declare them again */ export function joinScripts(scriptSources: string[]) { - const { length } = scriptSources; - if (scriptSources[0] == null) return ""; - if (length === 1) return UNWRAP(scriptSources[0]); + if (scriptSources.length === 0) return ""; return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",") + "].map(e=>e())"; } export { default as CloverScriptInclude } from "./add-script.marko"; -import { UNWRAP } from "@clo/lib/assert"; import type * as incremental from "@clo/lib/incremental"; import type { Meta } from "@clo/lib/meta"; import type * as css from "../css.ts"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index 060de87450501aee360910b9833ca522567d8ffe..3e56bd5affeebaf1064ca439081d9f86418601f2 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -33,7 +33,16 @@ export function serveCached( id: K, props: Record, ) { - return serveStep(context, cached(id, props), { "Content-Type": "text/html;charset=utf8" }); + const { output } = loaded(); + const view = UNWRAP(output.views[id], `Missing view ${id}`); + const twin = view.router && context.req.header("Accept") === router.type; + const style = UNWRAP(output.styles[view.styleKey]); + const step = twin ? sg.graph.lazy(renderTwin, view, props, output.meta, style, output.scripts) : cached(id, props); + return serveStep(context, step, { + "Content-Type": twin ? router.type : "text/html;charset=utf8", + // a router view and its twin share a url, which `Accept` tells apart + ...view.router ? { Vary: "Accept" } : {}, + }); } /** the step that {@linkcode serveCached} serves, which another step can read to follow the page */ @@ -66,34 +75,54 @@ export async function serveUncached( ) { const { output, modules } = loaded(); const view = UNWRAP(output.views[id], `Missing view ${id}`); - const html = await render( + const twin = view.router && context.req.header("Accept") === router.type; + const parts = await render( UNWRAP(modules[id]), { context, ...props }, output.meta, await UNWRAP(output.styles[view.styleKey]).read(), (await output.scripts.read()).scripts, + view.router, + twin, ); - return context.html(html, { headers: { "Content-Type": "text/html;charset=utf8" } }); + const vary: Record = view.router ? { Vary: "Accept" } : {}; + if (twin) { + return context.body(router.encode(parts), { headers: { "Content-Type": router.type, ...vary } }); + } + return context.html(sg.wrapDocument(parts), { headers: { "Content-Type": "text/html;charset=utf8", ...vary } }); } -async function renderView( - b: incremental.Build, +type Inputs = [ view: Output["views"][string], props: Record, template: Output["meta"], style: incremental.Step, scripts: incremental.Step<{ scripts: Record }>, -) { +]; + +async function renderView(b: incremental.Build, ...inputs: Inputs) { + return sg.wrapDocument(await renderParts(b, inputs, false)); +} + +async function renderTwin(b: incremental.Build, ...inputs: Inputs) { + return router.encode(await renderParts(b, inputs, true)); +} + +async function renderParts(b: incremental.Build, [view, props, template, style, scripts]: Inputs, twin: boolean) { const module = await b.import(view.file); - return render(module, props, template, await b.readStep(style), (await b.readStep(scripts)).scripts); + const { scripts: scriptMap } = await b.readStep(scripts); + return render(module, props, template, await b.readStep(style), scriptMap, view.router, twin); } +/** what a view's document or twin is made of */ async function render( { default: Page, meta: metadata }: ViewModule, attrs: Record, template: Output["meta"], inlineCss: string, scripts: Readonly>, + routed: boolean, + twin: boolean, ) { // -- metadata -- const renderedMetaPromise = Promise.resolve( @@ -111,17 +140,14 @@ async function render( }); // -- join document and send -- - return sg.wrapDocument({ + // the router goes first on a page, and a twin is shown by one already running + const refs = routed && !twin ? [router.script, ...cloverScripts] : [...cloverScripts]; + return { body: text, head: await renderedMetaPromise, inlineCss, - scripts: sg.joinScripts( - Array.from( - cloverScripts, - (id) => UNWRAP(scripts[id], `Missing script ${id}`), - ), - ), - }); + scripts: sg.joinScripts(refs.map((id) => UNWRAP(scripts[id], `Missing script ${id}`))), + }; } function loaded() { @@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta"; import type * as hono from "hono"; import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; import SitegenWrap from "../sitegen-wrap.marko"; +import * as router from "./router.ts"; import * as sg from "./sitegen.ts"; diff --git a/src/blog/pages/community-translations.mdo b/src/blog/pages/community-translations.mdo index bc2393ebc8d82a5b6433f3c6bdf3100be3252725..4b49725f502d423d7ccebb3bc6197124d3e036f5 100644 --- a/src/blog/pages/community-translations.mdo +++ b/src/blog/pages/community-translations.mdo @@ -1,4 +1,5 @@ --- +router: true meta: title: clover's community translations --- diff --git a/src/blog/tags/blog-layout.marko b/src/blog/tags/blog-layout.marko index 5fdbe78fc1d750ed925aa2a1a9c88001ede282d0..ee14b33566cbba42f787d028efa1a82690df75fb 100644 --- a/src/blog/tags/blog-layout.marko +++ b/src/blog/tags/blog-layout.marko @@ -4,6 +4,7 @@ export const theme = { fg: "#ffffff", primary: "#91ffc6", }; +export const router = true; export const components = { heading: Heading, codeBlock: CodeBlock, diff --git a/src/file-viewer/backend.ts b/src/file-viewer/backend.ts index 38025758267c3e64bdcadc7214b8485897969d57..742d3abb466af538242f7b8b2890a4ee10b2c98c 100644 --- a/src/file-viewer/backend.ts +++ b/src/file-viewer/backend.ts @@ -10,6 +10,8 @@ app.post("/file/cotyledon", async (c) => { }); app.get("/file/*", async (c, next) => { + // the file viewer has its own router, and a twin request must not start streaming a file + if (c.req.header("Accept") === router.type) return c.body(null, 204); const ua = c.req.header("User-Agent")?.toLowerCase() ?? ""; const lofi = ua.includes("msie") || ua.startsWith("w3m/") || false; @@ -282,6 +284,7 @@ import { type Context, Hono } from "hono"; import * as sg from "#sitegen"; import { etagMatches, serveAsset } from "#sitegen/assets"; +import * as router from "#sitegen/router"; import * as view from "#sitegen/view"; import * as http from "@clo/lib/http"; import type * as incremental from "@clo/lib/incremental"; diff --git a/src/pages/dream.mdo b/src/pages/dream.mdo index 69f3591933d804abc22d6edf1904db82686caa00..1a2826a34f14fce7d95053664a455c833dd3eab2 100644 --- a/src/pages/dream.mdo +++ b/src/pages/dream.mdo @@ -1,4 +1,5 @@ --- +router: true meta: title: clo's dream --- diff --git a/src/pages/index.marko b/src/pages/index.marko index c60b9500c64f6fc96118f5d3bfe4132ff12f4967..6434eb4ece69777dc0a0eb87c1d5e3958513dc8d 100644 --- a/src/pages/index.marko +++ b/src/pages/index.marko @@ -2,6 +2,7 @@ import type { Meta } from "#meta"; import "./index.css"; export const theme = { bg: "#fff", fg: "#666" }; +export const router = true; export const meta = { title: "paper clover", description: diff --git a/src/pages/license.mdo b/src/pages/license.mdo index 28a9a88faf492c279d2580c2a863548d0406dc86..9217458d3b6cff4237f1415f53d4ab4e90332726 100644 --- a/src/pages/license.mdo +++ b/src/pages/license.mdo @@ -1,4 +1,5 @@ --- +router: true meta: title: paper clover's media license theme: diff --git a/src/pages/log.mdo b/src/pages/log.mdo index 56da4a675d29daba987c54caa6359be497fcc93f..5f40cfd1bb5264fa65f2048bd5a9f20c230f9047 100644 --- a/src/pages/log.mdo +++ b/src/pages/log.mdo @@ -1,4 +1,5 @@ --- +router: true meta: title: clover's log --- diff --git a/src/pages/resume.marko b/src/pages/resume.marko index a0284a2586308021f5752ca2fb25b147d7d15370..ecb0f0d4e32b59985ec836570a5a02555ab13b74 100644 --- a/src/pages/resume.marko +++ b/src/pages/resume.marko @@ -1,6 +1,7 @@ import "./resume.css"; export const meta = { title: "clover's resume" }; +export const router = true;

clover's resume

diff --git a/src/pages/subscribe.marko b/src/pages/subscribe.marko index 943927bd0af760e23b2831bbca3200443d07e888..aec5621a46da9962aac9a46238e33d66c8c0347e 100644 --- a/src/pages/subscribe.marko +++ b/src/pages/subscribe.marko @@ -3,6 +3,7 @@ export const meta = { title: "paper clover mailing list", description: "subscribe to the mailing list", }; +export const router = true;

mailing list

diff --git a/src/paperclover.net.ts b/src/paperclover.net.ts index 26aefc596cc7a343eaaeffbb7199f1c931014028..5c51e8e05bf4e886b8a58f632f088437e7b78375 100644 --- a/src/paperclover.net.ts +++ b/src/paperclover.net.ts @@ -64,6 +64,8 @@ export interface Output { export interface Asset { hash: string; headers: Record; + /** a router page's twin, by hash, served at the same pathname */ + twin?: string; } export async function buildAndWriteOutput(ref: incremental.Ref): Promise { @@ -110,9 +112,14 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise } // -- assemble page assets -- - const documents = pages.map((page, i) => - incr.step(assemblePage, page, UNWRAP(styles.get(prepared[i]!.styleKey)), bundleScripts) - ); + const assembled = pages.map((page, i) => { + const { styleKey, router: routed } = prepared[i]!; + const style = UNWRAP(styles.get(styleKey)); + return { + document: incr.step(assemblePage, page, style, bundleScripts, false), + twin: routed ? incr.step(assemblePage, page, style, bundleScripts, true) : null, + }; + }); // -- write assets, once for all the pathnames with the same bytes -- // 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 const { assets: scriptAssets } = await bundleScripts.read(); const fontAssets = await Promise.all(fontFiles.map((font) => font.read())); const html = { "content-type": "text/html;charset=utf8" }; - const sources: Array<{ pathname: string; bytes: incremental.Step; headers?: typeof html }> = [ + const sources: Array<{ + pathname: string; + bytes: incremental.Step; + headers?: typeof html; + twin?: true; + }> = [ ...Object.keys(scriptAssets).map((pathname) => ({ pathname, bytes: incr.step(scriptAsset, bundleScripts, pathname), @@ -130,18 +142,27 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise Object.keys(fontAssets[i]!).map((pathname) => ({ pathname, bytes: incr.step(fontAsset, font, pathname) })) ), ...files.staticFiles.map((item, i) => ({ pathname: item.id, bytes: staticFiles[i]! })), - ...files.pages.map((item, i) => ({ pathname: item.id, bytes: documents[i]!, headers: html })), + ...files.pages.map((item, i) => ({ pathname: item.id, bytes: assembled[i]!.document, headers: html })), + // after every page, whose asset a twin belongs to + ...files.pages.flatMap((item, i) => { + const { twin } = assembled[i]!; + return twin ? [{ pathname: item.id, bytes: twin, twin: true as const }] : []; + }), ]; const hashes = await Promise.all(sources.map(({ bytes }) => incr.step(hashAsset, bytes).read())); const assets: Output["assets"] = {}; const writes = new Map>(); - sources.forEach(({ pathname, bytes, headers }, i) => { - ASSERT(pathname.startsWith("/") && !(pathname in assets), () => `asset ${pathname} is written twice`); + sources.forEach(({ pathname, bytes, headers, twin }, i) => { const hash = hashes[i]!; - assets[pathname] = { - hash, - headers: { "content-type": mime.for(pathname), ...headers, etag: JSON.stringify(hash) }, - }; + if (twin) { + UNWRAP(assets[pathname]).twin = hash; + } else { + ASSERT(pathname.startsWith("/") && !(pathname in assets), () => `asset ${pathname} is written twice`); + assets[pathname] = { + hash, + headers: { "content-type": mime.for(pathname), ...headers, etag: JSON.stringify(hash) }, + }; + } if (!writes.has(hash)) writes.set(hash, incr.step(writeAsset, hash, bytes).read()); }); await Promise.all(writes.values()); @@ -276,7 +297,7 @@ export async function scanSiteSection(b: incremental.Build, section: Readonly & { base: string }; @@ -284,11 +305,15 @@ type SiteMeta = Omit & { base: string } export async function preparePage(b: incremental.Build, item: Readonly, template: SiteMeta) { using _ = b.start(item.file); try { - const { default: Page, meta: metadata, theme: pageTheme, layout } = await b.import(item.file); + const { default: Page, meta: metadata, theme: pageTheme, layout, router: pageRouter } = await b.import( + item.file, + ); if (!Page) throw new Error("Page is missing a 'default' export."); if (!metadata) throw new Error("Page is missing 'meta' export with a title."); const theme: css.Theme = { ...css.defaultTheme, ...layout?.theme ?? pageTheme }; + // unlike its theme, a page can leave its layout's router + const routed = pageRouter ?? layout?.router ?? false; const cssImports = await findCssImports(b, item.file); const renderedMeta = meta.toHtml({ ...template, base: new URL(template.base) }, { @@ -313,7 +338,9 @@ export async function preparePage(b: incremental.Build, item: Readonly (module.data as { scannedClientRefs?: string[] } | null)?.scannedClientRefs ?? [] ); + const routed = module.router ?? module.layout?.router ?? false; return { id: item.id, file: item.file, cssImports, theme, - clientRefs: Array.from(new Set(clientRefs)), + clientRefs: Array.from(new Set(routed ? [router.script, ...clientRefs] : clientRefs)), hasLayout: !!module.layout?.default, styleKey: css.styleKey(cssImports, theme), + router: routed, }; } export type PreparedView = Awaited>; @@ -360,21 +389,25 @@ async function findCssImports(b: incremental.Build, file: string): Promise>>, style: incremental.Step, scripts: incremental.Step<{ scripts: Record }>, + twin: boolean, ) { const { html, meta, clientRefs } = await b.readStep(page); const scriptMap = (await b.readStep(scripts)).scripts; - return sg.wrapDocument({ + // a twin is shown by a router that is already running + const refs = twin ? clientRefs.filter((ref) => ref !== router.script) : clientRefs; + const parts = { body: html, head: meta, inlineCss: await b.readStep(style), - scripts: sg.joinScripts(clientRefs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))), - }); + scripts: sg.joinScripts(refs.map((ref) => UNWRAP(scriptMap[ref], `Missing script ${ref}`))), + }; + return twin ? router.encode(parts) : sg.wrapDocument(parts); } /** one of the scripts the bundle made, so writing it follows that script alone */ @@ -411,6 +444,7 @@ const gzip = util.promisify(zlib.gzip); const zstd = util.promisify(zlib.zstdCompress); import * as sg from "#sitegen"; +import * as router from "#sitegen/router"; import { ASSERT, UNWRAP } from "@clo/lib/assert"; import * as exception from "@clo/lib/exception"; import * as incremental from "@clo/lib/incremental"; diff --git a/src/q+a/pages/q+a.404.marko b/src/q+a/pages/q+a.404.marko index 3bd405110a745fa6a196656f48f01881d52de20a..992f17b6a63b305a37d2f6917147641401f5ec1b 100644 --- a/src/q+a/pages/q+a.404.marko +++ b/src/q+a/pages/q+a.404.marko @@ -1,4 +1,5 @@ export * as layout from "../tags/question-layout.marko"; +export const router = true; export const meta: Metadata = { title: "paper clover q+a", }; diff --git a/src/q+a/views/permalink.marko b/src/q+a/views/permalink.marko index eafd4d7a45427ffcddac568879864791fb082e7a..de6f2f089ae46946d7e2c4996a8089c612579f09 100644 --- a/src/q+a/views/permalink.marko +++ b/src/q+a/views/permalink.marko @@ -1,4 +1,5 @@ export { theme } from "../tags/question-layout.marko"; +export const router = true; export interface Input { question: Question; isDiscord: boolean;