sitegen/framework/lib/meta.ts

257 lines
9 KiB
TypeScript
Raw Permalink Normal View History

export { renderMetadata as render };
2025-07-07 20:58:02 -07:00
export interface Meta {
/** Required for all pages. `<title>{content}</title>` */
2025-07-07 20:58:02 -07:00
title: string;
/** 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;
2025-07-07 20:58:02 -07:00
}
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;
2025-08-14 20:35:33 -07:00
}
/** See https://ogp.me for extra rules. */
2025-07-07 20:58:02 -07:00
export interface OpenGraph {
/** The title of your object as it should appear within the graph */
2025-07-07 20:58:02 -07:00
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;
2025-07-07 20:58:02 -07:00
}
export interface Alternates {
canonical: string;
types: { [mime: string]: AlternateType };
}
export interface AlternateType {
url: string;
title: string;
}
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));
2025-07-07 20:58:02 -07:00
}
2025-08-14 20:35:33 -07:00
import { escapeHtmlContent as esc } from "#engine/render";
import * as render from "#engine/render";