summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHiDeoo2025-02-15 10:56:39 +0100
committerGitHub2025-02-15 10:56:39 +0100
commitf895f75b17f36c826cc871ba1826e5ae1dff44ca (patch)
tree1e6fc1fe79004e622fbcd2248a386a1700f22fde
parent2df9d05fe7b61282809aa85a1d77662fdd3b748f (diff)
downloadIT.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>
-rw-r--r--.changeset/chatty-jars-flash.md5
-rw-r--r--.changeset/large-balloons-compete.md29
-rw-r--r--.changeset/loud-wolves-decide.md5
-rw-r--r--.changeset/polite-fishes-remain.md21
-rw-r--r--.changeset/stupid-turkeys-appear.md11
-rw-r--r--.changeset/wet-cherries-try.md5
-rw-r--r--docs/src/content/docs/guides/i18n.mdx3
-rw-r--r--docs/src/content/docs/reference/plugins.md210
-rw-r--r--packages/docsearch/index.ts2
-rw-r--r--packages/docsearch/package.json2
-rw-r--r--packages/starlight/__tests__/plugins/config.test.ts68
-rw-r--r--packages/starlight/__tests__/plugins/integration.test.ts8
-rw-r--r--packages/starlight/__tests__/plugins/translations.test.ts25
-rw-r--r--packages/starlight/__tests__/plugins/vitest.config.ts39
-rw-r--r--packages/starlight/__tests__/remark-rehype/asides.test.ts67
-rw-r--r--packages/starlight/__tests__/test-plugin-utils.ts2
-rw-r--r--packages/starlight/index.ts16
-rw-r--r--packages/starlight/integrations/asides.ts11
-rw-r--r--packages/starlight/integrations/expressive-code/index.ts25
-rw-r--r--packages/starlight/integrations/shared/absolutePathToLang.ts (renamed from packages/starlight/integrations/shared/pathToLocale.ts)12
-rw-r--r--packages/starlight/types.ts1
-rw-r--r--packages/starlight/utils/plugins.ts390
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'
>;