summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2025-04-16 11:27:13 +0200
committerGitHub2025-04-16 11:27:13 +0200
commit3a087d8fbcd946336f8a0423203967e53e5678fe (patch)
tree6aa908b6dca5162fa83af28de626414bc66ea173
parent5527a7e37d82c3c79f6b3f12ad61cb2717ff3477 (diff)
downloadIT.starlight-3a087d8fbcd946336f8a0423203967e53e5678fe.tar.gz
IT.starlight-3a087d8fbcd946336f8a0423203967e53e5678fe.tar.bz2
IT.starlight-3a087d8fbcd946336f8a0423203967e53e5678fe.zip
Don’t set defaults for `attrs` and `content` in head entries (#3122)
-rw-r--r--.changeset/chilly-dolphins-clap.md24
-rw-r--r--packages/starlight/__tests__/basics/starlight-page-route-data.test.ts1
-rw-r--r--packages/starlight/__tests__/head/head.test.ts50
-rw-r--r--packages/starlight/__tests__/i18n/head.test.ts1
-rw-r--r--packages/starlight/schemas/head.ts4
-rw-r--r--packages/starlight/utils/head.ts16
6 files changed, 57 insertions, 39 deletions
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 <title> 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;