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.
256 lines
9 KiB
TypeScript
256 lines
9 KiB
TypeScript
export { renderMetadata as render };
|
|
export interface Meta {
|
|
/** Required for all pages. `<title>{content}</title>` */
|
|
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;
|
|
}
|
|
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<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;
|
|
}
|
|
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));
|
|
}
|
|
|
|
import { escapeHtmlContent as esc } from "#engine/render";
|
|
import * as render from "#engine/render";
|