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 url: canonical ?? undefined,
201 ...twitter,
202 };
203 if (thumbnail) {
204 const resolved = new URL(thumbnail, template.base).href;
205 openGraph.image = resolved;
206 twitter.image = resolved;
207 if (thumbnailAlt) {
208 openGraph["image:alt"] = thumbnailAlt;
209 twitter["image:alt"] = thumbnailAlt;
210 }
211 }
212 if (siteTitle) {
213 openGraph.site_name = siteTitle;
214 }
215 if (canonical) {
216 openGraph.url = canonical;
217 }
218 }
219
220 const metaTags: Array<Record<string, string>> = title == null ? [] : [{ title }];
221 const links: Array<Record<string, string>> = [];
222
223 if (description) metaTags.push({ name: "description", content: description });
224 for (const author of authors ?? []) metaTags.push({ name: "author", content: author });
225 if (keywords) metaTags.push({ name: "keywords", content: keywords.join(", ") });
226 if (generator) {
227 metaTags.push({ name: "generator", content: generator });
228 }
229 if (publisher) metaTags.push({ name: "publisher", content: publisher });
230 if (referrer) metaTags.push({ name: "referrer", content: referrer });
231 if (themeColor) {
232 if (typeof themeColor === "string") {
233 metaTags.push({ name: "theme-color", content: themeColor });
234 } else {
235 metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:light)", content: themeColor.light });
236 metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:dark)", content: themeColor.dark });
237 }
238 }
239 if (denyRobots) metaTags.push({ name: "robots", content: "noindex,nofollow" });
240 if (viewport) metaTags.push({ name: "viewport", content: viewport });
241 if (canonical) links.push({ rel: "canonical", href: canonical });
242 if (manifest) links.push({ rel: "manifest", href: manifest });
243 if (oEmbed) links.push({ rel: "alternate", type: "application/json+oembed", href: resolve(oEmbed) });
244 if (alternates) {
245 const items = Array.isArray(alternates)
246 ? alternates
247 : Object.entries(alternates).map(([key, url]) => key.includes("/") ? { type: key, url } : { lang: key, url });
248 for (const alt of items) {
249 const href = resolve(alt.url);
250 links.push(
251 "lang" in alt ? { rel: "alternate", hreflang: alt.lang, href } : { rel: "alternate", type: alt.type, href },
252 );
253 }
254 }
255
256 if (openGraph) metaTags.push(...collectOpenGraph("og", openGraph));
257 if (twitter) metaTags.push(...collectOpenGraph("twitter", twitter));
258
259 const scriptTags: Array<Record<string, string | boolean>> = [];
260 const styleTags: Array<Record<string, string>> = [];
261
262 for (const entry of pick(meta.scripts, template.scripts) ?? []) {
263 if ("content" in entry) {
264 scriptTags.push({ children: String(entry.content) });
265 } else {
266 const { src, ...rest } = entry;
267 const tag: Record<string, string | boolean> = { src };
268 for (const [k, v] of Object.entries(rest)) {
269 if (v != null && v !== false) tag[k] = v;
270 }
271 scriptTags.push(tag);
272 }
273 }
274
275 for (const entry of pick(meta.styles, template.styles) ?? []) {
276 if ("content" in entry) {
277 const tag: Record<string, string> = { children: String(entry.content) };
278 if ("media" in entry && entry.media) tag.media = entry.media;
279 styleTags.push(tag);
280 } else {
281 const { href, ...rest } = entry;
282 const tag: Record<string, string> = { rel: "stylesheet", href };
283 for (const [k, v] of Object.entries(rest)) {
284 if (v != null && v !== false && k !== "rel") tag[k] = v as string;
285 }
286 links.push(tag);
287 }
288 }
289
290 for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) {
291 const clean: Record<string, string | boolean> = {};
292 for (const [k, v] of Object.entries(attrs)) {
293 if (v != null && v !== false) clean[k] = v;
294 }
295 if (tag === "meta") metaTags.push(clean as Record<string, string>);
296 else if (tag === "link") links.push(clean as Record<string, string>);
297 else if (tag === "script") scriptTags.push(clean);
298 else if (tag === "style") styleTags.push(clean as Record<string, string>);
299 }
300
301 return { meta: metaTags, links, scripts: scriptTags, styles: styleTags };
302}
303
304/* convert a metadata definition into HTML text. */
305export function toHtml(template: Template, meta: Meta): string {
306 const tags = toTags(template, meta);
307 let out = "";
308 for (const attrs of tags.meta) {
309 if (Object.keys(attrs).length === 1 && "title" in attrs) {
310 out += `<title>${esc(attrs.title!)}</title>`;
311 } else {
312 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
313 }
314 }
315 for (const attrs of tags.links) {
316 out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
317 }
318 for (const entry of tags.scripts) {
319 if ("children" in entry) {
320 out += `<script>${entry.children}</script>`;
321 } else {
322 out += `<script` + serializeAttrs(entry) + `></script>`;
323 }
324 }
325 for (const entry of tags.styles) {
326 if ("children" in entry) {
327 const { children, ...rest } = entry;
328 out += `<style` + serializeAttrs(rest) + `>${children}</style>`;
329 }
330 }
331 return out;
332}
333
334function serializeAttrs(attrs: Record<string, string | boolean>): string {
335 let out = "";
336 for (const [k, v] of Object.entries(attrs)) {
337 if (v === true) out += ` ${k}`;
338 else if (v != null && v !== false) out += ` ${k}=${attr(v as string)}`;
339 }
340 return out;
341}
342
343function pick<T>(value: T | null | undefined, template: T | null | undefined, fallback: T | null = null): T | null {
344 if (value !== undefined) return value;
345 if (template !== undefined) return template;
346 return fallback;
347}
348
349function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> {
350 if (!value) return [];
351 if (typeof value === "string") {
352 // OG keys on `property`, twitter keys on `name`.
353 const key = prefix.startsWith("og:") ? "property" : "name";
354 return [{ [key]: prefix, content: value }];
355 }
356 if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item));
357 return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item));
358}
359
360function attr(value: string) {
361 value = string.escapeHtmlAttr(value);
362 if (value.match(/["/> ]/)) return "\"" + value + "\"";
363 return value;
364}
365
366import * as string from "./string.ts";
367import { escapeHtmlContent as esc } from "./string.ts";