diff options
author | HiDeoo | 2025-02-15 10:56:39 +0100 |
---|---|---|
committer | GitHub | 2025-02-15 10:56:39 +0100 |
commit | f895f75b17f36c826cc871ba1826e5ae1dff44ca (patch) | |
tree | 1e6fc1fe79004e622fbcd2248a386a1700f22fde | |
parent | 2df9d05fe7b61282809aa85a1d77662fdd3b748f (diff) | |
download | IT.starlight-f895f75b17f36c826cc871ba1826e5ae1dff44ca.tar.gz IT.starlight-f895f75b17f36c826cc871ba1826e5ae1dff44ca.tar.bz2 IT.starlight-f895f75b17f36c826cc871ba1826e5ae1dff44ca.zip |
Expose FS translation system to plugins (#2578)
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
22 files changed, 696 insertions, 261 deletions
diff --git a/.changeset/chatty-jars-flash.md b/.changeset/chatty-jars-flash.md new file mode 100644 index 00000000..fc52d627 --- /dev/null +++ b/.changeset/chatty-jars-flash.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds a new [`HookParameters`](https://starlight.astro.build/reference/plugins/#hooks) utility type to get the type of a plugin hookâs arguments. diff --git a/.changeset/large-balloons-compete.md b/.changeset/large-balloons-compete.md new file mode 100644 index 00000000..d6251002 --- /dev/null +++ b/.changeset/large-balloons-compete.md @@ -0,0 +1,29 @@ +--- +'@astrojs/starlight': minor +--- + +Exposes the built-in localization system in the Starlight plugin `config:setup` hook. + +â ď¸ **BREAKING CHANGE:** + +This addition changes how Starlight plugins add or update translation strings used in Starlightâs localization APIs. +Plugins previously using the [`injectTranslations()`](https://starlight.astro.build/reference/plugins/#injecttranslations) callback function from the plugin [`config:setup`](https://starlight.astro.build/reference/plugins/#configsetup) hook should now use the same function available in the [`i18n:setup`](https://starlight.astro.build/reference/plugins/#i18nsetup) hook. + +```diff +export default { + name: 'plugin-with-translations', + hooks: { +- 'config:setup'({ injectTranslations }) { ++ 'i18n:setup'({ injectTranslations }) { + injectTranslations({ + en: { + 'myPlugin.doThing': 'Do the thing', + }, + fr: { + 'myPlugin.doThing': 'Faire le truc', + }, + }); + }, + }, +}; +``` diff --git a/.changeset/loud-wolves-decide.md b/.changeset/loud-wolves-decide.md new file mode 100644 index 00000000..2a7d6cc6 --- /dev/null +++ b/.changeset/loud-wolves-decide.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds a new [`useTranslations()`](https://starlight.astro.build/reference/plugins/#usetranslations) callback function to the Starlight plugin `config:setup` hook to generate a utility function to access UI strings for a given language. diff --git a/.changeset/polite-fishes-remain.md b/.changeset/polite-fishes-remain.md new file mode 100644 index 00000000..f0be20ca --- /dev/null +++ b/.changeset/polite-fishes-remain.md @@ -0,0 +1,21 @@ +--- +'@astrojs/starlight': minor +--- + +Deprecates the Starlight plugin `setup` hook in favor of the new `config:setup` hook which provides the same functionality. + +â ď¸ **BREAKING CHANGE:** + +The Starlight plugin `setup` hook is now deprecated and will be removed in a future release. Please update your plugins to use the new `config:setup` hook instead. + +```diff +export default { + name: 'plugin-with-translations', + hooks: { +- 'setup'({ config }) { ++ 'config:setup'({ config }) { + // Your plugin configuration setup code + }, + }, +}; +``` diff --git a/.changeset/stupid-turkeys-appear.md b/.changeset/stupid-turkeys-appear.md new file mode 100644 index 00000000..f4dfb987 --- /dev/null +++ b/.changeset/stupid-turkeys-appear.md @@ -0,0 +1,11 @@ +--- +'@astrojs/starlight-docsearch': minor +--- + +â ď¸ **BREAKING CHANGE:** The minimum supported version of Starlight is now 0.32.0 + +Please use the `@astrojs/upgrade` command to upgrade your project: + +```sh +npx @astrojs/upgrade +``` diff --git a/.changeset/wet-cherries-try.md b/.changeset/wet-cherries-try.md new file mode 100644 index 00000000..65dce050 --- /dev/null +++ b/.changeset/wet-cherries-try.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': minor +--- + +Adds a new [`absolutePathToLang()`](https://starlight.astro.build/reference/plugins/#absolutepathtolang) callback function to the Starlight plugin `config:setup` to get the language for a given absolute file path. diff --git a/docs/src/content/docs/guides/i18n.mdx b/docs/src/content/docs/guides/i18n.mdx index 6014e568..45b28ded 100644 --- a/docs/src/content/docs/guides/i18n.mdx +++ b/docs/src/content/docs/guides/i18n.mdx @@ -299,6 +299,9 @@ export const GET = (context) => { }; ``` +In the context of a Starlight plugin, you can use the [`useTranslations()`](/reference/plugins/#usetranslations) helper to access this API for a specific language. +See the [plugins reference](/reference/plugins/) for more information. + ### Rendering a UI string Render UI strings using the `locals.t()` function. diff --git a/docs/src/content/docs/reference/plugins.md b/docs/src/content/docs/reference/plugins.md index e22d146a..ab9028f4 100644 --- a/docs/src/content/docs/reference/plugins.md +++ b/docs/src/content/docs/reference/plugins.md @@ -19,7 +19,12 @@ See below for details of the different properties and hook parameters. interface StarlightPlugin { name: string; hooks: { - setup: (options: { + 'i18n:setup'?: (options: { + injectTranslations: ( + translations: Record<string, Record<string, string>> + ) => void; + }) => void | Promise<void>; + 'config:setup': (options: { config: StarlightUserConfig; updateConfig: (newConfig: StarlightUserConfig) => void; addIntegration: (integration: AstroIntegration) => void; @@ -27,7 +32,8 @@ interface StarlightPlugin { command: 'dev' | 'build' | 'preview'; isRestart: boolean; logger: AstroIntegrationLogger; - injectTranslations: (Record<string, Record<string, string>>) => void; + useTranslations: (lang: string) => I18nT; + absolutePathToLang: (path: string) => string; }) => void | Promise<void>; }; } @@ -41,12 +47,100 @@ A plugin must provide a unique name that describes it. The name is used when [lo ## `hooks` -Hooks are functions which Starlight calls to run plugin code at specific times. Currently, Starlight supports a single `setup` hook. +Hooks are functions which Starlight calls to run plugin code at specific times. + +To get the type of a hook's arguments, use the `HookParameters` utility type and pass in the hook name. +In the following example, the `options` parameter is typed to match the arguments passed to the `config:setup` hook: + +```ts +import type { HookParameters } from '@astrojs/starlight/types'; + +function configSetup(options: HookParameters['config:setup']) { + options.useTranslations('en'); +} +``` + +### `i18n:setup` + +Plugin internationalization setup function called when Starlight is initialized. +The `i18n:setup` hook can be used to inject translation strings so a plugin can support different locales. +These translations will be available via [`useTranslations()`](#usetranslations) in the `config:setup` hook and in UI components via [`Astro.locals.t()`](/guides/i18n/#using-ui-translations). + +The `i18n:setup` hook is called with the following options: + +#### `injectTranslations` + +**type:** `(translations: Record<string, Record<string, string>>) => 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: { + 'i18n: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). +If you need to use UI strings in the context of the [`config:setup`](#configsetup) hook of your plugin, you can use the [`useTranslations()`](#usetranslations) callback. + +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 {} +} +``` -### `hooks.setup` +### `config:setup` -Plugin setup function called when Starlight is initialized (during the [`astro:config:setup`](https://docs.astro.build/en/reference/integrations-reference/#astroconfigsetup) integration hook). -The `setup` hook can be used to update the Starlight configuration or add Astro integrations. +Plugin configuration setup function called when Starlight is initialized (during the [`astro:config:setup`](https://docs.astro.build/en/reference/integrations-reference/#astroconfigsetup) integration hook). +The `config:setup` hook can be used to update the Starlight configuration or add Astro integrations. This hook is called with the following options: @@ -73,7 +167,7 @@ In the following example, a new [`social`](/reference/configuration/#social) med export default { name: 'add-twitter-plugin', hooks: { - setup({ config, updateConfig }) { + 'config:setup'({ config, updateConfig }) { updateConfig({ social: { ...config.social, @@ -100,7 +194,7 @@ import react from '@astrojs/react'; export default { name: 'plugin-using-react', hooks: { - setup({ addIntegration, astroConfig }) { + 'config:setup'({ addIntegration, astroConfig }) { const isReactLoaded = astroConfig.integrations.find( ({ name }) => name === '@astrojs/react' ); @@ -149,7 +243,7 @@ All logged messages will be prefixed with the plugin name. export default { name: 'long-process-plugin', hooks: { - setup({ logger }) { + 'config:setup'({ logger }) { logger.info('Starting long processâŚ'); // Some long process⌠}, @@ -163,70 +257,78 @@ The example above will log a message that includes the provided info message: [long-process-plugin] Starting long process⌠``` -#### `injectTranslations` - -**type:** `(translations: Record<string, Record<string, string>>) => void` +#### `useTranslations` -A callback function to add or update translation strings used in Starlightâs [localization APIs](/guides/i18n/#using-ui-translations). +**type:** `(lang: string) => I18nT` -In the following example, a plugin injects translations for a custom UI string named `myPlugin.doThing` for the `en` and `fr` locales: +Call `useTranslations()` with a BCP-47 language tag to generate a utility function that provides access to UI strings for that language. +`useTranslations()` returns an equivalent of the `Astro.locals.t()` API that is available in Astro components. +To learn more about the available APIs, see the [âUsing UI translationsâ](/guides/i18n/#using-ui-translations) guide. -```ts {6-13} /(injectTranslations)[^(]/ +```ts {6} // plugin.ts export default { - name: 'plugin-with-translations', + name: 'plugin-use-translations', hooks: { - setup({ injectTranslations }) { - injectTranslations({ - en: { - 'myPlugin.doThing': 'Do the thing', - }, - fr: { - 'myPlugin.doThing': 'Faire le truc', - }, - }); + 'config:setup'({ useTranslations, logger }) { + const t = useTranslations('zh-CN'); + logger.info(t('builtWithStarlight.label')); }, }, }; ``` -To use the injected translations in your plugin UI, follow the [âUsing UI translationsâ guide](/guides/i18n/#using-ui-translations). +The example above will log a message that includes a built-in UI string for the Simplified Chinese language: -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: +```shell +[plugin-use-translations] ĺşäş Starlight ćĺťş +``` -```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 {} -} +#### `absolutePathToLang` -declare namespace StarlightApp { - // Define the additional plugin translations in the `I18n` interface. - interface I18n { - 'myPlugin.doThing': string; - } -} -``` +**type:** `(path: string) => string` -You can also infer the types for the `StarlightApp.I18n` interface from a source file if you have an object containing your translations. +Call `absolutePathToLang()` with an absolute file path to get the language for that file. -For example, given the following source file: +This can be particularly useful when adding [remark or rehype plugins](https://docs.astro.build/en/guides/markdown-content/#markdown-plugins) to process Markdown or MDX files. +The [virtual file format](https://github.com/vfile/vfile) used by these plugins includes the [absolute path](https://github.com/vfile/vfile#filepath) of the file being processed, which can be used with `absolutePathToLang()` to determine the language of the file. +The returned language can be used with the [`useTranslations()`](#usetranslations) helper to get UI strings for that language. -```ts title="ui-strings.ts" -export const UIStrings = { - en: { 'myPlugin.doThing': 'Do the thing' }, - fr: { 'myPlugin.doThing': 'Faire le truc' }, +For example, given the following Starlight configuration: + +```js +starlight({ + title: 'My Docs', + defaultLocale: 'en', + locales: { + // English docs in `src/content/docs/en/` + en: { label: 'English' }, + // French docs in `src/content/docs/fr/` + fr: { label: 'Français', lang: 'fr' }, + }, +}); +``` + +A plugin can determine the language of a file using its absolute path: + +```ts {6-8} /fr/ +// plugin.ts +export default { + name: 'plugin-use-translations', + hooks: { + 'config:setup'({ absolutePathToLang, useTranslations, logger }) { + const lang = absolutePathToLang( + '/absolute/path/to/project/src/content/docs/fr/index.mdx' + ); + const t = useTranslations(lang); + logger.info(t('aside.tip')); + }, + }, }; ``` -The following declaration would infer types from the English keys in the source file: +The example above will log a message that includes a built-in UI string for the French language: -```ts title="env.d.ts" -declare namespace StarlightApp { - type UIStrings = typeof import('./ui-strings').UIStrings.en; - interface I18n extends UIStrings {} -} +```shell +[plugin-use-translations] Astuce ``` diff --git a/packages/docsearch/index.ts b/packages/docsearch/index.ts index da68e7f2..e8cc7e5d 100644 --- a/packages/docsearch/index.ts +++ b/packages/docsearch/index.ts @@ -95,7 +95,7 @@ export default function starlightDocSearch(userConfig: DocSearchUserConfig): Sta return { name: 'starlight-docsearch', hooks: { - setup({ addIntegration, config, logger, updateConfig }) { + 'config:setup'({ addIntegration, config, logger, updateConfig }) { // If the user has already has a custom override for the Search component, don't override it. if (config.components?.Search) { logger.warn( diff --git a/packages/docsearch/package.json b/packages/docsearch/package.json index 4b3a0437..0d586666 100644 --- a/packages/docsearch/package.json +++ b/packages/docsearch/package.json @@ -25,7 +25,7 @@ "./schema": "./schema.ts" }, "peerDependencies": { - "@astrojs/starlight": ">=0.30.0" + "@astrojs/starlight": ">=0.32.0" }, "dependencies": { "@docsearch/css": "^3.6.0", diff --git a/packages/starlight/__tests__/plugins/config.test.ts b/packages/starlight/__tests__/plugins/config.test.ts index 8848e349..91b44120 100644 --- a/packages/starlight/__tests__/plugins/config.test.ts +++ b/packages/starlight/__tests__/plugins/config.test.ts @@ -3,6 +3,7 @@ import config from 'virtual:starlight/user-config'; import { getSidebar } from '../../utils/navigation'; import { runPlugins } from '../../utils/plugins'; import { createTestPluginContext } from '../test-plugin-utils'; +import pkg from '../../package.json'; test('reads and updates a configuration option', () => { expect(config.title).toMatchObject({ en: 'Plugins - Custom' }); @@ -31,12 +32,12 @@ test('receives the user provided configuration including the plugins list', asyn await runPlugins( { title: 'Test Docs' }, [ - { name: 'test-plugin-1', hooks: { setup: () => {} } }, - { name: 'test-plugin-2', hooks: { setup: () => {} } }, + { name: 'test-plugin-1', hooks: { 'config:setup'() {} } }, + { name: 'test-plugin-2', hooks: { 'config:setup'() {} } }, { name: 'test-plugin-3', hooks: { - setup: ({ config }) => { + 'config:setup'({ config }) { expect(config.plugins?.map(({ name }) => name)).toMatchObject([ 'test-plugin-1', 'test-plugin-2', @@ -120,6 +121,67 @@ describe('validation', () => { }); }); +describe('deprecated `setup` hook', () => { + test('supports the legacy `setup` hook in pre v1 versions', async () => { + const isPreV1 = pkg.version[0] === '0'; + + const pluginResult = runPlugins( + { title: 'Test Docs' }, + [ + { + name: 'valid-plugin', + hooks: { setup() {} }, + }, + ], + createTestPluginContext() + ); + + if (isPreV1) { + await expect(pluginResult).resolves.not.toThrow(); + } else { + await expect(pluginResult).rejects.toThrow(); + } + }); + + test('validates plugins have at least a `config:setup` or the deprecated `setup` hook', async () => { + await expect( + runPlugins( + { title: 'Test Docs' }, + [{ name: 'invalid-plugin', hooks: {} }], + createTestPluginContext() + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Invalid plugins config passed to starlight integration + Hint: + A plugin must define at least a \`config:setup\` hook." + `); + }); + + test('validates plugins does not have both a `config:setup` or the deprecated `setup` hook', async () => { + await expect( + runPlugins( + { title: 'Test Docs' }, + [ + { + name: 'invalid-plugin', + hooks: { + 'config:setup'() {}, + setup() {}, + }, + }, + ], + createTestPluginContext() + ) + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "[AstroUserError]: + Invalid plugins config passed to starlight integration + Hint: + A plugin cannot define both a \`config:setup\` and \`setup\` hook. As \`setup\` is deprecated and will be removed in a future version, consider using \`config:setup\` instead." + `); + }); +}); + test('does not expose plugins to the config virtual module', () => { // @ts-expect-error - plugins are not serializable and thus not in the config virtual module. expect(config.plugins).not.toBeDefined(); diff --git a/packages/starlight/__tests__/plugins/integration.test.ts b/packages/starlight/__tests__/plugins/integration.test.ts index ba775a34..8b3005a7 100644 --- a/packages/starlight/__tests__/plugins/integration.test.ts +++ b/packages/starlight/__tests__/plugins/integration.test.ts @@ -20,7 +20,7 @@ test('returns all integrations added by plugins without deduping them', async () { name: 'test-plugin-1', hooks: { - setup({ addIntegration, updateConfig }) { + 'config:setup'({ addIntegration, updateConfig }) { updateConfig({ description: 'test' }); addIntegration(integration1); }, @@ -29,7 +29,7 @@ test('returns all integrations added by plugins without deduping them', async () { name: 'test-plugin-2', hooks: { - setup({ addIntegration }) { + 'config:setup'({ addIntegration }) { addIntegration(integration1); addIntegration(integration2); }, @@ -55,7 +55,7 @@ test('receives the Astro config with a list of integrations including the ones a { name: 'test-plugin-1', hooks: { - setup({ addIntegration }) { + 'config:setup'({ addIntegration }) { addIntegration({ name: 'test-integration', hooks: {}, @@ -66,7 +66,7 @@ test('receives the Astro config with a list of integrations including the ones a { name: 'test-plugin-2', hooks: { - setup({ astroConfig }) { + 'config:setup'({ astroConfig }) { expect(astroConfig.integrations).toMatchObject([{ name: 'test-integration' }]); }, }, diff --git a/packages/starlight/__tests__/plugins/translations.test.ts b/packages/starlight/__tests__/plugins/translations.test.ts index 5ca269c6..d626bdac 100644 --- a/packages/starlight/__tests__/plugins/translations.test.ts +++ b/packages/starlight/__tests__/plugins/translations.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, vi } from 'vitest'; +import config from 'virtual:starlight/user-config'; import { useTranslations } from '../../utils/translations'; vi.mock('astro:content', async () => @@ -10,7 +11,7 @@ vi.mock('astro:content', async () => }) ); -describe('useTranslations()', () => { +describe('can access UI string in an Astro context', () => { test('includes UI strings injected by plugins for the default locale', () => { const t = useTranslations(undefined); expect(t).toBeTypeOf('function'); @@ -46,3 +47,25 @@ describe('useTranslations()', () => { expect(t('testPlugin3.doThing')).toBe('اŮؚ٠اŮŘ´ŮŘĄ'); }); }); + +test('can access UI strings in the plugin context using useTranslations()', () => { + const { uiStrings } = JSON.parse(config.titleDelimiter); + + // A built-in UI string. + expect(uiStrings[0]).toBe('Skip to content'); + // A built-in UI string overriden by a plugin. + expect(uiStrings[1]).toBe('Rechercher le truc'); + // A UI string injected by a plugin. + expect(uiStrings[2]).toBe('Do the Plugin 3 thing'); +}); + +test('can infer langs from an absolute path in the plugin context using absolutePathToLang()', () => { + const { langs } = JSON.parse(config.titleDelimiter); + + // The default language. + expect(langs[0]).toBe('en'); + // A language with a regional subtag. + expect(langs[1]).toBe('pt-BR'); + // A page not matching any language defaults to the default language. + expect(langs[2]).toBe('en'); +}); diff --git a/packages/starlight/__tests__/plugins/vitest.config.ts b/packages/starlight/__tests__/plugins/vitest.config.ts index 78c4c254..7a35ef91 100644 --- a/packages/starlight/__tests__/plugins/vitest.config.ts +++ b/packages/starlight/__tests__/plugins/vitest.config.ts @@ -14,7 +14,7 @@ export default defineVitestConfig({ { name: 'test-plugin-1', hooks: { - setup({ config, updateConfig }) { + 'config:setup'({ config, updateConfig }) { updateConfig({ title: `${config.title} - Custom`, description: 'plugin 1', @@ -33,7 +33,7 @@ export default defineVitestConfig({ { name: 'test-plugin-2', hooks: { - setup({ config, updateConfig }) { + 'config:setup'({ config, updateConfig }) { updateConfig({ description: `${config.description} - plugin 2`, sidebar: [{ label: 'Showcase', link: 'showcase' }], @@ -44,11 +44,7 @@ export default defineVitestConfig({ { name: 'test-plugin-3', hooks: { - async setup({ config, updateConfig, injectTranslations }) { - await Promise.resolve(); - updateConfig({ - description: `${config.description} - plugin 3`, - }); + 'i18n:setup'({ injectTranslations }) { injectTranslations({ en: { 'search.label': 'Search the thing', @@ -63,6 +59,35 @@ export default defineVitestConfig({ }, }); }, + async 'config:setup'({ config, updateConfig, useTranslations, absolutePathToLang }) { + // Fake an async operation to ensure that the plugin system can handle async hooks. + await Promise.resolve(); + + const docsUrl = new URL('../src/content/docs/', import.meta.url); + + // To test that a plugin can access UI strings in the plugin context using the + // `useTranslations()` helper and also use the `absolutePathToLang()` helper, we generate + // a bunch of expected values that are JSON stringified, passed to the config through the + // `titleDelimiter` option, and later parsed and verified in a test. + const result = { + uiStrings: [ + useTranslations('en')('skipLink.label'), + useTranslations('fr')('search.label'), + // @ts-expect-error - `testPlugin3.doThing` is a translation key injected by a test plugin + useTranslations('en')('testPlugin3.doThing'), + ], + langs: [ + absolutePathToLang(new URL('./en/index.md', docsUrl).pathname), + absolutePathToLang(new URL('./pt-br/index.md', docsUrl).pathname), + absolutePathToLang(new URL('./index.md', docsUrl).pathname), + ], + }; + + updateConfig({ + description: `${config.description} - plugin 3`, + titleDelimiter: JSON.stringify(result), + }); + }, }, }, ], diff --git a/packages/starlight/__tests__/remark-rehype/asides.test.ts b/packages/starlight/__tests__/remark-rehype/asides.test.ts index 456540b6..e1486a74 100644 --- a/packages/starlight/__tests__/remark-rehype/asides.test.ts +++ b/packages/starlight/__tests__/remark-rehype/asides.test.ts @@ -1,4 +1,4 @@ -import { createMarkdownProcessor } from '@astrojs/markdown-remark'; +import { createMarkdownProcessor, type MarkdownProcessor } from '@astrojs/markdown-remark'; import type { Root } from 'mdast'; import { visit } from 'unist-util-visit'; import { describe, expect, test } from 'vitest'; @@ -6,6 +6,7 @@ import { starlightAsides, remarkDirectivesRestoration } from '../../integrations import { createTranslationSystemFromFs } from '../../utils/translations-fs'; import { StarlightConfigSchema, type StarlightUserConfig } from '../../utils/user-config'; import { BuiltInDefaultLocale } from '../../utils/i18n'; +import { absolutePathToLang as getAbsolutePathFromLang } from '../../integrations/shared/absolutePathToLang'; const starlightConfig = StarlightConfigSchema.parse({ title: 'Asides Tests', @@ -13,18 +14,28 @@ const starlightConfig = StarlightConfigSchema.parse({ defaultLocale: 'en', } satisfies StarlightUserConfig); +const astroConfig = { + root: new URL(import.meta.url), + srcDir: new URL('./_src/', import.meta.url), +}; + const useTranslations = createTranslationSystemFromFs( starlightConfig, // Using non-existent `_src/` to ignore custom files in this test fixture. { srcDir: new URL('./_src/', import.meta.url) } ); +function absolutePathToLang(path: string) { + return getAbsolutePathFromLang(path, { astroConfig, starlightConfig }); +} + const processor = await createMarkdownProcessor({ remarkPlugins: [ ...starlightAsides({ starlightConfig, - astroConfig: { root: new URL(import.meta.url), srcDir: new URL('./_src/', import.meta.url) }, + astroConfig, useTranslations, + absolutePathToLang, }), // The restoration plugin is run after the asides and any other plugin that may have been // injected by Starlight plugins. @@ -32,8 +43,19 @@ const processor = await createMarkdownProcessor({ ], }); +function renderMarkdown( + content: string, + options: { fileURL?: URL; processor?: MarkdownProcessor } = {} +) { + return (options.processor ?? processor).render( + content, + // @ts-expect-error fileURL is part of MarkdownProcessor's options + { fileURL: options.fileURL ?? new URL(`./_src/content/docs/index.md`, import.meta.url) } + ); +} + test('generates aside', async () => { - const res = await processor.render(` + const res = await renderMarkdown(` :::note Some text ::: @@ -48,7 +70,7 @@ describe('default labels', () => { ['caution', 'Caution'], ['danger', 'Danger'], ])('%s has label %s', async (type, label) => { - const res = await processor.render(` + const res = await renderMarkdown(` :::${type} Some text ::: @@ -61,7 +83,7 @@ Some text describe('custom labels', () => { test.each(['note', 'tip', 'caution', 'danger'])('%s with custom label', async (type) => { const label = 'Custom Label'; - const res = await processor.render(` + const res = await renderMarkdown(` :::${type}[${label}] Some text ::: @@ -76,7 +98,7 @@ describe('custom labels with nested markdown', () => { const label = 'Custom `code` Label'; const labelWithoutMarkdown = 'Custom code Label'; const labelHtml = 'Custom <code>code</code> Label'; - const res = await processor.render(` + const res = await renderMarkdown(` :::${type}[${label}] Some text ::: @@ -93,7 +115,7 @@ describe('custom labels with doubly-nested markdown', () => { const label = 'Custom **strong with _emphasis_** Label'; const labelWithoutMarkdown = 'Custom strong with emphasis Label'; const labelHtml = 'Custom <strong>strong with <em>emphasis</em></strong> Label'; - const res = await processor.render(` + const res = await renderMarkdown(` :::${type}[${label}] Some text ::: @@ -105,7 +127,7 @@ Some text }); test('ignores unknown directive variants', async () => { - const res = await processor.render(` + const res = await renderMarkdown(` :::unknown Some text ::: @@ -114,7 +136,7 @@ Some text }); test('handles complex children', async () => { - const res = await processor.render(` + const res = await renderMarkdown(` :::note Paragraph [link](/href/). @@ -132,7 +154,7 @@ More. }); test('nested asides', async () => { - const res = await processor.render(` + const res = await renderMarkdown(` ::::note Note contents. @@ -146,7 +168,7 @@ Nested tip. }); test('nested asides with custom titles', async () => { - const res = await processor.render(` + const res = await renderMarkdown(` :::::caution[Caution with a custom title] Nested caution. @@ -181,13 +203,12 @@ describe('translated labels in French', () => { ['caution', 'Attention'], ['danger', 'Danger'], ])('%s has label %s', async (type, label) => { - const res = await processor.render( + const res = await renderMarkdown( ` :::${type} Some text ::: `, - // @ts-expect-error fileURL is part of MarkdownProcessor's options { fileURL: new URL('./_src/content/docs/fr/index.md', import.meta.url) } ); expect(res.code).includes(`aria-label="${label}"`); @@ -209,16 +230,17 @@ test('runs without locales config', async () => { srcDir: new URL('./_src/', import.meta.url), }, useTranslations, + absolutePathToLang, }), remarkDirectivesRestoration, ], }); - const res = await processor.render(':::note\nTest\n::'); + const res = await renderMarkdown(':::note\nTest\n::', { processor }); expect(res.code.includes('aria-label=Note"')); }); test('transforms back unhandled text directives', async () => { - const res = await processor.render( + const res = await renderMarkdown( `This is a:test of a sentence with a text:name[content]{key=val} directive.` ); expect(res.code).toMatchInlineSnapshot(` @@ -227,14 +249,14 @@ test('transforms back unhandled text directives', async () => { }); test('transforms back unhandled leaf directives', async () => { - const res = await processor.render(`::video[Title]{v=xxxxxxxxxxx}`); + const res = await renderMarkdown(`::video[Title]{v=xxxxxxxxxxx}`); expect(res.code).toMatchInlineSnapshot(` "<p>::video[Title]{v="xxxxxxxxxxx"}</p>" `); }); test('does not add any whitespace character after any unhandled directive', async () => { - const res = await processor.render(`## Environment variables (astro:env)`); + const res = await renderMarkdown(`## Environment variables (astro:env)`); expect(res.code).toMatchInlineSnapshot( `"<h2 id="environment-variables-astroenv">Environment variables (astro:env)</h2>"` ); @@ -251,6 +273,7 @@ test('lets remark plugin injected by Starlight plugins handle text and leaf dire srcDir: new URL('./_src/', import.meta.url), }, useTranslations, + absolutePathToLang, }), // A custom remark plugin injected by a Starlight plugin through an Astro integration would // run before the restoration plugin. @@ -268,8 +291,9 @@ test('lets remark plugin injected by Starlight plugins handle text and leaf dire ], }); - const res = await processor.render( - `This is a:test of a sentence with a :abbr[SL]{name="Starlight"} directive handled by another remark plugin and some other text:name[content]{key=val} directives not handled by any plugin.` + const res = await renderMarkdown( + `This is a:test of a sentence with a :abbr[SL]{name="Starlight"} directive handled by another remark plugin and some other text:name[content]{key=val} directives not handled by any plugin.`, + { processor } ); expect(res.code).toMatchInlineSnapshot(` "<p>This is a:test of a sentence with a TEXT FROM REMARK PLUGIN directive handled by another remark plugin and some other text:name[content]{key="val"} directives not handled by any plugin.</p>" @@ -286,6 +310,7 @@ test('does not transform back directive nodes with data', async () => { srcDir: new URL('./_src/', import.meta.url), }, useTranslations, + absolutePathToLang, }), // A custom remark plugin updating the node with data that should be consumed by rehype. function customRemarkPlugin() { @@ -302,7 +327,9 @@ test('does not transform back directive nodes with data', async () => { ], }); - const res = await processor.render(`This method is available in the :api[thing] API.`); + const res = await renderMarkdown(`This method is available in the :api[thing] API.`, { + processor, + }); expect(res.code).toMatchInlineSnapshot( `"<p>This method is available in the <span class="api">thing</span> API.</p>"` ); diff --git a/packages/starlight/__tests__/test-plugin-utils.ts b/packages/starlight/__tests__/test-plugin-utils.ts index 32b60119..dd2357a6 100644 --- a/packages/starlight/__tests__/test-plugin-utils.ts +++ b/packages/starlight/__tests__/test-plugin-utils.ts @@ -6,7 +6,7 @@ export function createTestPluginContext(): StarlightPluginContext { command: 'dev', // @ts-expect-error - we don't provide a full Astro config but only what is needed for the // plugins to run. - config: { integrations: [] }, + config: { srcDir: new URL('./src/', import.meta.url), integrations: [] }, isRestart: false, logger: new TestAstroIntegrationLogger(), }; diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index 910601ba..9e463bdf 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -18,7 +18,6 @@ import { starlightExpressiveCode } from './integrations/expressive-code/index'; 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 { injectPluginTranslationsTypes, runPlugins, @@ -65,16 +64,10 @@ export default function StarlightIntegration( config.i18n ); - const integrations = pluginResult.integrations; + const { integrations, useTranslations, absolutePathToLang } = pluginResult; pluginTranslations = pluginResult.pluginTranslations; userConfig = starlightConfig; - const useTranslations = createTranslationSystemFromFs( - starlightConfig, - config, - pluginTranslations - ); - addMiddleware({ entrypoint: '@astrojs/starlight/locals', order: 'pre' }); if (!starlightConfig.disable404Route) { @@ -127,7 +120,12 @@ export default function StarlightIntegration( }, markdown: { remarkPlugins: [ - ...starlightAsides({ starlightConfig, astroConfig: config, useTranslations }), + ...starlightAsides({ + starlightConfig, + astroConfig: config, + useTranslations, + absolutePathToLang, + }), ], rehypePlugins: [rehypeRtlCodeSupport()], shikiConfig: diff --git a/packages/starlight/integrations/asides.ts b/packages/starlight/integrations/asides.ts index 8b888c35..939cfe7c 100644 --- a/packages/starlight/integrations/asides.ts +++ b/packages/starlight/integrations/asides.ts @@ -14,15 +14,13 @@ import { toString } from 'mdast-util-to-string'; import remarkDirective from 'remark-directive'; import type { Plugin, Transformer } from 'unified'; import { visit } from 'unist-util-visit'; -import type { StarlightConfig } from '../types'; -import type { createTranslationSystemFromFs } from '../utils/translations-fs'; -import { pathToLocale } from './shared/pathToLocale'; -import { localeToLang } from './shared/localeToLang'; +import type { HookParameters, StarlightConfig } from '../types'; interface AsidesOptions { starlightConfig: Pick<StarlightConfig, 'defaultLocale' | 'locales'>; astroConfig: { root: AstroConfig['root']; srcDir: AstroConfig['srcDir'] }; - useTranslations: ReturnType<typeof createTranslationSystemFromFs>; + useTranslations: HookParameters<'config:setup'>['useTranslations']; + absolutePathToLang: HookParameters<'config:setup'>['absolutePathToLang']; } /** Hacky function that generates an mdast HTML tree ready for conversion to HTML by rehype. */ @@ -151,8 +149,7 @@ function remarkAsides(options: AsidesOptions): Plugin<[], Root> { }; const transformer: Transformer<Root> = (tree, file) => { - const locale = pathToLocale(file.history[0], options); - const lang = localeToLang(options.starlightConfig, locale); + const lang = options.absolutePathToLang(file.path); const t = options.useTranslations(lang); visit(tree, (node, index, parent) => { if (!parent || index === undefined || !isNodeDirective(node)) { diff --git a/packages/starlight/integrations/expressive-code/index.ts b/packages/starlight/integrations/expressive-code/index.ts index 600daf46..146d315b 100644 --- a/packages/starlight/integrations/expressive-code/index.ts +++ b/packages/starlight/integrations/expressive-code/index.ts @@ -5,10 +5,10 @@ import { } from 'astro-expressive-code'; import { addClassName } from 'astro-expressive-code/hast'; import type { AstroIntegration } from 'astro'; -import type { StarlightConfig } from '../../types'; -import type { createTranslationSystemFromFs } from '../../utils/translations-fs'; -import { pathToLocale } from '../shared/pathToLocale'; +import type { HookParameters, StarlightConfig } from '../../types'; +import { absolutePathToLang } from '../shared/absolutePathToLang'; import { slugToLocale } from '../shared/slugToLocale'; +import { localeToLang } from '../shared/localeToLang'; import { applyStarlightUiThemeColors, preprocessThemes, @@ -64,7 +64,7 @@ export type StarlightExpressiveCodeOptions = Omit<AstroExpressiveCodeOptions, 't type StarlightEcIntegrationOptions = { starlightConfig: StarlightConfig; - useTranslations?: ReturnType<typeof createTranslationSystemFromFs> | undefined; + useTranslations: HookParameters<'config:setup'>['useTranslations']; }; /** @@ -110,8 +110,8 @@ export function getStarlightEcConfigPreprocessor({ }, }); - // Add Expressive Code UI translations (if any) for all defined locales - if (useTranslations) addTranslations(starlightConfig, useTranslations); + // Add Expressive Code UI translations for all defined locales + addTranslations(starlightConfig, useTranslations); return { themes, @@ -153,10 +153,15 @@ export function getStarlightEcConfigPreprocessor({ }, ...otherStyleOverrides, }, - getBlockLocale: ({ file }) => - file.url - ? slugToLocale(file.url.pathname.slice(1), starlightConfig) - : pathToLocale(file.path, { starlightConfig, astroConfig }), + getBlockLocale: ({ file }) => { + if (file.url) { + const locale = slugToLocale(file.url.pathname.slice(1), starlightConfig); + return localeToLang(starlightConfig, locale); + } + // Note that EC cannot use the `absolutePathToLang` helper passed down to plugins as this callback + // is also called in the context of the `<Code>` component. + return absolutePathToLang(file.path, { starlightConfig, astroConfig }); + }, plugins, ...rest, }; diff --git a/packages/starlight/integrations/shared/pathToLocale.ts b/packages/starlight/integrations/shared/absolutePathToLang.ts index c91a56f3..24ab2245 100644 --- a/packages/starlight/integrations/shared/pathToLocale.ts +++ b/packages/starlight/integrations/shared/absolutePathToLang.ts @@ -1,11 +1,12 @@ import type { AstroConfig } from 'astro'; import type { StarlightConfig } from '../../types'; +import { localeToLang } from './localeToLang'; import { getCollectionPath } from '../../utils/collection'; import { slugToLocale } from './slugToLocale'; -/** Get current locale from the full file path. */ -export function pathToLocale( - path: string | undefined, +/** Get current language from an absolute file path. */ +export function absolutePathToLang( + path: string, { starlightConfig, astroConfig, @@ -13,7 +14,7 @@ export function pathToLocale( starlightConfig: Pick<StarlightConfig, 'defaultLocale' | 'locales'>; astroConfig: { root: AstroConfig['root']; srcDir: AstroConfig['srcDir'] }; } -): string | undefined { +): string { const docsPath = getCollectionPath('docs', astroConfig.srcDir); // Format path to unix style path. path = path?.replace(/\\/g, '/'); @@ -23,5 +24,6 @@ export function pathToLocale( // Strip docs path leaving only content collection file ID. // Example: /Users/houston/repo/src/content/docs/en/guide.md => en/guide.md const slug = path?.replace(docsPath, ''); - return slugToLocale(slug, starlightConfig); + const locale = slugToLocale(slug, starlightConfig); + return localeToLang(starlightConfig, locale); } diff --git a/packages/starlight/types.ts b/packages/starlight/types.ts index 5a48998b..c549ca8a 100644 --- a/packages/starlight/types.ts +++ b/packages/starlight/types.ts @@ -2,5 +2,6 @@ export type { StarlightConfig } from './utils/user-config'; export type { StarlightPlugin, StarlightUserConfigWithPlugins as StarlightUserConfig, + HookParameters, } from './utils/plugins'; export type { StarlightIcon } from './components/Icons'; diff --git a/packages/starlight/utils/plugins.ts b/packages/starlight/utils/plugins.ts index 8e0795b9..b5dc7f15 100644 --- a/packages/starlight/utils/plugins.ts +++ b/packages/starlight/utils/plugins.ts @@ -1,8 +1,10 @@ -import type { AstroIntegration, HookParameters } from 'astro'; +import type { AstroIntegration, HookParameters as AstroHookParameters } from 'astro'; import { z } from 'astro/zod'; import { StarlightConfigSchema, type StarlightUserConfig } from '../utils/user-config'; import { parseWithFriendlyErrors } from '../utils/error-map'; import type { UserI18nSchema } from './translations'; +import { createTranslationSystemFromFs } from './translations-fs'; +import { absolutePathToLang as getAbsolutePathFromLang } from '../integrations/shared/absolutePathToLang'; /** * Runs Starlight plugins in the order that they are configured after validating the user-provided @@ -30,15 +32,45 @@ export async function runPlugins( 'Invalid plugins config passed to starlight integration' ); - // A list of Astro integrations added by the various plugins. - const integrations: AstroIntegration[] = []; // A list of translations injected by the various plugins keyed by locale. const pluginTranslations: PluginTranslations = {}; for (const { + hooks: { 'i18n:setup': i18nSetup }, + } of pluginsConfig) { + if (i18nSetup) { + await i18nSetup({ + injectTranslations(translations) { + // Merge the translations injected by the plugin. + for (const [locale, localeTranslations] of Object.entries(translations)) { + pluginTranslations[locale] ??= {}; + Object.assign(pluginTranslations[locale]!, localeTranslations); + } + }, + }); + } + } + + const useTranslations = createTranslationSystemFromFs( + starlightConfig, + context.config, + pluginTranslations + ); + + function absolutePathToLang(path: string) { + return getAbsolutePathFromLang(path, { astroConfig: context.config, starlightConfig }); + } + + // A list of Astro integrations added by the various plugins. + const integrations: AstroIntegration[] = []; + + for (const { name, - hooks: { setup }, + hooks: { 'config:setup': configSetup, setup: deprecatedSetup }, } of pluginsConfig) { + // A refinement in the schema ensures that at least one of the two hooks is defined. + const setup = (configSetup ?? deprecatedSetup)!; + await setup({ config: pluginsUserConfig ? { ...userConfig, plugins: pluginsUserConfig } : userConfig, updateConfig(newConfig) { @@ -72,22 +104,17 @@ export async function runPlugins( command: context.command, isRestart: context.isRestart, logger: context.logger.fork(name), - injectTranslations(translations) { - // Merge the translations injected by the plugin. - for (const [locale, localeTranslations] of Object.entries(translations)) { - pluginTranslations[locale] ??= {}; - Object.assign(pluginTranslations[locale]!, localeTranslations); - } - }, + useTranslations, + absolutePathToLang, }); } - return { integrations, starlightConfig, pluginTranslations }; + return { integrations, starlightConfig, pluginTranslations, useTranslations, absolutePathToLang }; } export function injectPluginTranslationsTypes( translations: PluginTranslations, - injectTypes: HookParameters<'astro:config:done'>['injectTypes'] + injectTypes: AstroHookParameters<'astro:config:done'>['injectTypes'] ) { const allKeys = new Set<string>(); @@ -123,135 +150,217 @@ const baseStarlightPluginSchema = z.object({ name: z.string(), }); +const configSetupHookSchema = z + .function( + z.tuple([ + z.object({ + /** + * A read-only copy of the user-supplied Starlight configuration. + * + * Note that this configuration may have been updated by other plugins configured + * before this one. + */ + config: z.any() as z.Schema< + // The configuration passed to plugins should contains the list of plugins. + StarlightUserConfig & { plugins?: z.input<typeof baseStarlightPluginSchema>[] } + >, + /** + * A callback function to update the user-supplied Starlight configuration. + * + * You only need to provide the configuration values that you want to update but no deep + * merge is performed. + * + * @example + * { + * name: 'My Starlight Plugin', + * hooks: { + * 'config:setup'({ updateConfig }) { + * updateConfig({ + * description: 'Custom description', + * }); + * } + * } + * } + */ + updateConfig: z.function( + z.tuple([z.record(z.any()) as z.Schema<Partial<StarlightUserConfig>>]), + z.void() + ), + /** + * A callback function to add an Astro integration required by this plugin. + * + * @see https://docs.astro.build/en/reference/integrations-reference/ + * + * @example + * { + * name: 'My Starlight Plugin', + * hooks: { + * 'config:setup'({ addIntegration }) { + * addIntegration({ + * name: 'My Plugin Astro Integration', + * hooks: { + * 'astro:config:setup': () => { + * // ⌠+ * }, + * }, + * }); + * } + * } + * } + */ + addIntegration: z.function(z.tuple([astroIntegrationSchema]), z.void()), + /** + * A read-only copy of the user-supplied Astro configuration. + * + * Note that this configuration is resolved before any other integrations have run. + * + * @see https://docs.astro.build/en/reference/integrations-reference/#config-option + */ + astroConfig: z.any() as z.Schema<StarlightPluginContext['config']>, + /** + * The command used to run Starlight. + * + * @see https://docs.astro.build/en/reference/integrations-reference/#command-option + */ + command: z.any() as z.Schema<StarlightPluginContext['command']>, + /** + * `false` when the dev server starts, `true` when a reload is triggered. + * + * @see https://docs.astro.build/en/reference/integrations-reference/#isrestart-option + */ + isRestart: z.any() as z.Schema<StarlightPluginContext['isRestart']>, + /** + * An instance of the Astro integration logger with all logged messages prefixed with the + * plugin name. + * + * @see https://docs.astro.build/en/reference/integrations-reference/#astrointegrationlogger + */ + logger: z.any() as z.Schema<StarlightPluginContext['logger']>, + /** + * A callback function to generate a utility function to access UI strings for a given + * language. + * + * @see https://starlight.astro.build/guides/i18n/#using-ui-translations + * + * @example + * { + * name: 'My Starlight Plugin', + * hooks: { + * 'config:setup'({ useTranslations, logger }) { + * const t = useTranslations('en'); + * logger.info(t('builtWithStarlight.label')); + * // ^ Logs 'Built with Starlight' to the console. + * } + * } + * } + */ + useTranslations: z.any() as z.Schema<ReturnType<typeof createTranslationSystemFromFs>>, + /** + * A callback function to get the language for a given absolute file path. The returned + * language can be used with the `useTranslations` helper to get UI strings for that + * language. + * + * This can be particularly useful in remark or rehype plugins to get the language for + * the current file being processed and use it to get the appropriate UI strings for that + * language. + * + * @example + * { + * name: 'My Starlight Plugin', + * hooks: { + * 'config:setup'({ absolutePathToLang, useTranslations, logger }) { + * const lang = absolutePathToLang('/absolute/path/to/project/src/content/docs/fr/index.mdx'); + * const t = useTranslations(lang); + * logger.info(t('aside.tip')); + * // ^ Logs 'Astuce' to the console. + * } + * } + * } + */ + absolutePathToLang: z.function(z.tuple([z.string()]), z.string()), + }), + ]), + z.union([z.void(), z.promise(z.void())]) + ) + .optional(); + /** * A plugin `config` and `updateConfig` argument are purposely not validated using the Starlight * user config schema but properly typed for user convenience because we do not want to run any of * the Zod `transform`s used in the user config schema when running plugins. */ -const starlightPluginSchema = baseStarlightPluginSchema.extend({ - /** The different hooks available to the plugin. */ - hooks: z.object({ - /** - * Plugin setup function called with an object containing various values that can be used by - * the plugin to interact with Starlight. - */ - setup: z.function( - z.tuple([ - z.object({ - /** - * A read-only copy of the user-supplied Starlight configuration. - * - * Note that this configuration may have been updated by other plugins configured - * before this one. - */ - config: z.any() as z.Schema< - // The configuration passed to plugins should contains the list of plugins. - StarlightUserConfig & { plugins?: z.input<typeof baseStarlightPluginSchema>[] } - >, - /** - * A callback function to update the user-supplied Starlight configuration. - * - * You only need to provide the configuration values that you want to update but no deep - * merge is performed. - * - * @example - * { - * name: 'My Starlight Plugin', - * hooks: { - * setup({ updateConfig }) { - * updateConfig({ - * description: 'Custom description', - * }); - * } - * } - * } - */ - updateConfig: z.function( - z.tuple([z.record(z.any()) as z.Schema<Partial<StarlightUserConfig>>]), - z.void() - ), - /** - * A callback function to add an Astro integration required by this plugin. - * - * @see https://docs.astro.build/en/reference/integrations-reference/ - * - * @example - * { - * name: 'My Starlight Plugin', - * hooks: { - * setup({ addIntegration }) { - * addIntegration({ - * name: 'My Plugin Astro Integration', - * hooks: { - * 'astro:config:setup': () => { - * // ⌠- * }, - * }, - * }); - * } - * } - * } - */ - addIntegration: z.function(z.tuple([astroIntegrationSchema]), z.void()), - /** - * A read-only copy of the user-supplied Astro configuration. - * - * Note that this configuration is resolved before any other integrations have run. - * - * @see https://docs.astro.build/en/reference/integrations-reference/#config-option - */ - astroConfig: z.any() as z.Schema<StarlightPluginContext['config']>, - /** - * The command used to run Starlight. - * - * @see https://docs.astro.build/en/reference/integrations-reference/#command-option - */ - command: z.any() as z.Schema<StarlightPluginContext['command']>, - /** - * `false` when the dev server starts, `true` when a reload is triggered. - * - * @see https://docs.astro.build/en/reference/integrations-reference/#isrestart-option - */ - isRestart: z.any() as z.Schema<StarlightPluginContext['isRestart']>, - /** - * An instance of the Astro integration logger with all logged messages prefixed with the - * plugin name. - * - * @see https://docs.astro.build/en/reference/integrations-reference/#astrointegrationlogger - */ - logger: z.any() as z.Schema<StarlightPluginContext['logger']>, - /** - * A callback function to add or update translations strings. - * - * @see https://starlight.astro.build/guides/i18n/#extend-translation-schema - * - * @example - * { - * name: 'My Starlight Plugin', - * hooks: { - * setup({ injectTranslations }) { - * injectTranslations({ - * en: { - * 'myPlugin.doThing': 'Do the thing', - * }, - * fr: { - * 'myPlugin.doThing': 'Faire le truc', - * }, - * }); - * } - * } - * } - */ - injectTranslations: z.function( - z.tuple([z.record(z.string(), z.record(z.string(), z.string()))]), - z.void() - ), - }), - ]), - z.union([z.void(), z.promise(z.void())]) - ), - }), -}); +const starlightPluginSchema = baseStarlightPluginSchema + .extend({ + /** The different hooks available to the plugin. */ + hooks: z.object({ + /** + * Plugin internationalization setup function allowing to inject translations strings for the + * plugin in various locales. These translations will be available in the `config:setup` hook + * and plugin UI. + */ + 'i18n:setup': z + .function( + z.tuple([ + z.object({ + /** + * A callback function to add or update translations strings. + * + * @see https://starlight.astro.build/guides/i18n/#extend-translation-schema + * + * @example + * { + * name: 'My Starlight Plugin', + * hooks: { + * 'i18n:setup'({ injectTranslations }) { + * injectTranslations({ + * en: { + * 'myPlugin.doThing': 'Do the thing', + * }, + * fr: { + * 'myPlugin.doThing': 'Faire le truc', + * }, + * }); + * } + * } + * } + */ + injectTranslations: z.function( + z.tuple([z.record(z.string(), z.record(z.string(), z.string()))]), + z.void() + ), + }), + ]), + z.union([z.void(), z.promise(z.void())]) + ) + .optional(), + /** + * Plugin configuration setup function called with an object containing various values that + * can be used by the plugin to interact with Starlight. + */ + 'config:setup': configSetupHookSchema, + /** + * @deprecated Use the `config:setup` hook instead as `setup` will be removed in a future + * version. + */ + setup: configSetupHookSchema, + }), + }) + .superRefine((plugin, ctx) => { + if (!plugin.hooks['config:setup'] && !plugin.hooks.setup) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'A plugin must define at least a `config:setup` hook.', + }); + } else if (plugin.hooks['config:setup'] && plugin.hooks.setup) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'A plugin cannot define both a `config:setup` and `setup` hook. ' + + 'As `setup` is deprecated and will be removed in a future version, ' + + 'consider using `config:setup` instead.', + }); + } + }); const starlightPluginsConfigSchema = z.array(starlightPluginSchema).default([]); @@ -259,6 +368,11 @@ type StarlightPluginsUserConfig = z.input<typeof starlightPluginsConfigSchema>; export type StarlightPlugin = z.input<typeof starlightPluginSchema>; +export type HookParameters< + Hook extends keyof StarlightPlugin['hooks'], + HookFn = StarlightPlugin['hooks'][Hook], +> = HookFn extends (...args: any) => any ? Parameters<HookFn>[0] : never; + export type StarlightUserConfigWithPlugins = StarlightUserConfig & { /** * A list of plugins to extend Starlight with. @@ -273,7 +387,7 @@ export type StarlightUserConfigWithPlugins = StarlightUserConfig & { }; export type StarlightPluginContext = Pick< - Parameters<NonNullable<AstroIntegration['hooks']['astro:config:setup']>>[0], + AstroHookParameters<'astro:config:setup'>, 'command' | 'config' | 'isRestart' | 'logger' >; |