| 1 | /** `null` unsets something, `undefined` falls back to what the template says. */ |
| 2 | export 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 | } |
| 57 | export 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 | } |
| 64 | export 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 | } |
| 72 | export type Alternate = { type: string; url: string } | { |
| 73 | lang: string; |
| 74 | url: string; |
| 75 | }; |
| 76 | export 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. */ |
| 91 | export 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 | */ |
| 120 | type OpenGraphField = |
| 121 | | string |
| 122 | | { [field: string]: OpenGraphField } |
| 123 | | Array<OpenGraphField> |
| 124 | | (null | undefined); |
| 125 | /** Twitter uses various OpenGraph fields if these are not specified. */ |
| 126 | export 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 | } |
| 136 | export interface Template extends Omit<Meta, "title" | "description" | "canonical"> { |
| 137 | base: URL; |
| 138 | titleTemplate?: (title: string) => string; |
| 139 | } |
| 140 | export 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"; |
| 148 | export type ExtraItem = { |
| 149 | tag: "meta" | "link" | "script" | "style"; |
| 150 | [attr: string]: string | boolean | null | undefined; |
| 151 | }; |
| 152 | export 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 */ |
| 160 | export 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. */ |
| 304 | export 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 | |
| 333 | function 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 | |
| 342 | function 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 | |
| 348 | function 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 | |
| 359 | function attr(value: string) { |
| 360 | value = string.escapeHtmlAttr(value); |
| 361 | if (value.match(/["/> ]/)) return "\"" + value + "\""; |
| 362 | return value; |
| 363 | } |
| 364 | |
| 365 | import * as string from "./string.ts"; |
| 366 | import { escapeHtmlContent as esc } from "./string.ts"; |