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