diff options
author | Chris Swithinbank | 2023-05-14 01:49:11 +0200 |
---|---|---|
committer | GitHub | 2023-05-14 01:49:11 +0200 |
commit | c6c1b6727140a76c42c661f406000cc6e9b175de (patch) | |
tree | b4778897195ed7a3fd9470b7ef0d9def5ebb9a0f | |
parent | 63485b4a73584cdd4387cd95676879331dcb3a28 (diff) | |
download | IT.starlight-c6c1b6727140a76c42c661f406000cc6e9b175de.tar.gz IT.starlight-c6c1b6727140a76c42c661f406000cc6e9b175de.tar.bz2 IT.starlight-c6c1b6727140a76c42c661f406000cc6e9b175de.zip |
Support setting custom `<head>` tags in config or frontmatter. (#42)
-rw-r--r-- | .changeset/happy-pigs-roll.md | 5 | ||||
-rw-r--r-- | docs/astro.config.mjs | 10 | ||||
-rw-r--r-- | docs/src/content/docs/reference/configuration.md | 37 | ||||
-rw-r--r-- | packages/starlight/components/HeadSEO.astro | 125 | ||||
-rw-r--r-- | packages/starlight/index.astro | 2 | ||||
-rw-r--r-- | packages/starlight/schema.ts | 4 | ||||
-rw-r--r-- | packages/starlight/schemas/head.ts | 29 | ||||
-rw-r--r-- | packages/starlight/utils/head.ts | 95 | ||||
-rw-r--r-- | packages/starlight/utils/user-config.ts | 23 |
9 files changed, 286 insertions, 44 deletions
diff --git a/.changeset/happy-pigs-roll.md b/.changeset/happy-pigs-roll.md new file mode 100644 index 00000000..ca174267 --- /dev/null +++ b/.changeset/happy-pigs-roll.md @@ -0,0 +1,5 @@ +--- +"@astrojs/starlight": patch +--- + +Support setting custom `<head>` tags in config or frontmatter. diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 908dd922..4503ad7f 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -14,6 +14,16 @@ export default defineConfig({ github: 'https://github.com/withastro/starlight', discord: 'https://astro.build/chat', }, + head: [ + { + tag: 'script', + attrs: { + src: 'https://cdn.usefathom.com/script.js', + 'data-site': 'EZBHTSIG', + defer: true, + }, + }, + ], locales: { root: { label: 'English', diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index ae0809da..cddc7906 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -192,6 +192,8 @@ The default locale will be used to provide fallback content where translations a ### `social` +**type:** `{ discord?: string; github?: string; mastodon?: string; twitter?: string }` + Optional details about the social media accounts for this site. Adding any of these will display them as icon links in the site header. ```js @@ -207,6 +209,8 @@ starlight({ ### `customCss` +**type:** `string[]` + Provide CSS files to customize the look and feel of your Starlight site. Supports local CSS files relative to the root of your project, e.g. `'/src/custom.css'`, and CSS you installed as an npm module, e.g. `'@fontsource/roboto'`. @@ -216,3 +220,36 @@ starlight({ customCss: ['/src/custom-styles.css', '@fontsource/roboto'], }); ``` + +### `head` + +**type:** `HeadConfig[]` + +Add custom tags to the `<head>` of your Starlight site. +Can be useful for adding analytics and other third-party scripts and resources. + +```js +starlight({ + head: [ + // Example: add Fathom analytics script tag. + { + tag: 'script', + attrs: { + src: 'https://cdn.usefathom.com/script.js', + 'data-site': 'MY-FATHOM-ID', + defer: true, + }, + }, + ], +}); +``` + +#### `HeadConfig` + +```ts +interface HeadConfig { + tag: string; + attrs?: Record<string, string | boolean | undefined>; + content?: string; +} +``` diff --git a/packages/starlight/components/HeadSEO.astro b/packages/starlight/components/HeadSEO.astro index 25bab249..62cee271 100644 --- a/packages/starlight/components/HeadSEO.astro +++ b/packages/starlight/components/HeadSEO.astro @@ -1,6 +1,8 @@ --- -import type { CollectionEntry } from 'astro:content'; +import type { CollectionEntry, z } from 'astro:content'; import config from 'virtual:starlight/user-config'; +import type { HeadConfigSchema } from '../schemas/head'; +import { createHead } from '../utils/head'; import { localizedUrl } from '../utils/localizedUrl'; interface Props { @@ -15,48 +17,87 @@ const canonical = Astro.site : undefined; const title = data.title || config.title; const description = data.description || config.description; ---- -<title>{title}</title> -{description && <meta name="description" content={description} />} -<link rel="canonical" href={canonical} /> -{ - canonical && - config.isMultilingual && - Object.entries(config.locales).map( - ([locale, localeOpts]) => - localeOpts && ( - <link - rel="alternate" - hreflang={localeOpts.lang} - href={localizedUrl(canonical, locale)} - /> - ) - ) +const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [ + { tag: 'meta', attrs: { charset: 'utf-8' } }, + { tag: 'meta', attrs: { name: 'viewport', content: 'width=device-width' } }, + { tag: 'title', content: title }, + { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } }, + { tag: 'meta', attrs: { name: 'generator', content: Astro.generator } }, + // Favicon + { + tag: 'link', + attrs: { + rel: 'shortcut icon', + href: import.meta.env.BASE_URL + 'favicon.svg', + type: 'image/svg+xml', + }, + }, + // OpenGraph Tags + { tag: 'meta', attrs: { property: 'og:title', content: title } }, + { tag: 'meta', attrs: { property: 'og:type', content: 'article' } }, + { tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } }, + { tag: 'meta', attrs: { property: 'og:locale', content: lang } }, + { tag: 'meta', attrs: { property: 'og:description', content: description } }, + { tag: 'meta', attrs: { property: 'og:site_name', content: config.title } }, + // Twitter Tags + { + tag: 'meta', + attrs: { name: 'twitter:card', content: 'summary_large_image' }, + }, + { tag: 'meta', attrs: { name: 'twitter:title', content: title } }, + { tag: 'meta', attrs: { name: 'twitter:description', content: description } }, +]; + +if (description) + headDefaults.push({ + tag: 'meta', + attrs: { name: 'description', content: description }, + }); + +// Link to language alternates. +if (canonical && config.isMultilingual) { + for (const locale in config.locales) { + const localeOpts = config.locales[locale]; + if (!localeOpts) continue; + headDefaults.push({ + tag: 'link', + attrs: { + rel: 'alternate', + hreflang: localeOpts.lang, + href: localizedUrl(canonical, locale).href, + }, + }); + } +} + +// Link to sitemap, but only when `site` is set. +if (Astro.site) { + headDefaults.push({ + tag: 'link', + attrs: { + rel: 'sitemap', + href: import.meta.env.BASE_URL + 'sitemap-index.xml', + }, + }); } -<meta name="generator" content={Astro.generator} /> -<link - rel="shortcut icon" - href={import.meta.env.BASE_URL + 'favicon.svg'} - type="image/svg+xml" -/> -{/* Link to sitemap, but only when `site` is set. */} -{Astro.site && <link rel="sitemap" href="/sitemap-index.xml" />} - -<!-- OpenGraph Tags --> -<meta property="og:title" content={title} /> -<meta property="og:type" content="article" /> -<meta property="og:url" content={canonical} /> -<meta property="og:locale" content={lang} /> -<meta property="og:description" content={description} /> -<meta property="og:site_name" content={config.title} /> - -<!-- Twitter Tags --> -<meta name="twitter:card" content="summary_large_image" /> + +// Link to Twitter account if set in Starlight config. +if (config.social?.twitter) { + headDefaults.push({ + tag: 'meta', + attrs: { + name: 'twitter:site', + content: new URL(config.social.twitter).pathname, + }, + }); +} + +const head = createHead(headDefaults, config.head, data.head); +--- + { - config.social?.twitter && ( - <meta name="twitter:site" content={config.social.twitter} /> - ) + head.map(({ tag: Tag, attrs, content }) => ( + <Tag {...attrs} set:html={content} /> + )) } -<meta name="twitter:title" content={title} /> -<meta name="twitter:description" content={description} /> diff --git a/packages/starlight/index.astro b/packages/starlight/index.astro index d0c79d34..102677e7 100644 --- a/packages/starlight/index.astro +++ b/packages/starlight/index.astro @@ -46,8 +46,6 @@ const prevNextLinks = getPrevNextLinks(sidebar); <html lang={lang} dir={dir}> <head> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width" /> <HeadSEO data={entry.data} lang={lang} /> </head> <body> diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 43d3329a..0da35089 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -1,4 +1,5 @@ import { z } from 'astro/zod'; +import { HeadConfigSchema } from './schemas/head'; export function docsSchema() { return z.object({ @@ -19,5 +20,8 @@ export function docsSchema() { * Can also be set to `false` to disable showing an edit link on this page. */ editUrl: z.union([z.string().url(), z.boolean()]).optional().default(true), + + /** Set custom `<head>` tags just for this page. */ + head: HeadConfigSchema(), }); } diff --git a/packages/starlight/schemas/head.ts b/packages/starlight/schemas/head.ts new file mode 100644 index 00000000..9aa2d5ca --- /dev/null +++ b/packages/starlight/schemas/head.ts @@ -0,0 +1,29 @@ +import { z } from 'astro/zod'; + +export const HeadConfigSchema = () => + z + .array( + z.object({ + /** Name of the HTML tag to add to `<head>`, e.g. `'meta'`, `'link'`, or `'script'`. */ + tag: z.enum([ + 'title', + 'base', + 'link', + 'style', + 'meta', + 'script', + 'noscript', + 'template', + ]), + /** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */ + attrs: z + .record(z.union([z.string(), z.boolean(), z.undefined()])) + .default({}), + /** Content to place inside the tag (optional). */ + content: z.string().default(''), + }) + ) + .default([]); + +export type HeadUserConfig = z.input<ReturnType<typeof HeadConfigSchema>>; +export type HeadConfig = z.output<ReturnType<typeof HeadConfigSchema>>; diff --git a/packages/starlight/utils/head.ts b/packages/starlight/utils/head.ts new file mode 100644 index 00000000..32da69fa --- /dev/null +++ b/packages/starlight/utils/head.ts @@ -0,0 +1,95 @@ +import { HeadConfig, HeadConfigSchema, HeadUserConfig } from '../schemas/head'; + +const HeadSchema = HeadConfigSchema(); + +/** Create a fully parsed, merged, and sorted head entry array from multiple sources. */ +export function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) { + let head = HeadSchema.parse(defaults); + for (const next of heads) { + head = mergeHead(head, next); + } + return sortHead(head); +} + +/** + * Test if a head config object contains a matching `<title>` or `<meta>` tag. + * + * For example, will return true if `head` already contains + * `<meta name="description" content="A">` and the passed `tag` + * is `<meta name="description" content="B">`. Tests against `name`, + * `property`, and `http-equiv` attributes for `<meta>` tags. + */ +function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean { + switch (entry.tag) { + case 'title': + return head.some(({ tag }) => tag === 'title'); + case 'meta': + return hasOneOf(head, entry, ['name', 'property', 'http-equiv']); + default: + return false; + } +} + +/** + * Test if a head config object contains a tag of the same type + * as `entry` and a matching attribute for one of the passed `keys`. + */ +function hasOneOf( + head: HeadConfig, + entry: HeadConfig[number], + keys: string[] +): boolean { + const attr = getAttr(keys, entry); + if (!attr) return false; + const [key, val] = attr; + return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val); +} + +/** Find the first matching key–value pair in a head entry’s attributes. */ +function getAttr( + keys: string[], + entry: HeadConfig[number] +): [key: string, value: string | boolean] | undefined { + let attr: [string, string | boolean] | undefined; + for (const key of keys) { + const val = entry.attrs[key]; + if (val) { + attr = [key, val]; + break; + } + } + return attr; +} + +/** Merge two heads, overwriting entries in the first head that exist in the second. */ +function mergeHead(oldHead: HeadConfig, newHead: HeadConfig) { + return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead]; +} + +/** Sort head tags to place important tags first and relegate “SEO” meta tags. */ +function sortHead(head: HeadConfig) { + return head.sort((a, b) => { + const aImportance = getImportance(a); + const bImportance = getImportance(b); + return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0; + }); +} + +/** Get the relative importance of a specific head tag. */ +function getImportance(entry: HeadConfig[number]) { + // 1. Important meta tags. + if ( + entry.tag === 'meta' && + ('charset' in entry.attrs || + 'http-equiv' in entry.attrs || + entry.attrs.name === 'viewport') + ) { + return 100; + } + // 2. Page title + if (entry.tag === 'title') return 90; + // 3. Anything that isn’t an SEO meta tag. + if (entry.tag !== 'meta') return 80; + // 4. SEO meta tags. + return 0; +} diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index b5032704..434c088e 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -1,5 +1,6 @@ import { z } from 'astro/zod'; import { parse as bcpParse, stringify as bcpStringify } from 'bcp-47'; +import { HeadConfigSchema } from '../schemas/head'; const LocaleSchema = z.object({ /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */ @@ -194,6 +195,28 @@ const UserConfigSchema = z.object({ sidebar: SidebarGroupSchema.array().optional(), /** + * Add extra tags to your site’s `<head>`. + * + * Can also be set for a single page in a page’s frontmatter. + * + * @example + * // Add Fathom analytics to your site + * starlight({ + * head: [ + * { + * tag: 'script', + * attrs: { + * src: 'https://cdn.usefathom.com/script.js', + * 'data-site': 'MY-FATHOM-ID', + * defer: true, + * }, + * }, + * ], + * }) + */ + head: HeadConfigSchema(), + + /** * Provide CSS files to customize the look and feel of your Starlight site. * * Supports local CSS files relative to the root of your project, |