diff options
author | HiDeoo | 2025-07-16 15:52:11 +0200 |
---|---|---|
committer | GitHub | 2025-07-16 15:52:11 +0200 |
commit | 1161af0c2fe26485da6123f8fd7205c53b0e45e5 (patch) | |
tree | 9be931235e28027fb9b369cc7542f5aabe782c3a | |
parent | 778b743cdb832551ed576c745728358d8bbf9d7a (diff) | |
download | IT.starlight-1161af0c2fe26485da6123f8fd7205c53b0e45e5.tar.gz IT.starlight-1161af0c2fe26485da6123f8fd7205c53b0e45e5.tar.bz2 IT.starlight-1161af0c2fe26485da6123f8fd7205c53b0e45e5.zip |
Autogenerated link custom attributes (#3266)
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Kevin Zuniga Cuellar <kzunigac@uvm.edu>
Co-authored-by: Felix Schneider <99918022+trueberryless@users.noreply.github.com>
15 files changed, 234 insertions, 21 deletions
diff --git a/.changeset/loud-guests-pay.md b/.changeset/loud-guests-pay.md new file mode 100644 index 00000000..7f802016 --- /dev/null +++ b/.changeset/loud-guests-pay.md @@ -0,0 +1,7 @@ +--- +'@astrojs/starlight': patch +--- + +Ensures invalid sidebar group configurations using the `attrs` option are properly reported as a type error. + +Previously, invalid sidebar group configurations using the `attrs` option were not reported as a type error but only surfaced at runtime. This change is only a type-level change and does not affect the runtime behavior of Starlight which does not support the `attrs` option for sidebar groups. diff --git a/.changeset/twenty-donuts-greet.md b/.changeset/twenty-donuts-greet.md new file mode 100644 index 00000000..1a816d82 --- /dev/null +++ b/.changeset/twenty-donuts-greet.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds support for custom HTML attributes on autogenerated sidebar links using the [`autogenerate.attrs`](https://starlight.astro.build/guides/sidebar/#custom-html-attributes-for-autogenerated-links) option. diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx index 1e0f55f5..270eabaf 100644 --- a/docs/src/content/docs/guides/sidebar.mdx +++ b/docs/src/content/docs/guides/sidebar.mdx @@ -244,7 +244,7 @@ The following sidebar will be generated: Use the [`sidebar` frontmatter field](/reference/frontmatter/#sidebar) in individual pages to customize autogenerated links. -Sidebar frontmatter options allow you to set a [custom label](/reference/frontmatter/#label) or add a [badge](/reference/frontmatter/#badge) to a link, [hide](/reference/frontmatter/#hidden) a link from the sidebar, or define a [custom sort weighting](/reference/frontmatter/#order). +Sidebar frontmatter options allow you to set a [custom label](/reference/frontmatter/#label), use [custom attributes](/reference/frontmatter/#attrs), add a [badge](/reference/frontmatter/#badge) to a link, [hide](/reference/frontmatter/#hidden) a link from the sidebar, or define a [custom sort weighting](/reference/frontmatter/#order). ```md "sidebar:" --- @@ -436,6 +436,72 @@ The configuration above generates the following sidebar: ]} /> +### Custom HTML attributes for autogenerated links + +Customize HTML attributes of all links in [autogenerated groups](#autogenerated-groups) by defining the `attrs` property in the `autogenerate` configuration. +Individual pages can specify custom attributes using the [`sidebar.attrs` frontmatter field](/reference/frontmatter/#attrs) which will be merged with the `autogenerate.attrs` configuration. + +For example, with the following configuration: + +```js {9} +starlight({ + sidebar: [ + { + label: 'Constellations', + autogenerate: { + // Autogenerate a group of links for the 'constellations' directory. + directory: 'constellations', + // Italicize all link labels in this group. + attrs: { style: 'font-style: italic' }, + }, + }, + ], +}); +``` + +And the following file structure: + +<FileTree> + +- src/ + - content/ + - docs/ + - constellations/ + - carina.md + - centaurus.md + - seasonal/ + - andromeda.md + +</FileTree> + +The following sidebar will be generated with all autogenerated links italicized: + +<SidebarPreview + config={[ + { + label: 'Constellations', + items: [ + { label: 'Carina', link: '', attrs: { style: 'font-style: italic' } }, + { + label: 'Centaurus', + link: '', + attrs: { style: 'font-style: italic' }, + }, + { + label: 'seasonal', + items: [ + { + label: 'Andromeda', + link: '', + attrs: { style: 'font-style: italic' }, + }, + ], + }, + ], + }, + ]} +/> + ## Internationalization Use the `translations` property on link and group entries to translate the link or group label for each supported language by specifying a [BCP-47](https://www.w3.org/International/questions/qa-choosing-language-tags) language tag, e.g. `"en"`, `"ar"`, or `"zh-CN"`, as the key and the translated label as the value. diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 7166da59..9d245531 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -224,7 +224,11 @@ type SidebarItem = | { // Autogenerated link group label: string; - autogenerate: { directory: string; collapsed?: boolean }; + autogenerate: { + directory: string; + collapsed?: boolean; + attrs?: Record<string, string | number | boolean | undefined>; + }; collapsed?: boolean; } )); diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 37c4513e..b8028a7d 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -386,6 +386,7 @@ sidebar: **type:** `Record<string, string | number | boolean | undefined>` HTML attributes to add to the page link in the sidebar when displayed in an autogenerated group of links. +If [`autogenerate.attrs`](/guides/sidebar/#custom-html-attributes-for-autogenerated-links) is set on the autogenerated group this page belongs to, frontmatter attributes will be merged with the group attributes. ```md --- diff --git a/packages/starlight/__tests__/basics/config.test-d.ts b/packages/starlight/__tests__/basics/config.test-d.ts new file mode 100644 index 00000000..8e1e4ebf --- /dev/null +++ b/packages/starlight/__tests__/basics/config.test-d.ts @@ -0,0 +1,51 @@ +import { describe, expectTypeOf, test } from 'vitest'; +import type { StarlightUserConfig } from '../../utils/user-config'; + +describe('sidebar', () => { + test('emits a type error for custom attributes on groups', () => { + type SidebarUserItem = NonNullable<StarlightUserConfig['sidebar']>[number]; + + // Links + expectTypeOf('getting-started').toExtend<SidebarUserItem>(); + expectTypeOf({ slug: 'getting-started' }).toExtend<SidebarUserItem>(); + expectTypeOf({ + label: 'Getting Started', + link: '/getting-started/', + }).toExtend<SidebarUserItem>(); + + // Groups + expectTypeOf({ + label: 'References', + items: [], + }).toExtend<SidebarUserItem>(); + expectTypeOf({ + label: 'References', + autogenerate: { directory: 'references' }, + }).toExtend<SidebarUserItem>(); + + // Links with attributes + expectTypeOf({ + slug: 'getting-started', + attrs: { class: 'test' }, + }).toExtend<SidebarUserItem>(); + expectTypeOf({ + label: 'Getting Started', + link: '/getting-started/', + attrs: { class: 'test' }, + }).toExtend<SidebarUserItem>(); + + // Groups with attributes which are not supported + expectTypeOf({ + label: 'References', + items: [], + attrs: { class: 'test' }, + // @ts-expect-error - Attributes are not supported on groups + }).toExtend<SidebarUserItem>(); + expectTypeOf({ + label: 'References', + autogenerate: { directory: 'references' }, + attrs: { class: 'test' }, + // @ts-expect-error - Attributes are not supported on groups + }).toExtend<SidebarUserItem>(); + }); +}); diff --git a/packages/starlight/__tests__/sidebar/navigation-attributes.test.ts b/packages/starlight/__tests__/sidebar/navigation-attributes.test.ts index 60d24a04..7424e9eb 100644 --- a/packages/starlight/__tests__/sidebar/navigation-attributes.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation-attributes.test.ts @@ -15,7 +15,18 @@ vi.mock('astro:content', async () => sidebar: { attrs: { class: 'advanced', ping: 'https://example.com' } }, }, ], + // Links to pages in the `api/v1/` directory have custom attributes, even nested ones. ['api/v1/users.md', { title: 'Users API' }], + ['api/v1/products/add.md', { title: 'Add Product' }], + [ + 'api/v1/products/remove.md', + // A page in the `api/v1/` directory can specify custom attributes to be merged with the + // default ones. + { + title: 'Remove Product', + sidebar: { attrs: { 'data-experimental': true } }, + }, + ], ['Deprecated API/users.md', { title: 'Deprecated Users API' }], ], }) @@ -125,7 +136,41 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "badge": undefined, + "collapsed": false, + "entries": [ + { + "attrs": { + "class": "current", + "data-version": "1", + }, + "badge": undefined, + "href": "/api/v1/products/add/", + "isCurrent": false, + "label": "Add Product", + "type": "link", + }, + { + "attrs": { + "class": "current", + "data-experimental": true, + "data-version": "1", + }, + "badge": undefined, + "href": "/api/v1/products/remove/", + "isCurrent": false, + "label": "Remove Product", + "type": "link", + }, + ], + "label": "products", + "type": "group", + }, + { + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/users/", "isCurrent": false, diff --git a/packages/starlight/__tests__/sidebar/navigation-badges.test.ts b/packages/starlight/__tests__/sidebar/navigation-badges.test.ts index 1a4e9e25..5cc37fd6 100644 --- a/packages/starlight/__tests__/sidebar/navigation-badges.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation-badges.test.ts @@ -143,7 +143,10 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/users/", "isCurrent": false, diff --git a/packages/starlight/__tests__/sidebar/navigation-hidden.test.ts b/packages/starlight/__tests__/sidebar/navigation-hidden.test.ts index d2d92024..567bccdd 100644 --- a/packages/starlight/__tests__/sidebar/navigation-hidden.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation-hidden.test.ts @@ -118,7 +118,10 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/users/", "isCurrent": false, diff --git a/packages/starlight/__tests__/sidebar/navigation-order.test.ts b/packages/starlight/__tests__/sidebar/navigation-order.test.ts index 31e64413..87b25a56 100644 --- a/packages/starlight/__tests__/sidebar/navigation-order.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation-order.test.ts @@ -126,7 +126,10 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/users/", "isCurrent": false, diff --git a/packages/starlight/__tests__/sidebar/navigation-unicode.test.ts b/packages/starlight/__tests__/sidebar/navigation-unicode.test.ts index c5bb2643..0f59f83f 100644 --- a/packages/starlight/__tests__/sidebar/navigation-unicode.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation-unicode.test.ts @@ -126,7 +126,10 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/用户/", "isCurrent": true, diff --git a/packages/starlight/__tests__/sidebar/navigation.test.ts b/packages/starlight/__tests__/sidebar/navigation.test.ts index a2291819..faa8855e 100644 --- a/packages/starlight/__tests__/sidebar/navigation.test.ts +++ b/packages/starlight/__tests__/sidebar/navigation.test.ts @@ -144,7 +144,10 @@ describe('getSidebar', () => { "collapsed": false, "entries": [ { - "attrs": {}, + "attrs": { + "class": "current", + "data-version": "1", + }, "badge": undefined, "href": "/api/v1/users/", "isCurrent": false, diff --git a/packages/starlight/__tests__/sidebar/vitest.config.ts b/packages/starlight/__tests__/sidebar/vitest.config.ts index 205238c9..0528a3b1 100644 --- a/packages/starlight/__tests__/sidebar/vitest.config.ts +++ b/packages/starlight/__tests__/sidebar/vitest.config.ts @@ -39,7 +39,7 @@ export default defineVitestConfig({ // A group linking to all pages in the `api/v1` directory. { label: 'API v1', - autogenerate: { directory: '/api/v1/' }, + autogenerate: { directory: '/api/v1/', attrs: { class: 'current', 'data-version': '1' } }, }, // A group linking to all pages in the `Deprecated API/` directory. { diff --git a/packages/starlight/schemas/sidebar.ts b/packages/starlight/schemas/sidebar.ts index 2c4cc1d5..b76bc80e 100644 --- a/packages/starlight/schemas/sidebar.ts +++ b/packages/starlight/schemas/sidebar.ts @@ -14,6 +14,14 @@ const SidebarBaseSchema = z.object({ }); const SidebarGroupSchema = SidebarBaseSchema.extend({ + /** + * Explicitly prevent custom attributes on groups as the final type for supported sidebar item + * is a non-discriminated union where TypeScript will not perform excess property checks. + * This means that a user could define a sidebar group with custom attributes, not getting a + * TypeScript error, and only have it fail at runtime. + * @see https://github.com/microsoft/TypeScript/issues/20863 + */ + attrs: z.never().optional(), /** Whether this item should be collapsed by default. */ collapsed: z.boolean().default(false), }); @@ -22,7 +30,7 @@ const SidebarGroupSchema = SidebarBaseSchema.extend({ // `Record<string, string | number | boolean | undefined>` but typed as `HTMLAttributes<'a'>` // for user convenience. const linkHTMLAttributesSchema = z.record( - z.union([z.string(), z.number(), z.boolean(), z.undefined()]) + z.union([z.string(), z.number(), z.boolean(), z.undefined(), z.null()]) ) as z.Schema<Omit<HTMLAttributes<'a'>, keyof AstroBuiltinAttributes | 'children'>>; export type LinkHTMLAttributes = z.infer<typeof linkHTMLAttributesSchema>; @@ -46,6 +54,8 @@ const AutoSidebarGroupSchema = SidebarGroupSchema.extend({ * Defaults to the `AutoSidebarGroup` `collapsed` value. */ collapsed: z.boolean().optional(), + /** HTML attributes to add to the autogenerated link items. */ + attrs: SidebarLinkItemHTMLAttributesSchema(), // TODO: not supported by Docusaurus but would be good to have /** How many directories deep to include from this directory in the sidebar. Default: `Infinity`. */ // depth: z.number().optional(), diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index 2870d619..9e0e96dc 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -96,7 +96,7 @@ function groupFromAutogenerateConfig( routes: Route[], currentPathname: string ): SidebarGroup { - const { collapsed: subgroupCollapsed, directory } = item.autogenerate; + const { attrs, collapsed: subgroupCollapsed, directory } = item.autogenerate; const localeDir = locale ? locale + '/' + directory : directory; const dirDocs = routes.filter((doc) => { const filePathFromContentDir = getRoutePathRelativeToCollectionRoot(doc, locale); @@ -112,7 +112,13 @@ function groupFromAutogenerateConfig( return { type: 'group', label, - entries: sidebarFromDir(tree, currentPathname, locale, subgroupCollapsed ?? item.collapsed), + entries: sidebarFromDir( + tree, + currentPathname, + locale, + subgroupCollapsed ?? item.collapsed, + attrs + ), collapsed: item.collapsed, badge: getSidebarBadge(item.badge, locale, label), }; @@ -268,12 +274,12 @@ function treeify(routes: Route[], locale: string | undefined, baseDir: string): } /** Create a link entry for a given content collection entry. */ -function linkFromRoute(route: Route): SidebarLink { +function linkFromRoute(route: Route, attrs?: LinkHTMLAttributes): SidebarLink { return makeSidebarLink( slugToPathname(route.slug), route.entry.data.sidebar.label || route.entry.data.title, route.entry.data.sidebar.badge, - route.entry.data.sidebar.attrs + { ...attrs, ...route.entry.data.sidebar.attrs } ); } @@ -307,10 +313,11 @@ function groupFromDir( dirName: string, currentPathname: string, locale: string | undefined, - collapsed: boolean + collapsed: boolean, + attrs?: LinkHTMLAttributes ): SidebarGroup { const entries = sortDirEntries(Object.entries(dir)).map(([key, dirOrRoute]) => - dirToItem(dirOrRoute, `${fullPath}/${key}`, key, currentPathname, locale, collapsed) + dirToItem(dirOrRoute, `${fullPath}/${key}`, key, currentPathname, locale, collapsed, attrs) ); return { type: 'group', @@ -328,11 +335,12 @@ function dirToItem( dirName: string, currentPathname: string, locale: string | undefined, - collapsed: boolean + collapsed: boolean, + attrs?: LinkHTMLAttributes ): SidebarEntry { return isDir(dirOrRoute) - ? groupFromDir(dirOrRoute, fullPath, dirName, currentPathname, locale, collapsed) - : linkFromRoute(dirOrRoute); + ? groupFromDir(dirOrRoute, fullPath, dirName, currentPathname, locale, collapsed, attrs) + : linkFromRoute(dirOrRoute, attrs); } /** Create a sidebar entry for a given content directory. */ @@ -340,10 +348,11 @@ function sidebarFromDir( tree: Dir, currentPathname: string, locale: string | undefined, - collapsed: boolean + collapsed: boolean, + attrs?: LinkHTMLAttributes ) { return sortDirEntries(Object.entries(tree)).map(([key, dirOrRoute]) => - dirToItem(dirOrRoute, key, key, currentPathname, locale, collapsed) + dirToItem(dirOrRoute, key, key, currentPathname, locale, collapsed, attrs) ); } |