sitegen/framework/lib/view.ts

127 lines
3.5 KiB
TypeScript
Raw Normal View History

// The "view" system allows rendering dynamic pages within backends.
// This is done by scanning all `views` dirs, bundling their client
// resources, and then providing `serve` which renders a page.
//
// This system also implements page regeneration.
let codegen: Codegen;
try {
codegen = require("$views");
} catch {
throw new Error("Can only import '#sitegen/view' in backends.");
}
// Generated in `bundle.ts`
export interface Codegen {
views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
scripts: Record<string, string>;
metaTemplate: meta.Template;
regenTtls: Ttl[];
regenTags: Record<RegenKey, Key[]>;
}
// The view contains pre-bundled CSS and scripts, but keeps the scripts
// separate for run-time dynamic scripts. For example, the file viewer
// includes the canvas for the current page, but only the current page.
export interface View<Props extends Record<string, unknown>> {
component: render.Component;
meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta);
layout?: render.Component;
2025-07-07 20:58:02 -07:00
inlineCss: string;
scripts: Record<string, string>;
}
export interface Ttl {
seconds: number;
key: Key;
}
export type Key = keyof ViewMap;
2025-07-07 20:58:02 -07:00
export async function serve<K extends Key>(
2025-07-07 20:58:02 -07:00
context: hono.Context,
id: K,
props: PropsFromModule<ViewMap[K]>,
2025-07-07 20:58:02 -07:00
) {
return context.html(await renderToString(id, { context, ...props }), {
headers: {
"Content-Type": "text/html;charset=utf8",
},
});
2025-07-07 20:58:02 -07:00
}
type PropsFromModule<M extends any> = M extends {
default: (props: infer T) => render.Node;
} ? T
: never;
export async function renderToString<K extends Key>(
id: K,
props: PropsFromModule<ViewMap[K]>,
2025-07-07 20:58:02 -07:00
) {
const {
component,
inlineCss,
layout,
meta: metadata,
}: View<PropsFromModule<ViewMap[K]>> = UNWRAP(
codegen.views[id],
`Missing view ${id}`,
);
2025-07-07 20:58:02 -07:00
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata(props) : metadata,
).then((m) => meta.render(m, codegen.metaTemplate));
2025-07-07 20:58:02 -07:00
// -- html --
let page: render.Element = render.element(component, props);
if (layout) page = render.element(layout, { children: page });
const {
text: body,
addon: { [sg.userData.key]: sitegen },
} = await render.async(page, { [sg.userData.key]: sg.initRender() });
2025-07-07 20:58:02 -07:00
// -- join document and send --
return sg.wrapDocument({
2025-07-07 20:58:02 -07:00
body,
head: await renderedMetaPromise,
inlineCss,
scripts: joinScripts(
Array.from(
sitegen!.scripts,
(id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
2025-07-07 20:58:02 -07:00
),
),
});
}
export function regenerate(tag: RegenKey) {
for (const view of codegen.regenTags[tag]) {
const key = view.slice("page:".length);
renderToString(view, {})
.then((result) => {
console.info(`regenerate ${key}`);
asset.overwriteDynamic(key as asset.Key, result, {
"content-type": "text/html",
});
})
.catch((e) => {
console.error(`Failed regenerating ${view} from tag ${tag}`, e);
});
}
2025-07-07 20:58:02 -07:00
}
function joinScripts(scriptSources: string[]) {
2025-07-07 20:58:02 -07:00
const { length } = scriptSources;
if (length === 0) return "";
if (0 in scriptSources) return scriptSources[0];
2025-07-07 20:58:02 -07:00
return scriptSources.map((source) => `{${source}}`).join(";");
}
import * as meta from "./meta.ts";
import type * as hono from "#hono";
import * as render from "#engine/render";
2025-07-07 20:58:02 -07:00
import * as sg from "./sitegen.ts";
import * as asset from "./assets.ts";
import type {
RegenKey,
RegisteredViews as ViewMap,
} from "../../.clover/ts/view.d.ts";