From b304e2ac9f877ca57b8694c8ba5a6d25c8a976b1 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 15 Aug 2025 22:30:58 -0700 Subject: [PATCH] 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. --- framework/bundle.ts | 9 ++ framework/engine/render.ts | 2 +- framework/font.ts | 31 +++-- framework/generate.ts | 34 ++++- framework/incremental.ts | 4 +- framework/lib/meta.ts | 253 +++++++++++++++++++++++++++++++++++-- framework/lib/view.ts | 9 +- readme.md | 8 +- src/pages/index.marko | 27 +--- src/pages/resume.marko | 2 +- src/site.ts | 8 +- 11 files changed, 323 insertions(+), 64 deletions(-) diff --git a/framework/bundle.ts b/framework/bundle.ts index 01c9e02..37daa2b 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -114,6 +114,7 @@ export interface ServerSideOptions { styleMap: Map>; scriptMap: incr.Ref>; 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 = {}; 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"; diff --git a/framework/engine/render.ts b/framework/engine/render.ts index 836ef4b..727c364 100644 --- a/framework/engine/render.ts +++ b/framework/engine/render.ts @@ -268,7 +268,7 @@ export function stringifyStyleAttribute(style: Record) { return "style=" + quoteIfNeeded(out); } export function quoteIfNeeded(text: string) { - if (text.match(/["/>]/)) return '"' + text + '"'; + if (text.match(/["/> ]/)) return '"' + text + '"'; return text; } diff --git a/framework/font.ts b/framework/font.ts index 7645a05..24d4bb9 100644 --- a/framework/font.ts +++ b/framework/font.ts @@ -17,7 +17,6 @@ export async function buildFonts(fonts: sg.Font[]) { } const instances = new async.OnceMap(); - const subsets = new async.OnceMap(); async function makeInstance(input: string, vars: sg.FontVars) { const args = Object.entries(vars).map((x) => { const lower = x[0].toLowerCase(); @@ -89,18 +88,23 @@ export async function fetchFont(name: string, sources: string[]) { if (fs.existsSync(cacheName)) return cacheName; if (source.startsWith("https://")) { - const response = await fetch(source); - if (response.ok) { - await fs.writeMkdir( - cacheName, - Buffer.from(await response.arrayBuffer()), - ); - } else { - errs.push( - new Error( - `Fetching from ${source} failed: ${response.status} ${response.statusText}`, - ), - ); + try { + const response = await fetch(source); + if (response.ok) { + await fs.writeMkdir( + cacheName, + Buffer.from(await response.arrayBuffer()), + ); + } else { + errs.push( + new Error( + `Fetching from ${source} failed: ${response.status} ${response.statusText}`, + ), + ); + continue; + } + } catch (err) { + errs.push(`Fetching from ${source} failed: ${error.message(err)}`); continue; } } @@ -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"; diff --git a/framework/generate.ts b/framework/generate.ts index 90fb65a..c9737e8 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -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, }); diff --git a/framework/incremental.ts b/framework/incremental.ts index c94d34f..0c81b88 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -277,8 +277,8 @@ type SerializedState = ReturnType; /* 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) { diff --git a/framework/lib/meta.ts b/framework/lib/meta.ts index 0e2c272..846551e 100644 --- a/framework/lib/meta.ts +++ b/framework/lib/meta.ts @@ -1,17 +1,119 @@ +export { renderMetadata as render }; export interface Meta { + /** Required for all pages. `{content}` */ title: string; - description?: string | undefined; - openGraph?: OpenGraph; - alternates?: Alternates; + /** Recommended for all pages. `` */ + description?: string | null; + /** Automatically added for static renders from the 'pages' folders. */ + canonical?: string | null; + /** Add ``. Object keys are interpretted as + * mime types if they contain a slash, otherwise seen as an alternative language. */ + alternates?: Alternate[] | Record; + + /** 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: [ + * , + * ], + */ + extra?: render.Node; + + /** Adds `` */ + authors?: string[]; + /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */ + generator?: string | true | null; + /** Adds `` */ + keywords?: string[]; + /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ + manifest?: string | null; + /** Adds `` */ + publisher?: string | null; + /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */ + referrer?: Referrer | null; + /** Adds `` */ + 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 { - 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 + | (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 { + 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 `${esc(title)}`; +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 = `${esc(title)}`; + if (description) { + out += ``; + } + for (const author of authors ?? []) { + out += ``; + } + if (keywords) { + out += ``; + } + if (generator) { + if (generator === true) { + generator = "clover sitegen 3; paperclover.net/sitegen"; + } + out += ``; + } + if (publisher) { + out += ``; + } + if (referrer) { + out += ``; + } + if (themeColor) { + if (typeof themeColor === "string") { + out += ``; + } else { + out += '`; + out += '`; + } + } + if (denyRobots) { + out += ``; + } + if (description) { + out += ``; + } + if (canonical) { + out += ``; + } + if (manifest) { + out += ``; + } + + 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 ``; + } + 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"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index b1c8ab5..7a42479 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -14,6 +14,7 @@ try { export interface Codegen { views: { [K in Key]: View> }; scripts: Record; + metaTemplate: meta.Template; regenTtls: Ttl[]; regenTags: Record; } @@ -38,7 +39,11 @@ export async function serve( id: K, props: PropsFromModule, ) { - 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 { @@ -63,7 +68,7 @@ export async function renderToString( // -- 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); diff --git a/readme.md b/readme.md index 8301190..efa3901 100644 --- a/readme.md +++ b/readme.md @@ -18,10 +18,10 @@ that assist building websites. these tools power . 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.** diff --git a/src/pages/index.marko b/src/pages/index.marko index 047da17..2c8bb37 100644 --- a/src/pages/index.marko +++ b/src/pages/index.marko @@ -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", }, }; diff --git a/src/pages/resume.marko b/src/pages/resume.marko index bf61e4c..53e1654 100644 --- a/src/pages/resume.marko +++ b/src/pages/resume.marko @@ -1,6 +1,6 @@ import "./resume.css"; -export const meta = { title: 'clover\'s resume' }; +export const meta = { title: "clover's resume" };

clover's resume

diff --git a/src/site.ts b/src/site.ts index 214e8f6..247a434 100644 --- a/src/site.ts +++ b/src/site.ts @@ -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";