From 3a087d8fbcd946336f8a0423203967e53e5678fe Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Wed, 16 Apr 2025 11:27:13 +0200 Subject: Don’t set defaults for `attrs` and `content` in head entries (#3122) --- .changeset/chilly-dolphins-clap.md | 24 +++++++++++ .../basics/starlight-page-route-data.test.ts | 1 - packages/starlight/__tests__/head/head.test.ts | 50 ++++++++-------------- packages/starlight/__tests__/i18n/head.test.ts | 1 - packages/starlight/schemas/head.ts | 4 +- packages/starlight/utils/head.ts | 16 +++++-- 6 files changed, 57 insertions(+), 39 deletions(-) create mode 100644 .changeset/chilly-dolphins-clap.md diff --git a/.changeset/chilly-dolphins-clap.md b/.changeset/chilly-dolphins-clap.md new file mode 100644 index 00000000..35418ec5 --- /dev/null +++ b/.changeset/chilly-dolphins-clap.md @@ -0,0 +1,24 @@ +--- +'@astrojs/starlight': minor +--- + +Removes default `attrs` and `content` values from head entries parsed using Starlight’s schema. + +Previously when adding `head` metadata via frontmatter or user config, Starlight would automatically add values for `attrs` and `content` if not provided. Now, these properties are left `undefined`. + +This makes it simpler to add tags in route middleware for example as you no longer need to provide empty values for `attrs` and `content`: + +```diff +head.push({ + tag: 'style', + content: 'div { color: red }' +- attrs: {}, +}); +head.push({ + tag: 'link', +- content: '' + attrs: { rel: 'me', href: 'https://example.com' }, +}); +``` + +This is mostly an internal API but if you are overriding Starlight’s `Head` component or processing head entries in some way, you may wish to double check your handling of `Astro.locals.starlightRoute.head` is compatible with `attrs` and `content` potentially being `undefined`. 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 6ac530b3..950eeab4 100644 --- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts +++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts @@ -99,7 +99,6 @@ test('adds custom frontmatter data to route shape', async () => { "content": "test", "name": "og:test", }, - "content": "", "tag": "meta", }, ] diff --git a/packages/starlight/__tests__/head/head.test.ts b/packages/starlight/__tests__/head/head.test.ts index 8ed7e1a7..4c51a083 100644 --- a/packages/starlight/__tests__/head/head.test.ts +++ b/packages/starlight/__tests__/head/head.test.ts @@ -28,7 +28,6 @@ test('includes custom tags defined in the Starlight configuration', () => { defer: true, src: 'https://example.com/analytics', }, - content: '', tag: 'script', }); }); @@ -41,7 +40,6 @@ test('includes description based on Starlight `description` configuration', () = name: 'description', content: 'Docs with a custom head', }, - content: '', }); }); @@ -54,7 +52,6 @@ test('includes description based on page `description` frontmatter field if prov content: 'Learn how Starlight can help you build greener documentation sites and reduce your carbon footprint.', }, - content: '', }); }); @@ -66,14 +63,13 @@ test('includes `twitter:site` based on Starlight `social` configuration', () => name: 'twitter:site', content: '@astrodotbuild', }, - content: '', }); }); test('merges two tags', () => { - const head = getTestHead([{ tag: 'title', content: 'Override', attrs: {} }]); + const head = getTestHead([{ tag: 'title', content: 'Override' }]); expect(head.filter((tag) => tag.tag === 'title')).toEqual([ - { tag: 'title', content: 'Override', attrs: {} }, + { tag: 'title', content: 'Override' }, ]); }); @@ -81,10 +77,9 @@ 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([ + expect(head.filter((tag) => tag.tag === 'link' && tag.attrs?.rel === 'canonical')).toEqual([ customLink, ]); }); @@ -93,11 +88,10 @@ 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: '' }, + expect(head.filter((tag) => tag.tag === 'link' && tag.attrs?.rel === 'stylesheet')).toEqual([ + { tag: 'link', attrs: { rel: 'stylesheet', href: 'primary.css' } }, customLink, ]); }); @@ -109,10 +103,9 @@ describe.each([['name'], ['property'], ['http-equiv']])( 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([ + expect(head.filter((tag) => tag.tag === 'meta' && tag.attrs?.[prop] === 'x')).toEqual([ customMeta, ]); }); @@ -121,17 +114,13 @@ describe.each([['name'], ['property'], ['http-equiv']])( 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') + (tag) => tag.tag === 'meta' && (tag.attrs?.[prop] === 'x' || tag.attrs?.[prop] === 'y') ) - ).toEqual([ - { tag: 'meta', attrs: { [prop]: 'x', content: 'Default' }, content: '' }, - customMeta, - ]); + ).toEqual([{ tag: 'meta', attrs: { [prop]: 'x', content: 'Default' } }, customMeta]); }); } ); @@ -141,25 +130,25 @@ test('sorts head by tag importance', () => { 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: 'meta', attrs: { charset: 'utf-8' } }, + { tag: 'meta', attrs: expect.objectContaining({ name: 'viewport' }) }, + { tag: 'meta', attrs: expect.objectContaining({ 'http-equiv': 'x' }) }, // <title> - { tag: 'title', attrs: {}, content: 'Home Page | Docs With Custom Head' }, + { tag: 'title', content: 'Home Page | Docs With Custom Head' }, // Sitemap - { tag: 'link', attrs: { rel: 'sitemap', href: '/sitemap-index.xml' }, content: '' }, + { tag: 'link', attrs: { rel: 'sitemap', href: '/sitemap-index.xml' } }, // Canonical link - { tag: 'link', attrs: { rel: 'canonical', href: 'https://example.com/test' }, content: '' }, + { tag: 'link', attrs: { rel: 'canonical', href: 'https://example.com/test' } }, // Others - { tag: 'link', attrs: expect.objectContaining({ rel: 'stylesheet' }), content: '' }, + { tag: 'link', attrs: expect.objectContaining({ rel: 'stylesheet' }) }, ]; 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: '' }, + { tag: 'meta', attrs: expect.objectContaining({ name: 'x' }) }, + { tag: 'meta', attrs: expect.objectContaining({ property: 'x' }) }, ]; expect(head.slice(-expectedHeadEnd.length)).toEqual(expectedHeadEnd); @@ -174,14 +163,13 @@ test('places the default favicon below any user provided icons', () => { href: '/favicon.ico', sizes: '32x32', }, - content: '', }, ]); const defaultFaviconIndex = head.findIndex( - (tag) => tag.tag === 'link' && tag.attrs.rel === 'shortcut icon' + (tag) => tag.tag === 'link' && tag.attrs?.rel === 'shortcut icon' ); - const userFaviconIndex = head.findIndex((tag) => tag.tag === 'link' && tag.attrs.rel === 'icon'); + const userFaviconIndex = head.findIndex((tag) => tag.tag === 'link' && tag.attrs?.rel === 'icon'); expect(defaultFaviconIndex).toBeGreaterThan(userFaviconIndex); }); diff --git a/packages/starlight/__tests__/i18n/head.test.ts b/packages/starlight/__tests__/i18n/head.test.ts index dbf25aa3..755a0b68 100644 --- a/packages/starlight/__tests__/i18n/head.test.ts +++ b/packages/starlight/__tests__/i18n/head.test.ts @@ -24,7 +24,6 @@ test('includes links to language alternates', () => { href: `https://example.com/${locale}/`, hreflang: localeConfig?.lang, }, - content: '', }); } }); diff --git a/packages/starlight/schemas/head.ts b/packages/starlight/schemas/head.ts index d942c57b..d868e859 100644 --- a/packages/starlight/schemas/head.ts +++ b/packages/starlight/schemas/head.ts @@ -7,9 +7,9 @@ export const HeadConfigSchema = () => /** Name of the HTML tag to add to `<head>`, e.g. `'meta'`, `'link'`, or `'script'`. */ tag: z.enum(['title', 'base', 'link', 'style', 'meta', 'script', 'noscript', 'template']), /** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */ - attrs: z.record(z.union([z.string(), z.boolean(), z.undefined()])).default({}), + attrs: z.record(z.union([z.string(), z.boolean(), z.undefined()])).optional(), /** Content to place inside the tag (optional). */ - content: z.string().default(''), + content: z.string().optional(), }) ) .default([]); diff --git a/packages/starlight/utils/head.ts b/packages/starlight/utils/head.ts index 2222e0b3..0f3095e6 100644 --- a/packages/starlight/utils/head.ts +++ b/packages/starlight/utils/head.ts @@ -134,7 +134,9 @@ function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean { case 'meta': return hasOneOf(head, entry, ['name', 'property', 'http-equiv']); case 'link': - return head.some(({ attrs }) => entry.attrs.rel === 'canonical' && attrs.rel === 'canonical'); + return head.some( + ({ attrs }) => entry.attrs?.rel === 'canonical' && attrs?.rel === 'canonical' + ); default: return false; } @@ -148,7 +150,7 @@ function hasOneOf(head: HeadConfig, entry: HeadConfig[number], keys: string[]): const attr = getAttr(keys, entry); if (!attr) return false; const [key, val] = attr; - return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val); + return head.some(({ tag, attrs }) => tag === entry.tag && attrs?.[key] === val); } /** Find the first matching key–value pair in a head entry’s attributes. */ @@ -158,7 +160,7 @@ function getAttr( ): [key: string, value: string | boolean] | undefined { let attr: [string, string | boolean] | undefined; for (const key of keys) { - const val = entry.attrs[key]; + const val = entry.attrs?.[key]; if (val) { attr = [key, val]; break; @@ -186,6 +188,7 @@ function getImportance(entry: HeadConfig[number]) { // 1. Important meta tags. if ( entry.tag === 'meta' && + entry.attrs && ('charset' in entry.attrs || 'http-equiv' in entry.attrs || entry.attrs.name === 'viewport') ) { return 100; @@ -197,7 +200,12 @@ function getImportance(entry: HeadConfig[number]) { // The default favicon should be below any extra icons that the user may have set // because if several icons are equally appropriate, the last one is used and we // want to use the SVG icon when supported. - if (entry.tag === 'link' && 'rel' in entry.attrs && entry.attrs.rel === 'shortcut icon') { + if ( + entry.tag === 'link' && + entry.attrs && + 'rel' in entry.attrs && + entry.attrs.rel === 'shortcut icon' + ) { return 70; } return 80; -- cgit