diff --git a/.changeset/slow-flowers-sort.md b/.changeset/slow-flowers-sort.md new file mode 100644 index 00000000000..c93a151fb03 --- /dev/null +++ b/.changeset/slow-flowers-sort.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds support for translating sidebar badges. diff --git a/docs/src/components/sidebar-preview.astro b/docs/src/components/sidebar-preview.astro index 810ac1cc925..b7761defa40 100644 --- a/docs/src/components/sidebar-preview.astro +++ b/docs/src/components/sidebar-preview.astro @@ -5,13 +5,16 @@ import type { InternalSidebarLinkItem, } from '../../../packages/starlight/schemas/sidebar'; import SidebarSublist from '../../../packages/starlight/components/SidebarSublist.astro'; +import type { Badge } from '../../../packages/starlight/schemas/badge'; import type { SidebarEntry } from '../../../packages/starlight/utils/navigation'; interface Props { config: SidebarConfig; } -type SidebarConfig = Exclude[]; +type SidebarConfig = (Exclude & { + badge?: Badge; +})[]; const { config } = Astro.props; diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx index 8e368b15f5c..35500284651 100644 --- a/docs/src/content/docs/guides/sidebar.mdx +++ b/docs/src/content/docs/guides/sidebar.mdx @@ -520,6 +520,52 @@ Browsing the documentation in Brazilian Portuguese will generate the following s In multilingual sites, the value of `slug` does not include the language portion of the URL. For example, if you have pages at `en/intro` and `pt-br/intro`, the slug is `intro` when configuring the sidebar. +### Internationalization with badges + +For [badges](#badges), the `text` property can be a string, or for multilingual sites, an object with values for each different locale. +When using the object form, the keys must be [BCP-47](https://www.w3.org/International/questions/qa-choosing-language-tags) tags (e.g. `en`, `ar`, or `zh-CN`): + +```js {11-16} +starlight({ + sidebar: [ + { + label: 'Constellations', + translations: { + 'pt-BR': 'Constelações', + }, + items: [ + { + slug: 'constellations/andromeda', + badge: { + text: { + en: 'New', + 'pt-BR': 'Novo', + }, + }, + }, + ], + }, + ], +}); +``` + +Browsing the documentation in Brazilian Portuguese will generate the following sidebar: + + + ## Collapsing groups Groups of links can be collapsed by default by setting the `collapsed` property to `true`. diff --git a/packages/starlight/__tests__/i18n-sidebar-badge-error/i18n-sidebar-badge-error.test.ts b/packages/starlight/__tests__/i18n-sidebar-badge-error/i18n-sidebar-badge-error.test.ts new file mode 100644 index 00000000000..fddea454474 --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar-badge-error/i18n-sidebar-badge-error.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test, vi } from 'vitest'; +import { getSidebar } from '../../utils/navigation'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + docs: [['getting-started.mdx', { title: 'Getting Started' }]], + }) +); + +describe('getSidebar', () => { + test('throws an error if an i18n badge doesn’t have a key for the default language', () => { + expect(() => getSidebar('/', undefined)).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The badge text for "Getting Started" must have a key for the default language "en-US". + Hint: + Update the Starlight config to include a badge text for the default language. + Learn more about sidebar badges internationalization at https://starlight.astro.build/guides/sidebar/#internationalization-with-badges" + `); + }); +}); diff --git a/packages/starlight/__tests__/i18n-sidebar-badge-error/vitest.config.ts b/packages/starlight/__tests__/i18n-sidebar-badge-error/vitest.config.ts new file mode 100644 index 00000000000..a741e609357 --- /dev/null +++ b/packages/starlight/__tests__/i18n-sidebar-badge-error/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineVitestConfig } from '../test-config'; + +export default defineVitestConfig({ + title: 'i18n sidebar badge error', + locales: { + fr: { label: 'French' }, + root: { label: 'English', lang: 'en-US' }, + }, + sidebar: [ + { + slug: 'getting-started', + badge: { text: { fr: 'Nouveau' } }, + }, + ], +}); diff --git a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts index d6496d42fb7..750c42446ea 100644 --- a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts +++ b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar-fallback-slug.test.ts @@ -37,7 +37,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "New", + "variant": "default", + }, "href": "/manual-setup", "isCurrent": false, "label": "Do it yourself", @@ -45,7 +48,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Eco-friendly", + "variant": "success", + }, "href": "/environmental-impact", "isCurrent": false, "label": "Eco-friendly docs", @@ -65,7 +71,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Deprecated", + "variant": "default", + }, "href": "/guides/authoring-content", "isCurrent": false, "label": "Authoring Content in Markdown", @@ -107,7 +116,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Nouveau", + "variant": "default", + }, "href": "/fr/manual-setup", "isCurrent": false, "label": "Fait maison", @@ -115,7 +127,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Écologique", + "variant": "success", + }, "href": "/fr/environmental-impact", "isCurrent": false, "label": "Eco-friendly docs", @@ -135,7 +150,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Deprecated", + "variant": "default", + }, "href": "/fr/guides/authoring-content", "isCurrent": false, "label": "Authoring Content in Markdown", diff --git a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts index 7206579a1a5..52b7ff7b05c 100644 --- a/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts +++ b/packages/starlight/__tests__/i18n-sidebar/i18n-sidebar.test.ts @@ -44,7 +44,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "New", + "variant": "default", + }, "href": "/manual-setup", "isCurrent": false, "label": "Do it yourself", @@ -52,7 +55,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Eco-friendly", + "variant": "success", + }, "href": "/environmental-impact", "isCurrent": false, "label": "Eco-friendly docs", @@ -72,7 +78,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Deprecated", + "variant": "default", + }, "href": "/guides/authoring-content", "isCurrent": false, "label": "Authoring Content in Markdown", @@ -114,7 +123,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Nouveau", + "variant": "default", + }, "href": "/fr/manual-setup", "isCurrent": false, "label": "Fait maison", @@ -122,7 +134,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Écologique", + "variant": "success", + }, "href": "/fr/environmental-impact", "isCurrent": false, "label": "Documents écologiques", @@ -142,7 +157,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Deprecated", + "variant": "default", + }, "href": "/fr/guides/authoring-content", "isCurrent": false, "label": "Création de contenu en Markdown", @@ -184,7 +202,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Nouveau", + "variant": "default", + }, "href": "/fr/manual-setup", "isCurrent": false, "label": "Fait maison", @@ -192,7 +213,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Écologique", + "variant": "success", + }, "href": "/fr/environmental-impact", "isCurrent": false, "label": "Documents écologiques", @@ -212,7 +236,10 @@ describe('getSidebar', () => { }, { "attrs": {}, - "badge": undefined, + "badge": { + "text": "Deprecated", + "variant": "default", + }, "href": "/fr/guides/authoring-content", "isCurrent": false, "label": "Création de contenu en Markdown", diff --git a/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts b/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts index 59f3bdf141f..6cdb4d746f6 100644 --- a/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts +++ b/packages/starlight/__tests__/i18n-sidebar/vitest.config.ts @@ -9,11 +9,22 @@ export default defineVitestConfig({ sidebar: [ { slug: 'index' }, 'getting-started', - { slug: 'manual-setup', label: 'Do it yourself', translations: { fr: 'Fait maison' } }, - { slug: 'environmental-impact' }, + { + slug: 'manual-setup', + label: 'Do it yourself', + translations: { fr: 'Fait maison' }, + badge: { text: { 'en-US': 'New', fr: 'Nouveau' } }, + }, + { + slug: 'environmental-impact', + badge: { + variant: 'success', + text: { 'en-US': 'Eco-friendly', fr: 'Écologique' }, + }, + }, { label: 'Guides', - items: [{ slug: 'guides/pages' }, { slug: 'guides/authoring-content' }], + items: [{ slug: 'guides/pages' }, { slug: 'guides/authoring-content', badge: 'Deprecated' }], }, 'resources/plugins', ], diff --git a/packages/starlight/schemas/badge.ts b/packages/starlight/schemas/badge.ts index a2cac1b177a..f3f1f738f68 100644 --- a/packages/starlight/schemas/badge.ts +++ b/packages/starlight/schemas/badge.ts @@ -1,13 +1,19 @@ import { z } from 'astro/zod'; -const badgeSchema = () => - z.object({ - variant: z.enum(['note', 'danger', 'success', 'caution', 'tip', 'default']).default('default'), - text: z.string(), - class: z.string().optional(), - }); - -export const BadgeComponentSchema = badgeSchema() +const badgeBaseSchema = z.object({ + variant: z.enum(['note', 'danger', 'success', 'caution', 'tip', 'default']).default('default'), + class: z.string().optional(), +}); + +const badgeSchema = badgeBaseSchema.extend({ + text: z.string(), +}); + +const i18nBadgeSchema = badgeBaseSchema.extend({ + text: z.union([z.string(), z.record(z.string())]), +}); + +export const BadgeComponentSchema = badgeSchema .extend({ size: z.enum(['small', 'medium', 'large']).default('small'), }) @@ -17,7 +23,7 @@ export type BadgeComponentProps = z.input; export const BadgeConfigSchema = () => z - .union([z.string(), badgeSchema()]) + .union([z.string(), badgeSchema]) .transform((badge) => { if (typeof badge === 'string') { return { variant: 'default' as const, text: badge }; @@ -26,4 +32,8 @@ export const BadgeConfigSchema = () => }) .optional(); -export type Badge = z.output>; +export const I18nBadgeConfigSchema = () => z.union([z.string(), i18nBadgeSchema]).optional(); + +export type Badge = z.output; +export type I18nBadge = z.output; +export type I18nBadgeConfig = z.output>; diff --git a/packages/starlight/schemas/sidebar.ts b/packages/starlight/schemas/sidebar.ts index b71d159ed44..2c4cc1d588f 100644 --- a/packages/starlight/schemas/sidebar.ts +++ b/packages/starlight/schemas/sidebar.ts @@ -1,7 +1,7 @@ import type { AstroBuiltinAttributes } from 'astro'; import type { HTMLAttributes } from 'astro/types'; import { z } from 'astro/zod'; -import { BadgeConfigSchema } from './badge'; +import { I18nBadgeConfigSchema } from './badge'; import { stripLeadingAndTrailingSlashes } from '../utils/path'; const SidebarBaseSchema = z.object({ @@ -9,8 +9,8 @@ const SidebarBaseSchema = z.object({ label: z.string(), /** Translations of the `label` for each supported language. */ translations: z.record(z.string()).default({}), - /** Adds a badge to the link item */ - badge: BadgeConfigSchema(), + /** Adds a badge to the item */ + badge: I18nBadgeConfigSchema(), }); const SidebarGroupSchema = SidebarBaseSchema.extend({ diff --git a/packages/starlight/utils/navigation.ts b/packages/starlight/utils/navigation.ts index cd1475d1522..526d9f29ddc 100644 --- a/packages/starlight/utils/navigation.ts +++ b/packages/starlight/utils/navigation.ts @@ -1,6 +1,6 @@ import { AstroError } from 'astro/errors'; import config from 'virtual:starlight/user-config'; -import type { Badge } from '../schemas/badge'; +import type { Badge, I18nBadge, I18nBadgeConfig } from '../schemas/badge'; import type { PrevNextLinkConfig } from '../schemas/prevNextLink'; import type { AutoSidebarGroup, @@ -11,7 +11,7 @@ import type { } from '../schemas/sidebar'; import { createPathFormatter } from './createPathFormatter'; import { formatPath } from './format-path'; -import { pickLang } from './i18n'; +import { BuiltInDefaultLocale, pickLang } from './i18n'; import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingAndTrailingSlashes } from './path'; import { getLocaleRoutes, routes, type Route } from './routing'; import { localeToLang, slugToPathname } from './slugs'; @@ -79,12 +79,13 @@ function configItemToEntry( } else if ('slug' in item) { return linkFromInternalSidebarLinkItem(item, locale, currentPathname); } else { + const label = pickLang(item.translations, localeToLang(locale)) || item.label; return { type: 'group', - label: pickLang(item.translations, localeToLang(locale)) || item.label, + label, entries: item.items.map((i) => configItemToEntry(i, currentPathname, locale, routes)), collapsed: item.collapsed, - badge: item.badge, + badge: getSidebarBadge(item.badge, locale, label), }; } } @@ -106,12 +107,13 @@ function groupFromAutogenerateConfig( doc.id.startsWith(localeDir + '/') ); const tree = treeify(dirDocs, localeDir); + const label = pickLang(item.translations, localeToLang(locale)) || item.label; return { type: 'group', - label: pickLang(item.translations, localeToLang(locale)) || item.label, + label, entries: sidebarFromDir(tree, currentPathname, locale, subgroupCollapsed ?? item.collapsed), collapsed: item.collapsed, - badge: item.badge, + badge: getSidebarBadge(item.badge, locale, label), }; } @@ -131,7 +133,13 @@ function linkFromSidebarLinkItem( if (locale) href = '/' + locale + href; } const label = pickLang(item.translations, localeToLang(locale)) || item.label; - return makeSidebarLink(href, label, currentPathname, item.badge, item.attrs); + return makeSidebarLink( + href, + label, + currentPathname, + getSidebarBadge(item.badge, locale, label), + item.attrs + ); } /** Create a link entry from an automatic internal link item in user config. */ @@ -161,7 +169,13 @@ function linkFromInternalSidebarLinkItem( } const label = pickLang(item.translations, localeToLang(locale)) || item.label || entry.entry.data.title; - return makeSidebarLink(entry.slug, label, currentPathname, item.badge, item.attrs); + return makeSidebarLink( + entry.slug, + label, + currentPathname, + getSidebarBadge(item.badge, locale, label), + item.attrs + ); } /** Process sidebar link options to create a link entry. */ @@ -446,3 +460,38 @@ function stripExtension(path: string) { const periodIndex = path.lastIndexOf('.'); return path.slice(0, periodIndex > -1 ? periodIndex : undefined); } + +/** Get a sidebar badge for a given item. */ +function getSidebarBadge( + config: I18nBadgeConfig, + locale: string | undefined, + itemLabel: string +): Badge | undefined { + if (!config) return; + if (typeof config === 'string') { + return { variant: 'default', text: config }; + } + return { ...config, text: getSidebarBadgeText(config.text, locale, itemLabel) }; +} + +/** Get the badge text for a sidebar item. */ +function getSidebarBadgeText( + text: I18nBadge['text'], + locale: string | undefined, + itemLabel: string +): string { + if (typeof text === 'string') return text; + const defaultLang = + config.defaultLocale?.lang || config.defaultLocale?.locale || BuiltInDefaultLocale.lang; + const defaultText = text[defaultLang]; + + if (!defaultText) { + throw new AstroError( + `The badge text for "${itemLabel}" must have a key for the default language "${defaultLang}".`, + 'Update the Starlight config to include a badge text for the default language.\n' + + 'Learn more about sidebar badges internationalization at https://starlight.astro.build/guides/sidebar/#internationalization-with-badges' + ); + } + + return pickLang(text, localeToLang(locale)) || defaultText; +} diff --git a/packages/starlight/vitest.config.ts b/packages/starlight/vitest.config.ts index 47ca99be5ea..d94437430c5 100644 --- a/packages/starlight/vitest.config.ts +++ b/packages/starlight/vitest.config.ts @@ -21,10 +21,10 @@ export default defineConfig({ ], thresholds: { autoUpdate: true, - lines: 89.18, - functions: 92.7, - branches: 93.04, - statements: 89.18, + lines: 89.28, + functions: 92.78, + branches: 92.83, + statements: 89.28, }, }, },