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.
6let current: Loaded | null = null;
7
8interface Loaded {
9 output: Pick<Output, "views" | "scripts" | "styles" | "meta">;
10 /** each view's module, by id */
11 modules: Record<string, ViewModule>;
12}
13
14interface 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 */
20export function load(output: Loaded["output"], modules: Loaded["modules"]) {
21 current = { output, modules };
22}
23
24export 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 */
31export 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 */
40export 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 */
48export 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` */
62export 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
79async 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
91async 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
127function loaded() {
128 return UNWRAP(current, "views are rendered once a build has loaded them");
129}
130
131import { etagMatches } from "#sitegen/assets";
132import type { Output } from "#src/paperclover.net.ts";
133import { UNWRAP } from "@clo/lib/assert";
134import type * as incremental from "@clo/lib/incremental";
135import * as meta from "@clo/lib/meta";
136import type * as hono from "hono";
137import type { RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";
138import SitegenWrap from "../sitegen-wrap.marko";
139import * as sg from "./sitegen.ts";