From 418861b947737336087371ca7d7abb297188adcd 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 | 249 +++++++++++++++++++++++++++++++++++-- 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, 321 insertions(+), 62 deletions(-) diff --git a/framework/bundle.ts b/framework/bundle.ts index 01c9e02d4bd43177bfb83dade79bab18965ab34e..37daa2b5fc586683159816df1aab079613fd326d 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 836ef4b614d82c7ec87c019275b20c370a5167b2..727c364fa34760c1962554e320a920bb590280be 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 7645a05b8f54b0bbb4018d728a3c65a19cc726f3..24d4bb98e0305bc322e05b56c7db734648f248c2 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 90fb65a5d509ee5b3bfe5577403422c9b3f15af4..c9737e8bb17f9934343e15e9472ef35d151bef22 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 c94d34f1071d3a5ac5328ce3d9fa8352bd317c0f..0c81b886a58087099ec1fcae94c7e84311d55e2c 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 0e2c272899676863e8fd6daf3fa8bdf8fb9e15c0..846551e663a2bd492cf07481c10c606f8bc39283 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 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; - description?: string | undefined; - type: string; - url: 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 b1c8ab5f13c1cf7a0393ca947763f086bf0df6b4..7a424790a8fd8cdedf66674704a6801fe5fc604e 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 830119073ada4e43856d6721ab0167aa31e05ced..efa3901f7f79ba33f5ab9382191e8a4836fc08ca 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 047da17c44aeab0c4cb1e2cda7b3269c1fb61d24..2c8bb37f8ad7bebbebd43ebf6ce3f8af4210c81c 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 bf61e4c7cb8d62c255b20017b27b2b5bc22dace6..53e16547875558ccd103b7152790f51bff85db3e 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 214e8f6a93368669df1dfd12d377f1ccb57d5b3c..247a4346cba5260ed0d4ce78b61464498b2d0b70 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"; -- 2.54.0