| 1 | // The "view" system allows rendering dynamic pages within backends. |
| 2 | // The build scans all `views` dirs and prepares their styles and client |
| 3 | // scripts. It hands that to `load`. A backend serves a view with |
| 4 | // `serveCached`, which renders it once for each set of props as a step of |
| 5 | // the graph, or with `serveUncached`, which renders it for every request. |
| 6 | let current: Loaded | null = null; |
| 7 | |
| 8 | interface Loaded { |
| 9 | output: Pick<Output, "views" | "scripts" | "styles" | "meta">; |
| 10 | /** each view's module, by id */ |
| 11 | modules: Record<string, ViewModule>; |
| 12 | } |
| 13 | |
| 14 | interface ViewModule { |
| 15 | default: Marko.Component; |
| 16 | meta: meta.Meta | ((props: Record<string, unknown>) => Promise<meta.Meta> | meta.Meta); |
| 17 | } |
| 18 | |
| 19 | /** render the views of a build, given the module of each */ |
| 20 | export function load(output: Loaded["output"], modules: Loaded["modules"]) { |
| 21 | current = { output, modules }; |
| 22 | } |
| 23 | |
| 24 | export type Key = keyof ViewMap; |
| 25 | |
| 26 | /** |
| 27 | * serve a view rendered by a step of the graph, which is reused for the same |
| 28 | * props until something it read changes. the props are the key, so they are |
| 29 | * plain data, and the view can't read the request. |
| 30 | */ |
| 31 | export function serveCached<K extends Key>( |
| 32 | context: hono.Context, |
| 33 | id: K, |
| 34 | props: Record<string, incremental.Serializable>, |
| 35 | ) { |
| 36 | return serveStep(context, cached(id, props), { "Content-Type": "text/html;charset=utf8" }); |
| 37 | } |
| 38 | |
| 39 | /** the step that {@linkcode serveCached} serves, which another step can read to follow the page */ |
| 40 | export function cached<K extends Key>(id: K, props: Record<string, incremental.Serializable>) { |
| 41 | const { output } = loaded(); |
| 42 | const view = UNWRAP(output.views[id], `Missing view ${id}`); |
| 43 | const style = UNWRAP(output.styles[view.styleKey]); |
| 44 | return sg.graph.lazy(renderView, view, props, output.meta, style, output.scripts); |
| 45 | } |
| 46 | |
| 47 | /** respond with a step's value, which can change at any time, so a browser asks before it uses its copy */ |
| 48 | export async function serveStep( |
| 49 | context: hono.Context, |
| 50 | step: incremental.Step<string | Uint8Array>, |
| 51 | headers: Record<string, string>, |
| 52 | ) { |
| 53 | const body = await step.read(); |
| 54 | const etag = JSON.stringify(await step.hash()); |
| 55 | const sent = { ...headers, "Cache-Control": "no-cache", ETag: etag }; |
| 56 | const ifNoneMatch = context.req.header("If-None-Match"); |
| 57 | if (ifNoneMatch && etagMatches(etag, ifNoneMatch)) return context.body(null, 304, sent); |
| 58 | return context.body(body as string | Uint8Array<ArrayBuffer>, 200, sent); |
| 59 | } |
| 60 | |
| 61 | /** serve a view rendered for this request, which it can read as `context` */ |
| 62 | export async function serveUncached<K extends Key>( |
| 63 | context: hono.Context, |
| 64 | id: K, |
| 65 | props: Record<string, unknown>, |
| 66 | ) { |
| 67 | const { output, modules } = loaded(); |
| 68 | const view = UNWRAP(output.views[id], `Missing view ${id}`); |
| 69 | const html = await render( |
| 70 | UNWRAP(modules[id]), |
| 71 | { context, ...props }, |
| 72 | output.meta, |
| 73 | await UNWRAP(output.styles[view.styleKey]).read(), |
| 74 | (await output.scripts.read()).scripts, |
| 75 | ); |
| 76 | return context.html(html, { headers: { "Content-Type": "text/html;charset=utf8" } }); |
| 77 | } |
| 78 | |
| 79 | async function renderView( |
| 80 | b: incremental.Build, |
| 81 | view: Output["views"][string], |
| 82 | props: Record<string, incremental.Serializable>, |
| 83 | template: Output["meta"], |
| 84 | style: incremental.Step<string>, |
| 85 | scripts: incremental.Step<{ scripts: Record<string, string> }>, |
| 86 | ) { |
| 87 | const module = await b.import<ViewModule>(view.file); |
| 88 | return render(module, props, template, await b.readStep(style), (await b.readStep(scripts)).scripts); |
| 89 | } |
| 90 | |
| 91 | async function render( |
| 92 | { default: Page, meta: metadata }: ViewModule, |
| 93 | attrs: Record<string, unknown>, |
| 94 | template: Output["meta"], |
| 95 | inlineCss: string, |
| 96 | scripts: Readonly<Record<string, string>>, |
| 97 | ) { |
| 98 | // -- metadata -- |
| 99 | const renderedMetaPromise = Promise.resolve( |
| 100 | typeof metadata === "function" ? metadata(attrs) : metadata, |
| 101 | ).then((m) => meta.toHtml({ ...template, base: new URL(template.base) }, m)); |
| 102 | |
| 103 | // marko would pick a random id for the render, which would change the |
| 104 | // etag of a page each time it renders, like a page's html in a build would |
| 105 | const cloverScripts = new Set<string>(); |
| 106 | const text = await SitegenWrap.render({ |
| 107 | $global: { renderId: "_" }, |
| 108 | cloverScripts, |
| 109 | page: Page, |
| 110 | attrs, |
| 111 | }); |
| 112 | |
| 113 | // -- join document and send -- |
| 114 | return sg.wrapDocument({ |
| 115 | body: text, |
| 116 | head: await renderedMetaPromise, |
| 117 | inlineCss, |
| 118 | scripts: sg.joinScripts( |
| 119 | Array.from( |
| 120 | cloverScripts, |
| 121 | (id) => UNWRAP(scripts[id], `Missing script ${id}`), |
| 122 | ), |
| 123 | ), |
| 124 | }); |
| 125 | } |
| 126 | |
| 127 | function loaded() { |
| 128 | return UNWRAP(current, "views are rendered once a build has loaded them"); |
| 129 | } |
| 130 | |
| 131 | import { etagMatches } from "#sitegen/assets"; |
| 132 | import type { Output } from "#src/paperclover.net.ts"; |
| 133 | import { UNWRAP } from "@clo/lib/assert"; |
| 134 | import type * as incremental from "@clo/lib/incremental"; |
| 135 | import * as meta from "@clo/lib/meta"; |
| 136 | import type * as hono from "hono"; |
| 137 | import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; |
| 138 | import SitegenWrap from "../sitegen-wrap.marko"; |
| 139 | import * as sg from "./sitegen.ts"; |