1/** `null` unsets something, `undefined` falls back to what the template says. */
2export interface Meta {
3 /** recommended for all pages. `<title>{content}</title>` */
4 title?: string | null;
5 /** recommended for all pages. `<meta name="description" content="{...}" />` */
6 description?: string | null;
7 /** automatically added for static renders from the 'pages' folders. */
8 canonical?: string | null;
9 /** add `<link rel="alternate" ... />`. Object keys are interpretted as
10 * mime types if they contain a slash, otherwise seen as an alternative language. */
11 alternates?: Alternate[] | Record<string, string> | null;
12
13 /** automatically generate both OpenGraph and Twitter meta tags */
14 embed?: AutoEmbed | null;
15 /** add a robots tag for `noindex` and `nofollow` */
16 denyRobots?: boolean | null;
17 /** add 'og:*' meta tags */
18 openGraph?: OpenGraph | null;
19 /** add 'twitter:*' meta tags */
20 twitter?: Twitter | null;
21 /** refer to an oEmbed file. See https://oembed.com */
22 oEmbed?: string | null;
23 /** add arbitrary scripts to the document head */
24 scripts?: Array<{ content: string } | ExternalScript> | null;
25 /** add arbitrary styles to the document head */
26 styles?: Array<{ content: string } | ExternalStylesheet> | null;
27 /**
28 * '@clo/lib/meta' intentionally excludes a lot of exotic tags.
29 * Use `{ tag, ...attrs }` to add arbitrary head elements:
30 *
31 * ```ts
32 * extra: [
33 * { tag: "meta", name: "site-verification", content: "waffles" },
34 * { tag: "link", rel: "icon", href: "/favicon.ico" },
35 * ],
36 * ```
37 */
38 extra?: Array<ExtraItem> | null;
39
40 /** adds `<meta name="author" content="{...}" />` */
41 authors?: string[] | null;
42 /** credit your framework or toolchain */
43 generator?: string | null;
44 /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
45 keywords?: string[] | null;
46 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
47 manifest?: string | null;
48 /** adds `<meta name="publisher" content="{...}" />` */
49 publisher?: string | null;
50 /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */
51 referrer?: Referrer | null;
52 /** adds `<meta name="theme-color" content="{...}" />` */
53 themeColor?: string | { dark: string; light: string } | null;
54 /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */
55 viewport?: string | null;
56}
57export interface ExternalStylesheet {
58 rel?: "stylesheet";
59 href: string;
60 media?: string;
61 [attr: string]: string | boolean | null | undefined;
62 // TODO: add the rest of the important attributes for autocomplete
63}
64export interface ExternalScript {
65 src: string;
66 async?: boolean;
67 defer?: boolean;
68 crossorigin?: "anonymous" | "use-credentials" | false;
69 [attr: string]: string | boolean | null | undefined;
70 // TODO: add the rest of the attributes for autocomplete
71}
72export type Alternate = { type: string; url: string } | {
73 lang: string;
74 url: string;
75};
76export interface AutoEmbed {
77 /* defaults to the top level page title. */
78 title?: string | null;
79 /* defaults to the top level page description. */
80 description?: string | null;
81 /* add an image url to the embed card. */
82 thumbnail?: string | null;
83 /** describe the embed image. */
84 thumbnailAlt?: string | null;
85 /** @default "banner", which applies twitter:card = "summary_large_image" */
86 thumbnailSize?: "banner" | "icon" | null;
87 /** @default null - no site title */
88 siteTitle?: string | null;
89}
90/** See https://ogp.me for extra rules. */
91export interface OpenGraph {
92 /** the title of your object as it should appear within the graph */
93 title?: string;
94 /** a one to two sentence description of your object. */
95 description?: string | null;
96 /** the type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */
97 type?: string;
98 /** an image URL which should represent your object within the graph */
99 image?: OpenGraphField;
100 /** 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/" */
101 url?: string;
102 /** a URL to an audio file to accompany this object */
103 audio?: OpenGraphField;
104 /** 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) */
105 determiner?: string;
106 /** the locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */
107 locale?: string;
108 /** an array of other locales this page is available in */
109 "locale:alternate"?: string[];
110 /** if your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */
111 site_name?: string;
112 /** a URL to a video file that complements this object */
113 video?: OpenGraphField;
114 [field: string]: OpenGraphField;
115}
116/**
117 * when passing an array, the property is duplicated.
118 * when passing an object, the fields are emitted as namespaced with ':'.
119 */
120type OpenGraphField =
121 | string
122 | { [field: string]: OpenGraphField }
123 | Array<OpenGraphField>
124 | (null | undefined);
125/** Twitter uses various OpenGraph fields if these are not specified. */
126export interface Twitter {
127 card: string;
128 title?: string;
129 description?: string | null;
130 url?: string;
131 image?: string;
132 player?: string;
133 /** Same logic as Open Graph */
134 [field: string]: OpenGraphField;
135}
136export interface Template extends Omit<Meta, "title" | "description" | "canonical"> {
137 base: URL;
138 titleTemplate?: (title: string) => string;
139}
140export type Referrer =
141 | "no-referrer"
142 | "origin"
143 | "no-referrer-when-downgrade"
144 | "origin-when-cross-origin"
145 | "same-origin"
146 | "strict-origin"
147 | "strict-origin-when-cross-origin";
148export type ExtraItem = {
149 tag: "meta" | "link" | "script" | "style";
150 [attr: string]: string | boolean | null | undefined;
151};
152export interface Tags {
153 meta: Array<Record<string, string>>;
154 links: Array<Record<string, string>>;
155 scripts: Array<Record<string, string | boolean>>;
156 styles: Array<Record<string, string>>;
157}
158
159/** convert a metadata definition into a structured list of tags, compatible with TanStack start */
160export function toTags(template: Template, meta: Meta): Tags {
161 const { titleTemplate, base } = template;
162 const resolve = (str: string) => new URL(str, base).href;
163
164 const title = meta.title == null ? null : titleTemplate ? titleTemplate(meta.title) : meta.title;
165 const description = meta.description ?? null;
166 const canonical = meta.canonical ? resolve(meta.canonical) : null;
167 const denyRobots = Boolean(pick(meta.denyRobots, template.denyRobots));
168 const authors = pick(meta.authors, template.authors);
169 const generator = pick(meta.generator, template.generator);
170 const keywords = pick(meta.keywords, template.keywords);
171 const manifest = pick(meta.manifest, template.manifest);
172 const publisher = pick(meta.publisher, template.publisher);
173 const referrer = pick(meta.referrer, template.referrer);
174 const themeColor = pick(meta.themeColor, template.themeColor);
175 const viewport = pick(
176 meta.viewport,
177 template.viewport,
178 "width=device-width, initial-scale=1, maximum-scale=1",
179 );
180
181 const alternates = pick(meta.alternates, template.alternates);
182 const oEmbed = pick(meta.oEmbed, template.oEmbed);
183 const embed = pick(meta.embed, template.embed);
184 let openGraph = pick(meta.openGraph, template.openGraph);
185 let twitter = pick(meta.twitter, template.twitter);
186 if (embed) {
187 const { thumbnail, thumbnailSize, thumbnailAlt, siteTitle } = embed;
188 openGraph = {
189 type: "website",
190 title: embed.title ?? title ?? undefined,
191 description: embed.description ?? description,
192 ...openGraph,
193 };
194 twitter = {
195 title: embed.title ?? title ?? undefined,
196 description: embed.description ?? description ?? undefined,
197 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
198 ? "summary_large_image"
199 : "summary",
200 ...twitter,
201 };
202 if (thumbnail) {
203 const resolved = new URL(thumbnail, template.base).href;
204 openGraph.image = resolved;
205 twitter.image = resolved;
206 if (thumbnailAlt) {
207 openGraph["image:alt"] = thumbnailAlt;
208 twitter["image:alt"] = thumbnailAlt;
209 }
210 }
211 if (siteTitle) {
212 openGraph.site_name = siteTitle;
213 }
214 if (canonical) {
215 openGraph.url = canonical;
216 }
217 }
218
219 const metaTags: Array<Record<string, string>> = title == null ? [] : [{ title }];
220 const links: Array<Record<string, string>> = [];
221
222 if (description) metaTags.push({ name: "description", content: description });
223 for (const author of authors ?? []) metaTags.push({ name: "author", content: author });
224 if (keywords) metaTags.push({ name: "keywords", content: keywords.join(", ") });
225 if (generator) {
226 metaTags.push({ name: "generator", content: generator });
227 }
228 if (publisher) metaTags.push({ name: "publisher", content: publisher });
229 if (referrer) metaTags.push({ name: "referrer", content: referrer });
230 if (themeColor) {
231 if (typeof themeColor === "string") {
232 metaTags.push({ name: "theme-color", content: themeColor });
233 } else {
234 metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:light)", content: themeColor.light });
235 metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:dark)", content: themeColor.dark });
236 }
237 }
238 if (denyRobots) metaTags.push({ name: "robots", content: "noindex,nofollow" });
239 if (viewport) metaTags.push({ name: "viewport", content: viewport });
240 if (canonical) links.push({ rel: "canonical", href: canonical });
241 if (manifest) links.push({ rel: "manifest", href: manifest });
242 if (oEmbed) links.push({ rel: "alternate", type: "application/json+oembed", href: resolve(oEmbed) });
243 if (alternates) {
244 const items = Array.isArray(alternates)
245 ? alternates
246 : Object.entries(alternates).map(([key, url]) => key.includes("/") ? { type: key, url } : { lang: key, url });
247 for (const alt of items) {
248 const href = resolve(alt.url);
249 links.push(
250 "lang" in alt ? { rel: "alternate", hreflang: alt.lang, href } : { rel: "alternate", type: alt.type, href },
251 );
252 }
253 }
254
255 if (openGraph) metaTags.push(...collectOpenGraph("og", openGraph));
256 if (twitter) metaTags.push(...collectOpenGraph("twitter", twitter));
257
258 const scriptTags: Array<Record<string, string | boolean>> = [];
259 const styleTags: Array<Record<string, string>> = [];
260
261 for (const entry of pick(meta.scripts, template.scripts) ?? []) {
262 if ("content" in entry) {
263 scriptTags.push({ children: String(entry.content) });
264 } else {
265 const { src, ...rest } = entry;
266 const tag: Record<string, string | boolean> = { src };
267 for (const [k, v] of Object.entries(rest)) {
268 if (v != null && v !== false) tag[k] = v;
269 }
270 scriptTags.push(tag);
271 }
272 }
273
274 for (const entry of pick(meta.styles, template.styles) ?? []) {
275 if ("content" in entry) {
276 const tag: Record<string, string> = { children: String(entry.content) };
277 if ("media" in entry && entry.media) tag.media = entry.media;
278 styleTags.push(tag);
279 } else {
280 const { href, ...rest } = entry;
281 const tag: Record<string, string> = { rel: "stylesheet", href };
282 for (const [k, v] of Object.entries(rest)) {
283 if (v != null && v !== false && k !== "rel") tag[k] = v as string;
284 }
285 links.push(tag);
286 }
287 }
288
289 for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) {
290 const clean: Record<string, string | boolean> = {};
291 for (const [k, v] of Object.entries(attrs)) {
292 if (v != null && v !== false) clean[k] = v;
293 }
294 if (tag === "meta") metaTags.push(clean as Record<string, string>);
295 else if (tag === "link") links.push(clean as Record<string, string>);
296 else if (tag === "script") scriptTags.push(clean);
297 else if (tag === "style") styleTags.push(clean as Record<string, string>);
298 }
299
300 return { meta: metaTags, links, scripts: scriptTags, styles: styleTags };
301}
302
303/* convert a metadata definition into HTML text. */
304export function toHtml(template: Template, meta: Meta): string {
305 const tags = toTags(template, meta);
306 let out = "";
307 for (const attrs of tags.meta) {
308 if (Object.keys(attrs).length === 1 && "title" in attrs) {
309 out += `<title>${esc(attrs.title!)}</title>`;
310 } else {
311 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
312 }
313 }
314 for (const attrs of tags.links) {
315 out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
316 }
317 for (const entry of tags.scripts) {
318 if ("children" in entry) {
319 out += `<script>${entry.children}</script>`;
320 } else {
321 out += `<script` + serializeAttrs(entry) + `></script>`;
322 }
323 }
324 for (const entry of tags.styles) {
325 if ("children" in entry) {
326 const { children, ...rest } = entry;
327 out += `<style` + serializeAttrs(rest) + `>${children}</style>`;
328 }
329 }
330 return out;
331}
332
333function serializeAttrs(attrs: Record<string, string | boolean>): string {
334 let out = "";
335 for (const [k, v] of Object.entries(attrs)) {
336 if (v === true) out += ` ${k}`;
337 else if (v != null && v !== false) out += ` ${k}=${attr(v as string)}`;
338 }
339 return out;
340}
341
342function pick<T>(value: T | null | undefined, template: T | null | undefined, fallback: T | null = null): T | null {
343 if (value !== undefined) return value;
344 if (template !== undefined) return template;
345 return fallback;
346}
347
348function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> {
349 if (!value) return [];
350 if (typeof value === "string") {
351 // OG keys on `property`, twitter keys on `name`.
352 const key = prefix.startsWith("og:") ? "property" : "name";
353 return [{ [key]: prefix, content: value }];
354 }
355 if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item));
356 return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item));
357}
358
359function attr(value: string) {
360 value = string.escapeHtmlAttr(value);
361 if (value.match(/["/> ]/)) return "\"" + value + "\"";
362 return value;
363}
364
365import * as string from "./string.ts";
366import { escapeHtmlContent as esc } from "./string.ts";