| 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,7 +21,9 @@ let current: { assets: Output["assets"]; bytes: Map<string, Encodings> } | null |
| 21 | */ | 21 | */ |
| 22 | export async function load(assets: Output["assets"], dir: string) { | 22 | export 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 | } |
| 93 | 102 | ||
| 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}`); |
| 100 | 108 | ||
| ... | @@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer"; | ... | @@ -128,3 +136,4 @@ import type { Buffer } from "node:buffer"; |
| 128 | import * as fs from "node:fs/promises"; | 136 | import * as fs from "node:fs/promises"; |
| 129 | import * as path from "node:path"; | 137 | import * as path from "node:path"; |
| 130 | import type { AssetKey as Key } from "../../.clover/ts/asset.d.ts"; | 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,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 | } |
| 16 | 23 | ||
| 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 | } |
| 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 | export function joinScripts(scriptSources: string[]) { | 82 | export 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 | } |
| 81 | 87 | ||
| 82 | export { default as CloverScriptInclude } from "./add-script.marko"; | 88 | export { default as CloverScriptInclude } from "./add-script.marko"; |
| 83 | 89 | ||
| 84 | import { UNWRAP } from "@clo/lib/assert"; | ||
| 85 | import type * as incremental from "@clo/lib/incremental"; | 90 | import type * as incremental from "@clo/lib/incremental"; |
| 86 | import type { Meta } from "@clo/lib/meta"; | 91 | import type { Meta } from "@clo/lib/meta"; |
| 87 | import type * as css from "../css.ts"; | 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,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 | } |
| 38 | 47 | ||
| 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 | } |
| 78 | 94 | ||
| 79 | async function renderView( | 95 | type 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 | |||
| 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 | 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 | } |
| 90 | 116 | ||
| 117 | /** what a view's document or twin is made of */ | ||
| 91 | async function render( | 118 | async 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 | }); |
| 112 | 141 | ||
| 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 | } |
| 126 | 152 | ||
| 127 | function loaded() { | 153 | function loaded() { |
| ... | @@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta"; | ... | @@ -136,4 +162,5 @@ import * as meta from "@clo/lib/meta"; |
| 136 | import type * as hono from "hono"; | 162 | import type * as hono from "hono"; |
| 137 | import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; | 163 | import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; |
| 138 | import SitegenWrap from "../sitegen-wrap.marko"; | 164 | import SitegenWrap from "../sitegen-wrap.marko"; |
| 165 | import * as router from "./router.ts"; | ||
| 139 | import * as sg from "./sitegen.ts"; | 166 | import * as sg from "./sitegen.ts"; |
src/blog/pages/community-translations.mdo+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | --- | 1 | --- |
| 2 | router: true | ||
| 2 | meta: | 3 | meta: |
| 3 | title: clover's community translations | 4 | 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 | }; |
| 7 | export const router = true; | ||
| 7 | export const components = { | 8 | export 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 | }); |
| 11 | 11 | ||
| 12 | app.get("/file/*", async (c, next) => { | 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 | 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; |
| 15 | 17 | ||
| ... | @@ -282,6 +284,7 @@ import { type Context, Hono } from "hono"; | ... | @@ -282,6 +284,7 @@ import { type Context, Hono } from "hono"; |
| 282 | 284 | ||
| 283 | import * as sg from "#sitegen"; | 285 | import * as sg from "#sitegen"; |
| 284 | import { etagMatches, serveAsset } from "#sitegen/assets"; | 286 | import { etagMatches, serveAsset } from "#sitegen/assets"; |
| 287 | import * as router from "#sitegen/router"; | ||
| 285 | import * as view from "#sitegen/view"; | 288 | import * as view from "#sitegen/view"; |
| 286 | import * as http from "@clo/lib/http"; | 289 | import * as http from "@clo/lib/http"; |
| 287 | import type * as incremental from "@clo/lib/incremental"; | 290 | import type * as incremental from "@clo/lib/incremental"; |
src/pages/dream.mdo+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | --- | 1 | --- |
| 2 | router: true | ||
| 2 | meta: | 3 | meta: |
| 3 | title: clo's dream | 4 | 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"; |
| 2 | import "./index.css"; | 2 | import "./index.css"; |
| 3 | 3 | ||
| 4 | export const theme = { bg: "#fff", fg: "#666" }; | 4 | export const theme = { bg: "#fff", fg: "#666" }; |
| 5 | export const router = true; | ||
| 5 | export const meta = { | 6 | export 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 | --- |
| 2 | router: true | ||
| 2 | meta: | 3 | meta: |
| 3 | title: paper clover's media license | 4 | title: paper clover's media license |
| 4 | theme: | 5 | theme: |
src/pages/log.mdo+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | --- | 1 | --- |
| 2 | router: true | ||
| 2 | meta: | 3 | meta: |
| 3 | title: clover's log | 4 | title: clover's log |
| 4 | --- | 5 | --- |
src/pages/resume.marko+1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import "./resume.css"; | 1 | import "./resume.css"; |
| 2 | 2 | ||
| 3 | export const meta = { title: "clover's resume" }; | 3 | export const meta = { title: "clover's resume" }; |
| 4 | export const router = true; | ||
| 4 | 5 | ||
| 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 | }; |
| 6 | export const router = true; | ||
| 6 | 7 | ||
| 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 { |
| 64 | export interface Asset { | 64 | export 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 | } |
| 68 | 70 | ||
| 69 | export async function buildAndWriteOutput(ref: incremental.Ref): Promise<Output> { | 71 | export 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 | } |
| 111 | 113 | ||
| 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 | }); | ||
| 116 | 123 | ||
| 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 and | 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,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 | } |
| 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 | // what a step can hold of a template: no url, and no function | 301 | // what a step can hold of a template: no url, and no function |
| 281 | type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string }; | 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,11 +305,15 @@ type SiteMeta = Omit<meta.Template, "base" | "titleTemplate"> & { base: string } |
| 284 | export async function preparePage(b: incremental.Build, item: Readonly<sg.FileItem>, template: SiteMeta) { | 305 | export 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."); |
| 290 | 313 | ||
| 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); |
| 293 | 318 | ||
| 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 | } |
| 345 | export type PreparedView = Awaited<ReturnType<typeof prepareView>>; | 374 | export 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 | } |
| 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 | export async function assemblePage( | 393 | export 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 | } |
| 379 | 412 | ||
| 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); |
| 411 | const zstd = util.promisify(zlib.zstdCompress); | 444 | const zstd = util.promisify(zlib.zstdCompress); |
| 412 | 445 | ||
| 413 | import * as sg from "#sitegen"; | 446 | import * as sg from "#sitegen"; |
| 447 | import * as router from "#sitegen/router"; | ||
| 414 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; | 448 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 415 | import * as exception from "@clo/lib/exception"; | 449 | import * as exception from "@clo/lib/exception"; |
| 416 | import * as incremental from "@clo/lib/incremental"; | 450 | import * as incremental from "@clo/lib/incremental"; |
src/q+a/pages/q+a.404.marko+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export * as layout from "../tags/question-layout.marko"; | 1 | export * as layout from "../tags/question-layout.marko"; |
| 2 | export const router = true; | ||
| 2 | export const meta: Metadata = { | 3 | export 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 @@ |
| 1 | export { theme } from "../tags/question-layout.marko"; | 1 | export { theme } from "../tags/question-layout.marko"; |
| 2 | export const router = true; | ||
| 2 | export interface Input { | 3 | export interface Input { |
| 3 | question: Question; | 4 | question: Question; |
| 4 | isDiscord: boolean; | 5 | isDiscord: boolean; |