| author | |
| committer | |
| log | 71fe97b0da367a55122a8d3a4d5db9243db592f6 |
| tree | bf31cc746d3567120a6d7915ca22d7867b2e4bb5 |
| parent | 62d00c88cc019087d79d6ac2fbb0d4ea4b56ad0e |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs (~clover) |
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.517 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 | 21 | */ |
| 22 | 22 | export async function load(assets: Output["assets"], dir: string) { |
| 23 | 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 | 27 | const read = async (hash: string): Promise<Encodings> => { |
| 26 | 28 | const [raw, gzip, zstd] = await Promise.all( |
| 27 | 29 | ["", ".gz", ".zst"].map((encoding) => fs.readFile(path.join(dir, hash + encoding))), |
| ... | ... | @@ -79,7 +81,13 @@ function respond( |
| 79 | 81 | status: StatusCode, |
| 80 | 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 | 91 | const ifNoneMatch = c.req.header("If-None-Match"); |
| 84 | 92 | if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) { |
| 85 | 93 | return (c.res = new Response(null, { |
| ... | ... | @@ -87,14 +95,14 @@ function respond( |
| 87 | 95 | statusText: "Not Modified", |
| 88 | 96 | headers: { |
| 89 | 97 | ETag: etag, |
| 98 | ...vary, | |
| 90 | 99 | }, |
| 91 | 100 | })); |
| 92 | 101 | } |
| 93 | 102 | |
| 94 | const encodings = UNWRAP(loaded().bytes.get(asset.hash)); | |
| 103 | const encodings = UNWRAP(loaded().bytes.get(twin ?? asset.hash)); | |
| 95 | 104 | const acceptEncoding = c.req.header("Accept-Encoding") ?? ""; |
| 96 | 105 | const encoding = acceptEncoding.includes("zstd") ? "zstd" : acceptEncoding.includes("gzip") ? "gzip" : "raw"; |
| 97 | const headers = { ...asset.headers }; | |
| 98 | 106 | if (encoding !== "raw") headers["Content-Encoding"] = encoding; |
| 99 | 107 | console.info(`${key} encoding=${encoding} status=${status}`); |
| 100 | 108 | |
| ... | ... | @@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer"; |
| 128 | 136 | import * as fs from "node:fs/promises"; |
| 129 | 137 | import * as path from "node:path"; |
| 130 | 138 | import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts"; |
| 139 | import * 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. | |
| 4 | import * as router from "./router.ts"; | |
| 5 | ||
| 6 | interface 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 */ | |
| 14 | interface Saved { | |
| 15 | scroll?: number; | |
| 16 | } | |
| 17 | ||
| 18 | const decoder = new TextDecoder(); | |
| 19 | const twins = new Map<string, Twin>(); | |
| 20 | // what a page puts in the head lies between these two | |
| 21 | const charset = document.querySelector("meta[charset]")!; | |
| 22 | const style = document.querySelector("head > style")!; | |
| 23 | /** the page shown, which a traversal to another of its fragments keeps */ | |
| 24 | let shown = location.pathname + location.search; | |
| 25 | /** counts clicks and traversals, so only the last one swaps */ | |
| 26 | let navigations = 0; | |
| 27 | ||
| 28 | history.scrollRestoration = "manual"; | |
| 29 | const save = () => history.replaceState({ scroll: scrollY } satisfies Saved, ""); | |
| 30 | let saving: ReturnType<typeof setTimeout> | undefined; | |
| 31 | addEventListener("scroll", () => { | |
| 32 | clearTimeout(saving); | |
| 33 | saving = setTimeout(save, 150); | |
| 34 | }, { passive: true }); | |
| 35 | // a reload, which the browser no longer scrolls back | |
| 36 | if (history.state?.scroll) scrollTo(0, history.state.scroll); | |
| 37 | ||
| 38 | function 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 | ||
| 50 | async 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` */ | |
| 68 | async 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 | ||
| 82 | function 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 */ | |
| 97 | function 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 | ||
| 104 | addEventListener("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 | ||
| 123 | addEventListener("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 | ||
| 131 | const 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 | }; | |
| 135 | let resting: ReturnType<typeof setTimeout> | undefined; | |
| 136 | addEventListener("mouseover", (event) => { | |
| 137 | clearTimeout(resting); | |
| 138 | resting = setTimeout(prefetch, 65, event); | |
| 139 | }); | |
| 140 | addEventListener("touchstart", prefetch, { passive: true }); | |
| 141 | addEventListener("focusin", prefetch); | |
| 142 | // a traversal back to the first page swaps it in like any other | |
| 143 | setTimeout(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 */ | |
| 7 | export const type = "application/x-clover-twin"; | |
| 8 | /** the format of a twin. a router that reads another version lets the browser navigate */ | |
| 9 | export const version = 1; | |
| 10 | /** the router's script id, a path within `src` like every other script's */ | |
| 11 | export const script = "../framework/lib/router.client.ts"; | |
| 12 | ||
| 13 | /** what a page is made of, which the router swaps into another page */ | |
| 14 | export 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 */ | |
| 22 | export 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 | 12 | default: Marko.Component; |
| 13 | 13 | meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta); |
| 14 | 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 | } |
| 16 | 23 | |
| 17 | 24 | /** |
| ... | ... | @@ -71,17 +78,15 @@ export function wrapDocument({ |
| 71 | 78 | }${analytics}</head><body>${body}${scripts ? `<script>${scripts}</script>` : ""}</body></html>`; |
| 72 | 79 | } |
| 73 | 80 | |
| 81 | /** each script in a function of its own, so its names stay off `window` and a page shown again can declare them again */ | |
| 74 | 82 | export 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 ""; | |
| 78 | 84 | return "[" + scriptSources.map((source) => `()=>{${source}}`).join(",") |
| 79 | 85 | + "].map(e=>e())"; |
| 80 | 86 | } |
| 81 | 87 | |
| 82 | 88 | export { default as CloverScriptInclude } from "./add-script.marko"; |
| 83 | 89 | |
| 84 | import { UNWRAP } from "@clo/lib/assert"; | |
| 85 | 90 | import type * as incremental from "@clo/lib/incremental"; |
| 86 | 91 | import type { Meta } from "@clo/lib/meta"; |
| 87 | 92 | import type * as css from "../css.ts"; |
framework/lib/view.ts+42-15| ... | ... | @@ -33,7 +33,16 @@ export function serveCached<K extends Key>( |
| 33 | 33 | id: K, |
| 34 | 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 | } |
| 38 | 47 | |
| 39 | 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 | 75 | ) { |
| 67 | 76 | const { output, modules } = loaded(); |
| 68 | 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 | 80 | UNWRAP(modules[id]), |
| 71 | 81 | { context, ...props }, |
| 72 | 82 | output.meta, |
| 73 | 83 | await UNWRAP(output.styles[view.styleKey]).read(), |
| 74 | 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 | } |
| 78 | 94 | |
| 79 | async function renderView( | |
| 80 | b: incremental.Build, | |
| 95 | type Inputs = [ | |
| 81 | 96 | view: Output["views"][string], |
| 82 | 97 | props: Record<string, incremental.Serializable>, |
| 83 | 98 | template: Output["meta"], |
| 84 | 99 | style: incremental.Step<string>, |
| 85 | 100 | scripts: incremental.Step<{ scripts: Record<string, string> }>, |
| 86 | ) { | |
| 101 | ]; | |
| 102 | ||
| 103 | async function renderView(b: incremental.Build, ...inputs: Inputs) { | |
| 104 | return sg.wrapDocument(await renderParts(b, inputs, false)); | |
| 105 | } | |
| 106 | ||
| 107 | async function renderTwin(b: incremental.Build, ...inputs: Inputs) { | |
| 108 | return router.encode(await renderParts(b, inputs, true)); | |
| 109 | } | |
| 110 | ||
| 111 | async function renderParts(b: incremental.Build, [view, props, template, style, scripts]: Inputs, twin: boolean) { | |
| 87 | 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 | } |
| 90 | 116 | |
| 117 | /** what a view's document or twin is made of */ | |
| 91 | 118 | async function render( |
| 92 | 119 | { default: Page, meta: metadata }: ViewModule, |
| 93 | 120 | attrs: Record<string, unknown>, |
| 94 | 121 | template: Output["meta"], |
| 95 | 122 | inlineCss: string, |
| 96 | 123 | scripts: Readonly<Record<string, string>>, |
| 124 | routed: boolean, | |
| 125 | twin: boolean, | |
| 97 | 126 | ) { |
| 98 | 127 | // -- metadata -- |
| 99 | 128 | const renderedMetaPromise = Promise.resolve( |
| ... | ... | @@ -111,17 +140,14 @@ async function render( |
| 111 | 140 | }); |
| 112 | 141 | |
| 113 | 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 | 146 | body: text, |
| 116 | 147 | head: await renderedMetaPromise, |
| 117 | 148 | 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 | }; | |
| 125 | 151 | } |
| 126 | 152 | |
| 127 | 153 | function loaded() { |
| ... | ... | @@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta"; |
| 136 | 162 | import type * as hono from "hono"; |
| 137 | 163 | import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; |
| 138 | 164 | import SitegenWrap from "../sitegen-wrap.marko"; |
| 165 | import * as router from "./router.ts"; | |
| 139 | 166 | import * as sg from "./sitegen.ts"; |
src/blog/pages/community-translations.mdo+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | --- |
| 2 | router: true | |
| 2 | 3 | meta: |
| 3 | 4 | title: clover's community translations |
| 4 | 5 | --- |
src/blog/tags/blog-layout.marko+1| ... | ... | @@ -4,6 +4,7 @@ export const theme = { |
| 4 | 4 | fg: "#ffffff", |
| 5 | 5 | primary: "#91ffc6", |
| 6 | 6 | }; |
| 7 | export const router = true; | |
| 7 | 8 | export const components = { |
| 8 | 9 | heading: Heading, |
| 9 | 10 | codeBlock: CodeBlock, |
src/file-viewer/backend.ts+3| ... | ... | @@ -10,6 +10,8 @@ app.post("/file/cotyledon", async (c) => { |
| 10 | 10 | }); |
| 11 | 11 | |
| 12 | 12 | app.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 | 15 | const ua = c.req.header("User-Agent")?.toLowerCase() ?? ""; |
| 14 | 16 | const lofi = ua.includes("msie") || ua.startsWith("w3m/") || false; |
| 15 | 17 | |
| ... | ... | @@ -282,6 +284,7 @@ import { type Context, Hono } from "hono"; |
| 282 | 284 | |
| 283 | 285 | import * as sg from "#sitegen"; |
| 284 | 286 | import { etagMatches, serveAsset } from "#sitegen/assets"; |
| 287 | import * as router from "#sitegen/router"; | |
| 285 | 288 | import * as view from "#sitegen/view"; |
| 286 | 289 | import * as http from "@clo/lib/http"; |
| 287 | 290 | import type * as incremental from "@clo/lib/incremental"; |
src/pages/dream.mdo+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | --- |
| 2 | router: true | |
| 2 | 3 | meta: |
| 3 | 4 | title: clo's dream |
| 4 | 5 | --- |
src/pages/index.marko+1| ... | ... | @@ -2,6 +2,7 @@ import type { Meta } from "#meta"; |
| 2 | 2 | import "./index.css"; |
| 3 | 3 | |
| 4 | 4 | export const theme = { bg: "#fff", fg: "#666" }; |
| 5 | export const router = true; | |
| 5 | 6 | export const meta = { |
| 6 | 7 | title: "paper clover", |
| 7 | 8 | description: |
src/pages/license.mdo+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | --- |
| 2 | router: true | |
| 2 | 3 | meta: |
| 3 | 4 | title: paper clover's media license |
| 4 | 5 | theme: |
src/pages/log.mdo+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | --- |
| 2 | router: true | |
| 2 | 3 | meta: |
| 3 | 4 | title: clover's log |
| 4 | 5 | --- |
src/pages/resume.marko+1| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import "./resume.css"; |
| 2 | 2 | |
| 3 | 3 | export const meta = { title: "clover's resume" }; |
| 4 | export const router = true; | |
| 4 | 5 | |
| 5 | 6 | <main> |
| 6 | 7 | <h1>clover's resume</h1> |
src/pages/subscribe.marko+1| ... | ... | @@ -3,6 +3,7 @@ export const meta = { |
| 3 | 3 | title: "paper clover mailing list", |
| 4 | 4 | description: "subscribe to the mailing list", |
| 5 | 5 | }; |
| 6 | export const router = true; | |
| 6 | 7 | |
| 7 | 8 | <main> |
| 8 | 9 | <h1>mailing list</h1> |
src/paperclover.net.ts+53-19| ... | ... | @@ -64,6 +64,8 @@ export interface Output { |
| 64 | 64 | export interface Asset { |
| 65 | 65 | hash: string; |
| 66 | 66 | headers: Record<string, string>; |
| 67 | /** a router page's twin, by hash, served at the same pathname */ | |
| 68 | twin?: string; | |
| 67 | 69 | } |
| 68 | 70 | |
| 69 | 71 | export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> { |
| ... | ... | @@ -110,9 +112,14 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> |
| 110 | 112 | } |
| 111 | 113 | |
| 112 | 114 | // -- 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 | }); | |
| 116 | 123 | |
| 117 | 124 | // -- write assets, once for all the pathnames with the same bytes -- |
| 118 | 125 | // 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 | 128 | const { assets: scriptAssets } = await bundleScripts.read(); |
| 122 | 129 | const fontAssets = await Promise.all(fontFiles.map((font) => font.read())); |
| 123 | 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 | 137 | ...Object.keys(scriptAssets).map((pathname) => ({ |
| 126 | 138 | pathname, |
| 127 | 139 | bytes: incr.step(scriptAsset, bundleScripts, pathname), |
| ... | ... | @@ -130,18 +142,27 @@ export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> |
| 130 | 142 | Object.keys(fontAssets[i]!).map((pathname) => ({ pathname, bytes: incr.step(fontAsset, font, pathname) })) |
| 131 | 143 | ), |
| 132 | 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 | 152 | const hashes = await Promise.all(sources.map(({ bytes }) => incr.step(hashAsset, bytes).read())); |
| 136 | 153 | const assets: Output["assets"] = {}; |
| 137 | 154 | 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) => { | |
| 140 | 156 | 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 | } | |
| 145 | 166 | if (!writes.has(hash)) writes.set(hash, incr.step(writeAsset, hash, bytes).read()); |
| 146 | 167 | }); |
| 147 | 168 | await Promise.all(writes.values()); |
| ... | ... | @@ -276,7 +297,7 @@ export async function scanSiteSection(b: incremental.Build, section: Readonly<sg |
| 276 | 297 | return { staticFiles, pages, views, scripts }; |
| 277 | 298 | } |
| 278 | 299 | |
| 279 | type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme } }; | |
| 300 | type Route = sg.ViewExports & { layout?: { default?: unknown; theme?: css.Theme; router?: boolean } }; | |
| 280 | 301 | // what a step can hold of a template: no url, and no function |
| 281 | 302 | type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string }; |
| 282 | 303 | |
| ... | ... | @@ -284,11 +305,15 @@ type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string } |
| 284 | 305 | export async function preparePage(b: incremental.Build, item: Readonly<sg.FileItem>, template: SiteMeta) { |
| 285 | 306 | using _ = b.start(item.file); |
| 286 | 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 | 311 | if (!Page) throw new Error("Page is missing a 'default' export."); |
| 289 | 312 | if (!metadata) throw new Error("Page is missing 'meta' export with a title."); |
| 290 | 313 | |
| 291 | 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 | 317 | const cssImports = await findCssImports(b, item.file); |
| 293 | 318 | |
| 294 | 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 | 338 | cssImports, |
| 314 | 339 | theme, |
| 315 | 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 | 345 | } catch (e) { |
| 319 | 346 | exception.rethrowWithMetadata(e, { page: item.id }); |
| ... | ... | @@ -332,14 +359,16 @@ export async function prepareView(b: incremental.Build, item: Readonly<sg.FileIt |
| 332 | 359 | const clientRefs = (await b.trackModule(item.file)).flatMap((module) => |
| 333 | 360 | (module.data as { scannedClientRefs?: string[] } | null)?.scannedClientRefs ?? [] |
| 334 | 361 | ); |
| 362 | const routed = module.router ?? module.layout?.router ?? false; | |
| 335 | 363 | return { |
| 336 | 364 | id: item.id, |
| 337 | 365 | file: item.file, |
| 338 | 366 | cssImports, |
| 339 | 367 | theme, |
| 340 | clientRefs: Array.from(new Set(clientRefs)), | |
| 368 | clientRefs: Array.from(new Set(routed ? [router.script, ...clientRefs] : clientRefs)), | |
| 341 | 369 | hasLayout: !!module.layout?.default, |
| 342 | 370 | styleKey: css.styleKey(cssImports, theme), |
| 371 | router: routed, | |
| 343 | 372 | }; |
| 344 | 373 | } |
| 345 | 374 | export type PreparedView = Awaited<ReturnType<typeof prepareView>>; |
| ... | ... | @@ -360,21 +389,25 @@ async function findCssImports(b: incremental.Build, file: string): Promise<strin |
| 360 | 389 | return Array.from(found); |
| 361 | 390 | } |
| 362 | 391 | |
| 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 */ | |
| 364 | 393 | export async function assemblePage( |
| 365 | 394 | b: incremental.Build, |
| 366 | 395 | page: incremental.Step<Awaited<ReturnType<typeof preparePage>>>, |
| 367 | 396 | style: incremental.Step<string>, |
| 368 | 397 | scripts: incremental.Step<{ scripts: Record<string, string> }>, |
| 398 | twin: boolean, | |
| 369 | 399 | ) { |
| 370 | 400 | const { html, meta, clientRefs } = await b.readStep(page); |
| 371 | 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 | 405 | body: html, |
| 374 | 406 | head: meta, |
| 375 | 407 | 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); | |
| 378 | 411 | } |
| 379 | 412 | |
| 380 | 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 | 444 | const zstd = util.promisify(zlib.zstdCompress); |
| 412 | 445 | |
| 413 | 446 | import * as sg from "#sitegen"; |
| 447 | import * as router from "#sitegen/router"; | |
| 414 | 448 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 415 | 449 | import * as exception from "@clo/lib/exception"; |
| 416 | 450 | import * as incremental from "@clo/lib/incremental"; |
src/q+a/pages/q+a.404.marko+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export * as layout from "../tags/question-layout.marko"; |
| 2 | export const router = true; | |
| 2 | 3 | export const meta: Metadata = { |
| 3 | 4 | title: "paper clover q+a", |
| 4 | 5 | }; |
src/q+a/views/permalink.marko+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export { theme } from "../tags/question-layout.marko"; |
| 2 | export const router = true; | |
| 2 | 3 | export interface Input { |
| 3 | 4 | question: Question; |
| 4 | 5 | isDiscord: boolean; |