2025-08-11 22:43:27 -07:00
|
|
|
// 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>;
|
|
|
|
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>> {
|
2025-08-02 19:22:07 -07:00
|
|
|
component: render.Component;
|
2025-08-11 22:43:27 -07:00
|
|
|
meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta);
|
2025-08-02 19:22:07 -07:00
|
|
|
layout?: render.Component;
|
2025-07-07 20:58:02 -07:00
|
|
|
inlineCss: string;
|
|
|
|
scripts: Record<string, string>;
|
|
|
|
}
|
2025-08-11 22:43:27 -07:00
|
|
|
export interface Ttl {
|
|
|
|
seconds: number;
|
|
|
|
key: Key;
|
|
|
|
}
|
|
|
|
export type Key = keyof ViewMap;
|
2025-07-07 20:58:02 -07:00
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
export async function serve<K extends Key>(
|
2025-07-07 20:58:02 -07:00
|
|
|
context: hono.Context,
|
2025-08-11 22:43:27 -07:00
|
|
|
id: K,
|
|
|
|
props: PropsFromModule<ViewMap[K]>,
|
2025-07-07 20:58:02 -07:00
|
|
|
) {
|
2025-08-11 22:43:27 -07:00
|
|
|
return context.html(await renderToString(id, { context, ...props }));
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -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,
|
2025-08-11 22:43:27 -07:00
|
|
|
}: 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.renderMeta(m));
|
|
|
|
|
|
|
|
// -- html --
|
2025-08-02 19:22:07 -07:00
|
|
|
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 --
|
2025-08-11 22:43:27 -07:00
|
|
|
return sg.wrapDocument({
|
2025-07-07 20:58:02 -07:00
|
|
|
body,
|
|
|
|
head: await renderedMetaPromise,
|
|
|
|
inlineCss,
|
|
|
|
scripts: joinScripts(
|
2025-08-11 22:43:27 -07:00
|
|
|
Array.from(
|
|
|
|
sitegen!.scripts,
|
|
|
|
(id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
|
2025-07-07 20:58:02 -07:00
|
|
|
),
|
|
|
|
),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -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
|
|
|
}
|
|
|
|
|
2025-08-11 22:43:27 -07:00
|
|
|
function joinScripts(scriptSources: string[]) {
|
2025-07-07 20:58:02 -07:00
|
|
|
const { length } = scriptSources;
|
|
|
|
if (length === 0) return "";
|
2025-08-11 22:43:27 -07:00
|
|
|
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";
|
2025-08-02 19:22:07 -07:00
|
|
|
import * as render from "#engine/render";
|
2025-07-07 20:58:02 -07:00
|
|
|
import * as sg from "./sitegen.ts";
|
2025-08-11 22:43:27 -07:00
|
|
|
import * as asset from "./assets.ts";
|
|
|
|
import type {
|
|
|
|
RegenKey,
|
|
|
|
RegisteredViews as ViewMap,
|
|
|
|
} from "../../.clover/ts/view.d.ts";
|