diff options
author | HiDeoo | 2024-06-05 19:23:37 +0200 |
---|---|---|
committer | GitHub | 2024-06-05 19:23:37 +0200 |
commit | ee0cd38a1fae31717fe820e779baeabe693cd67a (patch) | |
tree | 51f33ed1a351d1e2e099f152d889093daba390b1 | |
parent | dd64836af45f33df4a99ab864eabb91fc9b8e204 (diff) | |
download | IT.starlight-ee0cd38a1fae31717fe820e779baeabe693cd67a.tar.gz IT.starlight-ee0cd38a1fae31717fe820e779baeabe693cd67a.tar.bz2 IT.starlight-ee0cd38a1fae31717fe820e779baeabe693cd67a.zip |
Add support for `Astro.currentLocale` (#1841)
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
-rw-r--r-- | .changeset/early-pots-perform.md | 10 | ||||
-rw-r--r-- | docs/src/content/docs/guides/i18n.mdx | 16 | ||||
-rw-r--r-- | packages/starlight/404.astro | 4 | ||||
-rw-r--r-- | packages/starlight/__tests__/basics/config-errors.test.ts | 1 | ||||
-rw-r--r-- | packages/starlight/__tests__/basics/i18n.test.ts | 250 | ||||
-rw-r--r-- | packages/starlight/__tests__/i18n-non-root-single-locale/i18n.test.ts | 39 | ||||
-rw-r--r-- | packages/starlight/__tests__/i18n-root-locale/i18n.test.ts | 51 | ||||
-rw-r--r-- | packages/starlight/__tests__/i18n/i18n.test.ts | 57 | ||||
-rw-r--r-- | packages/starlight/index.ts | 15 | ||||
-rw-r--r-- | packages/starlight/integrations/shared/localeToLang.ts | 3 | ||||
-rw-r--r-- | packages/starlight/utils/createTranslationSystem.ts | 3 | ||||
-rw-r--r-- | packages/starlight/utils/i18n.ts | 166 | ||||
-rw-r--r-- | packages/starlight/utils/routing.ts | 3 | ||||
-rw-r--r-- | packages/starlight/utils/slugs.ts | 3 | ||||
-rw-r--r-- | packages/starlight/utils/user-config.ts | 11 |
15 files changed, 619 insertions, 13 deletions
diff --git a/.changeset/early-pots-perform.md b/.changeset/early-pots-perform.md new file mode 100644 index 00000000..14c33315 --- /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 6f1513f8..a7455d41 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 </Steps> +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 +--- + +<a href={`/${Astro.currentLocale}/about`}>About</a> +``` diff --git a/packages/starlight/404.astro b/packages/starlight/404.astro index ed7dc6f6..15cabd6a 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 c76e0675..551c5600 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 1ab6262f..f609e528 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 00000000..b42d2a29 --- /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 00000000..049fad8f --- /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 00000000..383e57ca --- /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 ee31491f..a7876ab8 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 5d79d017..6c21b814 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 de1d3c82..c65c7f6b 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<T extends i18nSchemaOutput>( @@ -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 e00dfc66..cb7c052a 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<AstroConfig['i18n']> { + 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<AstroConfig['i18n']> +): Pick<StarlightConfig, 'isMultilingual' | 'locales' | 'defaultLocale'> { + 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<AstroConfig['i18n']>, + 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<T extends Record<string, string>>( ): string | undefined { return dictionary[lang]; } + +type AstroLocale = NonNullable<AstroConfig['i18n']>['locales'][number]; +type AstroLocaleExtendedConfig = Exclude<AstroLocale, string>; diff --git a/packages/starlight/utils/routing.ts b/packages/starlight/utils/routing.ts index 8ca555b4..a0b15fd3 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 b7e076c9..75f90721 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 efd6193e..d80c5d83 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; |