From 5269aad928773ae08b35ba8e19c0f2832d0d2c89 Mon Sep 17 00:00:00 2001 From: HiDeoo <494699+HiDeoo@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:55:37 +0200 Subject: [PATCH] Use `i18next` for UI strings and add new `injectTranslations` plugin callback (#1923) Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com> Co-authored-by: Chris Swithinbank Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com> --- .changeset/cool-experts-sort.md | 19 +++ .changeset/eighty-beds-attack.md | 11 ++ .changeset/thirty-dodos-drop.md | 11 ++ .npmrc | 1 + docs/src/content/docs/guides/i18n.mdx | 112 +++++++++++++ docs/src/content/docs/reference/overrides.md | 6 - docs/src/content/docs/reference/plugins.md | 69 ++++++++ package.json | 10 +- packages/docsearch/DocSearch.astro | 19 ++- packages/docsearch/package.json | 5 +- packages/markdoc/package.json | 2 + .../__tests__/basics/route-data.test.ts | 19 ++- .../basics/starlight-page-route-data.test.ts | 11 +- .../__tests__/basics/translations.test.ts | 149 ++++++++++++++++++ .../route-data.test.ts | 19 --- .../route-data.test.ts | 19 --- .../__tests__/i18n/route-data.test.ts | 32 ---- .../__tests__/i18n/translations-fs.test.ts | 28 ++++ .../__tests__/i18n/translations.test.ts | 8 + .../__tests__/plugins/translations.test.ts | 48 ++++++ .../__tests__/plugins/vitest.config.ts | 22 ++- packages/starlight/__tests__/test-config.ts | 23 ++- packages/starlight/__tests__/test-utils.ts | 13 +- .../components/DraftContentNotice.astro | 4 +- packages/starlight/components/EditLink.astro | 4 +- .../components/FallbackContentNotice.astro | 4 +- packages/starlight/components/Footer.astro | 2 +- .../starlight/components/LanguageSelect.astro | 4 +- .../starlight/components/LastUpdated.astro | 4 +- .../components/MobileMenuToggle.astro | 4 +- .../components/MobileTableOfContents.astro | 4 +- packages/starlight/components/PageFrame.astro | 4 +- .../starlight/components/Pagination.astro | 6 +- packages/starlight/components/Search.astro | 18 +-- .../components/SidebarRestorePoint.astro | 2 +- packages/starlight/components/SkipLink.astro | 4 +- .../components/TableOfContents.astro | 4 +- .../starlight/components/ThemeSelect.astro | 10 +- packages/starlight/i18n.d.ts | 18 +++ packages/starlight/index.ts | 36 ++++- packages/starlight/integrations/asides.ts | 2 +- .../integrations/virtual-user-config.ts | 5 +- packages/starlight/locals.d.ts | 17 ++ packages/starlight/locals.ts | 8 + packages/starlight/package.json | 4 +- .../utils/createTranslationSystem.ts | 93 ++++++++--- packages/starlight/utils/i18n.ts | 20 +++ packages/starlight/utils/plugins.ts | 69 +++++++- packages/starlight/utils/route-data.ts | 9 +- packages/starlight/utils/starlight-page.ts | 21 +-- packages/starlight/utils/translations-fs.ts | 7 +- packages/starlight/utils/translations.ts | 13 +- packages/starlight/utils/types.ts | 15 ++ packages/starlight/virtual.d.ts | 5 + packages/starlight/vitest.config.ts | 8 +- packages/tailwind/package.json | 1 + pnpm-lock.yaml | 121 +++++++------- tsconfig.json | 1 - 58 files changed, 925 insertions(+), 282 deletions(-) create mode 100644 .changeset/cool-experts-sort.md create mode 100644 .changeset/eighty-beds-attack.md create mode 100644 .changeset/thirty-dodos-drop.md create mode 100644 packages/starlight/__tests__/basics/translations.test.ts delete mode 100644 packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts delete mode 100644 packages/starlight/__tests__/i18n-single-root-locale/route-data.test.ts delete mode 100644 packages/starlight/__tests__/i18n/route-data.test.ts create mode 100644 packages/starlight/__tests__/plugins/translations.test.ts create mode 100644 packages/starlight/i18n.d.ts create mode 100644 packages/starlight/locals.d.ts create mode 100644 packages/starlight/locals.ts create mode 100644 packages/starlight/utils/types.ts diff --git a/.changeset/cool-experts-sort.md b/.changeset/cool-experts-sort.md new file mode 100644 index 00000000000..6de9fd8c02f --- /dev/null +++ b/.changeset/cool-experts-sort.md @@ -0,0 +1,19 @@ +--- +'@astrojs/starlight': minor +--- + +Overhauls the built-in localization system which is now powered by the [`i18next`](https://www.i18next.com/) library and available to use anywhere in your documentation website. + +See the [“Using UI translations”](https://starlight.astro.build/guides/i18n/#using-ui-translations) guide to learn more about how to access built-in UI labels or your own custom strings in your project. Plugin authors can also use the new [`injectTranslations()`](https://starlight.astro.build/reference/plugins/#injecttranslations) helper to add or update translation strings. + +⚠️ **BREAKING CHANGE:** The `Astro.props.labels` props has been removed from the props passed down to custom component overrides. + +If you are relying on `Astro.props.labels` (for example to read a built-in UI label), you will need to update your code to use the new [`Astro.locals.t()`](https://starlight.astro.build/guides/i18n/#using-ui-translations) helper instead. + +```astro +--- +import type { Props } from '@astrojs/starlight/props'; +// The `search.label` UI label for this page’s language: +const searchLabel = Astro.locals.t('search.label'); +--- +``` diff --git a/.changeset/eighty-beds-attack.md b/.changeset/eighty-beds-attack.md new file mode 100644 index 00000000000..000fa3e945c --- /dev/null +++ b/.changeset/eighty-beds-attack.md @@ -0,0 +1,11 @@ +--- +'@astrojs/starlight-docsearch': minor +--- + +⚠️ **BREAKING CHANGE:** The minimum supported version of Starlight is now 0.28.0 + +Please use the `@astrojs/upgrade` command to upgrade your project: + +```sh +npx @astrojs/upgrade +``` diff --git a/.changeset/thirty-dodos-drop.md b/.changeset/thirty-dodos-drop.md new file mode 100644 index 00000000000..4fef44db65a --- /dev/null +++ b/.changeset/thirty-dodos-drop.md @@ -0,0 +1,11 @@ +--- +'@astrojs/starlight': minor +--- + +⚠️ **BREAKING CHANGE:** The minimum supported version of Astro is now 4.14.0 + +Please update Astro and Starlight together: + +```sh +npx @astrojs/upgrade +``` diff --git a/.npmrc b/.npmrc index 901b4e86a04..11cb30ee198 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,4 @@ prefer-workspace-packages=true link-workspace-packages=true shell-emulator=true +auto-install-peers=false diff --git a/docs/src/content/docs/guides/i18n.mdx b/docs/src/content/docs/guides/i18n.mdx index 2fe235b2b6a..a320fbab7af 100644 --- a/docs/src/content/docs/guides/i18n.mdx +++ b/docs/src/content/docs/guides/i18n.mdx @@ -276,6 +276,118 @@ 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. +## Using UI translations + +You can access Starlight’s [built-in UI strings](/guides/i18n/#translate-starlights-ui) as well as [user-defined](/guides/i18n/#extend-translation-schema), and [plugin-provided](/reference/plugins/#injecttranslations) UI strings through a unified API powered by [i18next](https://www.i18next.com/). +This includes support for features like [interpolation](https://www.i18next.com/translation-function/interpolation) and [pluralization](https://www.i18next.com/translation-function/plurals). + +In Astro components, this API is available as part of the [global `Astro` object](https://docs.astro.build/en/reference/api-reference/#astrolocals) as `Astro.locals.t`: + +```astro title="example.astro" +

+ {Astro.locals.t('404.text')} +

+``` + +You can also use the API in [endpoints](https://docs.astro.build/en/guides/endpoints/), where the `locals` object is available as part of the [endpoint context](https://docs.astro.build/en/reference/api-reference/#contextlocals): + +```ts title="src/pages/404.ts" +export const GET = (context) => { + return new Response(context.locals.t('404.text')); +}; +``` + +### Rendering a UI string + +Render UI strings using the `locals.t()` function. +This is an instance of i18next’s `t()` function, which takes a UI string key as its first argument and returns the corresponding translation for the current language. + +For example, given a custom translation file with the following content: + +```json title="src/content/i18n/en.json" +{ + "link.astro": "Astro documentation", + "link.astro.custom": "Astro documentation for {{feature}}" +} +``` + +The first UI string can be rendered by passing `'link.astro'` to the `t()` function: + +```astro {3} + + + {Astro.locals.t('link.astro')} + + +``` + +The second UI string uses i18next’s [interpolation syntax](https://www.i18next.com/translation-function/interpolation) for the `{{feature}}` placeholder. +The value for `feature` must be set in an options object passed as the second argument to `t()`: + +```astro {3} + + + {Astro.locals.t('link.astro.custom', { feature: 'Astro DB' })} + + +``` + +See the [i18next documentation](https://www.i18next.com/overview/api#t) for more information on how to use the `t()` function with interpolation, formatting, and more. + +### Advanced APIs + +#### `t.all()` + +The `locals.t.all()` function returns an object containing all UI strings available for the current locale. + +```astro +--- +// src/components/Example.astro +const allStrings = Astro.locals.t.all(); +// ^ +// { +// "skipLink.label": "Skip to content", +// "search.label": "Search", +// … +// } +--- +``` + +#### `t.exists()` + +To check if a translation key exists for a locale, use the `locals.t.exists()` function with the translation key as first argument. +Pass an optional second argument if you need to override the current locale. + +```astro +--- +// src/components/Example.astro +const keyExistsInCurrentLocale = Astro.locals.t.exists('a.key'); +// ^ true +const keyExistsInFrench = Astro.locals.t.exists('another.key', { lng: 'fr' }); +// ^ false +--- +``` + +See the [`exists()` reference in the i18next documentation](https://www.i18next.com/overview/api#exists) for more information. + +#### `t.dir()` + +The `locals.t.dir()` function returns the text direction of the current or a specific locale. + +```astro +--- +// src/components/Example.astro +const currentDirection = Astro.locals.t.dir(); +// ^ +// 'ltr' +const arabicDirection = Astro.locals.t.dir('ar'); +// ^ +// 'rtl' +--- +``` + +See the [`dir()` reference in the i18next documentation](https://www.i18next.com/overview/api#dir) for more information. + ## 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. diff --git a/docs/src/content/docs/reference/overrides.md b/docs/src/content/docs/reference/overrides.md index 9293654be2a..8e5ed0124cc 100644 --- a/docs/src/content/docs/reference/overrides.md +++ b/docs/src/content/docs/reference/overrides.md @@ -148,12 +148,6 @@ JavaScript `Date` object representing when this page was last updated if enabled `URL` object for the address where this page can be edited if enabled. -#### `labels` - -**Type:** `Record` - -An object containing UI strings localized for the current page. See the [“Translate Starlight’s UI”](/guides/i18n/#translate-starlights-ui) guide for a list of all the available keys. - --- ## Components diff --git a/docs/src/content/docs/reference/plugins.md b/docs/src/content/docs/reference/plugins.md index 7f44f60b6a0..e22d146a3b9 100644 --- a/docs/src/content/docs/reference/plugins.md +++ b/docs/src/content/docs/reference/plugins.md @@ -27,6 +27,7 @@ interface StarlightPlugin { command: 'dev' | 'build' | 'preview'; isRestart: boolean; logger: AstroIntegrationLogger; + injectTranslations: (Record>) => void; }) => void | Promise; }; } @@ -161,3 +162,71 @@ The example above will log a message that includes the provided info message: ```shell [long-process-plugin] Starting long process… ``` + +#### `injectTranslations` + +**type:** `(translations: Record>) => void` + +A callback function to add or update translation strings used in Starlight’s [localization APIs](/guides/i18n/#using-ui-translations). + +In the following example, a plugin injects translations for a custom UI string named `myPlugin.doThing` for the `en` and `fr` locales: + +```ts {6-13} /(injectTranslations)[^(]/ +// plugin.ts +export default { + name: 'plugin-with-translations', + hooks: { + setup({ injectTranslations }) { + injectTranslations({ + en: { + 'myPlugin.doThing': 'Do the thing', + }, + fr: { + 'myPlugin.doThing': 'Faire le truc', + }, + }); + }, + }, +}; +``` + +To use the injected translations in your plugin UI, follow the [“Using UI translations” guide](/guides/i18n/#using-ui-translations). + +Types for a plugin’s injected translation strings are generated automatically in a user’s project, but are not yet available when working in your plugin’s codebase. +To type the `locals.t` object in the context of your plugin, declare the following global namespaces in a TypeScript declaration file: + +```ts +// env.d.ts +declare namespace App { + type StarlightLocals = import('@astrojs/starlight').StarlightLocals; + // Define the `locals.t` object in the context of a plugin. + interface Locals extends StarlightLocals {} +} + +declare namespace StarlightApp { + // Define the additional plugin translations in the `I18n` interface. + interface I18n { + 'myPlugin.doThing': string; + } +} +``` + +You can also infer the types for the `StarlightApp.I18n` interface from a source file if you have an object containing your translations. + +For example, given the following source file: + +```ts title="ui-strings.ts" +export const UIStrings = { + en: { 'myPlugin.doThing': 'Do the thing' }, + fr: { 'myPlugin.doThing': 'Faire le truc' }, +}; +``` + +The following declaration would infer types from the English keys in the source file: + +```ts title="env.d.ts" +declare namespace StarlightApp { + type UIStrings = typeof import('./ui-strings').UIStrings.en; + interface I18n extends UIStrings {} +} +``` diff --git a/package.json b/package.json index e328ac87f59..41c2ad50b0d 100644 --- a/package.json +++ b/package.json @@ -42,5 +42,13 @@ "limit": "14.5 kB", "gzip": true } - ] + ], + "pnpm": { + "peerDependencyRules": { + "ignoreMissing": [ + "@algolia/client-search", + "search-insights" + ] + } + } } diff --git a/packages/docsearch/DocSearch.astro b/packages/docsearch/DocSearch.astro index 12db6b465bf..467192afee5 100644 --- a/packages/docsearch/DocSearch.astro +++ b/packages/docsearch/DocSearch.astro @@ -4,8 +4,6 @@ import '@docsearch/css/dist/modal.css'; import type docsearch from '@docsearch/js'; import './variables.css'; -const { labels } = Astro.props; - type DocSearchTranslationProps = Pick< Parameters[0], 'placeholder' | 'translations' @@ -13,15 +11,18 @@ type DocSearchTranslationProps = Pick< const pick = (keyStart: string) => Object.fromEntries( - Object.entries(labels) + Object.entries(Astro.locals.t.all()) .filter(([key]) => key.startsWith(keyStart)) .map(([key, value]) => [key.replace(keyStart, ''), value]) ); const docsearchTranslations: DocSearchTranslationProps = { - placeholder: labels['search.label'], + placeholder: Astro.locals.t('search.label'), translations: { - button: { buttonText: labels['search.label'], buttonAriaLabel: labels['search.label'] }, + button: { + buttonText: Astro.locals.t('search.label'), + buttonAriaLabel: Astro.locals.t('search.label'), + }, modal: { searchBox: pick('docsearch.searchBox.'), startScreen: pick('docsearch.startScreen.'), @@ -34,7 +35,11 @@ const docsearchTranslations: DocSearchTranslationProps = { --- - diff --git a/packages/docsearch/package.json b/packages/docsearch/package.json index d4ec221e6d0..8826ac25aa6 100644 --- a/packages/docsearch/package.json +++ b/packages/docsearch/package.json @@ -25,10 +25,13 @@ "./schema": "./schema.ts" }, "peerDependencies": { - "@astrojs/starlight": ">=0.14.0" + "@astrojs/starlight": ">=0.28.0" }, "dependencies": { "@docsearch/css": "^3.6.0", "@docsearch/js": "^3.6.0" + }, + "devDependencies": { + "@astrojs/starlight": "workspace:*" } } diff --git a/packages/markdoc/package.json b/packages/markdoc/package.json index 81e79cd8249..8b5898c46b7 100644 --- a/packages/markdoc/package.json +++ b/packages/markdoc/package.json @@ -17,6 +17,8 @@ "./components": "./components.ts" }, "devDependencies": { + "@astrojs/markdoc": "^0.11.4", + "@astrojs/starlight": "workspace:*", "vitest": "^1.6.0" }, "peerDependencies": { diff --git a/packages/starlight/__tests__/basics/route-data.test.ts b/packages/starlight/__tests__/basics/route-data.test.ts index d59ce0135e2..f3293259b4b 100644 --- a/packages/starlight/__tests__/basics/route-data.test.ts +++ b/packages/starlight/__tests__/basics/route-data.test.ts @@ -1,6 +1,7 @@ import { expect, test, vi } from 'vitest'; import { generateRouteData } from '../../utils/route-data'; import { routes } from '../../utils/routing'; +import pkg from '../../package.json'; vi.mock('astro:content', async () => (await import('../test-utils')).mockedAstroContent({ @@ -87,12 +88,24 @@ test('uses explicit last updated date from frontmatter', () => { expect(data.lastUpdated).toEqual(route.entry.data.lastUpdated); }); -test('includes localized labels', () => { +test('throws when accessing a label using the deprecated `labels` prop in pre v1 versions', () => { + const isPreV1 = pkg.version[0] === '0'; + const route = routes[0]!; const data = generateRouteData({ props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, url: new URL('https://example.com'), }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Skip to content'); + + if (isPreV1) { + expect(() => data.labels['any']).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`labels\` prop in component overrides has been removed. + Hint: + Replace \`Astro.props.labels["any"]\` with \`Astro.locals.t("any")\` instead. + For more information see https://starlight.astro.build/guides/i18n/#using-ui-translations" + `); + } else { + expect(() => data.labels['any']).not.toThrow(); + } }); diff --git a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts index 63ac6efd38a..c3510a65c4b 100644 --- a/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts +++ b/packages/starlight/__tests__/basics/starlight-page-route-data.test.ts @@ -467,13 +467,18 @@ test('hides the sidebar if the `hasSidebar` option is not specified and the spla expect(data.hasSidebar).toBe(false); }); -test('includes localized labels', async () => { +test('throws when accessing a label using the deprecated `labels` prop', async () => { const data = await generateStarlightPageRouteData({ props: starlightPageProps, url: starlightPageUrl, }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Skip to content'); + expect(() => data.labels['any']).toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + The \`labels\` prop in component overrides has been removed. + Hint: + Replace \`Astro.props.labels["any"]\` with \`Astro.locals.t("any")\` instead. + For more information see https://starlight.astro.build/guides/i18n/#using-ui-translations" + `); }); test('uses provided edit URL if any', async () => { diff --git a/packages/starlight/__tests__/basics/translations.test.ts b/packages/starlight/__tests__/basics/translations.test.ts new file mode 100644 index 00000000000..abada3e4101 --- /dev/null +++ b/packages/starlight/__tests__/basics/translations.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test, vi } from 'vitest'; +import { useTranslations } from '../../utils/translations'; +import translations from '../../translations'; + +describe('useTranslations()', () => { + test('includes localized UI strings', () => { + const t = useTranslations(undefined); + expect(t).toBeTypeOf('function'); + expect(t('skipLink.label')).toBe('Skip to content'); + }); +}); + +describe('t()', async () => { + // The mocked user-defined translations are scoped to this `describe` block so that they do not + // affect other tests (`vi.mock` → `vi.doMock`). + vi.doMock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + i18n: [ + [ + 'en', + { + 'test.interpolation': '{{subject}} is {{adjective}}', + 'test.dataModel': 'Powered by {{integration.name}}', + 'test.escape': 'The tag is {{tag}}', + 'test.unescape': 'The tag is {{- tag}}', + 'test.currency': 'The price is {{price, currency(USD)}}', + 'test.list': '{{subjects, list}} are awesome', + 'test.count_one': '{{count}} project', + 'test.count_other': '{{count}} projects', + 'test.nesting1': '$t(test.nesting2) is nested', + 'test.nesting2': 'this UI string', + }, + // We do not strip unknown translations in this test so that user-defined translations can + // override plugin translations like it would in a real- world scenario were the plugin + // would have provided a custom schema to extend the translations. + { stripUnknown: false }, + ], + ], + }) + ); + // Reset the modules registry so that re-importing `../../utils/translations` re-evaluates the + // module and re-computes `useTranslations`. Re-importing the module is necessary because + // top-level imports cannot be re-evaluated. + vi.resetModules(); + const { useTranslations } = await import('../../utils/translations'); + const t = useTranslations(undefined); + + test('supports using interpolation', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.interpolation', { subject: 'Starlight', adjective: 'amazing' })).toBe( + 'Starlight is amazing' + ); + }); + + test('supports using data models', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.dataModel', { integration: { name: 'Starlight' } })).toBe( + 'Powered by Starlight' + ); + }); + + test('escapes by default', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.escape', { tag: '' })).toBe('The tag is <img />'); + }); + + test('supports unescaped strings', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.unescape', { tag: '' })).toBe('The tag is '); + }); + + test('supports currencies', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.currency', { price: 1000 })).toBe('The price is $1,000.00'); + }); + + test('supports lists', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.list', { subjects: ['Astro', 'Starlight', 'Astro DB'] })).toBe( + 'Astro, Starlight, and Astro DB are awesome' + ); + }); + + test('supports counts', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.count', { count: 1 })).toBe('1 project'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.count', { count: 20 })).toBe('20 projects'); + }); + + test('supports nesting', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a mocked translation key. + expect(t('test.nesting1')).toBe('this UI string is nested'); + }); + + test('returns the UI string key if the translation is missing', () => { + expect(t).toBeTypeOf('function'); + // @ts-expect-error - using a missing translation key. + expect(t('test.unknown')).toBe('test.unknown'); + }); +}); + +describe('t.all()', async () => { + // See the `t()` tests for an explanation of how the user-defined translations are mocked. + vi.doMock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + i18n: [['en', { 'test.foo': 'bar' }, { stripUnknown: false }]], + }) + ); + vi.resetModules(); + const { useTranslations } = await import('../../utils/translations'); + const t = useTranslations(undefined); + + test('returns all translations including custom ones', () => { + expect(t.all).toBeTypeOf('function'); + expect(t.all()).toEqual({ ...translations.en, 'test.foo': 'bar' }); + }); +}); + +describe('t.exists()', async () => { + // See the `t()` tests for an explanation of how the user-defined translations are mocked. + vi.doMock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + i18n: [['en', { 'test.foo': 'bar' }, { stripUnknown: false }]], + }) + ); + vi.resetModules(); + const { useTranslations } = await import('../../utils/translations'); + const t = useTranslations(undefined); + + test('returns `true` for existing translations', () => { + expect(t.exists).toBeTypeOf('function'); + expect(t.exists('skipLink.label')).toBe(true); + expect(t.exists('test.foo')).toBe(true); + }); + + test('returns `false` for unknown translations', () => { + expect(t.exists).toBeTypeOf('function'); + expect(t.exists('test.unknown')).toBe(false); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts deleted file mode 100644 index dbf5d354816..00000000000 --- a/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { generateRouteData } from '../../utils/route-data'; -import { routes } from '../../utils/routing'; - -vi.mock('astro:content', async () => - (await import('../test-utils')).mockedAstroContent({ - docs: [['fr/index.mdx', { title: 'Accueil' }]], - }) -); - -test('includes localized labels (fr)', () => { - const route = routes[0]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - url: new URL('https://example.com'), - }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Aller au contenu'); -}); diff --git a/packages/starlight/__tests__/i18n-single-root-locale/route-data.test.ts b/packages/starlight/__tests__/i18n-single-root-locale/route-data.test.ts deleted file mode 100644 index 654ba1b7467..00000000000 --- a/packages/starlight/__tests__/i18n-single-root-locale/route-data.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { generateRouteData } from '../../utils/route-data'; -import { routes } from '../../utils/routing'; - -vi.mock('astro:content', async () => - (await import('../test-utils')).mockedAstroContent({ - docs: [['index.mdx', { title: 'Accueil' }]], - }) -); - -test('includes localized labels (fr)', () => { - const route = routes[0]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - url: new URL('https://example.com'), - }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Aller au contenu'); -}); diff --git a/packages/starlight/__tests__/i18n/route-data.test.ts b/packages/starlight/__tests__/i18n/route-data.test.ts deleted file mode 100644 index 57beed0c1d8..00000000000 --- a/packages/starlight/__tests__/i18n/route-data.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { generateRouteData } from '../../utils/route-data'; -import { routes } from '../../utils/routing'; - -vi.mock('astro:content', async () => - (await import('../test-utils')).mockedAstroContent({ - docs: [ - ['fr/index.mdx', { title: 'Accueil' }], - ['pt-br/index.mdx', { title: 'Pagina inicial' }], - ], - }) -); - -test('includes localized labels (fr)', () => { - const route = routes[0]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - url: new URL('https://example.com'), - }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Aller au contenu'); -}); - -test('includes localized labels (pt-br)', () => { - const route = routes[1]!; - const data = generateRouteData({ - props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] }, - url: new URL('https://example.com'), - }); - expect(data.labels).toBeDefined(); - expect(data.labels['skipLink.label']).toBe('Pular para o conteúdo'); -}); diff --git a/packages/starlight/__tests__/i18n/translations-fs.test.ts b/packages/starlight/__tests__/i18n/translations-fs.test.ts index 5b9025aa950..0115f1d355d 100644 --- a/packages/starlight/__tests__/i18n/translations-fs.test.ts +++ b/packages/starlight/__tests__/i18n/translations-fs.test.ts @@ -73,4 +73,32 @@ describe('createTranslationSystemFromFs', () => { ) ).toThrow(SyntaxError); }); + + test('creates a translation system that uses custom strings injected by plugins', () => { + const useTranslations = createTranslationSystemFromFs( + { + locales: { en: { label: 'English', dir: 'ltr' } }, + defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' }, + }, + // Using non-existent `_src/` to ignore custom files in this test fixture. + { srcDir: new URL('./_src/', import.meta.url) }, + { en: { 'page.editLink': 'Make this page even more different' } } + ); + const t = useTranslations('en'); + expect(t('page.editLink')).toMatchInlineSnapshot('"Make this page even more different"'); + }); + + test('creates a translation system that prioritizes user translations over plugin translations', () => { + const useTranslations = createTranslationSystemFromFs( + { + locales: { en: { label: 'English', dir: 'ltr' } }, + defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' }, + }, + // Using `src/` to load custom files in this test fixture. + { srcDir: new URL('./src/', import.meta.url) }, + { en: { 'page.editLink': 'Make this page even more different' } } + ); + const t = useTranslations('en'); + expect(t('page.editLink')).toMatchInlineSnapshot('"Make this page different"'); + }); }); diff --git a/packages/starlight/__tests__/i18n/translations.test.ts b/packages/starlight/__tests__/i18n/translations.test.ts index 35cd94916a9..e0c0ccbef37 100644 --- a/packages/starlight/__tests__/i18n/translations.test.ts +++ b/packages/starlight/__tests__/i18n/translations.test.ts @@ -28,3 +28,11 @@ describe('useTranslations()', () => { expect(t('page.nextLink')).not.toBe(translations.en?.['page.nextLink']); }); }); + +describe('t.dir()', async () => { + test('returns text directions', () => { + expect(useTranslations(undefined).dir()).toBe('ltr'); + expect(useTranslations('fr').dir()).toBe('ltr'); + expect(useTranslations('ar').dir()).toBe('rtl'); + }); +}); diff --git a/packages/starlight/__tests__/plugins/translations.test.ts b/packages/starlight/__tests__/plugins/translations.test.ts new file mode 100644 index 00000000000..3f6574f7297 --- /dev/null +++ b/packages/starlight/__tests__/plugins/translations.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test, vi } from 'vitest'; +import { useTranslations } from '../../utils/translations'; + +vi.mock('astro:content', async () => + (await import('../test-utils')).mockedAstroContent({ + // We do not strip unknown translations in this test so that user-defined translations can + // override plugin translations like it would in a real- world scenario were the plugin would + // have provided a custom schema to extend the translations. + i18n: [['ar', { 'testPlugin3.doThing': 'افعل الشيء' }, { stripUnknown: false }]], + }) +); + +describe('useTranslations()', () => { + test('includes UI strings injected by plugins for the default locale', () => { + const t = useTranslations(undefined); + expect(t).toBeTypeOf('function'); + // Include the default locale strings. + expect(t('skipLink.label')).toBe('Skip to content'); + // Include a built-in translation overriden by a plugin. + expect(t('search.label')).toBe('Search the thing'); + // Include a translation injected by a plugin. + // @ts-expect-error - translation key injected by a test plugin. + expect(t('testPlugin3.doThing')).toBe('Do the Plugin 3 thing'); + }); + + test('includes UI strings injected by plugins', () => { + const t = useTranslations('fr'); + // Include the default locale strings. + expect(t('skipLink.label')).toBe('Aller au contenu'); + // Include a built-in translation overriden by a plugin. + expect(t('search.label')).toBe('Rechercher le truc'); + // Include a translation injected by a plugin. + // @ts-expect-error - translation key injected by a test plugin. + expect(t('testPlugin3.doThing')).toBe('Faire la chose du plugin 3'); + }); + + test('uses user-defined translations for untranslated strings injected by plugins', () => { + const t = useTranslations('pt-br'); + // @ts-expect-error - translation key injected by a test plugin. + expect(t('testPlugin3.doThing')).toBe('Do the Plugin 3 thing'); + }); + + test('prefers user-defined translations over plugin translations', () => { + const t = useTranslations('ar'); + // @ts-expect-error - translation key injected by a test plugin. + expect(t('testPlugin3.doThing')).toBe('افعل الشيء'); + }); +}); diff --git a/packages/starlight/__tests__/plugins/vitest.config.ts b/packages/starlight/__tests__/plugins/vitest.config.ts index a6eaa3b296e..78c4c254204 100644 --- a/packages/starlight/__tests__/plugins/vitest.config.ts +++ b/packages/starlight/__tests__/plugins/vitest.config.ts @@ -3,6 +3,13 @@ import { defineVitestConfig } from '../test-config'; export default defineVitestConfig({ title: 'Plugins', sidebar: [{ label: 'Getting Started', link: 'getting-started' }], + defaultLocale: 'en', + locales: { + en: { label: 'English', lang: 'en' }, + fr: { label: 'French' }, + ar: { label: 'Arabic', dir: 'rtl' }, + 'pt-br': { label: 'Brazilian Portuguese', lang: 'pt-BR' }, + }, plugins: [ { name: 'test-plugin-1', @@ -37,11 +44,24 @@ export default defineVitestConfig({ { name: 'test-plugin-3', hooks: { - async setup({ config, updateConfig }) { + async setup({ config, updateConfig, injectTranslations }) { await Promise.resolve(); updateConfig({ description: `${config.description} - plugin 3`, }); + injectTranslations({ + en: { + 'search.label': 'Search the thing', + 'testPlugin3.doThing': 'Do the Plugin 3 thing', + }, + fr: { + 'search.label': 'Rechercher le truc', + 'testPlugin3.doThing': 'Faire la chose du plugin 3', + }, + ar: { + 'testPlugin3.doThing': 'قم بعمل المكون الإضافي 3', + }, + }); }, }, }, diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts index b1a34440b9e..47ab267d211 100644 --- a/packages/starlight/__tests__/test-config.ts +++ b/packages/starlight/__tests__/test-config.ts @@ -20,15 +20,24 @@ export async function defineVitestConfig( const trailingSlash = opts?.trailingSlash ?? 'ignore'; const command = opts?.command ?? 'dev'; - const { starlightConfig } = await runPlugins(config, plugins, createTestPluginContext()); + const { starlightConfig, pluginTranslations } = await runPlugins( + config, + plugins, + createTestPluginContext() + ); return getViteConfig({ plugins: [ - vitePluginStarlightUserConfig(command, starlightConfig, { - root, - srcDir, - build, - trailingSlash, - }), + vitePluginStarlightUserConfig( + command, + starlightConfig, + { + root, + srcDir, + build, + trailingSlash, + }, + pluginTranslations + ), ], test: { snapshotSerializers: ['./snapshot-serializer-astro-error.ts'], diff --git a/packages/starlight/__tests__/test-utils.ts b/packages/starlight/__tests__/test-utils.ts index 2e821544d40..69b01edaf2e 100644 --- a/packages/starlight/__tests__/test-utils.ts +++ b/packages/starlight/__tests__/test-utils.ts @@ -37,8 +37,17 @@ function mockDoc( }; } -function mockDict(id: string, data: z.input>) { - return { id, data: i18nSchema().parse(data) }; +function mockDict( + id: string, + data: z.input>, + { stripUnknown } = { stripUnknown: true } +) { + return { + id, + data: stripUnknown + ? i18nSchema().parse(data) + : i18nSchema().and(z.record(z.string())).parse(data), + }; } export async function mockedAstroContent({ diff --git a/packages/starlight/components/DraftContentNotice.astro b/packages/starlight/components/DraftContentNotice.astro index 67cd0466de8..70d0d4fab73 100644 --- a/packages/starlight/components/DraftContentNotice.astro +++ b/packages/starlight/components/DraftContentNotice.astro @@ -1,8 +1,6 @@ --- import ContentNotice from './ContentNotice.astro'; import type { Props } from '../props'; - -const { labels } = Astro.props; --- - + diff --git a/packages/starlight/components/EditLink.astro b/packages/starlight/components/EditLink.astro index 74be4c8f79d..711d3999ba6 100644 --- a/packages/starlight/components/EditLink.astro +++ b/packages/starlight/components/EditLink.astro @@ -2,14 +2,14 @@ import Icon from '../user-components/Icon.astro'; import type { Props } from '../props'; -const { editUrl, labels } = Astro.props; +const { editUrl } = Astro.props; --- { editUrl && ( - {labels['page.editLink']} + {Astro.locals.t('page.editLink')} ) } diff --git a/packages/starlight/components/FallbackContentNotice.astro b/packages/starlight/components/FallbackContentNotice.astro index b3474fb28fb..616d06fe929 100644 --- a/packages/starlight/components/FallbackContentNotice.astro +++ b/packages/starlight/components/FallbackContentNotice.astro @@ -1,8 +1,6 @@ --- import ContentNotice from './ContentNotice.astro'; import type { Props } from '../props'; - -const { labels } = Astro.props; --- - + diff --git a/packages/starlight/components/Footer.astro b/packages/starlight/components/Footer.astro index f75b5e40703..0bba68258c3 100644 --- a/packages/starlight/components/Footer.astro +++ b/packages/starlight/components/Footer.astro @@ -18,7 +18,7 @@ import { Icon } from '../components'; { config.credits && ( - {Astro.props.labels['builtWithStarlight.label']} + {Astro.locals.t('builtWithStarlight.label')} ) } diff --git a/packages/starlight/components/LanguageSelect.astro b/packages/starlight/components/LanguageSelect.astro index c85292dc7bb..42999eac9b2 100644 --- a/packages/starlight/components/LanguageSelect.astro +++ b/packages/starlight/components/LanguageSelect.astro @@ -10,8 +10,6 @@ import type { Props } from '../props'; function localizedPathname(locale: string | undefined): string { return localizedUrl(Astro.url, locale).pathname; } - -const { labels } = Astro.props; --- { @@ -19,7 +17,7 @@ const { labels } = Astro.props; diff --git a/packages/starlight/i18n.d.ts b/packages/starlight/i18n.d.ts new file mode 100644 index 00000000000..8cb82217cd7 --- /dev/null +++ b/packages/starlight/i18n.d.ts @@ -0,0 +1,18 @@ +/* + * This file imports the original `i18next` types and extends them to configure the + * Starlight namespace. + * + * Note that the top-level `import` makes this module non-ambient, so can’t be + * combined with other `.d.ts` files such as `locals.d.ts`. + */ + +import 'i18next'; + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: typeof import('./utils/createTranslationSystem').I18nextNamespace; + resources: { + starlight: Record; + }; + } +} diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index d2a5e572eb7..50b1a87ea0c 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -1,3 +1,11 @@ +/** + * These triple-slash directives defines dependencies to various declaration files that will be + * loaded when a user imports the Starlight integration in their Astro configuration file. These + * directives must be first at the top of the file and can only be preceded by this comment. + */ +/// +/// + import mdx from '@astrojs/mdx'; import type { AstroIntegration } from 'astro'; import { spawn } from 'node:child_process'; @@ -9,7 +17,12 @@ import { starlightSitemap } from './integrations/sitemap'; import { vitePluginStarlightUserConfig } from './integrations/virtual-user-config'; import { rehypeRtlCodeSupport } from './integrations/code-rtl-support'; import { createTranslationSystemFromFs } from './utils/translations-fs'; -import { runPlugins, type StarlightUserConfigWithPlugins } from './utils/plugins'; +import { + injectPluginTranslationsTypes, + runPlugins, + type PluginTranslations, + type StarlightUserConfigWithPlugins, +} from './utils/plugins'; import { processI18nConfig } from './utils/i18n'; import type { StarlightConfig } from './types'; @@ -18,10 +31,12 @@ export default function StarlightIntegration({ ...opts }: StarlightUserConfigWithPlugins): AstroIntegration { let userConfig: StarlightConfig; + let pluginTranslations: PluginTranslations = {}; return { name: '@astrojs/starlight', hooks: { 'astro:config:setup': async ({ + addMiddleware, command, config, injectRoute, @@ -42,10 +57,17 @@ export default function StarlightIntegration({ config.i18n ); - const { integrations } = pluginResult; + const integrations = pluginResult.integrations; + pluginTranslations = pluginResult.pluginTranslations; userConfig = starlightConfig; - const useTranslations = createTranslationSystemFromFs(starlightConfig, config); + const useTranslations = createTranslationSystemFromFs( + starlightConfig, + config, + pluginTranslations + ); + + addMiddleware({ entrypoint: '@astrojs/starlight/locals', order: 'pre' }); if (!starlightConfig.disable404Route) { injectRoute({ @@ -91,7 +113,9 @@ export default function StarlightIntegration({ updateConfig({ vite: { - plugins: [vitePluginStarlightUserConfig(command, starlightConfig, config)], + plugins: [ + vitePluginStarlightUserConfig(command, starlightConfig, config, pluginTranslations), + ], }, markdown: { remarkPlugins: [ @@ -112,6 +136,10 @@ export default function StarlightIntegration({ }); }, + 'astro:config:done': ({ injectTypes }) => { + injectPluginTranslationsTypes(pluginTranslations, injectTypes); + }, + 'astro:build:done': ({ dir }) => { if (!userConfig.pagefind) return; const targetDir = fileURLToPath(dir); diff --git a/packages/starlight/integrations/asides.ts b/packages/starlight/integrations/asides.ts index 7c4dfb74f24..00ca11a603c 100644 --- a/packages/starlight/integrations/asides.ts +++ b/packages/starlight/integrations/asides.ts @@ -166,7 +166,7 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> { // children with the `directiveLabel` property set to true. We want to pass it as the title // prop to