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 <link rel="canonical" href="" /> 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 <meta> 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 <meta> 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' } }, - // <title> - { 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: '' }, - // <title> - { 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 <title> 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 <link rel="canonical" href="" /> 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']])( + "<meta> tags with '%s' attributes", + (prop) => { + test(`merges two <meta> 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 <meta> 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: '' }, + // <title> + { 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<typeof }, }; } + +export function getRouteDataTestContext(pathname?: string): RouteDataContext { + const site = new URL('https://example.com'); + return { + generator: 'Astro', + site, + url: pathname ? new URL(pathname, site) : site, + }; +} diff --git a/packages/starlight/components/Head.astro b/packages/starlight/components/Head.astro index 924b9968f62..eeccf17932e 100644 --- a/packages/starlight/components/Head.astro +++ b/packages/starlight/components/Head.astro @@ -1,100 +1,5 @@ --- -import type { z } from 'astro/zod'; -import context from 'virtual:starlight/project-context'; -import config from 'virtual:starlight/user-config'; -import { version } from '../package.json'; -import type { HeadConfigSchema } from '../schemas/head'; -import { fileWithBase } from '../utils/base'; -import { createHead } from '../utils/head'; -import { localizedUrl } from '../utils/localizedUrl'; - -const { entry, lang, siteTitle } = Astro.locals.starlightRoute; -const { data } = entry; - -const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined; -const description = data.description || config.description; - -const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [ - { 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 }) => <Tag {...attrs} set:html={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<APIContext, 'generator' | 'site' | 'url'>; + export async function useRouteData(context: APIContext): Promise<StarlightRouteData> { 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 `<head>` 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<StarlightDocsEntry, 'id' | 'render'> & { export async function generateStarlightPageRouteData({ props, - url, + context, }: { props: StarlightPageProps; - url: URL; + context: RouteDataContext; }): Promise<StarlightRouteData> { 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 `<link>` tags from the `<head>`. 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 `<head>`](https://developer. **Default component:** [`Head.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Head.astro) Component rendered inside each page’s `<head>`. -Includes important tags including `<title>`, and `<meta charset="utf-8">`. 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 `<head>` of the current page. +Includes important tags including `<title>`, and `<meta charset="utf-8">`. + ## 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 `<head>` of the current page. + +Previously, the [`<Head>`](https://starlight.astro.build/reference/overrides/#head-1) component was responsible for generating a list of tags to include in the `<head>` 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 `<Head>` component now only renders the tags provided in `Astro.locals.starlightRoute.head`.