summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2023-05-14 01:49:11 +0200
committerGitHub2023-05-14 01:49:11 +0200
commitc6c1b6727140a76c42c661f406000cc6e9b175de (patch)
treeb4778897195ed7a3fd9470b7ef0d9def5ebb9a0f
parent63485b4a73584cdd4387cd95676879331dcb3a28 (diff)
downloadIT.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.md5
-rw-r--r--docs/astro.config.mjs10
-rw-r--r--docs/src/content/docs/reference/configuration.md37
-rw-r--r--packages/starlight/components/HeadSEO.astro125
-rw-r--r--packages/starlight/index.astro2
-rw-r--r--packages/starlight/schema.ts4
-rw-r--r--packages/starlight/schemas/head.ts29
-rw-r--r--packages/starlight/utils/head.ts95
-rw-r--r--packages/starlight/utils/user-config.ts23
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,