feat: metadata generation library

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.
This commit is contained in:
clover caruso 2025-08-15 22:30:58 -07:00
parent 3a36e53635
commit b304e2ac9f
11 changed files with 323 additions and 64 deletions

View file

@ -114,6 +114,7 @@ export interface ServerSideOptions {
styleMap: Map<string, incr.Ref<string>>;
scriptMap: incr.Ref<Record<string, string>>;
platform: ServerPlatform;
metaTemplate: meta.Template;
}
export async function bundleServerJavaScript({
viewItems,
@ -122,6 +123,7 @@ export async function bundleServerJavaScript({
scriptMap: wScriptMap,
entries,
platform,
metaTemplate,
}: ServerSideOptions) {
const regenKeys: Record<string, string[]> = {};
const regenTtls: view.Ttl[] = [];
@ -173,6 +175,12 @@ export async function bundleServerJavaScript({
"}",
`export const regenTags = ${JSON.stringify(regenKeys)};`,
`export const regenTtls = ${JSON.stringify(regenTtls)};`,
`export const metaTemplate = {`,
` base: new URL(${JSON.stringify(metaTemplate.base)}),`,
...Object.entries(metaTemplate)
.filter(([k]) => k !== 'base')
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`),
`};`,
].join("\n"),
};
},
@ -371,3 +379,4 @@ import * as incr from "./incremental.ts";
import * as sg from "#sitegen";
import type { PageOrView } from "./generate.ts";
import type * as view from "#sitegen/view";
import * as meta from "#sitegen/meta";

View file

@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record<string, string>) {
return "style=" + quoteIfNeeded(out);
}
export function quoteIfNeeded(text: string) {
if (text.match(/["/>]/)) return '"' + text + '"';
if (text.match(/["/> ]/)) return '"' + text + '"';
return text;
}

View file

@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) {
}
const instances = new async.OnceMap<void>();
const subsets = new async.OnceMap<void>();
async function makeInstance(input: string, vars: sg.FontVars) {
const args = Object.entries(vars).map((x) => {
const lower = x[0].toLowerCase();
@ -89,6 +88,7 @@ export async function fetchFont(name: string, sources: string[]) {
if (fs.existsSync(cacheName)) return cacheName;
if (source.startsWith("https://")) {
try {
const response = await fetch(source);
if (response.ok) {
await fs.writeMkdir(
@ -103,6 +103,10 @@ export async function fetchFont(name: string, sources: string[]) {
);
continue;
}
} catch (err) {
errs.push(`Fetching from ${source} failed: ${error.message(err)}`);
continue;
}
}
if (path.isAbsolute(source)) {
@ -190,3 +194,4 @@ import * as subprocess from "#sitegen/subprocess";
import * as incr from "./incremental.ts";
import * as crypto from "node:crypto";
import * as async from "#sitegen/async";
import * as error from "#sitegen/error";

View file

@ -14,9 +14,16 @@ export async function main() {
export async function generate() {
// -- read config and discover files --
const siteConfig = await incr.work(readManifest);
const { staticFiles, scripts, views, pages } = await discoverAllFiles(
siteConfig,
);
const metaTemplate: meta.Template = {
...siteConfig.meta,
base: new URL(siteConfig.meta.base),
};
const {
staticFiles,
scripts,
views,
pages,
} = await discoverAllFiles(siteConfig);
// TODO: make sure that `static` and `pages` does not overlap
@ -26,7 +33,9 @@ export async function generate() {
const builtFonts = fonts.buildFonts(siteConfig.fonts);
// -- perform build-time rendering --
const builtPages = pages.map((item) => incr.work(preparePage, item));
const builtPages = pages.map((item) =>
incr.work(preparePage, { item, metaTemplate })
);
const builtViews = views.map((item) => incr.work(prepareView, item));
const builtStaticFiles = Promise.all(
staticFiles.map((item) =>
@ -66,6 +75,7 @@ export async function generate() {
return { id: type === "page" ? `page:${id}` : id, file };
}),
viewRefs: viewsAndDynPages,
metaTemplate,
});
// -- assemble page assets --
@ -89,6 +99,10 @@ export async function readManifest(io: Io) {
})),
backends: cfg.backends.map(toRel),
fonts: cfg.fonts,
meta: {
...cfg.meta,
base: cfg.meta.base.toString(),
},
};
}
@ -194,7 +208,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
return { staticFiles, pages, views, scripts };
}
export async function preparePage(io: Io, item: sg.FileItem) {
export async function preparePage(
io: Io,
{ item, metaTemplate }: { item: sg.FileItem; metaTemplate: meta.Template },
) {
// -- load and validate module --
let {
default: Page,
@ -220,7 +237,10 @@ export async function preparePage(io: Io, item: sg.FileItem) {
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
).then((m) => meta.renderMeta(m));
).then((m) => meta.render({
canonical: item.id,
...m,
}, metaTemplate));
// -- html --
let page = render.element(Page);
@ -322,7 +342,7 @@ export async function assembleAndWritePage(
pathname: id,
buffer,
headers: {
"Content-Type": "text/html",
"Content-Type": "text/html;charset=utf8",
},
regenerative: !!regenerate,
});

View file

@ -277,8 +277,8 @@ type SerializedState = ReturnType<typeof serialize>;
/* No-op on failure */
async function deserialize(buffer: Buffer) {
const decoded = msgpackr.decode(buffer) as SerializedState;
if (!Array.isArray(decoded)) return false;
if (decoded[0] !== 1) return false;
if (!Array.isArray(decoded)) return;
if (decoded[0] !== 1) return;
const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
decoded;
for (const [k, type, content, ...affects] of fileEntries) {

View file

@ -1,17 +1,119 @@
export { renderMetadata as render };
export interface Meta {
/** Required for all pages. `<title>{content}</title>` */
title: string;
description?: string | undefined;
openGraph?: OpenGraph;
alternates?: Alternates;
/** Recommended for all pages. `<meta name="description" content="{...}" />` */
description?: string | null;
/** Automatically added for static renders from the 'pages' folders. */
canonical?: string | null;
/** Add `<link rel="alternate" ... />`. Object keys are interpretted as
* mime types if they contain a slash, otherwise seen as an alternative language. */
alternates?: Alternate[] | Record<string, string>;
/** Automatically generate both OpenGraph and Twitter meta tags */
embed?: AutoEmbed | null;
/** Add a robots tag for `noindex` and `nofollow` */
denyRobots?: boolean | null;
/** Add 'og:*' meta tags */
openGraph?: OpenGraph | null;
/** Add 'twitter:*' meta tags */
twitter?: Twitter | null;
/** * Refer to an oEmbed file. See https://oembed.com
* TODO: support passing the oEmbed file directly here. */
oEmbed?: string;
/**
* '#sitegen/meta' intentionally excludes a lot of exotic tags.
* Add these manually using JSX syntax:
*
* extra: [
* <meta name="site-verification" content="waffles" />,
* ],
*/
extra?: render.Node;
/** Adds `<meta name="author" content="{...}" />` */
authors?: string[];
/** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
generator?: string | true | null;
/** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
keywords?: string[];
/** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
manifest?: string | null;
/** Adds `<meta name="publisher" content="{...}" />` */
publisher?: string | null;
/** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
referrer?: Referrer | null;
/** Adds `<meta name="theme-color" content="{...}" />` */
themeColor?: string | { dark: string; light: string } | null;
/** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
viewport?: string;
}
export interface Template extends Omit<Meta, "title"> {
titleTemplate?: (title: string) => string,
}
export interface OpenGraph {
title?: string;
description?: string | undefined;
type: string;
export type Alternate = { type: string; url: string } | {
lang: string;
url: string;
};
export interface AutoEmbed {
/* Defaults to the page title. */
title?: string | null;
/* Defaults to the page description. */
description?: string | null;
/* Provide to add an embed image. */
thumbnail?: string | null;
/** @default "banner", which applies twitter:card = "summary_large_image" */
thumbnailSize?: "banner" | "icon";
/* Ignored if not passed */
siteTitle?: string | null;
}
/** See https://ogp.me for extra rules. */
export interface OpenGraph {
/** The title of your object as it should appear within the graph */
title?: string;
/** A one to two sentence description of your object. */
description?: string | null;
/** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
type?: string;
/** An image URL which should represent your object within the graph */
image?: OpenGraphField;
/** The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */
url?: string;
/** A URL to an audio file to accompany this object */
audio?: OpenGraphField;
/** The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */
determiner?: string;
/** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
locale?: string;
/** An array of other locales this page is available in */
"locale:alternate"?: string[];
/** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
site_name?: string;
/** A URL to a video file that complements this object */
video?: OpenGraphField;
[field: string]: OpenGraphField;
}
/**
* When passing an array, the property is duplicated.
* When passing an object, the fields are emitted as namespaced with ':'.
*/
type OpenGraphField =
| string
| { [field: string]: OpenGraphField }
| Array<OpenGraphField>
| (null | undefined);
/** Twitter uses various OpenGraph fields if these are not specified. */
export interface Twitter {
card: string;
title?: string;
description?: string | null;
url?: string;
image?: string;
player?: string;
/** Same logic as Open Graph */
[field: string]: OpenGraphField;
}
export interface Template
extends Omit<Meta, "title" | "description" | "canonical"> {
base: URL;
titleTemplate?: (title: string) => string;
}
export interface Alternates {
canonical: string;
@ -21,7 +123,134 @@ export interface AlternateType {
url: string;
title: string;
}
export function renderMeta({ title }: Meta): string {
return `<title>${esc(title)}</title><link rel="icon" type="image/x-icon" href="/favicon.ico">`;
export type Referrer =
| "no-referrer"
| "origin"
| "no-referrer-when-downgrade"
| "origin-when-cross-origin"
| "same-origin"
| "strict-origin"
| "strict-origin-when-cross-origin";
/* Convert a metadata definition into text. */
function renderMetadata(meta: Meta, template: Template): string {
const { titleTemplate, base } = template;
const resolve = (str: string) => new URL(str, base).href;
const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
const description = meta.description ?? null;
const canonical = meta.canonical ? resolve(meta.canonical) : null;
const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
const authors = meta.authors ?? template.authors ?? null;
let generator = meta.generator ?? template.generator ?? null;
const keywords = meta.keywords ?? template.keywords ?? null;
const manifest = meta.manifest ?? template.manifest ?? null;
const publisher = meta.publisher ?? template.publisher ?? null;
const referrer = meta.referrer ?? template.referrer ?? null;
const themeColor = meta.themeColor ?? template.themeColor ?? null;
const viewport = meta.viewport ?? template.viewport ??
"width=device-width, initial-scale=1.0";
const embed = meta.embed ?? template.embed ?? null;
let openGraph = meta.openGraph ?? template.openGraph ?? null;
let twitter = meta.twitter ?? template.twitter ?? null;
if (embed) {
const { thumbnail, thumbnailSize, siteTitle } = embed;
openGraph = {
type: "website",
title: embed.title ?? title,
description: embed.description ?? description,
...openGraph,
};
twitter = {
card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
? "summary_large_image"
: "summary",
...twitter,
};
if (thumbnail) {
openGraph.image = embed.thumbnail;
}
if (siteTitle) {
openGraph.site_name = siteTitle;
}
if (canonical) {
openGraph.url = canonical;
}
}
let out = `<title>${esc(title)}</title>`;
if (description) {
out += `<meta name=description content=${attr(description)}>`;
}
for (const author of authors ?? []) {
out += `<meta name=author content=${attr(author)}>`;
}
if (keywords) {
out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
}
if (generator) {
if (generator === true) {
generator = "clover sitegen 3; paperclover.net/sitegen";
}
out += `<meta name=generator content=${attr(generator)}>`;
}
if (publisher) {
out += `<meta name=publisher content=${attr(publisher)}>`;
}
if (referrer) {
out += `<meta name=referrer content=${attr(referrer)}>`;
}
if (themeColor) {
if (typeof themeColor === "string") {
out += `<meta name=theme-color content=${attr(themeColor)}>`;
} else {
out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
`content=${attr(themeColor.light)}>`;
out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
`content=${attr(themeColor.dark)}>`;
}
}
if (denyRobots) {
out += `<meta name=robots content=noindex,nofollow>`;
}
if (description) {
out += `<meta name=viewport content=${attr(viewport)}>`;
}
if (canonical) {
out += `<link rel=canonical href=${attr(canonical)}>`;
}
if (manifest) {
out += `<link rel=manifest href=${attr(manifest)}>`;
}
if (openGraph) out += renderOpenGraph("og:", openGraph);
if (twitter) out += renderOpenGraph("twitter:", twitter);
if (meta.extra) out += render.sync(meta.extra);
if (template.extra) out += render.sync(template.extra);
return out;
}
function renderOpenGraph(prefix: string, value: OpenGraphField): string {
if (!value) return "";
if (typeof value === "string") {
return `<meta name=${attr(prefix)} content=${attr(value)}>`;
}
if (Array.isArray(value)) {
return value
.map((item) => renderOpenGraph(prefix, item))
.join("");
}
return Object.entries(value)
.map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
.join("");
}
function attr(value: string) {
return render.quoteIfNeeded(esc(value));
}
import { escapeHtmlContent as esc } from "#engine/render";
import * as render from "#engine/render";

View file

@ -14,6 +14,7 @@ try {
export interface Codegen {
views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
scripts: Record<string, string>;
metaTemplate: meta.Template;
regenTtls: Ttl[];
regenTags: Record<RegenKey, Key[]>;
}
@ -38,7 +39,11 @@ export async function serve<K extends Key>(
id: K,
props: PropsFromModule<ViewMap[K]>,
) {
return context.html(await renderToString(id, { context, ...props }));
return context.html(await renderToString(id, { context, ...props }), {
headers: {
"Content-Type": "text/html;charset=utf8",
},
});
}
type PropsFromModule<M extends any> = M extends {
@ -63,7 +68,7 @@ export async function renderToString<K extends Key>(
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata(props) : metadata,
).then((m) => meta.renderMeta(m));
).then((m) => meta.render(m, codegen.metaTemplate));
// -- html --
let page: render.Element = render.element(component, props);

View file

@ -18,10 +18,10 @@ that assist building websites. these tools power <https://paperclover.net>.
checks when the files change. For example, changing a component re-tests
only pages that use that component and re-lints only the changed file.
- **Integrated libraries for building complex, content heavy web sites.**
- Static asset serving with ETag and build-time compression.
- Dynamicly rendered pages with static client. (`import "#sitegen/view"`)
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
- TODO: Meta and Open Graph generation. (`export const meta`)
- Static asset serving with automatic ETag and build-time compression.
- Dynamic pages. Static pages can be regenerated (and cached) at runtime.
- Databases with a minimal SQLite wrapper.
- Meta Tags and Open Graph generation.
- Font subsetting to reduce page bandwidth.
- **Built on the stable and battle-tested Node.js runtime.**

View file

@ -1,28 +1,13 @@
import type { Meta } from "#meta";
import "./index.css";
export const theme = {
bg: "#fff",
fg: "#666",
};
static const title = "paper clover";
static const description = "and then we knew, just like paper airplanes: that we could fly...";
export const meta: Meta = {
title,
description,
openGraph: {
title,
description,
type: "website",
url: "https://paperclover.net",
},
export const theme = { bg: "#fff", fg: "#666" };
export const meta = {
title: "paper clover",
description: "and then we knew, just like paper airplanes: that we could fly...",
embed: { /* Default embed */ },
alternates: {
canonical: "https://paperclover.net",
types: {
"application/rss+xml": [
{ url: "rss.xml", title: "rss" },
],
},
"application/rss+xml": "rss.xml",
},
};

View file

@ -1,6 +1,6 @@
import "./resume.css";
export const meta = { title: 'clover\'s resume' };
export const meta = { title: "clover's resume" };
<main>
<h1>clover's resume</h1>

View file

@ -20,8 +20,13 @@ export const backends: string[] = [
join("source-of-truth.ts"),
];
export const meta: MetaTemplate = {
base: new URL("https://paperclover.net"),
generator: true,
};
// Font subsets reduce bandwidth and protect against proprietary font theft.
const fontRoot = path.join(nasRoot, 'Documents/Font');
const fontRoot = path.join(nasRoot, "Documents/Font");
const ascii = { start: 0x20, end: 0x7E };
const nonAscii: sg.FontRange[] = [
{ start: 0xC0, end: 0xFF },
@ -105,5 +110,6 @@ export async function main() {
import * as path from "node:path";
import * as font from "../framework/font.ts";
import type { Template as MetaTemplate } from "#sitegen/meta";
import type * as sg from "#sitegen";
import { nasRoot } from "./file-viewer/paths.ts";