authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-08-15 22:30:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:46-07:00
log418861b947737336087371ca7d7abb297188adcd
tree52f638e42b282b5debb9ee9d07d22bca64e620da
parent89a4f02a8f324f23275dd26bb4a23988ca07bc59
signature Commit is signed but in an unrecognized format.

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.

11 files changed, 321 insertions(+), 62 deletions(-)

framework/bundle.ts+9
......@@ -114,6 +114,7 @@ export interface ServerSideOptions {
114114 styleMap: Map<string, incr.Ref<string>>;
115115 scriptMap: incr.Ref<Record<string, string>>;
116116 platform: ServerPlatform;
117 metaTemplate: meta.Template;
117118}
118119export async function bundleServerJavaScript({
119120 viewItems,
......@@ -122,6 +123,7 @@ export async function bundleServerJavaScript({
122123 scriptMap: wScriptMap,
123124 entries,
124125 platform,
126 metaTemplate,
125127}: ServerSideOptions) {
126128 const regenKeys: Record<string, string[]> = {};
127129 const regenTtls: view.Ttl[] = [];
......@@ -173,6 +175,12 @@ export async function bundleServerJavaScript({
173175 "}",
174176 `export const regenTags = ${JSON.stringify(regenKeys)};`,
175177 `export const regenTtls = ${JSON.stringify(regenTtls)};`,
178 `export const metaTemplate = {`,
179 ` base: new URL(${JSON.stringify(metaTemplate.base)}),`,
180 ...Object.entries(metaTemplate)
181 .filter(([k]) => k !== 'base')
182 .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)},`),
183 `};`,
176184 ].join("\n"),
177185 };
178186 },
......@@ -371,3 +379,4 @@ import * as incr from "./incremental.ts";
371379import * as sg from "#sitegen";
372380import type { PageOrView } from "./generate.ts";
373381import type * as view from "#sitegen/view";
382import * as meta from "#sitegen/meta";
framework/engine/render.ts+1-1
......@@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record<string, string>) {
268268 return "style=" + quoteIfNeeded(out);
269269}
270270export function quoteIfNeeded(text: string) {
271 if (text.match(/["/>]/)) return '"' + text + '"';
271 if (text.match(/["/> ]/)) return '"' + text + '"';
272272 return text;
273273}
274274
framework/font.ts+18-13
......@@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) {
1717 }
1818
1919 const instances = new async.OnceMap<void>();
20 const subsets = new async.OnceMap<void>();
2120 async function makeInstance(input: string, vars: sg.FontVars) {
2221 const args = Object.entries(vars).map((x) => {
2322 const lower = x[0].toLowerCase();
......@@ -89,18 +88,23 @@ export async function fetchFont(name: string, sources: string[]) {
8988 if (fs.existsSync(cacheName)) return cacheName;
9089
9190 if (source.startsWith("https://")) {
92 const response = await fetch(source);
93 if (response.ok) {
94 await fs.writeMkdir(
95 cacheName,
96 Buffer.from(await response.arrayBuffer()),
97 );
98 } else {
99 errs.push(
100 new Error(
101 `Fetching from ${source} failed: ${response.status} ${response.statusText}`,
102 ),
103 );
91 try {
92 const response = await fetch(source);
93 if (response.ok) {
94 await fs.writeMkdir(
95 cacheName,
96 Buffer.from(await response.arrayBuffer()),
97 );
98 } else {
99 errs.push(
100 new Error(
101 `Fetching from ${source} failed: ${response.status} ${response.statusText}`,
102 ),
103 );
104 continue;
105 }
106 } catch (err) {
107 errs.push(`Fetching from ${source} failed: ${error.message(err)}`);
104108 continue;
105109 }
106110 }
......@@ -190,3 +194,4 @@ import * as subprocess from "#sitegen/subprocess";
190194import * as incr from "./incremental.ts";
191195import * as crypto from "node:crypto";
192196import * as async from "#sitegen/async";
197import * as error from "#sitegen/error";
framework/generate.ts+27-7
......@@ -14,9 +14,16 @@ export async function main() {
1414export async function generate() {
1515 // -- read config and discover files --
1616 const siteConfig = await incr.work(readManifest);
17 const { staticFiles, scripts, views, pages } = await discoverAllFiles(
18 siteConfig,
19 );
17 const metaTemplate: meta.Template = {
18 ...siteConfig.meta,
19 base: new URL(siteConfig.meta.base),
20 };
21 const {
22 staticFiles,
23 scripts,
24 views,
25 pages,
26 } = await discoverAllFiles(siteConfig);
2027
2128 // TODO: make sure that `static` and `pages` does not overlap
2229
......@@ -26,7 +33,9 @@ export async function generate() {
2633 const builtFonts = fonts.buildFonts(siteConfig.fonts);
2734
2835 // -- perform build-time rendering --
29 const builtPages = pages.map((item) => incr.work(preparePage, item));
36 const builtPages = pages.map((item) =>
37 incr.work(preparePage, { item, metaTemplate })
38 );
3039 const builtViews = views.map((item) => incr.work(prepareView, item));
3140 const builtStaticFiles = Promise.all(
3241 staticFiles.map((item) =>
......@@ -66,6 +75,7 @@ export async function generate() {
6675 return { id: type === "page" ? `page:${id}` : id, file };
6776 }),
6877 viewRefs: viewsAndDynPages,
78 metaTemplate,
6979 });
7080
7181 // -- assemble page assets --
......@@ -89,6 +99,10 @@ export async function readManifest(io: Io) {
8999 })),
90100 backends: cfg.backends.map(toRel),
91101 fonts: cfg.fonts,
102 meta: {
103 ...cfg.meta,
104 base: cfg.meta.base.toString(),
105 },
92106 };
93107}
94108
......@@ -194,7 +208,10 @@ export async function scanSiteSection(io: Io, sectionRoot: string) {
194208 return { staticFiles, pages, views, scripts };
195209}
196210
197export async function preparePage(io: Io, item: sg.FileItem) {
211export async function preparePage(
212 io: Io,
213 { item, metaTemplate }: { item: sg.FileItem; metaTemplate: meta.Template },
214) {
198215 // -- load and validate module --
199216 let {
200217 default: Page,
......@@ -220,7 +237,10 @@ export async function preparePage(io: Io, item: sg.FileItem) {
220237 // -- metadata --
221238 const renderedMetaPromise = Promise.resolve(
222239 typeof metadata === "function" ? metadata({ ssr: true }) : metadata,
223 ).then((m) => meta.renderMeta(m));
240 ).then((m) => meta.render({
241 canonical: item.id,
242 ...m,
243 }, metaTemplate));
224244
225245 // -- html --
226246 let page = render.element(Page);
......@@ -322,7 +342,7 @@ export async function assembleAndWritePage(
322342 pathname: id,
323343 buffer,
324344 headers: {
325 "Content-Type": "text/html",
345 "Content-Type": "text/html;charset=utf8",
326346 },
327347 regenerative: !!regenerate,
328348 });
framework/incremental.ts+2-2
......@@ -277,8 +277,8 @@ type SerializedState = ReturnType<typeof serialize>;
277277/* No-op on failure */
278278async function deserialize(buffer: Buffer) {
279279 const decoded = msgpackr.decode(buffer) as SerializedState;
280 if (!Array.isArray(decoded)) return false;
281 if (decoded[0] !== 1) return false;
280 if (!Array.isArray(decoded)) return;
281 if (decoded[0] !== 1) return;
282282 const [, fileEntries, workEntries, expectedFilesOnDisk, assetEntries] =
283283 decoded;
284284 for (const [k, type, content, ...affects] of fileEntries) {
framework/lib/meta.ts+239-10
......@@ -1,17 +1,119 @@
1export { renderMetadata as render };
12export interface Meta {
3 /** Required for all pages. `<title>{content}</title>` */
24 title: string;
3 description?: string | undefined;
4 openGraph?: OpenGraph;
5 alternates?: Alternates;
5 /** Recommended for all pages. `<meta name="description" content="{...}" />` */
6 description?: string | null;
7 /** Automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;
9 /** Add `<link rel="alternate" ... />`. Object keys are interpretted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string>;
12
13 /** Automatically generate both OpenGraph and Twitter meta tags */
14 embed?: AutoEmbed | null;
15 /** Add a robots tag for `noindex` and `nofollow` */
16 denyRobots?: boolean | null;
17 /** Add 'og:*' meta tags */
18 openGraph?: OpenGraph | null;
19 /** Add 'twitter:*' meta tags */
20 twitter?: Twitter | null;
21 /** * Refer to an oEmbed file. See https://oembed.com
22 * TODO: support passing the oEmbed file directly here. */
23 oEmbed?: string;
24 /**
25 * '#sitegen/meta' intentionally excludes a lot of exotic tags.
26 * Add these manually using JSX syntax:
27 *
28 * extra: [
29 * <meta name="site-verification" content="waffles" />,
30 * ],
31 */
32 extra?: render.Node;
33
34 /** Adds `<meta name="author" content="{...}" />` */
35 authors?: string[];
36 /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */
37 generator?: string | true | null;
38 /** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
39 keywords?: string[];
40 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
41 manifest?: string | null;
42 /** Adds `<meta name="publisher" content="{...}" />` */
43 publisher?: string | null;
44 /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
45 referrer?: Referrer | null;
46 /** Adds `<meta name="theme-color" content="{...}" />` */
47 themeColor?: string | { dark: string; light: string } | null;
48 /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */
49 viewport?: string;
650}
7export interface Template extends Omit<Meta, "title"> {
8 titleTemplate?: (title: string) => string,
51export type Alternate = { type: string; url: string } | {
52 lang: string;
53 url: string;
54};
55export interface AutoEmbed {
56 /* Defaults to the page title. */
57 title?: string | null;
58 /* Defaults to the page description. */
59 description?: string | null;
60 /* Provide to add an embed image. */
61 thumbnail?: string | null;
62 /** @default "banner", which applies twitter:card = "summary_large_image" */
63 thumbnailSize?: "banner" | "icon";
64 /* Ignored if not passed */
65 siteTitle?: string | null;
966}
67/** See https://ogp.me for extra rules. */
1068export interface OpenGraph {
69 /** The title of your object as it should appear within the graph */
1170 title?: string;
12 description?: string | undefined;
13 type: string;
14 url: string;
71 /** A one to two sentence description of your object. */
72 description?: string | null;
73 /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
74 type?: string;
75 /** An image URL which should represent your object within the graph */
76 image?: OpenGraphField;
77 /** 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/" */
78 url?: string;
79 /** A URL to an audio file to accompany this object */
80 audio?: OpenGraphField;
81 /** 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) */
82 determiner?: string;
83 /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
84 locale?: string;
85 /** An array of other locales this page is available in */
86 "locale:alternate"?: string[];
87 /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
88 site_name?: string;
89 /** A URL to a video file that complements this object */
90 video?: OpenGraphField;
91 [field: string]: OpenGraphField;
92}
93/**
94 * When passing an array, the property is duplicated.
95 * When passing an object, the fields are emitted as namespaced with ':'.
96 */
97type OpenGraphField =
98 | string
99 | { [field: string]: OpenGraphField }
100 | Array<OpenGraphField>
101 | (null | undefined);
102/** Twitter uses various OpenGraph fields if these are not specified. */
103export interface Twitter {
104 card: string;
105 title?: string;
106 description?: string | null;
107 url?: string;
108 image?: string;
109 player?: string;
110 /** Same logic as Open Graph */
111 [field: string]: OpenGraphField;
112}
113export interface Template
114 extends Omit<Meta, "title" | "description" | "canonical"> {
115 base: URL;
116 titleTemplate?: (title: string) => string;
15117}
16118export interface Alternates {
17119 canonical: string;
......@@ -21,7 +123,134 @@ export interface AlternateType {
21123 url: string;
22124 title: string;
23125}
24export function renderMeta({ title }: Meta): string {
25 return `<title>${esc(title)}</title><link rel="icon" type="image/x-icon" href="/favicon.ico">`;
126export type Referrer =
127 | "no-referrer"
128 | "origin"
129 | "no-referrer-when-downgrade"
130 | "origin-when-cross-origin"
131 | "same-origin"
132 | "strict-origin"
133 | "strict-origin-when-cross-origin";
134
135/* Convert a metadata definition into text. */
136function renderMetadata(meta: Meta, template: Template): string {
137 const { titleTemplate, base } = template;
138 const resolve = (str: string) => new URL(str, base).href;
139
140 const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
141 const description = meta.description ?? null;
142 const canonical = meta.canonical ? resolve(meta.canonical) : null;
143 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
144 const authors = meta.authors ?? template.authors ?? null;
145 let generator = meta.generator ?? template.generator ?? null;
146 const keywords = meta.keywords ?? template.keywords ?? null;
147 const manifest = meta.manifest ?? template.manifest ?? null;
148 const publisher = meta.publisher ?? template.publisher ?? null;
149 const referrer = meta.referrer ?? template.referrer ?? null;
150 const themeColor = meta.themeColor ?? template.themeColor ?? null;
151 const viewport = meta.viewport ?? template.viewport ??
152 "width=device-width, initial-scale=1.0";
153
154 const embed = meta.embed ?? template.embed ?? null;
155 let openGraph = meta.openGraph ?? template.openGraph ?? null;
156 let twitter = meta.twitter ?? template.twitter ?? null;
157 if (embed) {
158 const { thumbnail, thumbnailSize, siteTitle } = embed;
159 openGraph = {
160 type: "website",
161 title: embed.title ?? title,
162 description: embed.description ?? description,
163 ...openGraph,
164 };
165 twitter = {
166 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
167 ? "summary_large_image"
168 : "summary",
169 ...twitter,
170 };
171 if (thumbnail) {
172 openGraph.image = embed.thumbnail;
173 }
174 if (siteTitle) {
175 openGraph.site_name = siteTitle;
176 }
177 if (canonical) {
178 openGraph.url = canonical;
179 }
180 }
181
182 let out = `<title>${esc(title)}</title>`;
183 if (description) {
184 out += `<meta name=description content=${attr(description)}>`;
185 }
186 for (const author of authors ?? []) {
187 out += `<meta name=author content=${attr(author)}>`;
188 }
189 if (keywords) {
190 out += `<meta name=keywords content=${attr(keywords.join(", "))}>`;
191 }
192 if (generator) {
193 if (generator === true) {
194 generator = "clover sitegen 3; paperclover.net/sitegen";
195 }
196 out += `<meta name=generator content=${attr(generator)}>`;
197 }
198 if (publisher) {
199 out += `<meta name=publisher content=${attr(publisher)}>`;
200 }
201 if (referrer) {
202 out += `<meta name=referrer content=${attr(referrer)}>`;
203 }
204 if (themeColor) {
205 if (typeof themeColor === "string") {
206 out += `<meta name=theme-color content=${attr(themeColor)}>`;
207 } else {
208 out += '<meta name=theme-color media="(prefers-color-scheme:light)" ' +
209 `content=${attr(themeColor.light)}>`;
210 out += '<meta name=theme-color media="(prefers-color-scheme:dark)" ' +
211 `content=${attr(themeColor.dark)}>`;
212 }
213 }
214 if (denyRobots) {
215 out += `<meta name=robots content=noindex,nofollow>`;
216 }
217 if (description) {
218 out += `<meta name=viewport content=${attr(viewport)}>`;
219 }
220 if (canonical) {
221 out += `<link rel=canonical href=${attr(canonical)}>`;
222 }
223 if (manifest) {
224 out += `<link rel=manifest href=${attr(manifest)}>`;
225 }
226
227 if (openGraph) out += renderOpenGraph("og:", openGraph);
228 if (twitter) out += renderOpenGraph("twitter:", twitter);
229
230 if (meta.extra) out += render.sync(meta.extra);
231 if (template.extra) out += render.sync(template.extra);
232
233 return out;
234}
235
236function renderOpenGraph(prefix: string, value: OpenGraphField): string {
237 if (!value) return "";
238 if (typeof value === "string") {
239 return `<meta name=${attr(prefix)} content=${attr(value)}>`;
240 }
241 if (Array.isArray(value)) {
242 return value
243 .map((item) => renderOpenGraph(prefix, item))
244 .join("");
245 }
246 return Object.entries(value)
247 .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item))
248 .join("");
249}
250
251function attr(value: string) {
252 return render.quoteIfNeeded(esc(value));
26253}
254
27255import { escapeHtmlContent as esc } from "#engine/render";
256import * as render from "#engine/render";
framework/lib/view.ts+7-2
......@@ -14,6 +14,7 @@ try {
1414export interface Codegen {
1515 views: { [K in Key]: View<PropsFromModule<ViewMap[K]>> };
1616 scripts: Record<string, string>;
17 metaTemplate: meta.Template;
1718 regenTtls: Ttl[];
1819 regenTags: Record<RegenKey, Key[]>;
1920}
......@@ -38,7 +39,11 @@ export async function serve<K extends Key>(
3839 id: K,
3940 props: PropsFromModule<ViewMap[K]>,
4041) {
41 return context.html(await renderToString(id, { context, ...props }));
42 return context.html(await renderToString(id, { context, ...props }), {
43 headers: {
44 "Content-Type": "text/html;charset=utf8",
45 },
46 });
4247}
4348
4449type PropsFromModule<M extends any> = M extends {
......@@ -63,7 +68,7 @@ export async function renderToString<K extends Key>(
6368 // -- metadata --
6469 const renderedMetaPromise = Promise.resolve(
6570 typeof metadata === "function" ? metadata(props) : metadata,
66 ).then((m) => meta.renderMeta(m));
71 ).then((m) => meta.render(m, codegen.metaTemplate));
6772
6873 // -- html --
6974 let page: render.Element = render.element(component, props);
readme.md+4-4
......@@ -18,10 +18,10 @@ that assist building websites. these tools power <https://paperclover.net>.
1818 checks when the files change. For example, changing a component re-tests
1919 only pages that use that component and re-lints only the changed file.
2020- **Integrated libraries for building complex, content heavy web sites.**
21 - Static asset serving with ETag and build-time compression.
22 - Dynamicly rendered pages with static client. (`import "#sitegen/view"`)
23 - Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
24 - TODO: Meta and Open Graph generation. (`export const meta`)
21 - Static asset serving with automatic ETag and build-time compression.
22 - Dynamic pages. Static pages can be regenerated (and cached) at runtime.
23 - Databases with a minimal SQLite wrapper.
24 - Meta Tags and Open Graph generation.
2525 - Font subsetting to reduce page bandwidth.
2626- **Built on the stable and battle-tested Node.js runtime.**
2727
src/pages/index.marko+6-21
......@@ -1,28 +1,13 @@
11import type { Meta } from "#meta";
22import "./index.css";
33
4export const theme = {
5 bg: "#fff",
6 fg: "#666",
7};
8static const title = "paper clover";
9static const description = "and then we knew, just like paper airplanes: that we could fly...";
10export const meta: Meta = {
11 title,
12 description,
13 openGraph: {
14 title,
15 description,
16 type: "website",
17 url: "https://paperclover.net",
18 },
4export const theme = { bg: "#fff", fg: "#666" };
5export const meta = {
6 title: "paper clover",
7 description: "and then we knew, just like paper airplanes: that we could fly...",
8 embed: { /* Default embed */ },
199 alternates: {
20 canonical: "https://paperclover.net",
21 types: {
22 "application/rss+xml": [
23 { url: "rss.xml", title: "rss" },
24 ],
25 },
10 "application/rss+xml": "rss.xml",
2611 },
2712};
2813
src/pages/resume.marko+1-1
......@@ -1,6 +1,6 @@
11import "./resume.css";
22
3export const meta = { title: 'clover\'s resume' };
3export const meta = { title: "clover's resume" };
44
55<main>
66<h1>clover's resume</h1>
src/site.ts+7-1
......@@ -20,8 +20,13 @@ export const backends: string[] = [
2020 join("source-of-truth.ts"),
2121];
2222
23export const meta: MetaTemplate = {
24 base: new URL("https://paperclover.net"),
25 generator: true,
26};
27
2328// Font subsets reduce bandwidth and protect against proprietary font theft.
24const fontRoot = path.join(nasRoot, 'Documents/Font');
29const fontRoot = path.join(nasRoot, "Documents/Font");
2530const ascii = { start: 0x20, end: 0x7E };
2631const nonAscii: sg.FontRange[] = [
2732 { start: 0xC0, end: 0xFF },
......@@ -105,5 +110,6 @@ export async function main() {
105110
106111import * as path from "node:path";
107112import * as font from "../framework/font.ts";
113import type { Template as MetaTemplate } from "#sitegen/meta";
108114import type * as sg from "#sitegen";
109115import { nasRoot } from "./file-viewer/paths.ts";