From dd64836af45f33df4a99ab864eabb91fc9b8e204 Mon Sep 17 00:00:00 2001 From: Kevin <46791833+kevinzunigacuellar@users.noreply.github.com> Date: Wed, 5 Jun 2024 13:11:15 -0400 Subject: [PATCH 1/2] Add `` component (#1530) Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com> Co-authored-by: Sarah Rainsberger Co-authored-by: Chris Swithinbank Co-authored-by: trueberryless <99918022+trueberryless@users.noreply.github.com> Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com> --- .changeset/wicked-melons-study.md | 6 + docs/src/content/docs/guides/components.mdx | 30 ++++ docs/src/content/docs/guides/sidebar.mdx | 8 +- .../content/docs/reference/configuration.mdx | 3 +- .../src/content/docs/reference/frontmatter.md | 2 +- packages/starlight/components.ts | 1 + packages/starlight/components/Badge.astro | 87 ----------- .../starlight/components/SidebarSublist.astro | 23 ++- packages/starlight/schemas/badge.ts | 9 ++ .../starlight/user-components/Badge.astro | 141 ++++++++++++++++++ 10 files changed, 206 insertions(+), 104 deletions(-) create mode 100644 .changeset/wicked-melons-study.md delete mode 100644 packages/starlight/components/Badge.astro create mode 100644 packages/starlight/user-components/Badge.astro diff --git a/.changeset/wicked-melons-study.md b/.changeset/wicked-melons-study.md new file mode 100644 index 0000000000..9de08e60dd --- /dev/null +++ b/.changeset/wicked-melons-study.md @@ -0,0 +1,6 @@ +--- +'@astrojs/starlight': minor +--- + +Adds a new `` component + diff --git a/docs/src/content/docs/guides/components.mdx b/docs/src/content/docs/guides/components.mdx index 30178aec7d..95a5e30f2d 100644 --- a/docs/src/content/docs/guides/components.mdx +++ b/docs/src/content/docs/guides/components.mdx @@ -412,6 +412,36 @@ import { Steps } from '@astrojs/starlight/components'; +### Badges + +import { Badge } from '@astrojs/starlight/components'; + +Use the `` component to display small pieces of information, such as status or labels. + +Pass the content you want to display to the `text` attribute of the `` component. + +By default, the badge will use the theme accent color of your site. To use a built-in badge color, set the `variant` attribute to one of the following values: `note` (blue), `tip` (purple), `danger` (red), `caution` (orange), or `success` (green). + +The `size` attribute (default: `small`) controls the size of the badge text. `medium` and `large` are also available options for displaying a larger badge. + +For further customization, use other `` attributes such as `class` or `style` with custom CSS. + +```mdx title="src/content/docs/example.mdx" +import { Badge } from '@astrojs/starlight/components'; + + + + + +``` + +The code above generates the following on the page: + + + + + + ### Icon import { Icon } from '@astrojs/starlight/components'; diff --git a/docs/src/content/docs/guides/sidebar.mdx b/docs/src/content/docs/guides/sidebar.mdx index bf9d360564..0632b53713 100644 --- a/docs/src/content/docs/guides/sidebar.mdx +++ b/docs/src/content/docs/guides/sidebar.mdx @@ -294,12 +294,14 @@ The configuration above generates the following sidebar: ]} /> -### Badge variants +### Badge variants and custom styling -Customize the badge styling using an object with `text` and `variant` properties. +Customize the badge styling using an object with `text`, `variant`, and `class` properties. The `text` represents the content to display (e.g. "New"). -Override the `default` styling, which uses the accent color of your site, by setting the `variant` property to one of the following values: `note`, `tip`, `danger`, `caution` or `success`. +By default, the badge will use the accent color of your site. To use a built-in badge style, set the `variant` property to one of the following values: `note`, `tip`, `danger`, `caution` or `success`. + +Optionally, you can create a custom badge style by setting the `class` property to a CSS class name. ```js {10} starlight({ diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index a940a844ea..59816e1a87 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -214,7 +214,8 @@ type SidebarItem = { ```ts interface BadgeConfig { text: string; - variant: 'note' | 'tip' | 'caution' | 'danger' | 'success' | 'default'; + variant?: 'note' | 'tip' | 'caution' | 'danger' | 'success' | 'default'; + class?: string; } ``` diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index 32ae46cabd..41fc4a5b78 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -355,7 +355,7 @@ sidebar: Add a badge to the page in the sidebar when displayed in an autogenerated group of links. When using a string, the badge will be displayed with a default accent color. -Optionally, pass a [`BadgeConfig` object](/reference/configuration/#badgeconfig) with `text` and `variant` fields to customize the badge. +Optionally, pass a [`BadgeConfig` object](/reference/configuration/#badgeconfig) with `text`, `variant`, and `class` fields to customize the badge. ```md --- diff --git a/packages/starlight/components.ts b/packages/starlight/components.ts index e564b62b73..2935e9fff8 100644 --- a/packages/starlight/components.ts +++ b/packages/starlight/components.ts @@ -1,4 +1,5 @@ export { default as Aside } from './user-components/Aside.astro'; +export { default as Badge } from './user-components/Badge.astro'; export { default as Card } from './user-components/Card.astro'; export { default as CardGrid } from './user-components/CardGrid.astro'; export { default as Icon } from './user-components/Icon.astro'; diff --git a/packages/starlight/components/Badge.astro b/packages/starlight/components/Badge.astro deleted file mode 100644 index 7e8c0da2d4..0000000000 --- a/packages/starlight/components/Badge.astro +++ /dev/null @@ -1,87 +0,0 @@ ---- -import type { Badge } from '../schemas/badge'; - -interface Props { - variant?: Badge['variant'] | 'outline'; - text?: string; -} -const { variant = 'default', text } = Astro.props; ---- - - - - diff --git a/packages/starlight/components/SidebarSublist.astro b/packages/starlight/components/SidebarSublist.astro index 399d191fa9..232525016e 100644 --- a/packages/starlight/components/SidebarSublist.astro +++ b/packages/starlight/components/SidebarSublist.astro @@ -1,7 +1,7 @@ --- import { flattenSidebar, type SidebarEntry } from '../utils/navigation'; import Icon from '../user-components/Icon.astro'; -import Badge from './Badge.astro'; +import Badge from '../user-components/Badge.astro'; interface Props { sublist: SidebarEntry[]; @@ -24,13 +24,11 @@ const { sublist, nested } = Astro.props; > {entry.label} {entry.badge && ( - <> - {' '} - - + )} ) : ( @@ -41,10 +39,11 @@ const { sublist, nested } = Astro.props;
{entry.label} {entry.badge && ( - <> - {' '} - - + )}
diff --git a/packages/starlight/schemas/badge.ts b/packages/starlight/schemas/badge.ts index dc6fdb67a1..a2cac1b177 100644 --- a/packages/starlight/schemas/badge.ts +++ b/packages/starlight/schemas/badge.ts @@ -4,8 +4,17 @@ 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() + .extend({ + size: z.enum(['small', 'medium', 'large']).default('small'), + }) + .passthrough(); + +export type BadgeComponentProps = z.input; + export const BadgeConfigSchema = () => z .union([z.string(), badgeSchema()]) diff --git a/packages/starlight/user-components/Badge.astro b/packages/starlight/user-components/Badge.astro new file mode 100644 index 0000000000..821cd0a32e --- /dev/null +++ b/packages/starlight/user-components/Badge.astro @@ -0,0 +1,141 @@ +--- +import { BadgeComponentSchema, type BadgeComponentProps } from '../schemas/badge'; +import { parseWithFriendlyErrors } from '../utils/error-map'; +import type { HTMLAttributes } from 'astro/types'; + +type Props = BadgeComponentProps & HTMLAttributes<'span'>; + +const { + text, + variant, + size, + class: customClass, + ...attrs +} = parseWithFriendlyErrors( + BadgeComponentSchema, + Astro.props, + 'Invalid prop passed to the `` component.' +); +--- + +{text} + + From ee0cd38a1fae31717fe820e779baeabe693cd67a Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Wed, 5 Jun 2024 19:23:37 +0200 Subject: [PATCH 2/2] Add support for `Astro.currentLocale` (#1841) Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank --- .changeset/early-pots-perform.md | 10 + docs/src/content/docs/guides/i18n.mdx | 16 ++ packages/starlight/404.astro | 4 +- .../__tests__/basics/config-errors.test.ts | 1 + .../starlight/__tests__/basics/i18n.test.ts | 250 +++++++++++++++++- .../i18n-non-root-single-locale/i18n.test.ts | 39 +++ .../__tests__/i18n-root-locale/i18n.test.ts | 51 ++++ .../starlight/__tests__/i18n/i18n.test.ts | 57 ++++ packages/starlight/index.ts | 15 +- .../integrations/shared/localeToLang.ts | 3 +- .../utils/createTranslationSystem.ts | 3 +- packages/starlight/utils/i18n.ts | 166 ++++++++++++ packages/starlight/utils/routing.ts | 3 +- packages/starlight/utils/slugs.ts | 3 +- packages/starlight/utils/user-config.ts | 11 +- 15 files changed, 619 insertions(+), 13 deletions(-) create mode 100644 .changeset/early-pots-perform.md create mode 100644 packages/starlight/__tests__/i18n-non-root-single-locale/i18n.test.ts create mode 100644 packages/starlight/__tests__/i18n-root-locale/i18n.test.ts create mode 100644 packages/starlight/__tests__/i18n/i18n.test.ts diff --git a/.changeset/early-pots-perform.md b/.changeset/early-pots-perform.md new file mode 100644 index 0000000000..14c33315c4 --- /dev/null +++ b/.changeset/early-pots-perform.md @@ -0,0 +1,10 @@ +--- +"@astrojs/starlight": minor +--- + +Adds support for `Astro.currentLocale` and Astro’s i18n routing. + +⚠️ **Potentially breaking change:** Starlight now configures Astro’s `i18n` option for you based on its `locales` config. + +If you are currently using Astro’s `i18n` option as well as Starlight’s `locales` option, you will need to remove one of these. +In general we recommend using Starlight’s `locales`, but if you have a more advanced configuration you may choose to keep Astro’s `i18n` config instead. diff --git a/docs/src/content/docs/guides/i18n.mdx b/docs/src/content/docs/guides/i18n.mdx index 6f1513f8a1..a7455d419f 100644 --- a/docs/src/content/docs/guides/i18n.mdx +++ b/docs/src/content/docs/guides/i18n.mdx @@ -67,6 +67,8 @@ Starlight provides built-in support for multilingual sites, including routing, f +For more advanced i18n scenarios, Starlight also supports configuring internationalization using the [Astro’s `i18n` config](https://docs.astro.build/en/guides/internationalization/#configure-i18n-routing) option. + ### Use a root locale You can use a “root” locale to serve a language without any i18n prefix in its path. For example, if English is your root locale, an English page path would look like `/about` instead of `/en/about`. @@ -272,3 +274,17 @@ export const collections = { ``` Learn more about content collection schemas in [“Defining a collection schema”](https://docs.astro.build/en/guides/content-collections/#defining-a-collection-schema) in the Astro docs. + +## Accessing the current locale + +You can use [`Astro.currentLocale`](https://docs.astro.build/en/reference/api-reference/#astrocurrentlocale) to read the current locale in `.astro` components. + +The following example reads the current locale and uses it to generate a link to an about page in the current language: + +```astro +--- +// src/components/AboutLink.astro +--- + +About +``` diff --git a/packages/starlight/404.astro b/packages/starlight/404.astro index ed7dc6f6c0..15cabd6a23 100644 --- a/packages/starlight/404.astro +++ b/packages/starlight/404.astro @@ -6,10 +6,12 @@ import Page from './components/Page.astro'; import { generateRouteData } from './utils/route-data'; import type { StarlightDocsEntry } from './utils/routing'; import { useTranslations } from './utils/translations'; +import { BuiltInDefaultLocale } from './utils/i18n'; export const prerender = true; -const { lang = 'en', dir = 'ltr' } = config.defaultLocale || {}; +const { lang = BuiltInDefaultLocale.lang, dir = BuiltInDefaultLocale.dir } = + config.defaultLocale || {}; let locale = config.defaultLocale?.locale; if (locale === 'root') locale = undefined; diff --git a/packages/starlight/__tests__/basics/config-errors.test.ts b/packages/starlight/__tests__/basics/config-errors.test.ts index c76e06757a..551c560018 100644 --- a/packages/starlight/__tests__/basics/config-errors.test.ts +++ b/packages/starlight/__tests__/basics/config-errors.test.ts @@ -59,6 +59,7 @@ test('parses valid config successfully', () => { }, "head": [], "isMultilingual": false, + "isUsingBuiltInDefaultLocale": true, "lastUpdated": false, "locales": undefined, "pagefind": true, diff --git a/packages/starlight/__tests__/basics/i18n.test.ts b/packages/starlight/__tests__/basics/i18n.test.ts index 1ab6262fa6..f609e528c0 100644 --- a/packages/starlight/__tests__/basics/i18n.test.ts +++ b/packages/starlight/__tests__/basics/i18n.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from 'vitest'; -import { pickLang } from '../../utils/i18n'; +import { assert, describe, expect, test } from 'vitest'; +import config from 'virtual:starlight/user-config'; +import { processI18nConfig, pickLang } from '../../utils/i18n'; +import type { AstroConfig } from 'astro'; +import type { AstroUserConfig } from 'astro/config'; describe('pickLang', () => { const dictionary = { en: 'Hello', fr: 'Bonjour' }; @@ -13,3 +16,246 @@ describe('pickLang', () => { expect(pickLang(dictionary, 'ar' as any)).toBeUndefined(); }); }); + +describe('processI18nConfig', () => { + test('returns the Astro i18n config for an unconfigured monolingual site using the built-in default locale', () => { + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, undefined); + + expect(astroI18nConfig.defaultLocale).toBe('en'); + expect(astroI18nConfig.locales).toEqual(['en']); + assert(typeof astroI18nConfig.routing !== 'string'); + expect(astroI18nConfig.routing?.prefixDefaultLocale).toBe(false); + + // The Starlight configuration should not be modified. + expect(config).toStrictEqual(starlightConfig); + }); + + describe('with a provided Astro i18n config', () => { + test('throws an error when an Astro i18n `manual` routing option is used', () => { + expect(() => + processI18nConfig( + config, + getAstroI18nTestConfig({ + defaultLocale: 'en', + locales: ['en', 'fr'], + routing: 'manual', + }) + ) + ).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Starlight is not compatible with the \`manual\` routing option in the Astro i18n configuration. + Hint: + " + `); + }); + + test('throws an error when an Astro i18n config contains an invalid locale', () => { + expect(() => + processI18nConfig( + config, + getAstroI18nTestConfig({ + defaultLocale: 'en', + locales: ['en', 'foo'], + }) + ) + ).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Failed to get locale informations for the 'foo' locale. + Hint: + Make sure to provide a valid BCP-47 tags (e.g. en, ar, or zh-CN)." + `); + }); + + test.each([ + { + i18nConfig: { defaultLocale: 'en', locales: ['en'] }, + expected: { + defaultLocale: { label: 'English', lang: 'en', dir: 'ltr', locale: undefined }, + }, + }, + { + i18nConfig: { defaultLocale: 'fr', locales: [{ codes: ['fr'], path: 'fr' }] }, + expected: { + defaultLocale: { label: 'Français', lang: 'fr', dir: 'ltr', locale: undefined }, + }, + }, + { + i18nConfig: { + defaultLocale: 'fa', + locales: ['fa'], + routing: { prefixDefaultLocale: false }, + }, + expected: { + defaultLocale: { label: 'فارسی', lang: 'fa', dir: 'rtl', locale: undefined }, + }, + }, + ])( + 'updates the Starlight i18n config for a monolingual site with a single root locale', + ({ i18nConfig, expected }) => { + const astroI18nTestConfig = getAstroI18nTestConfig(i18nConfig); + + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, astroI18nTestConfig); + + expect(starlightConfig.isMultilingual).toBe(false); + expect(starlightConfig.locales).not.toBeDefined(); + expect(starlightConfig.defaultLocale).toStrictEqual(expected.defaultLocale); + + // The Astro i18n configuration should not be modified. + expect(astroI18nConfig).toStrictEqual(astroI18nConfig); + } + ); + + test.each([ + { + i18nConfig: { + defaultLocale: 'en', + locales: ['en'], + routing: { prefixDefaultLocale: true }, + }, + expected: { + defaultLocale: { label: 'English', lang: 'en', dir: 'ltr', locale: 'en' }, + locales: { en: { label: 'English', lang: 'en', dir: 'ltr' } }, + }, + }, + { + i18nConfig: { + defaultLocale: 'french', + locales: [{ codes: ['fr'], path: 'french' }], + routing: { prefixDefaultLocale: true }, + }, + expected: { + defaultLocale: { label: 'Français', lang: 'fr', dir: 'ltr', locale: 'fr' }, + locales: { french: { label: 'Français', lang: 'fr', dir: 'ltr' } }, + }, + }, + { + i18nConfig: { + defaultLocale: 'farsi', + locales: [{ codes: ['fa'], path: 'farsi' }], + routing: { prefixDefaultLocale: true }, + }, + expected: { + defaultLocale: { label: 'فارسی', lang: 'fa', dir: 'rtl', locale: 'fa' }, + locales: { farsi: { label: 'فارسی', lang: 'fa', dir: 'rtl' } }, + }, + }, + ])( + 'updates the Starlight i18n config for a monolingual site with a single non-root locale', + ({ i18nConfig, expected }) => { + const astroI18nTestConfig = getAstroI18nTestConfig(i18nConfig); + + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, astroI18nTestConfig); + + expect(starlightConfig.isMultilingual).toBe(false); + expect(starlightConfig.locales).toStrictEqual(expected.locales); + expect(starlightConfig.defaultLocale).toStrictEqual(expected.defaultLocale); + + // The Astro i18n configuration should not be modified. + expect(astroI18nConfig).toStrictEqual(astroI18nConfig); + } + ); + + test.each([ + { + i18nConfig: { + defaultLocale: 'en', + locales: ['en', { codes: ['fr'], path: 'french' }], + }, + expected: { + defaultLocale: { label: 'English', lang: 'en', dir: 'ltr', locale: 'en' }, + locales: { + root: { label: 'English', lang: 'en', dir: 'ltr' }, + french: { label: 'Français', lang: 'fr', dir: 'ltr' }, + }, + }, + }, + { + i18nConfig: { + defaultLocale: 'farsi', + // This configuration is a bit confusing as `prefixDefaultLocale` is `false` but the + // default locale is defined with a custom path. + // In this case, the default locale is considered to be a root locale and the custom path + // is ignored. + locales: [{ codes: ['fa'], path: 'farsi' }, 'de'], + routing: { prefixDefaultLocale: false }, + }, + expected: { + defaultLocale: { label: 'فارسی', lang: 'fa', dir: 'rtl', locale: 'fa' }, + locales: { + root: { label: 'فارسی', lang: 'fa', dir: 'rtl' }, + de: { label: 'Deutsch', lang: 'de', dir: 'ltr' }, + }, + }, + }, + ])( + 'updates the Starlight i18n config for a multilingual site with a root locale', + ({ i18nConfig, expected }) => { + const astroI18nTestConfig = getAstroI18nTestConfig(i18nConfig); + + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, astroI18nTestConfig); + + expect(starlightConfig.isMultilingual).toBe(true); + expect(starlightConfig.locales).toEqual(expected.locales); + expect(starlightConfig.defaultLocale).toEqual(expected.defaultLocale); + + // The Astro i18n configuration should not be modified. + expect(astroI18nConfig).toEqual(astroI18nConfig); + } + ); + + test.each([ + { + i18nConfig: { + defaultLocale: 'en', + locales: ['en', { codes: ['fr'], path: 'french' }], + routing: { prefixDefaultLocale: true }, + }, + expected: { + defaultLocale: { label: 'English', lang: 'en', dir: 'ltr', locale: 'en' }, + locales: { + en: { label: 'English', lang: 'en', dir: 'ltr' }, + french: { label: 'Français', lang: 'fr', dir: 'ltr' }, + }, + }, + }, + { + i18nConfig: { + defaultLocale: 'farsi', + locales: [{ codes: ['fa'], path: 'farsi' }, 'de'], + routing: { prefixDefaultLocale: true }, + }, + expected: { + defaultLocale: { label: 'فارسی', lang: 'fa', dir: 'rtl', locale: 'fa' }, + locales: { + farsi: { label: 'فارسی', lang: 'fa', dir: 'rtl' }, + de: { label: 'Deutsch', lang: 'de', dir: 'ltr' }, + }, + }, + }, + ])( + 'updates the Starlight i18n config for a multilingual site with no root locale', + ({ i18nConfig, expected }) => { + const astroI18nTestConfig = getAstroI18nTestConfig(i18nConfig); + + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, astroI18nTestConfig); + + expect(starlightConfig.isMultilingual).toBe(true); + expect(starlightConfig.locales).toEqual(expected.locales); + expect(starlightConfig.defaultLocale).toEqual(expected.defaultLocale); + + // The Astro i18n configuration should not be modified. + expect(astroI18nConfig).toEqual(astroI18nConfig); + } + ); + }); +}); + +function getAstroI18nTestConfig(i18nConfig: AstroUserConfig['i18n']): AstroConfig['i18n'] { + return { + ...i18nConfig, + routing: + typeof i18nConfig?.routing !== 'string' + ? { prefixDefaultLocale: false, ...i18nConfig?.routing } + : i18nConfig.routing, + } as AstroConfig['i18n']; +} diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/i18n.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/i18n.test.ts new file mode 100644 index 0000000000..b42d2a2915 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/i18n.test.ts @@ -0,0 +1,39 @@ +import { assert, describe, expect, test } from 'vitest'; +import type { AstroConfig } from 'astro'; +import config from 'virtual:starlight/user-config'; +import { processI18nConfig } from '../../utils/i18n'; + +describe('processI18nConfig', () => { + test('returns the Astro i18n config for a monolingual site with a non-root single locale', () => { + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, undefined); + + expect(astroI18nConfig.defaultLocale).toBe('fr-CA'); + expect(astroI18nConfig.locales).toMatchInlineSnapshot(` + [ + { + "codes": [ + "fr-CA", + ], + "path": "fr", + }, + ] + `); + assert(typeof astroI18nConfig.routing !== 'string'); + expect(astroI18nConfig.routing?.prefixDefaultLocale).toBe(true); + + // The Starlight configuration should not be modified. + expect(config).toStrictEqual(starlightConfig); + }); + + test('throws an error when an Astro i18n config is also provided', () => { + expect(() => + processI18nConfig(config, { defaultLocale: 'en', locales: ['en'] } as AstroConfig['i18n']) + ).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Cannot provide both an Astro \`i18n\` configuration and a Starlight \`locales\` configuration. + Hint: + Remove one of the two configurations. + See more at https://starlight.astro.build/guides/i18n/" + `); + }); +}); diff --git a/packages/starlight/__tests__/i18n-root-locale/i18n.test.ts b/packages/starlight/__tests__/i18n-root-locale/i18n.test.ts new file mode 100644 index 0000000000..049fad8fc2 --- /dev/null +++ b/packages/starlight/__tests__/i18n-root-locale/i18n.test.ts @@ -0,0 +1,51 @@ +import { assert, describe, expect, test } from 'vitest'; +import type { AstroConfig } from 'astro'; +import config from 'virtual:starlight/user-config'; +import { processI18nConfig } from '../../utils/i18n'; + +describe('processI18nConfig', () => { + test('returns Astro i18n config for a multilingual site with a root locale', () => { + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, undefined); + + expect(astroI18nConfig.defaultLocale).toBe('fr'); + expect(astroI18nConfig.locales).toMatchInlineSnapshot(` + [ + { + "codes": [ + "fr", + ], + "path": "fr", + }, + { + "codes": [ + "en-US", + ], + "path": "en", + }, + { + "codes": [ + "ar", + ], + "path": "ar", + }, + ] + `); + assert(typeof astroI18nConfig.routing !== 'string'); + expect(astroI18nConfig.routing?.prefixDefaultLocale).toBe(false); + + // The Starlight configuration should not be modified. + expect(config).toStrictEqual(starlightConfig); + }); + + test('throws an error when an Astro i18n config is also provided', () => { + expect(() => + processI18nConfig(config, { defaultLocale: 'en', locales: ['en'] } as AstroConfig['i18n']) + ).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Cannot provide both an Astro \`i18n\` configuration and a Starlight \`locales\` configuration. + Hint: + Remove one of the two configurations. + See more at https://starlight.astro.build/guides/i18n/" + `); + }); +}); diff --git a/packages/starlight/__tests__/i18n/i18n.test.ts b/packages/starlight/__tests__/i18n/i18n.test.ts new file mode 100644 index 0000000000..383e57ca29 --- /dev/null +++ b/packages/starlight/__tests__/i18n/i18n.test.ts @@ -0,0 +1,57 @@ +import { assert, describe, expect, test } from 'vitest'; +import type { AstroConfig } from 'astro'; +import config from 'virtual:starlight/user-config'; +import { processI18nConfig } from '../../utils/i18n'; + +describe('processI18nConfig', () => { + test('returns the Astro i18n config for a multilingual site with no root locale', () => { + const { astroI18nConfig, starlightConfig } = processI18nConfig(config, undefined); + + expect(astroI18nConfig.defaultLocale).toBe('en-US'); + expect(astroI18nConfig.locales).toMatchInlineSnapshot(` + [ + { + "codes": [ + "fr", + ], + "path": "fr", + }, + { + "codes": [ + "en-US", + ], + "path": "en", + }, + { + "codes": [ + "ar", + ], + "path": "ar", + }, + { + "codes": [ + "pt-BR", + ], + "path": "pt-br", + }, + ] + `); + assert(typeof astroI18nConfig.routing !== 'string'); + expect(astroI18nConfig.routing?.prefixDefaultLocale).toBe(true); + + // The Starlight configuration should not be modified. + expect(config).toStrictEqual(starlightConfig); + }); + + test('throws an error when an Astro i18n config is also provided', () => { + expect(() => + processI18nConfig(config, { defaultLocale: 'en', locales: ['en'] } as AstroConfig['i18n']) + ).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Cannot provide both an Astro \`i18n\` configuration and a Starlight \`locales\` configuration. + Hint: + Remove one of the two configurations. + See more at https://starlight.astro.build/guides/i18n/" + `); + }); +}); diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index ee31491f13..a7876ab80d 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -10,6 +10,7 @@ import { vitePluginStarlightUserConfig } from './integrations/virtual-user-confi import { rehypeRtlCodeSupport } from './integrations/code-rtl-support'; import { createTranslationSystemFromFs } from './utils/translations-fs'; import { runPlugins, type StarlightUserConfigWithPlugins } from './utils/plugins'; +import { processI18nConfig } from './utils/i18n'; import type { StarlightConfig } from './types'; export default function StarlightIntegration({ @@ -28,18 +29,25 @@ export default function StarlightIntegration({ logger, updateConfig, }) => { - // Run plugins to get the final configuration and any extra Astro integrations to load. - const { integrations, starlightConfig } = await runPlugins(opts, plugins, { + // Run plugins to get the updated configuration and any extra Astro integrations to load. + const pluginResult = await runPlugins(opts, plugins, { command, config, isRestart, logger, }); + // Process the Astro and Starlight configurations for i18n and translations. + const { astroI18nConfig, starlightConfig } = processI18nConfig( + pluginResult.starlightConfig, + config.i18n + ); + + const { integrations } = pluginResult; userConfig = starlightConfig; const useTranslations = createTranslationSystemFromFs(starlightConfig, config); - if (!userConfig.disable404Route) { + if (!starlightConfig.disable404Route) { injectRoute({ pattern: '404', entrypoint: '@astrojs/starlight/404.astro', @@ -92,6 +100,7 @@ export default function StarlightIntegration({ experimental: { globalRoutePriority: true, }, + i18n: astroI18nConfig, }); }, diff --git a/packages/starlight/integrations/shared/localeToLang.ts b/packages/starlight/integrations/shared/localeToLang.ts index 5d79d01785..6c21b8144b 100644 --- a/packages/starlight/integrations/shared/localeToLang.ts +++ b/packages/starlight/integrations/shared/localeToLang.ts @@ -1,4 +1,5 @@ import type { StarlightConfig } from '../../types'; +import { BuiltInDefaultLocale } from '../../utils/i18n'; /** * Get the BCP-47 language tag for the given locale. @@ -7,5 +8,5 @@ import type { StarlightConfig } from '../../types'; export function localeToLang(config: StarlightConfig, locale: string | undefined): string { const lang = locale ? config.locales?.[locale]?.lang : config.locales?.root?.lang; const defaultLang = config.defaultLocale?.lang || config.defaultLocale?.locale; - return lang || defaultLang || 'en'; + return lang || defaultLang || BuiltInDefaultLocale.lang; } diff --git a/packages/starlight/utils/createTranslationSystem.ts b/packages/starlight/utils/createTranslationSystem.ts index de1d3c822d..c65c7f6b95 100644 --- a/packages/starlight/utils/createTranslationSystem.ts +++ b/packages/starlight/utils/createTranslationSystem.ts @@ -1,5 +1,6 @@ import type { i18nSchemaOutput } from '../schemas/i18n'; import builtinTranslations from '../translations/index'; +import { BuiltInDefaultLocale } from './i18n'; import type { StarlightConfig } from './user-config'; export function createTranslationSystem( @@ -64,7 +65,7 @@ function localeToLang( ): string { const lang = locale ? locales?.[locale]?.lang : locales?.root?.lang; const defaultLang = defaultLocale?.lang || defaultLocale?.locale; - return lang || defaultLang || 'en'; + return lang || defaultLang || BuiltInDefaultLocale.lang; } type BuiltInStrings = (typeof builtinTranslations)['en']; diff --git a/packages/starlight/utils/i18n.ts b/packages/starlight/utils/i18n.ts index e00dfc66c2..cb7c052a18 100644 --- a/packages/starlight/utils/i18n.ts +++ b/packages/starlight/utils/i18n.ts @@ -1,3 +1,166 @@ +import type { AstroConfig } from 'astro'; +import { AstroError } from 'astro/errors'; +import type { StarlightConfig } from './user-config'; + +/** Informations about the built-in default locale used as a fallback when no locales are defined. */ +export const BuiltInDefaultLocale = { ...getLocaleInfo('en'), lang: 'en' }; + +/** + * Processes the Astro and Starlight i18n configurations to generate/update them accordingly: + * + * - If no Astro and Starlight i18n configurations are provided, the built-in default locale is + * used in Starlight and the generated Astro i18n configuration will match it. + * - If only a Starlight i18n configuration is provided, an equivalent Astro i18n configuration is + * generated. + * - If only an Astro i18n configuration is provided, an equivalent Starlight i18n configuration is + * used. + * - If both an Astro and Starlight i18n configurations are provided, an error is thrown. + */ +export function processI18nConfig( + starlightConfig: StarlightConfig, + astroI18nConfig: AstroConfig['i18n'] +) { + // We don't know what to do if both an Astro and Starlight i18n configuration are provided. + if (astroI18nConfig && !starlightConfig.isUsingBuiltInDefaultLocale) { + throw new AstroError( + 'Cannot provide both an Astro `i18n` configuration and a Starlight `locales` configuration.', + 'Remove one of the two configurations.\nSee more at https://starlight.astro.build/guides/i18n/' + ); + } else if (astroI18nConfig) { + // If a Starlight compatible Astro i18n configuration is provided, we generate the matching + // Starlight configuration. + return { + astroI18nConfig, + starlightConfig: { + ...starlightConfig, + ...getStarlightI18nConfig(astroI18nConfig), + } as StarlightConfig, + }; + } + // Otherwise, we generate the Astro i18n configuration based on the Starlight configuration. + return { astroI18nConfig: getAstroI18nConfig(starlightConfig), starlightConfig: starlightConfig }; +} + +/** Generate an Astro i18n configuration based on a Starlight configuration. */ +function getAstroI18nConfig(config: StarlightConfig): NonNullable { + return { + defaultLocale: + config.defaultLocale.lang ?? config.defaultLocale.locale ?? BuiltInDefaultLocale.lang, + locales: config.locales + ? Object.entries(config.locales).map(([locale, localeConfig]) => { + return { + codes: [localeConfig?.lang ?? locale], + path: locale === 'root' ? localeConfig?.lang ?? BuiltInDefaultLocale.lang : locale, + }; + }) + : [BuiltInDefaultLocale.lang], + routing: { + prefixDefaultLocale: + // Sites with multiple languages without a root locale. + (config.isMultilingual && config.locales?.root === undefined) || + // Sites with a single non-root language different from the built-in default locale. + (!config.isMultilingual && config.locales !== undefined), + redirectToDefaultLocale: false, + }, + }; +} + +/** Generate a Starlight i18n configuration based on an Astro configuration. */ +function getStarlightI18nConfig( + astroI18nConfig: NonNullable +): Pick { + if (astroI18nConfig.routing === 'manual') { + throw new AstroError( + 'Starlight is not compatible with the `manual` routing option in the Astro i18n configuration.' + ); + } + + const prefixDefaultLocale = astroI18nConfig.routing.prefixDefaultLocale; + const isMultilingual = astroI18nConfig.locales.length > 1; + const isMonolingualWithRootLocale = !isMultilingual && !prefixDefaultLocale; + + const locales = isMonolingualWithRootLocale + ? undefined + : Object.fromEntries( + astroI18nConfig.locales.map((locale) => [ + isDefaultAstroLocale(astroI18nConfig, locale) && !prefixDefaultLocale + ? 'root' + : isAstroLocaleExtendedConfig(locale) + ? locale.path + : locale, + inferStarlightLocaleFromAstroLocale(locale), + ]) + ); + + const defaultAstroLocale = astroI18nConfig.locales.find((locale) => + isDefaultAstroLocale(astroI18nConfig, locale) + ); + + // This should never happen as Astro validation should prevent this case. + if (!defaultAstroLocale) { + throw new AstroError( + 'Astro default locale not found.', + 'This should never happen. Please open a new issue: https://github.com/withastro/starlight/issues/new?template=---01-bug-report.yml' + ); + } + + return { + isMultilingual, + locales, + defaultLocale: { + ...inferStarlightLocaleFromAstroLocale(defaultAstroLocale), + locale: isMonolingualWithRootLocale + ? undefined + : isAstroLocaleExtendedConfig(defaultAstroLocale) + ? defaultAstroLocale.codes[0] + : defaultAstroLocale, + }, + }; +} + +/** Infer Starlight locale informations based on a locale from an Astro i18n configuration. */ +function inferStarlightLocaleFromAstroLocale(astroLocale: AstroLocale) { + const lang = isAstroLocaleExtendedConfig(astroLocale) ? astroLocale.codes[0] : astroLocale; + return { ...getLocaleInfo(lang), lang }; +} + +/** Check if the passed locale is the default locale in an Astro i18n configuration. */ +function isDefaultAstroLocale( + astroI18nConfig: NonNullable, + locale: AstroLocale +) { + return ( + (isAstroLocaleExtendedConfig(locale) ? locale.path : locale) === astroI18nConfig.defaultLocale + ); +} + +/** + * Check if the passed Astro locale is using the object variant. + * @see AstroLocaleExtendedConfig + */ +function isAstroLocaleExtendedConfig(locale: AstroLocale): locale is AstroLocaleExtendedConfig { + return typeof locale !== 'string'; +} + +/** Returns the locale informations such as a label and a direction based on a BCP-47 tag. */ +function getLocaleInfo(lang: string) { + try { + const locale = new Intl.Locale(lang); + const label = new Intl.DisplayNames(locale, { type: 'language' }).of(lang); + if (!label || lang === label) throw new Error('Label not found.'); + return { + label: label[0]?.toLocaleUpperCase(locale) + label.slice(1), + // @ts-expect-error - `textInfo` is not part of the `Intl.Locale` type but is available in Node.js 18.0.0+. + dir: locale.textInfo.direction as 'ltr' | 'rtl', + }; + } catch (error) { + throw new AstroError( + `Failed to get locale informations for the '${lang}' locale.`, + 'Make sure to provide a valid BCP-47 tags (e.g. en, ar, or zh-CN).' + ); + } +} + /** * Get the string for the passed language from a dictionary object. * @@ -14,3 +177,6 @@ export function pickLang>( ): string | undefined { return dictionary[lang]; } + +type AstroLocale = NonNullable['locales'][number]; +type AstroLocaleExtendedConfig = Exclude; diff --git a/packages/starlight/utils/routing.ts b/packages/starlight/utils/routing.ts index 8ca555b4b4..a0b15fd386 100644 --- a/packages/starlight/utils/routing.ts +++ b/packages/starlight/utils/routing.ts @@ -9,6 +9,7 @@ import { slugToParam, } from './slugs'; import { validateLogoImports } from './validateLogoImports'; +import { BuiltInDefaultLocale } from './i18n'; // Validate any user-provided logos imported correctly. // We do this here so all pages trigger it and at the top level so it runs just once. @@ -86,7 +87,7 @@ function getRoutes(): Route[] { slug, id, isFallback: true, - lang: localeConfig.lang || 'en', + lang: localeConfig.lang || BuiltInDefaultLocale.lang, locale, dir: localeConfig.dir, entryMeta: slugToLocaleData(fallback.slug), diff --git a/packages/starlight/utils/slugs.ts b/packages/starlight/utils/slugs.ts index b7e076c907..75f907213a 100644 --- a/packages/starlight/utils/slugs.ts +++ b/packages/starlight/utils/slugs.ts @@ -1,4 +1,5 @@ import config from 'virtual:starlight/user-config'; +import { BuiltInDefaultLocale } from './i18n'; export interface LocaleData { /** Writing direction. */ @@ -35,7 +36,7 @@ export function slugToLocaleData(slug: string): LocaleData { export function localeToLang(locale: string | undefined): string { const lang = locale ? config.locales?.[locale]?.lang : config.locales?.root?.lang; const defaultLang = config.defaultLocale?.lang || config.defaultLocale?.locale; - return lang || defaultLang || 'en'; + return lang || defaultLang || BuiltInDefaultLocale.lang; } /** diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index efd6193eff..d80c5d83ac 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -9,6 +9,7 @@ import { SidebarItemSchema } from '../schemas/sidebar'; import { SocialLinksSchema } from '../schemas/social'; import { TableOfContentsSchema } from '../schemas/tableOfContents'; import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title'; +import { BuiltInDefaultLocale } from './i18n'; const LocaleSchema = z.object({ /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */ @@ -244,6 +245,8 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: configuredLocales.length > 1, + /** Flag indicating if the Starlight built-in default locale is used. */ + isUsingBuiltInDefaultLocale: false, /** Full locale object for this site’s default language. */ defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale }, locales, @@ -254,9 +257,9 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( // pretty simple. /** Full locale object for this site’s default language. */ const defaultLocaleConfig = { - label: 'English', - lang: 'en', - dir: 'ltr' as const, + label: BuiltInDefaultLocale.label, + lang: BuiltInDefaultLocale.lang, + dir: BuiltInDefaultLocale.dir, locale: undefined, ...locales?.root, }; @@ -268,6 +271,8 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: false, + /** Flag indicating if the Starlight built-in default locale is used. */ + isUsingBuiltInDefaultLocale: locales?.root === undefined, defaultLocale: defaultLocaleConfig, locales: undefined, } as const;