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";