From 510a20bf4aefb210cf597a8880ddb3a51c2bec0a Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 27 Feb 2025 16:26:35 +0100
Subject: [PATCH 1/5] feat: move head content to route data
---
.../starlight/__tests__/basics/head.test.ts | 115 -------------
.../__tests__/basics/route-data.test.ts | 9 +-
.../starlight-page-route-data-extend.test.ts | 5 +-
.../basics/starlight-page-route-data.test.ts | 44 +++--
.../__tests__/edit-url/edit-url.test.ts | 7 +-
.../starlight/__tests__/head/head.test.ts | 158 ++++++++++++++++++
.../starlight/__tests__/head/vitest.config.ts | 20 +++
.../i18n-root-locale/routing.test.ts | 3 +-
packages/starlight/__tests__/test-utils.ts | 10 ++
packages/starlight/components/Head.astro | 97 +----------
.../starlight/components/StarlightPage.astro | 2 +-
packages/starlight/utils/head.ts | 78 ++++++++-
packages/starlight/utils/routing/data.ts | 12 +-
packages/starlight/utils/routing/types.ts | 3 +
packages/starlight/utils/starlight-page.ts | 39 +++--
15 files changed, 343 insertions(+), 259 deletions(-)
delete mode 100644 packages/starlight/__tests__/basics/head.test.ts
create mode 100644 packages/starlight/__tests__/head/head.test.ts
create mode 100644 packages/starlight/__tests__/head/vitest.config.ts
diff --git a/packages/starlight/__tests__/basics/head.test.ts b/packages/starlight/__tests__/basics/head.test.ts
deleted file mode 100644
index 3824d1ccfd9..00000000000
--- a/packages/starlight/__tests__/basics/head.test.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { describe, expect, test } from 'vitest';
-import { createHead } from '../../utils/head';
-
-describe('createHead', () => {
- test('merges two
tags', () => {
- expect(
- createHead(
- [{ tag: 'title', content: 'Default' }],
- [{ tag: 'title', content: 'Override', attrs: {} }]
- )
- ).toEqual([{ tag: 'title', content: 'Override', attrs: {} }]);
- });
-
- test('merges two tags', () => {
- expect(
- createHead(
- [{ tag: 'link', attrs: { rel: 'canonical', href: 'https://example.com' } }],
- [{ tag: 'link', attrs: { rel: 'canonical', href: 'https://astro.build' }, content: '' }]
- )
- ).toEqual([
- { tag: 'link', attrs: { rel: 'canonical', href: 'https://astro.build' }, content: '' },
- ]);
- });
-
- test('does not merge same link tags', () => {
- expect(
- createHead(
- [{ tag: 'link', attrs: { rel: 'stylesheet', href: 'primary.css' }, content: '' }],
- [{ tag: 'link', attrs: { rel: 'stylesheet', href: 'secondary.css' }, content: '' }]
- )
- ).toEqual([
- { tag: 'link', attrs: { rel: 'stylesheet', href: 'primary.css' }, content: '' },
- { tag: 'link', attrs: { rel: 'stylesheet', href: 'secondary.css' }, content: '' },
- ]);
- });
-
- for (const prop of ['name', 'property', 'http-equiv']) {
- test(`merges two tags with same ${prop} value`, () => {
- expect(
- createHead(
- [{ tag: 'meta', attrs: { [prop]: 'x', content: 'Default' } }],
- [{ tag: 'meta', attrs: { [prop]: 'x', content: 'Test' }, content: '' }]
- )
- ).toEqual([{ tag: 'meta', content: '', attrs: { [prop]: 'x', content: 'Test' } }]);
- });
- }
-
- for (const prop of ['name', 'property', 'http-equiv']) {
- test(`does not merge tags with different ${prop} values`, () => {
- expect(
- createHead(
- [{ tag: 'meta', attrs: { [prop]: 'x', content: 'X' } }],
- [{ tag: 'meta', attrs: { [prop]: 'y', content: 'Y' }, content: '' }]
- )
- ).toEqual([
- { tag: 'meta', content: '', attrs: { [prop]: 'x', content: 'X' } },
- { tag: 'meta', content: '', attrs: { [prop]: 'y', content: 'Y' } },
- ]);
- });
- }
-
- test('sorts head by tag importance', () => {
- expect(
- createHead([
- // SEO meta tags
- { tag: 'meta', attrs: { name: 'x' } },
- // Others
- { tag: 'link', attrs: { rel: 'stylesheet' } },
- // Important meta tags
- { tag: 'meta', attrs: { charset: 'utf-8' } },
- { tag: 'meta', attrs: { name: 'viewport' } },
- { tag: 'meta', attrs: { 'http-equiv': 'x' } },
- //
- { tag: 'title', content: 'Title' },
- ])
- ).toEqual([
- // Important meta tags
- { tag: 'meta', attrs: { charset: 'utf-8' }, content: '' },
- { tag: 'meta', attrs: { name: 'viewport' }, content: '' },
- { tag: 'meta', attrs: { 'http-equiv': 'x' }, content: '' },
- //
- { tag: 'title', attrs: {}, content: 'Title' },
- // Others
- { tag: 'link', attrs: { rel: 'stylesheet' }, content: '' },
- // SEO meta tags
- { tag: 'meta', attrs: { name: 'x' }, content: '' },
- ]);
- });
-
- test('places the default favicon below any user provided icons', () => {
- const defaultFavicon = {
- tag: 'link',
- attrs: {
- rel: 'shortcut icon',
- href: '/favicon.svg',
- type: 'image/svg+xml',
- },
- } as const;
-
- const userFavicon = {
- tag: 'link',
- attrs: {
- rel: 'icon',
- href: '/favicon.ico',
- sizes: '32x32',
- },
- content: '',
- } as const;
-
- expect(createHead([defaultFavicon], [userFavicon])).toMatchObject([
- userFavicon,
- defaultFavicon,
- ]);
- });
-});
diff --git a/packages/starlight/__tests__/basics/route-data.test.ts b/packages/starlight/__tests__/basics/route-data.test.ts
index 18c457f2c5e..a5e3bdee9a2 100644
--- a/packages/starlight/__tests__/basics/route-data.test.ts
+++ b/packages/starlight/__tests__/basics/route-data.test.ts
@@ -1,4 +1,5 @@
import { expect, test, vi } from 'vitest';
+import { getRouteDataTestContext } from '../test-utils';
import { generateRouteData } from '../../utils/routing/data';
import { routes } from '../../utils/routing';
@@ -17,7 +18,7 @@ test('adds data to route shape', () => {
const route = routes[0]!;
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
- url: new URL('https://example.com'),
+ context: getRouteDataTestContext(),
});
expect(data.hasSidebar).toBe(true);
expect(data).toHaveProperty('lastUpdated');
@@ -62,7 +63,7 @@ test('disables table of contents for splash template', () => {
const route = routes[1]!;
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
- url: new URL('https://example.com/getting-started/'),
+ context: getRouteDataTestContext('/getting-started/'),
});
expect(data.toc).toBeUndefined();
});
@@ -71,7 +72,7 @@ test('disables table of contents if frontmatter includes `tableOfContents: false
const route = routes[2]!;
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
- url: new URL('https://example.com/showcase/'),
+ context: getRouteDataTestContext('/showcase/'),
});
expect(data.toc).toBeUndefined();
});
@@ -80,7 +81,7 @@ test('uses explicit last updated date from frontmatter', () => {
const route = routes[3]!;
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
- url: new URL('https://example.com/showcase/'),
+ context: getRouteDataTestContext('/showcase/'),
});
expect(data.lastUpdated).toBeInstanceOf(Date);
expect(data.lastUpdated).toEqual(route.entry.data.lastUpdated);
diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
index 246163303c2..a559b71c5e6 100644
--- a/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
+++ b/packages/starlight/__tests__/basics/starlight-page-route-data-extend.test.ts
@@ -1,4 +1,5 @@
import { expect, test, vi } from 'vitest';
+import { getRouteDataTestContext } from '../test-utils';
import {
generateStarlightPageRouteData,
type StarlightPageProps,
@@ -26,7 +27,7 @@ test('throws a validation error if a built-in field required by the user schema
await expect(() =>
generateStarlightPageRouteData({
props: starlightPageProps,
- url: new URL('https://example.com/test-slug'),
+ context: getRouteDataTestContext('/test-slug'),
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"[AstroUserError]:
@@ -48,7 +49,7 @@ test('returns new field defined in the user schema', async () => {
category,
},
},
- url: new URL('https://example.com/test-slug'),
+ context: getRouteDataTestContext('/test-slug'),
});
// @ts-expect-error - Custom field defined in the user schema.
expect(data.entry.data.category).toBe(category);
diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
index 972dbfe41ed..590cee1c639 100644
--- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
+++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts
@@ -1,4 +1,5 @@
import { expect, test, vi } from 'vitest';
+import { getRouteDataTestContext } from '../test-utils';
import { generateRouteData } from '../../utils/routing/data';
import { routes } from '../../utils/routing';
import {
@@ -26,12 +27,12 @@ const starlightPageProps: StarlightPageProps = {
frontmatter: { title: 'This is a test title' },
};
-const starlightPageUrl = new URL('https://example.com/test-slug');
+const starlightPagePathname = '/test-slug';
test('adds data to route shape', async () => {
const data = await generateStarlightPageRouteData({
props: starlightPageProps,
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
// Starlight pages infer the slug from the URL.
expect(data.slug).toBe('test-slug');
@@ -65,7 +66,10 @@ test('adds custom data to route shape', async () => {
dir: 'rtl',
lang: 'ks',
};
- const data = await generateStarlightPageRouteData({ props, url: starlightPageUrl });
+ const data = await generateStarlightPageRouteData({
+ props,
+ context: getRouteDataTestContext(starlightPagePathname),
+ });
expect(data.hasSidebar).toBe(props.hasSidebar);
expect(data.entryMeta.dir).toBe(props.dir);
expect(data.entryMeta.lang).toBe(props.lang);
@@ -82,7 +86,10 @@ test('adds custom frontmatter data to route shape', async () => {
template: 'splash',
},
};
- const data = await generateStarlightPageRouteData({ props, url: starlightPageUrl });
+ const data = await generateStarlightPageRouteData({
+ props,
+ context: getRouteDataTestContext(starlightPagePathname),
+ });
expect(data.entry.data.head).toMatchInlineSnapshot(`
[
{
@@ -103,7 +110,7 @@ test('adds custom frontmatter data to route shape', async () => {
test('uses generated sidebar when no sidebar is provided', async () => {
const data = await generateStarlightPageRouteData({
props: starlightPageProps,
- url: new URL('https://example.com/getting-started/'),
+ context: getRouteDataTestContext('/getting-started/'),
});
expect(data.sidebar).toMatchInlineSnapshot(`
[
@@ -188,7 +195,7 @@ test('uses provided sidebar if any', async () => {
'reference/frontmatter',
],
},
- url: new URL('https://example.com/test/2'),
+ context: getRouteDataTestContext('/test/2'),
});
expect(data.sidebar).toMatchInlineSnapshot(`
[
@@ -262,7 +269,7 @@ test('throws error if sidebar is malformated', async () => {
},
],
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
})
).rejects.toThrowErrorMatchingInlineSnapshot(`
"[AstroUserError]:
@@ -290,7 +297,7 @@ test('uses provided pagination if any', async () => {
},
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.pagination).toMatchInlineSnapshot(`
{
@@ -321,7 +328,7 @@ test('uses provided headings if any', async () => {
];
const data = await generateStarlightPageRouteData({
props: { ...starlightPageProps, headings },
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.headings).toEqual(headings);
});
@@ -337,7 +344,7 @@ test('generates the table of contents for provided headings', async () => {
{ depth: 4, slug: 'heading-3', text: 'Heading 3' },
],
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.toc).toMatchInlineSnapshot(`
{
@@ -386,7 +393,7 @@ test('respects the `tableOfContents` level configuration', async () => {
},
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.toc).toMatchInlineSnapshot(`
{
@@ -431,7 +438,7 @@ test('disables table of contents if frontmatter includes `tableOfContents: false
tableOfContents: false,
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.toc).toBeUndefined();
});
@@ -449,7 +456,7 @@ test('disables table of contents for splash template', async () => {
template: 'splash',
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.toc).toBeUndefined();
});
@@ -464,7 +471,7 @@ test('hides the sidebar if the `hasSidebar` option is not specified and the spla
template: 'splash',
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.hasSidebar).toBe(false);
});
@@ -479,7 +486,7 @@ test('uses provided edit URL if any', async () => {
editUrl,
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect(data.editUrl).toEqual(new URL(editUrl));
expect(data.entry.data.editUrl).toEqual(editUrl);
@@ -495,21 +502,22 @@ test('strips unknown frontmatter properties', async () => {
unknown: 'test',
},
},
- url: starlightPageUrl,
+ context: getRouteDataTestContext(starlightPagePathname),
});
expect('unknown' in data.entry.data).toBe(false);
});
test('generates data with a similar root shape to regular route data', async () => {
const route = routes[0]!;
+ const context = getRouteDataTestContext(starlightPagePathname);
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
- url: new URL('https://example.com'),
+ context,
});
const starlightPageData = await generateStarlightPageRouteData({
props: starlightPageProps,
- url: starlightPageUrl,
+ context,
});
expect(Object.keys(data).sort()).toEqual(Object.keys(starlightPageData).sort());
diff --git a/packages/starlight/__tests__/edit-url/edit-url.test.ts b/packages/starlight/__tests__/edit-url/edit-url.test.ts
index ea1477b5b73..197be334bb6 100644
--- a/packages/starlight/__tests__/edit-url/edit-url.test.ts
+++ b/packages/starlight/__tests__/edit-url/edit-url.test.ts
@@ -1,4 +1,5 @@
import { expect, test, vi } from 'vitest';
+import { getRouteDataTestContext } from '../test-utils';
import { generateRouteData } from '../../utils/routing/data';
import { routes } from '../../utils/routing';
@@ -20,7 +21,7 @@ test('synthesizes edit URL using file location and `editLink.baseUrl`', () => {
const route = routes[0]!;
const data = generateRouteData({
props: { ...route, headings: [] },
- url: new URL('https://example.com'),
+ context: getRouteDataTestContext(),
});
expect(data.editUrl?.href).toBe(
'https://github.com/withastro/starlight/edit/main/docs/src/content/docs/index.mdx'
@@ -30,7 +31,7 @@ test('synthesizes edit URL using file location and `editLink.baseUrl`', () => {
const route = routes[1]!;
const data = generateRouteData({
props: { ...route, headings: [] },
- url: new URL('https://example.com'),
+ context: getRouteDataTestContext(),
});
expect(data.editUrl?.href).toBe(
'https://github.com/withastro/starlight/edit/main/docs/src/content/docs/getting-started.mdx'
@@ -42,7 +43,7 @@ test('uses frontmatter `editUrl` if defined', () => {
const route = routes[2]!;
const data = generateRouteData({
props: { ...route, headings: [] },
- url: new URL('https://example.com'),
+ context: getRouteDataTestContext(),
});
expect(data.editUrl?.href).toBe('https://example.com/custom-edit?link');
});
diff --git a/packages/starlight/__tests__/head/head.test.ts b/packages/starlight/__tests__/head/head.test.ts
new file mode 100644
index 00000000000..966635ef47c
--- /dev/null
+++ b/packages/starlight/__tests__/head/head.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, test, vi } from 'vitest';
+import { getRouteDataTestContext } from '../test-utils';
+import { generateRouteData } from '../../utils/routing/data';
+import { routes } from '../../utils/routing';
+import type { HeadConfig } from '../../schemas/head';
+
+vi.mock('astro:content', async () =>
+ (await import('../test-utils')).mockedAstroContent({
+ docs: [['index.mdx', { title: 'Home Page' }]],
+ })
+);
+
+test('includes custom tags defined in the Starlight configuration', () => {
+ const head = getTestHead();
+ expect(head).toContainEqual({
+ attrs: {
+ 'data-site': 'TEST-ANALYTICS-ID',
+ defer: true,
+ src: 'https://example.com/analytics',
+ },
+ content: '',
+ tag: 'script',
+ });
+});
+
+test('merges two tags', () => {
+ const head = getTestHead([{ tag: 'title', content: 'Override', attrs: {} }]);
+ expect(head.filter((tag) => tag.tag === 'title')).toEqual([
+ { tag: 'title', content: 'Override', attrs: {} },
+ ]);
+});
+
+test('merges two tags', () => {
+ const customLink = {
+ tag: 'link',
+ attrs: { rel: 'canonical', href: 'https://astro.build' },
+ content: '',
+ } as const;
+ const head = getTestHead([customLink]);
+ expect(head.filter((tag) => tag.tag === 'link' && tag.attrs.rel === 'canonical')).toEqual([
+ customLink,
+ ]);
+});
+
+test('does not merge same link tags', () => {
+ const customLink = {
+ tag: 'link',
+ attrs: { rel: 'stylesheet', href: 'secondary.css' },
+ content: '',
+ } as const;
+ const head = getTestHead([customLink]);
+ expect(head.filter((tag) => tag.tag === 'link' && tag.attrs.rel === 'stylesheet')).toEqual([
+ { tag: 'link', attrs: { rel: 'stylesheet', href: 'primary.css' }, content: '' },
+ customLink,
+ ]);
+});
+
+describe.each([['name'], ['property'], ['http-equiv']])(
+ " tags with '%s' attributes",
+ (prop) => {
+ test(`merges two tags with same ${prop} values`, () => {
+ const customMeta = {
+ tag: 'meta',
+ attrs: { [prop]: 'x', content: 'Test' },
+ content: '',
+ } as const;
+ const head = getTestHead([customMeta]);
+ expect(head.filter((tag) => tag.tag === 'meta' && tag.attrs[prop] === 'x')).toEqual([
+ customMeta,
+ ]);
+ });
+
+ test(`does not merge two tags with different ${prop} values`, () => {
+ const customMeta = {
+ tag: 'meta',
+ attrs: { [prop]: 'y', content: 'Test' },
+ content: '',
+ } as const;
+ const head = getTestHead([customMeta]);
+ expect(
+ head.filter(
+ (tag) => tag.tag === 'meta' && (tag.attrs[prop] === 'x' || tag.attrs[prop] === 'y')
+ )
+ ).toEqual([
+ { tag: 'meta', attrs: { [prop]: 'x', content: 'Default' }, content: '' },
+ customMeta,
+ ]);
+ });
+ }
+);
+
+test('sorts head by tag importance', () => {
+ const head = getTestHead();
+
+ const expectedHeadStart = [
+ // Important meta tags
+ { tag: 'meta', attrs: { charset: 'utf-8' }, content: '' },
+ { tag: 'meta', attrs: expect.objectContaining({ name: 'viewport' }), content: '' },
+ { tag: 'meta', attrs: expect.objectContaining({ 'http-equiv': 'x' }), content: '' },
+ //
+ { tag: 'title', attrs: {}, content: 'Home Page | Docs With Custom Head' },
+ // Sitemap
+ { tag: 'link', attrs: { rel: 'sitemap', href: '/sitemap-index.xml' }, content: '' },
+ // Canonical link
+ { tag: 'link', attrs: { rel: 'canonical', href: 'https://example.com/test' }, content: '' },
+ // Others
+ { tag: 'link', attrs: expect.objectContaining({ rel: 'stylesheet' }), content: '' },
+ ];
+
+ expect(head.slice(0, expectedHeadStart.length)).toEqual(expectedHeadStart);
+
+ const expectedHeadEnd = [
+ // SEO meta tags
+ { tag: 'meta', attrs: expect.objectContaining({ name: 'x' }), content: '' },
+ { tag: 'meta', attrs: expect.objectContaining({ property: 'x' }), content: '' },
+ ];
+
+ expect(head.slice(-expectedHeadEnd.length)).toEqual(expectedHeadEnd);
+});
+
+test('places the default favicon below any user provided icons', () => {
+ const head = getTestHead([
+ {
+ tag: 'link',
+ attrs: {
+ rel: 'icon',
+ href: '/favicon.ico',
+ sizes: '32x32',
+ },
+ content: '',
+ },
+ ]);
+
+ const defaultFaviconIndex = head.findIndex(
+ (tag) => tag.tag === 'link' && tag.attrs.rel === 'shortcut icon'
+ );
+ const userFaviconIndex = head.findIndex((tag) => tag.tag === 'link' && tag.attrs.rel === 'icon');
+
+ expect(defaultFaviconIndex).toBeGreaterThan(userFaviconIndex);
+});
+
+function getTestHead(heads: HeadConfig = []): HeadConfig {
+ const route = routes[0]!;
+ return generateRouteData({
+ props: {
+ ...route,
+ headings: [],
+ entry: {
+ ...route.entry,
+ data: {
+ ...route.entry.data,
+ head: [...route.entry.data.head, ...heads],
+ },
+ },
+ },
+ context: getRouteDataTestContext(),
+ }).head;
+}
diff --git a/packages/starlight/__tests__/head/vitest.config.ts b/packages/starlight/__tests__/head/vitest.config.ts
new file mode 100644
index 00000000000..889b9dd5306
--- /dev/null
+++ b/packages/starlight/__tests__/head/vitest.config.ts
@@ -0,0 +1,20 @@
+import { defineVitestConfig } from '../test-config';
+
+export default defineVitestConfig({
+ title: 'Docs With Custom Head',
+ head: [
+ { tag: 'link', attrs: { rel: 'canonical', href: 'https://example.com/test' } },
+ { tag: 'link', attrs: { rel: 'stylesheet', href: 'primary.css' } },
+ { tag: 'meta', attrs: { name: 'x', content: 'Default' } },
+ { tag: 'meta', attrs: { property: 'x', content: 'Default' } },
+ { tag: 'meta', attrs: { 'http-equiv': 'x', content: 'Default' } },
+ {
+ tag: 'script',
+ attrs: {
+ src: 'https://example.com/analytics',
+ 'data-site': 'TEST-ANALYTICS-ID',
+ defer: true,
+ },
+ },
+ ],
+});
diff --git a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
index d01c4204120..09831854ec0 100644
--- a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
+++ b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts
@@ -1,4 +1,5 @@
import project from 'virtual:starlight/project-context';
+import { getRouteDataTestContext } from '../test-utils';
import config from 'virtual:starlight/user-config';
import { assert, expect, test, vi } from 'vitest';
import { routes } from '../../utils/routing';
@@ -83,7 +84,7 @@ test('fallback routes use fallback entry last updated dates', () => {
...route,
headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }],
},
- url: new URL('https://example.com/en'),
+ context: getRouteDataTestContext('/en'),
});
expect(getNewestCommitDate).toHaveBeenCalledOnce();
diff --git a/packages/starlight/__tests__/test-utils.ts b/packages/starlight/__tests__/test-utils.ts
index 1198ae4790e..c887f6aa169 100644
--- a/packages/starlight/__tests__/test-utils.ts
+++ b/packages/starlight/__tests__/test-utils.ts
@@ -2,6 +2,7 @@ import { z } from 'astro/zod';
import project from 'virtual:starlight/project-context';
import { docsSchema, i18nSchema } from '../schema';
import type { StarlightDocsCollectionEntry } from '../utils/routing/types';
+import type { RouteDataContext } from '../utils/routing/data';
import { vi } from 'vitest';
const frontmatterSchema = docsSchema()({
@@ -100,3 +101,12 @@ export async function mockedCollectionConfig(docsUserSchema?: Parameters> = [
- { tag: 'meta', attrs: { charset: 'utf-8' } },
- {
- tag: 'meta',
- attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
- },
- { tag: 'title', content: `${data.title} ${config.titleDelimiter} ${siteTitle}` },
- { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
- { tag: 'meta', attrs: { name: 'generator', content: Astro.generator } },
- {
- tag: 'meta',
- attrs: { name: 'generator', content: `Starlight v${version}` },
- },
- // Favicon
- {
- tag: 'link',
- attrs: {
- rel: 'shortcut icon',
- href: fileWithBase(config.favicon.href),
- type: config.favicon.type,
- },
- },
- // OpenGraph Tags
- { tag: 'meta', attrs: { property: 'og:title', content: data.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: siteTitle } },
- // Twitter Tags
- {
- tag: 'meta',
- attrs: { name: 'twitter:card', content: 'summary_large_image' },
- },
-];
-
-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, context.trailingSlash).href,
- },
- });
- }
-}
-
-// Link to sitemap, but only when `site` is set.
-if (Astro.site) {
- headDefaults.push({
- tag: 'link',
- attrs: {
- rel: 'sitemap',
- href: fileWithBase('/sitemap-index.xml'),
- },
- });
-}
-
-// 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.url).pathname,
- },
- });
-}
-
-const head = createHead(headDefaults, config.head, data.head);
+const { head } = Astro.locals.starlightRoute;
---
{head.map(({ tag: Tag, attrs, content }) => )}
diff --git a/packages/starlight/components/StarlightPage.astro b/packages/starlight/components/StarlightPage.astro
index 532e36ffac5..e92408fb0ab 100644
--- a/packages/starlight/components/StarlightPage.astro
+++ b/packages/starlight/components/StarlightPage.astro
@@ -10,7 +10,7 @@ export type StarlightPageProps = Props;
await attachRouteDataAndRunMiddleware(
Astro,
- await generateStarlightPageRouteData({ props: Astro.props, url: Astro.url })
+ await generateStarlightPageRouteData({ props: Astro.props, context: Astro })
);
---
diff --git a/packages/starlight/utils/head.ts b/packages/starlight/utils/head.ts
index 05be81f2bae..cec2644b6d9 100644
--- a/packages/starlight/utils/head.ts
+++ b/packages/starlight/utils/head.ts
@@ -1,9 +1,85 @@
+import config from 'virtual:starlight/user-config';
+import { version } from '../package.json';
import { type HeadConfig, HeadConfigSchema, type HeadUserConfig } from '../schemas/head';
+import type { PageProps, RouteDataContext } from './routing/data';
+import { fileWithBase } from './base';
const HeadSchema = HeadConfigSchema();
+/** Get the head for the current page. */
+export function getHead(
+ { entry, lang }: PageProps,
+ context: RouteDataContext,
+ siteTitle: string
+): HeadConfig {
+ const { data } = entry;
+
+ const canonical = context.site ? new URL(context.url.pathname, context.site) : undefined;
+ const description = data.description || config.description;
+
+ const headDefaults: HeadUserConfig = [
+ { tag: 'meta', attrs: { charset: 'utf-8' } },
+ {
+ tag: 'meta',
+ attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
+ },
+ { tag: 'title', content: `${data.title} ${config.titleDelimiter} ${siteTitle}` },
+ { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
+ { tag: 'meta', attrs: { name: 'generator', content: context.generator } },
+ {
+ tag: 'meta',
+ attrs: { name: 'generator', content: `Starlight v${version}` },
+ },
+ // Favicon
+ {
+ tag: 'link',
+ attrs: {
+ rel: 'shortcut icon',
+ href: fileWithBase(config.favicon.href),
+ type: config.favicon.type,
+ },
+ },
+ // OpenGraph Tags
+ { tag: 'meta', attrs: { property: 'og:title', content: data.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: siteTitle } },
+ // Twitter Tags
+ {
+ tag: 'meta',
+ attrs: { name: 'twitter:card', content: 'summary_large_image' },
+ },
+ ];
+
+ // Link to sitemap, but only when `site` is set.
+ if (context.site) {
+ headDefaults.push({
+ tag: 'link',
+ attrs: {
+ rel: 'sitemap',
+ href: fileWithBase('/sitemap-index.xml'),
+ },
+ });
+ }
+
+ // 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.url).pathname,
+ },
+ });
+ }
+
+ return createHead(headDefaults, config.head, data.head);
+}
+
/** Create a fully parsed, merged, and sorted head entry array from multiple sources. */
-export function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
+function createHead(defaults: HeadUserConfig, ...heads: HeadConfig[]) {
let head = HeadSchema.parse(defaults);
for (const next of heads) {
head = mergeHead(head, next);
diff --git a/packages/starlight/utils/routing/data.ts b/packages/starlight/utils/routing/data.ts
index 302532d98e5..343f8dbbba7 100644
--- a/packages/starlight/utils/routing/data.ts
+++ b/packages/starlight/utils/routing/data.ts
@@ -17,29 +17,32 @@ import { useTranslations } from '../translations';
import { BuiltInDefaultLocale } from '../i18n';
import { getEntry, render } from 'astro:content';
import { getCollectionPathFromRoot } from '../collection';
+import { getHead } from '../head';
export interface PageProps extends Route {
headings: MarkdownHeading[];
}
+export type RouteDataContext = Pick;
+
export async function useRouteData(context: APIContext): Promise {
const route =
('slug' in context.params && getRouteBySlugParam(context.params.slug)) ||
(await get404Route(context.locals));
const { Content, headings } = await render(route.entry);
- const routeData = generateRouteData({ props: { ...route, headings }, url: context.url });
+ const routeData = generateRouteData({ props: { ...route, headings }, context });
return { ...routeData, Content };
}
export function generateRouteData({
props,
- url,
+ context,
}: {
props: PageProps;
- url: URL;
+ context: RouteDataContext;
}): StarlightRouteData {
const { entry, locale, lang } = props;
- const sidebar = getSidebar(url.pathname, locale);
+ const sidebar = getSidebar(context.url.pathname, locale);
const siteTitle = getSiteTitle(lang);
return {
...props,
@@ -51,6 +54,7 @@ export function generateRouteData({
toc: getToC(props),
lastUpdated: getLastUpdated(props),
editUrl: getEditUrl(props),
+ head: getHead(props, context, siteTitle),
};
}
diff --git a/packages/starlight/utils/routing/types.ts b/packages/starlight/utils/routing/types.ts
index 1f352dd3d1e..07ee5f1133d 100644
--- a/packages/starlight/utils/routing/types.ts
+++ b/packages/starlight/utils/routing/types.ts
@@ -3,6 +3,7 @@ import type { CollectionEntry, RenderResult } from 'astro:content';
import type { TocItem } from '../generateToC';
import type { LinkHTMLAttributes } from '../../schemas/sidebar';
import type { Badge } from '../../schemas/badge';
+import type { HeadConfig } from '../../schemas/head';
export interface LocaleData {
/** Writing direction. */
@@ -93,4 +94,6 @@ export interface StarlightRouteData extends Route {
editUrl: URL | undefined;
/** An Astro component to render the current page’s content if this route is a Markdown page. */
Content?: RenderResult['Content'];
+ /** Array of tags to include in the `` of the current page. */
+ head: HeadConfig;
}
diff --git a/packages/starlight/utils/starlight-page.ts b/packages/starlight/utils/starlight-page.ts
index 97a3d7d9101..11bf016dda8 100644
--- a/packages/starlight/utils/starlight-page.ts
+++ b/packages/starlight/utils/starlight-page.ts
@@ -5,7 +5,13 @@ import config from 'virtual:starlight/user-config';
import { getCollectionPathFromRoot } from './collection';
import { parseWithFriendlyErrors, parseAsyncWithFriendlyErrors } from './error-map';
import { stripLeadingAndTrailingSlashes } from './path';
-import { getSiteTitle, getSiteTitleHref, getToC, type PageProps } from './routing/data';
+import {
+ getSiteTitle,
+ getSiteTitleHref,
+ getToC,
+ type PageProps,
+ type RouteDataContext,
+} from './routing/data';
import type { StarlightDocsEntry, StarlightRouteData } from './routing/types';
import { slugToLocaleData, urlToSlug } from './slugs';
import { getPrevNextLinks, getSidebar, getSidebarFromConfig } from './navigation';
@@ -13,6 +19,7 @@ import { docsSchema } from '../schema';
import type { Prettify, RemoveIndexSignature } from './types';
import { SidebarItemSchema } from '../schemas/sidebar';
import type { StarlightConfig, StarlightUserConfig } from './user-config';
+import { getHead } from './head';
/**
* The frontmatter schema for Starlight pages derived from the default schema for Starlight’s
@@ -100,12 +107,13 @@ type StarlightPageDocsEntry = Omit & {
export async function generateStarlightPageRouteData({
props,
- url,
+ context,
}: {
props: StarlightPageProps;
- url: URL;
+ context: RouteDataContext;
}): Promise {
const { isFallback, frontmatter, ...routeProps } = props;
+ const { url } = context;
const slug = urlToSlug(url);
const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter);
const id = project.legacyCollections ? `${stripLeadingAndTrailingSlashes(slug)}.md` : slug;
@@ -137,6 +145,17 @@ export async function generateStarlightPageRouteData({
const editUrl = pageFrontmatter.editUrl ? new URL(pageFrontmatter.editUrl) : undefined;
const lastUpdated =
pageFrontmatter.lastUpdated instanceof Date ? pageFrontmatter.lastUpdated : undefined;
+ const pageProps: PageProps = {
+ ...routeProps,
+ ...localeData,
+ entry,
+ entryMeta,
+ headings,
+ id,
+ locale: localeData.locale,
+ slug,
+ };
+ const siteTitle = getSiteTitle(localeData.lang);
const routeData: StarlightRouteData = {
...routeProps,
...localeData,
@@ -145,23 +164,15 @@ export async function generateStarlightPageRouteData({
entry,
entryMeta,
hasSidebar: props.hasSidebar ?? entry.data.template !== 'splash',
+ head: getHead(pageProps, context, siteTitle),
headings,
lastUpdated,
pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
sidebar,
- siteTitle: getSiteTitle(localeData.lang),
+ siteTitle,
siteTitleHref: getSiteTitleHref(localeData.locale),
slug,
- toc: getToC({
- ...routeProps,
- ...localeData,
- entry,
- entryMeta,
- headings,
- id,
- locale: localeData.locale,
- slug,
- }),
+ toc: getToC(pageProps),
};
if (isFallback) {
routeData.isFallback = true;
From 23756a3ac5f897c34d63a394307097b447addd7c Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 27 Feb 2025 16:32:50 +0100
Subject: [PATCH 2/5] fix: canonical override
---
.changeset/happy-pens-serve.md | 5 +++++
packages/starlight/utils/head.ts | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
create mode 100644 .changeset/happy-pens-serve.md
diff --git a/.changeset/happy-pens-serve.md b/.changeset/happy-pens-serve.md
new file mode 100644
index 00000000000..9b1fa92e4ce
--- /dev/null
+++ b/.changeset/happy-pens-serve.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/starlight': patch
+---
+
+Fixes an issue where overriding the [canonical URL](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#canonical) of a page using the [`head` configuration option](https://starlight.astro.build/reference/configuration/#head) or [`head` frontmatter field](https://starlight.astro.build/reference/frontmatter/#head) would strip any other `` tags from the ``.
diff --git a/packages/starlight/utils/head.ts b/packages/starlight/utils/head.ts
index cec2644b6d9..3a33a2e52ac 100644
--- a/packages/starlight/utils/head.ts
+++ b/packages/starlight/utils/head.ts
@@ -102,7 +102,7 @@ function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean {
case 'meta':
return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
case 'link':
- return head.some(({ attrs }) => attrs.rel === 'canonical');
+ return head.some(({ attrs }) => entry.attrs.rel === 'canonical' && attrs.rel === 'canonical');
default:
return false;
}
From a97015cbbeab60ef69e465afc8e81951c2788d46 Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 27 Feb 2025 17:55:08 +0100
Subject: [PATCH 3/5] fix: canonical trailing slash
---
.changeset/happy-snails-cheat.md | 5 ++
.../__tests__/basics/format-canonical.test.ts | 75 +++++++++++++++++++
packages/starlight/utils/canonical.ts | 19 +++++
packages/starlight/utils/head.ts | 12 ++-
4 files changed, 109 insertions(+), 2 deletions(-)
create mode 100644 .changeset/happy-snails-cheat.md
create mode 100644 packages/starlight/__tests__/basics/format-canonical.test.ts
create mode 100644 packages/starlight/utils/canonical.ts
diff --git a/.changeset/happy-snails-cheat.md b/.changeset/happy-snails-cheat.md
new file mode 100644
index 00000000000..f2b1ef99b4f
--- /dev/null
+++ b/.changeset/happy-snails-cheat.md
@@ -0,0 +1,5 @@
+---
+'@astrojs/starlight': patch
+---
+
+Fixes an issue where generated [canonical URLs](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel#canonical) would include a trailing slash when using the [`trailingSlash` Astro option](https://docs.astro.build/en/reference/configuration-reference/#trailingslash) is set to `'never'`.
diff --git a/packages/starlight/__tests__/basics/format-canonical.test.ts b/packages/starlight/__tests__/basics/format-canonical.test.ts
new file mode 100644
index 00000000000..e6b15550352
--- /dev/null
+++ b/packages/starlight/__tests__/basics/format-canonical.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, test } from 'vitest';
+import { formatCanonical, type FormatCanonicalOptions } from '../../utils/canonical';
+
+describe.each<{
+ options: FormatCanonicalOptions;
+ tests: Array<{ href: string; expected: string }>;
+}>([
+ {
+ options: { format: 'file', trailingSlash: 'ignore' },
+ tests: [
+ { href: 'https://example.com/index.html', expected: 'https://example.com/index.html' },
+ {
+ href: 'https://example.com/reference/configuration.html',
+ expected: 'https://example.com/reference/configuration.html',
+ },
+ ],
+ },
+ {
+ options: { format: 'file', trailingSlash: 'always' },
+ tests: [
+ { href: 'https://example.com/index.html', expected: 'https://example.com/index.html' },
+ {
+ href: 'https://example.com/reference/configuration.html',
+ expected: 'https://example.com/reference/configuration.html',
+ },
+ ],
+ },
+ {
+ options: { format: 'file', trailingSlash: 'never' },
+ tests: [
+ { href: 'https://example.com/index.html', expected: 'https://example.com/index.html' },
+ {
+ href: 'https://example.com/reference/configuration.html',
+ expected: 'https://example.com/reference/configuration.html',
+ },
+ ],
+ },
+ {
+ options: { format: 'directory', trailingSlash: 'always' },
+ tests: [
+ { href: 'https://example.com/', expected: 'https://example.com/' },
+ {
+ href: 'https://example.com/reference/configuration/',
+ expected: 'https://example.com/reference/configuration/',
+ },
+ ],
+ },
+ {
+ options: { format: 'directory', trailingSlash: 'never' },
+ tests: [
+ { href: 'https://example.com/', expected: 'https://example.com' },
+ {
+ href: 'https://example.com/reference/configuration/',
+ expected: 'https://example.com/reference/configuration',
+ },
+ ],
+ },
+ {
+ options: { format: 'directory', trailingSlash: 'ignore' },
+ tests: [
+ { href: 'https://example.com/', expected: 'https://example.com/' },
+ {
+ href: 'https://example.com/reference/configuration/',
+ expected: 'https://example.com/reference/configuration/',
+ },
+ ],
+ },
+])(
+ 'formatCanonical() with { format: $options.format, trailingSlash: $options.trailingSlash }',
+ ({ options, tests }) => {
+ test.each(tests)('returns $expected for $href', async ({ href, expected }) => {
+ expect(formatCanonical(href, options)).toBe(expected);
+ });
+ }
+);
diff --git a/packages/starlight/utils/canonical.ts b/packages/starlight/utils/canonical.ts
new file mode 100644
index 00000000000..5a0da5d4d5d
--- /dev/null
+++ b/packages/starlight/utils/canonical.ts
@@ -0,0 +1,19 @@
+import type { AstroConfig } from 'astro';
+import { ensureTrailingSlash, stripTrailingSlash } from './path';
+
+export interface FormatCanonicalOptions {
+ format: AstroConfig['build']['format'];
+ trailingSlash: AstroConfig['trailingSlash'];
+}
+
+const canonicalTrailingSlashStrategies = {
+ always: ensureTrailingSlash,
+ never: stripTrailingSlash,
+ ignore: ensureTrailingSlash,
+};
+
+/** Format a canonical link based on the project config. */
+export function formatCanonical(href: string, opts: FormatCanonicalOptions) {
+ if (opts.format === 'file') return href;
+ return canonicalTrailingSlashStrategies[opts.trailingSlash](href);
+}
diff --git a/packages/starlight/utils/head.ts b/packages/starlight/utils/head.ts
index 3a33a2e52ac..9a3ee178dfc 100644
--- a/packages/starlight/utils/head.ts
+++ b/packages/starlight/utils/head.ts
@@ -1,8 +1,10 @@
import config from 'virtual:starlight/user-config';
+import project from 'virtual:starlight/project-context';
import { version } from '../package.json';
import { type HeadConfig, HeadConfigSchema, type HeadUserConfig } from '../schemas/head';
import type { PageProps, RouteDataContext } from './routing/data';
import { fileWithBase } from './base';
+import { formatCanonical } from './canonical';
const HeadSchema = HeadConfigSchema();
@@ -15,6 +17,12 @@ export function getHead(
const { data } = entry;
const canonical = context.site ? new URL(context.url.pathname, context.site) : undefined;
+ const canonicalHref = canonical?.href
+ ? formatCanonical(canonical.href, {
+ format: project.build.format,
+ trailingSlash: project.trailingSlash,
+ })
+ : undefined;
const description = data.description || config.description;
const headDefaults: HeadUserConfig = [
@@ -24,7 +32,7 @@ export function getHead(
attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
},
{ tag: 'title', content: `${data.title} ${config.titleDelimiter} ${siteTitle}` },
- { tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
+ { tag: 'link', attrs: { rel: 'canonical', href: canonicalHref } },
{ tag: 'meta', attrs: { name: 'generator', content: context.generator } },
{
tag: 'meta',
@@ -42,7 +50,7 @@ export function getHead(
// OpenGraph Tags
{ tag: 'meta', attrs: { property: 'og:title', content: data.title } },
{ tag: 'meta', attrs: { property: 'og:type', content: 'article' } },
- { tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } },
+ { tag: 'meta', attrs: { property: 'og:url', content: canonicalHref } },
{ tag: 'meta', attrs: { property: 'og:locale', content: lang } },
{ tag: 'meta', attrs: { property: 'og:description', content: description } },
{ tag: 'meta', attrs: { property: 'og:site_name', content: siteTitle } },
From 896cde5dcec38af2b70da753514639a1844ef549 Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 27 Feb 2025 18:39:21 +0100
Subject: [PATCH 4/5] docs: head route data
---
docs/src/content/docs/reference/overrides.md | 3 +--
docs/src/content/docs/reference/route-data.mdx | 7 +++++++
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/docs/src/content/docs/reference/overrides.md b/docs/src/content/docs/reference/overrides.md
index ff5e14e14ac..decd9a9440d 100644
--- a/docs/src/content/docs/reference/overrides.md
+++ b/docs/src/content/docs/reference/overrides.md
@@ -22,10 +22,9 @@ They should only include [elements permitted inside ``](https://developer.
**Default component:** [`Head.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Head.astro)
Component rendered inside each page’s ``.
-Includes important tags including ``, and ``.
Override this component as a last resort.
-Prefer the [`head`](/reference/configuration/#head) option Starlight config if possible.
+Prefer the [`head` config option](/reference/configuration/#head), the [`head` frontmatter field](/reference/frontmatter/#head), or a [route data middleware](/guides/route-data/#customizing-route-data) to customize the route data rendered by the default component if possible.
#### `ThemeProvider`
diff --git a/docs/src/content/docs/reference/route-data.mdx b/docs/src/content/docs/reference/route-data.mdx
index c0fb08e08f3..49d53b8c1ce 100644
--- a/docs/src/content/docs/reference/route-data.mdx
+++ b/docs/src/content/docs/reference/route-data.mdx
@@ -150,6 +150,13 @@ JavaScript `Date` object representing when this page was last updated if enabled
`URL` object for the address where this page can be edited if enabled.
+### `head`
+
+**type:** [`HeadConfig[]`](/reference/configuration/#headconfig)
+
+Array of all tags to include in the `` of the current page.
+Includes important tags including ``, and ``.
+
## Utilities
### `defineRouteMiddleware()`
From 0882d465f6b82023731d47d0fddaf276292a1fa9 Mon Sep 17 00:00:00 2001
From: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Date: Thu, 27 Feb 2025 18:50:23 +0100
Subject: [PATCH 5/5] chore: add changeset
---
.changeset/long-emus-smile.md | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 .changeset/long-emus-smile.md
diff --git a/.changeset/long-emus-smile.md b/.changeset/long-emus-smile.md
new file mode 100644
index 00000000000..7756acaa21b
--- /dev/null
+++ b/.changeset/long-emus-smile.md
@@ -0,0 +1,9 @@
+---
+'@astrojs/starlight': minor
+---
+
+Adds the [`head`](https://starlight.astro.build/reference/route-data/#head) route data property which contains an array of all tags to include in the `` of the current page.
+
+Previously, the [``](https://starlight.astro.build/reference/overrides/#head-1) component was responsible for generating a list of tags to include in the `` of the current page and rendering them.
+This data is now available as `Astro.locals.starlightRoute.head` instead and can be modified using [route data middleware](https://starlight.astro.build/guides/route-data/#customizing-route-data).
+The `` component now only renders the tags provided in `Astro.locals.starlightRoute.head`.