this takes what i love about 'next/meta' (originally imported via my 'next-metadata' port) and consolidates it into a simple 250 line library. instead of supporting all meta tags under the sun, only the most essential ones are exposed. less common meta tags can be added with JSX under the 'extra' field. a common problem i had with next-metadata was that open graph embeds copied a lot of data from the main meta tags. to solve this, a highly opiniated 'embed' option exists, which simply passing '{}' will trigger the default behavior of copying the meta title, description, and canonical url into the open graph meta tags.
126 lines
3.5 KiB
TypeScript
126 lines
3.5 KiB
TypeScript
// 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;
|
|
inlineCss: string;
|
|
scripts: Record<string, string>;
|
|
}
|
|
export interface Ttl {
|
|
seconds: number;
|
|
key: Key;
|
|
}
|
|
export type Key = keyof ViewMap;
|
|
|
|
export async function serve<K extends Key>(
|
|
context: hono.Context,
|
|
id: K,
|
|
props: PropsFromModule<ViewMap[K]>,
|
|
) {
|
|
return context.html(await renderToString(id, { context, ...props }), {
|
|
headers: {
|
|
"Content-Type": "text/html;charset=utf8",
|
|
},
|
|
});
|
|
}
|
|
|
|
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]>,
|
|
) {
|
|
const {
|
|
component,
|
|
inlineCss,
|
|
layout,
|
|
meta: metadata,
|
|
}: View<PropsFromModule<ViewMap[K]>> = UNWRAP(
|
|
codegen.views[id],
|
|
`Missing view ${id}`,
|
|
);
|
|
|
|
// -- metadata --
|
|
const renderedMetaPromise = Promise.resolve(
|
|
typeof metadata === "function" ? metadata(props) : metadata,
|
|
).then((m) => meta.render(m, codegen.metaTemplate));
|
|
|
|
// -- 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() });
|
|
|
|
// -- join document and send --
|
|
return sg.wrapDocument({
|
|
body,
|
|
head: await renderedMetaPromise,
|
|
inlineCss,
|
|
scripts: joinScripts(
|
|
Array.from(
|
|
sitegen!.scripts,
|
|
(id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`),
|
|
),
|
|
),
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|
|
|
|
function joinScripts(scriptSources: string[]) {
|
|
const { length } = scriptSources;
|
|
if (length === 0) return "";
|
|
if (0 in scriptSources) return scriptSources[0];
|
|
return scriptSources.map((source) => `{${source}}`).join(";");
|
|
}
|
|
|
|
import * as meta from "./meta.ts";
|
|
import type * as hono from "#hono";
|
|
import * as render from "#engine/render";
|
|
import * as sg from "./sitegen.ts";
|
|
import * as asset from "./assets.ts";
|
|
import type {
|
|
RegenKey,
|
|
RegisteredViews as ViewMap,
|
|
} from "../../.clover/ts/view.d.ts";
|