summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHiDeoo2023-11-29 21:14:00 +0100
committerGitHub2023-11-29 21:14:00 +0100
commitefd7fdcb55b39988f157c1a4b2c368c86a39520f (patch)
tree0e02bb4ff6b7234a820e15eca7efba0f7727a54d
parent00d101b159bfa4bb307a66ccae53dd417d9564e0 (diff)
downloadIT.starlight-efd7fdcb55b39988f157c1a4b2c368c86a39520f.tar.gz
IT.starlight-efd7fdcb55b39988f157c1a4b2c368c86a39520f.tar.bz2
IT.starlight-efd7fdcb55b39988f157c1a4b2c368c86a39520f.zip
Initial Starlight Plugins support (#942)
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
-rw-r--r--.changeset/lovely-keys-wash.md7
-rw-r--r--docs/src/content/docs/reference/configuration.mdx15
-rw-r--r--docs/src/content/docs/reference/plugins.md163
-rw-r--r--packages/starlight/__tests__/plugins/config.test.ts126
-rw-r--r--packages/starlight/__tests__/plugins/integration.test.ts77
-rw-r--r--packages/starlight/__tests__/plugins/vitest.config.ts49
-rw-r--r--packages/starlight/__tests__/test-config.ts18
-rw-r--r--packages/starlight/__tests__/test-plugin-utils.ts23
-rw-r--r--packages/starlight/index.ts72
-rw-r--r--packages/starlight/types.ts1
-rw-r--r--packages/starlight/utils/plugins.ts226
-rw-r--r--packages/starlight/vitest.config.ts8
-rw-r--r--pnpm-lock.yaml1
13 files changed, 736 insertions, 50 deletions
diff --git a/.changeset/lovely-keys-wash.md b/.changeset/lovely-keys-wash.md
new file mode 100644
index 00000000..9ef58763
--- /dev/null
+++ b/.changeset/lovely-keys-wash.md
@@ -0,0 +1,7 @@
+---
+'@astrojs/starlight': minor
+---
+
+Adds plugin API
+
+See the [plugins reference](https://starlight.astro.build/reference/plugins/) to learn more about creating plugins for Starlight using this new API.
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index 9e546dc5..1247f395 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -529,3 +529,18 @@ starlight({
```
See the [Overrides Reference](/reference/overrides/) for details of all the components that you can override.
+
+### `plugins`
+
+**type:** [`StarlightPlugin[]`](/reference/plugins/#quick-api-reference)
+
+Extend Starlight with custom plugins.
+Plugins apply changes to your project to modify or add to Starlight's features.
+
+```js
+starlight({
+ plugins: [starlightPlugin()],
+});
+```
+
+See the [Plugins Reference](/reference/plugins/) for details about creating your own plugins.
diff --git a/docs/src/content/docs/reference/plugins.md b/docs/src/content/docs/reference/plugins.md
new file mode 100644
index 00000000..de859cf1
--- /dev/null
+++ b/docs/src/content/docs/reference/plugins.md
@@ -0,0 +1,163 @@
+---
+title: Plugins Reference
+description: An overview of the Starlight plugin API.
+tableOfContents:
+ maxHeadingLevel: 4
+---
+
+Starlight plugins can customize Starlight configuration, UI, and behavior, while also being easy to share and reuse.
+This reference page documents the API that plugins have access to.
+
+Lean more about using a Starlight plugin in the [Configuration Reference](/reference/configuration/#plugins).
+
+## Quick API Reference
+
+A Starlight plugin has the following shape.
+See below for details of the different properties and hook parameters.
+
+```ts
+interface StarlightPlugin {
+ name: string;
+ hooks: {
+ setup: (options: {
+ config: StarlightUserConfig;
+ updateConfig: (newConfig: StarlightUserConfig) => void;
+ addIntegration: (integration: AstroIntegration) => void;
+ astroConfig: AstroConfig;
+ command: 'dev' | 'build' | 'preview';
+ isRestart: boolean;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise<void>;
+ };
+}
+```
+
+## `name`
+
+**type:** `string`
+
+A plugin must provide a unique name that describes it. The name is used when [logging messages](#logger) related to this plugin and may be used by other plugins to detect the presence of this plugin.
+
+## `hooks`
+
+Hooks are functions which Starlight calls to run plugin code at specific times. Currently, Starlight supports a single `setup` hook.
+
+### `hooks.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.
+
+This hook is called with the following options:
+
+#### `config`
+
+**type:** `StarlightUserConfig`
+
+A read-only copy of the user-supplied [Starlight configuration](/reference/configuration).
+This configuration may have been updated by other plugins configured before the current one.
+
+#### `updateConfig`
+
+**type:** `(newConfig: StarlightUserConfig) => void`
+
+A callback function to update the user-supplied [Starlight configuration](/reference/configuration).
+Provide the root-level configuration keys you want to override.
+To update nested configuration values, you must provide the entire nested object.
+
+To extend an existing config option without overriding it, spread the existing value into your new value.
+In the following example, a new [`social`](/reference/configuration/#social) media account is added to the existing configuration by spreading `config.social` into the new `social` object:
+
+```ts {6-11}
+// plugin.ts
+export default {
+ name: 'add-twitter-plugin',
+ hooks: {
+ setup({ config, updateConfig }) {
+ updateConfig({
+ social: {
+ ...config.social,
+ twitter: 'https://twitter.com/astrodotbuild',
+ },
+ });
+ },
+ },
+};
+```
+
+#### `addIntegration`
+
+**type:** `(integration: AstroIntegration) => void`
+
+A callback function to add an [Astro integration](https://docs.astro.build/en/reference/integrations-reference/) required by the plugin.
+
+In the following example, the plugin first checks if [Astro’s React integration](https://docs.astro.build/en/guides/integrations-guide/react/) is configured and, if it isn’t, uses `addIntegration()` to add it:
+
+```ts {14} "addIntegration,"
+// plugin.ts
+import react from '@astrojs/react';
+
+export default {
+ name: 'plugin-using-react',
+ hooks: {
+ plugin({ addIntegration, astroConfig }) {
+ const isReactLoaded = astroConfig.integrations.find(
+ ({ name }) => name === '@astrojs/react'
+ );
+
+ // Only add the React integration if it's not already loaded.
+ if (!isReactLoaded) {
+ addIntegration(react());
+ }
+ },
+ },
+};
+```
+
+#### `astroConfig`
+
+**type:** `AstroConfig`
+
+A read-only copy of the user-supplied [Astro configuration](https://docs.astro.build/en/reference/configuration-reference/).
+
+#### `command`
+
+**type:** `'dev' | 'build' | 'preview'`
+
+The command used to run Starlight:
+
+- `dev` - Project is executed with `astro dev`
+- `build` - Project is executed with `astro build`
+- `preview` - Project is executed with `astro preview`
+
+#### `isRestart`
+
+**type:** `boolean`
+
+`false` when the dev server starts, `true` when a reload is triggered.
+Common reasons for a restart include a user editing their `astro.config.mjs` while the dev server is running.
+
+#### `logger`
+
+**type:** `AstroIntegrationLogger`
+
+An instance of the [Astro integration logger](https://docs.astro.build/en/reference/integrations-reference/#astrointegrationlogger) that you can use to write logs.
+All logged messages will be prefixed with the plugin name.
+
+```ts {6}
+// plugin.ts
+export default {
+ name: 'long-process-plugin',
+ hooks: {
+ plugin({ logger }) {
+ logger.info('Starting long process…');
+ // Some long process…
+ },
+ },
+};
+```
+
+The example above will log a message that includes the provided info message:
+
+```shell
+[long-process-plugin] Starting long process…
+```
diff --git a/packages/starlight/__tests__/plugins/config.test.ts b/packages/starlight/__tests__/plugins/config.test.ts
new file mode 100644
index 00000000..0b866b1d
--- /dev/null
+++ b/packages/starlight/__tests__/plugins/config.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, test } from 'vitest';
+import config from 'virtual:starlight/user-config';
+import { getSidebar } from '../../utils/navigation';
+import { runPlugins } from '../../utils/plugins';
+import { createTestPluginContext } from '../test-plugin-utils';
+
+test('reads and updates a configuration option', () => {
+ expect(config.title).toBe('Plugins - Custom');
+});
+
+test('overwrites a configuration option', () => {
+ expect(getSidebar('/', undefined)).toMatchObject([{ href: '/showcase', label: 'Showcase' }]);
+});
+
+test('runs plugins in the order that they are configured and always passes down the latest user config', () => {
+ expect(config.description).toBe('plugin 1 - plugin 2 - plugin 3');
+});
+
+test('receives the user provided configuration without any Zod `transform`s applied', () => {
+ /**
+ * If the `transform` associated to the favicon schema was applied, the favicon `href` would be
+ * `invalid.svg`.
+ * @see {@link file://./vitest.config.ts} for more details in the `test-plugin-1` plugin.
+ */
+ expect(config.favicon.href).toBe('valid.svg');
+});
+
+test('receives the user provided configuration including the plugins list', async () => {
+ expect.assertions(1);
+
+ await runPlugins(
+ { title: 'Test Docs' },
+ [
+ { name: 'test-plugin-1', hooks: { setup: () => {} } },
+ { name: 'test-plugin-2', hooks: { setup: () => {} } },
+ {
+ name: 'test-plugin-3',
+ hooks: {
+ setup: ({ config }) => {
+ expect(config.plugins?.map(({ name }) => name)).toMatchObject([
+ 'test-plugin-1',
+ 'test-plugin-2',
+ 'test-plugin-3',
+ ]);
+ },
+ },
+ },
+ ],
+ createTestPluginContext()
+ );
+});
+
+describe('validation', () => {
+ test('validates starlight configuration before running plugins', async () => {
+ expect(
+ async () =>
+ await runPlugins(
+ // @ts-expect-error - invalid sidebar config.
+ { title: 'Test Docs', sidebar: true },
+ [],
+ createTestPluginContext()
+ )
+ ).rejects.toThrowError(/Invalid config passed to starlight integration/);
+ });
+
+ test('validates plugins configuration before running them', async () => {
+ expect(
+ async () =>
+ await runPlugins(
+ { title: 'Test Docs' },
+ // @ts-expect-error - invalid plugin with no `hooks` defined.
+ [{ name: 'invalid-plugin' }],
+ createTestPluginContext()
+ )
+ ).rejects.toThrowError(/Invalid plugins config passed to starlight integration/);
+ });
+
+ test('validates configuration updates from plugins do not update the `plugins` config key', async () => {
+ expect(
+ async () =>
+ await runPlugins(
+ { title: 'Test Docs' },
+ [
+ {
+ name: 'test-plugin',
+ hooks: {
+ setup: ({ updateConfig }) => {
+ // @ts-expect-error - plugins cannot update the `plugins` config key.
+ updateConfig({ plugins: [{ name: 'invalid-plugin' }] });
+ },
+ },
+ },
+ ],
+ createTestPluginContext()
+ )
+ ).rejects.toThrowError(
+ /The 'test-plugin' plugin tried to update the 'plugins' config key which is not supported./
+ );
+ });
+
+ test('validates configuration updates from plugins', async () => {
+ expect(
+ async () =>
+ await runPlugins(
+ { title: 'Test Docs' },
+ [
+ {
+ name: 'test-plugin',
+ hooks: {
+ setup: ({ updateConfig }) => {
+ // @ts-expect-error - invalid sidebar config update.
+ updateConfig({ description: true });
+ },
+ },
+ },
+ ],
+ createTestPluginContext()
+ )
+ ).rejects.toThrowError(/Invalid config update provided by the 'test-plugin' plugin/);
+ });
+});
+
+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
new file mode 100644
index 00000000..ba775a34
--- /dev/null
+++ b/packages/starlight/__tests__/plugins/integration.test.ts
@@ -0,0 +1,77 @@
+import type { AstroIntegration } from 'astro';
+import { expect, test } from 'vitest';
+import { runPlugins } from '../../utils/plugins';
+import { createTestPluginContext } from '../test-plugin-utils';
+
+test('returns all integrations added by plugins without deduping them', async () => {
+ const integration1: AstroIntegration = {
+ name: 'test-integration-1',
+ hooks: {},
+ };
+
+ const integration2: AstroIntegration = {
+ name: 'test-integration-2',
+ hooks: {},
+ };
+
+ const { integrations } = await runPlugins(
+ { title: 'Test Docs' },
+ [
+ {
+ name: 'test-plugin-1',
+ hooks: {
+ setup({ addIntegration, updateConfig }) {
+ updateConfig({ description: 'test' });
+ addIntegration(integration1);
+ },
+ },
+ },
+ {
+ name: 'test-plugin-2',
+ hooks: {
+ setup({ addIntegration }) {
+ addIntegration(integration1);
+ addIntegration(integration2);
+ },
+ },
+ },
+ ],
+ createTestPluginContext()
+ );
+
+ expect(integrations).toMatchObject([
+ { name: 'test-integration-1' },
+ { name: 'test-integration-1' },
+ { name: 'test-integration-2' },
+ ]);
+});
+
+test('receives the Astro config with a list of integrations including the ones added by previous plugins', async () => {
+ expect.assertions(1);
+
+ await runPlugins(
+ { title: 'Test Docs' },
+ [
+ {
+ name: 'test-plugin-1',
+ hooks: {
+ setup({ addIntegration }) {
+ addIntegration({
+ name: 'test-integration',
+ hooks: {},
+ });
+ },
+ },
+ },
+ {
+ name: 'test-plugin-2',
+ hooks: {
+ setup({ astroConfig }) {
+ expect(astroConfig.integrations).toMatchObject([{ name: 'test-integration' }]);
+ },
+ },
+ },
+ ],
+ createTestPluginContext()
+ );
+});
diff --git a/packages/starlight/__tests__/plugins/vitest.config.ts b/packages/starlight/__tests__/plugins/vitest.config.ts
new file mode 100644
index 00000000..a6eaa3b2
--- /dev/null
+++ b/packages/starlight/__tests__/plugins/vitest.config.ts
@@ -0,0 +1,49 @@
+import { defineVitestConfig } from '../test-config';
+
+export default defineVitestConfig({
+ title: 'Plugins',
+ sidebar: [{ label: 'Getting Started', link: 'getting-started' }],
+ plugins: [
+ {
+ name: 'test-plugin-1',
+ hooks: {
+ setup({ config, updateConfig }) {
+ updateConfig({
+ title: `${config.title} - Custom`,
+ description: 'plugin 1',
+ /**
+ * The configuration received by a plugin should be the user provided configuration as-is
+ * befor any Zod `transform`s are applied.
+ * To test this, we use this plugin to update the `favicon` value to a specific value if
+ * the `favicon` config value is an object, which would mean that the associated Zod
+ * `transform` was applied.
+ */
+ favicon: typeof config.favicon === 'object' ? 'invalid.svg' : 'valid.svg',
+ });
+ },
+ },
+ },
+ {
+ name: 'test-plugin-2',
+ hooks: {
+ setup({ config, updateConfig }) {
+ updateConfig({
+ description: `${config.description} - plugin 2`,
+ sidebar: [{ label: 'Showcase', link: 'showcase' }],
+ });
+ },
+ },
+ },
+ {
+ name: 'test-plugin-3',
+ hooks: {
+ async setup({ config, updateConfig }) {
+ await Promise.resolve();
+ updateConfig({
+ description: `${config.description} - plugin 3`,
+ });
+ },
+ },
+ },
+ ],
+});
diff --git a/packages/starlight/__tests__/test-config.ts b/packages/starlight/__tests__/test-config.ts
index 66eebc79..e0cfe701 100644
--- a/packages/starlight/__tests__/test-config.ts
+++ b/packages/starlight/__tests__/test-config.ts
@@ -1,13 +1,13 @@
/// <reference types="vitest" />
+import type { AstroConfig } from 'astro';
import { getViteConfig } from 'astro/config';
-import type { z } from 'astro/zod';
import { vitePluginStarlightUserConfig } from '../integrations/virtual-user-config';
-import { StarlightConfigSchema } from '../utils/user-config';
-import type { AstroConfig } from 'astro';
+import { runPlugins, type StarlightUserConfigWithPlugins } from '../utils/plugins';
+import { createTestPluginContext } from './test-plugin-utils';
-export function defineVitestConfig(
- config: z.input<typeof StarlightConfigSchema>,
+export async function defineVitestConfig(
+ { plugins, ...config }: StarlightUserConfigWithPlugins,
opts?: {
build?: Pick<AstroConfig['build'], 'format'>;
trailingSlash?: AstroConfig['trailingSlash'];
@@ -18,14 +18,10 @@ export function defineVitestConfig(
const build = opts?.build ?? { format: 'directory' };
const trailingSlash = opts?.trailingSlash ?? 'ignore';
+ const { starlightConfig } = await runPlugins(config, plugins, createTestPluginContext());
return getViteConfig({
plugins: [
- vitePluginStarlightUserConfig(StarlightConfigSchema.parse(config), {
- root,
- srcDir,
- build,
- trailingSlash,
- }),
+ vitePluginStarlightUserConfig(starlightConfig, { root, srcDir, build, trailingSlash }),
],
});
}
diff --git a/packages/starlight/__tests__/test-plugin-utils.ts b/packages/starlight/__tests__/test-plugin-utils.ts
new file mode 100644
index 00000000..32b60119
--- /dev/null
+++ b/packages/starlight/__tests__/test-plugin-utils.ts
@@ -0,0 +1,23 @@
+import type { AstroIntegrationLogger } from 'astro';
+import { type StarlightPluginContext } from '../utils/plugins';
+
+export function createTestPluginContext(): StarlightPluginContext {
+ return {
+ 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: [] },
+ isRestart: false,
+ logger: new TestAstroIntegrationLogger(),
+ };
+}
+
+class TestAstroIntegrationLogger {
+ options = {} as AstroIntegrationLogger['options'];
+ constructor(public label = 'test-integration-logger') {}
+ fork = (label: string) => new TestAstroIntegrationLogger(label);
+ info = () => undefined;
+ warn = () => undefined;
+ error = () => undefined;
+ debug = () => undefined;
+}
diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts
index b40e0223..9afb36f4 100644
--- a/packages/starlight/index.ts
+++ b/packages/starlight/index.ts
@@ -7,28 +7,38 @@ import { starlightAsides } from './integrations/asides';
import { starlightExpressiveCode } from './integrations/expressive-code/index';
import { starlightSitemap } from './integrations/sitemap';
import { vitePluginStarlightUserConfig } from './integrations/virtual-user-config';
-import { errorMap } from './utils/error-map';
-import { StarlightConfigSchema, type StarlightUserConfig } from './utils/user-config';
import { rehypeRtlCodeSupport } from './integrations/code-rtl-support';
import { createTranslationSystemFromFs } from './utils/translations-fs';
+import { runPlugins, type StarlightUserConfigWithPlugins } from './utils/plugins';
+import type { StarlightConfig } from './types';
-export default function StarlightIntegration(opts: StarlightUserConfig): AstroIntegration {
- const parsedConfig = StarlightConfigSchema.safeParse(opts, { errorMap });
-
- if (!parsedConfig.success) {
- throw new Error(
- 'Invalid config passed to starlight integration\n' +
- parsedConfig.error.issues.map((i) => i.message).join('\n')
- );
- }
-
- const userConfig = parsedConfig.data;
-
- const Starlight: AstroIntegration = {
+export default function StarlightIntegration({
+ plugins,
+ ...opts
+}: StarlightUserConfigWithPlugins): AstroIntegration {
+ let userConfig: StarlightConfig;
+ return {
name: '@astrojs/starlight',
hooks: {
- 'astro:config:setup': ({ config, injectRoute, updateConfig }) => {
- const useTranslations = createTranslationSystemFromFs(userConfig, config);
+ 'astro:config:setup': async ({
+ command,
+ config,
+ injectRoute,
+ isRestart,
+ logger,
+ updateConfig,
+ }) => {
+ // Run plugins to get the final configuration and any extra Astro integrations to load.
+ const { integrations, starlightConfig } = await runPlugins(opts, plugins, {
+ command,
+ config,
+ isRestart,
+ logger,
+ });
+ userConfig = starlightConfig;
+
+ const useTranslations = createTranslationSystemFromFs(starlightConfig, config);
+
injectRoute({
pattern: '404',
entryPoint: '@astrojs/starlight/404.astro',
@@ -37,34 +47,28 @@ export default function StarlightIntegration(opts: StarlightUserConfig): AstroIn
pattern: '[...slug]',
entryPoint: '@astrojs/starlight/index.astro',
});
- const integrations: AstroIntegration[] = [];
- if (!config.integrations.find(({ name }) => name === 'astro-expressive-code')) {
+ // Add built-in integrations only if they are not already added by the user through the
+ // config or by a plugin.
+ const allIntegrations = [...config.integrations, ...integrations];
+ if (!allIntegrations.find(({ name }) => name === 'astro-expressive-code')) {
integrations.push(
- ...starlightExpressiveCode({
- starlightConfig: userConfig,
- astroConfig: config,
- useTranslations,
- })
+ ...starlightExpressiveCode({ starlightConfig, astroConfig: config, useTranslations })
);
}
- if (!config.integrations.find(({ name }) => name === '@astrojs/sitemap')) {
- integrations.push(starlightSitemap(userConfig));
+ if (!allIntegrations.find(({ name }) => name === '@astrojs/sitemap')) {
+ integrations.push(starlightSitemap(starlightConfig));
}
- if (!config.integrations.find(({ name }) => name === '@astrojs/mdx')) {
+ if (!allIntegrations.find(({ name }) => name === '@astrojs/mdx')) {
integrations.push(mdx());
}
const newConfig: AstroUserConfig = {
integrations,
vite: {
- plugins: [vitePluginStarlightUserConfig(userConfig, config)],
+ plugins: [vitePluginStarlightUserConfig(starlightConfig, config)],
},
markdown: {
remarkPlugins: [
- ...starlightAsides({
- starlightConfig: userConfig,
- astroConfig: config,
- useTranslations,
- }),
+ ...starlightAsides({ starlightConfig, astroConfig: config, useTranslations }),
],
rehypePlugins: [rehypeRtlCodeSupport()],
shikiConfig:
@@ -91,6 +95,4 @@ export default function StarlightIntegration(opts: StarlightUserConfig): AstroIn
},
},
};
-
- return Starlight;
}
diff --git a/packages/starlight/types.ts b/packages/starlight/types.ts
index 8d99451e..861d1afb 100644
--- a/packages/starlight/types.ts
+++ b/packages/starlight/types.ts
@@ -1 +1,2 @@
export type { StarlightConfig } from './utils/user-config';
+export type { StarlightPlugin } from './utils/plugins';
diff --git a/packages/starlight/utils/plugins.ts b/packages/starlight/utils/plugins.ts
new file mode 100644
index 00000000..40e449d5
--- /dev/null
+++ b/packages/starlight/utils/plugins.ts
@@ -0,0 +1,226 @@
+import type { AstroIntegration } from 'astro';
+import { z } from 'astro/zod';
+import { StarlightConfigSchema, type StarlightUserConfig } from '../utils/user-config';
+import { errorMap } from '../utils/error-map';
+
+/**
+ * Runs Starlight plugins in the order that they are configured after validating the user-provided
+ * configuration and returns the final validated user config that may have been updated by the
+ * plugins and a list of any integrations added by the plugins.
+ */
+export async function runPlugins(
+ starlightUserConfig: StarlightUserConfig,
+ pluginsUserConfig: StarlightPluginsUserConfig,
+ context: StarlightPluginContext
+) {
+ // Validate the user-provided configuration.
+ let userConfig = starlightUserConfig;
+ let starlightConfig = StarlightConfigSchema.safeParse(userConfig, { errorMap });
+
+ if (!starlightConfig.success) {
+ throwValidationError(starlightConfig.error, 'Invalid config passed to starlight integration');
+ }
+
+ // Validate the user-provided plugins configuration.
+ const pluginsConfig = starlightPluginsConfigSchema.safeParse(pluginsUserConfig, {
+ errorMap,
+ });
+
+ if (!pluginsConfig.success) {
+ throwValidationError(
+ pluginsConfig.error,
+ 'Invalid plugins config passed to starlight integration'
+ );
+ }
+
+ // A list of Astro integrations added by the various plugins.
+ const integrations: AstroIntegration[] = [];
+
+ for (const {
+ name,
+ hooks: { setup },
+ } of pluginsConfig.data) {
+ await setup({
+ config: pluginsUserConfig ? { ...userConfig, plugins: pluginsUserConfig } : userConfig,
+ updateConfig(newConfig) {
+ // Ensure that plugins do not update the `plugins` config key.
+ if ('plugins' in newConfig) {
+ throw new Error(
+ `The '${name}' plugin tried to update the 'plugins' config key which is not supported.`
+ );
+ }
+
+ // If the plugin is updating the user config, re-validate it.
+ const mergedUserConfig = { ...userConfig, ...newConfig };
+ const mergedConfig = StarlightConfigSchema.safeParse(mergedUserConfig, { errorMap });
+
+ if (!mergedConfig.success) {
+ throwValidationError(
+ mergedConfig.error,
+ `Invalid config update provided by the '${name}' plugin`
+ );
+ }
+
+ // If the updated config is valid, keep track of both the user config and parsed config.
+ userConfig = mergedUserConfig;
+ starlightConfig = mergedConfig;
+ },
+ addIntegration(integration) {
+ // Collect any Astro integrations added by the plugin.
+ integrations.push(integration);
+ },
+ astroConfig: {
+ ...context.config,
+ integrations: [...context.config.integrations, ...integrations],
+ },
+ command: context.command,
+ isRestart: context.isRestart,
+ logger: context.logger.fork(name),
+ });
+ }
+
+ return { integrations, starlightConfig: starlightConfig.data };
+}
+
+function throwValidationError(error: z.ZodError, message: string): never {
+ throw new Error(`${message}\n${error.issues.map((i) => i.message).join('\n')}`);
+}
+
+// https://github.com/withastro/astro/blob/910eb00fe0b70ca80bd09520ae100e8c78b675b5/packages/astro/src/core/config/schema.ts#L113
+const astroIntegrationSchema = z.object({
+ name: z.string(),
+ hooks: z.object({}).passthrough().default({}),
+}) as z.Schema<AstroIntegration>;
+
+const baseStarlightPluginSchema = z.object({
+ /** Name of the Starlight plugin. */
+ name: z.string(),
+});
+
+/**
+ * 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']>,
+ }),
+ ]),
+ z.union([z.void(), z.promise(z.void())])
+ ),
+ }),
+});
+
+const starlightPluginsConfigSchema = z.array(starlightPluginSchema).default([]);
+
+type StarlightPluginsUserConfig = z.input<typeof starlightPluginsConfigSchema>;
+
+export type StarlightPlugin = z.input<typeof starlightPluginSchema>;
+
+export type StarlightUserConfigWithPlugins = StarlightUserConfig & {
+ /**
+ * A list of plugins to extend Starlight with.
+ *
+ * @example
+ * // Add Starlight Algolia plugin.
+ * starlight({
+ * plugins: [starlightAlgolia({ … })],
+ * })
+ */
+ plugins?: StarlightPluginsUserConfig;
+};
+
+export type StarlightPluginContext = Pick<
+ Parameters<NonNullable<AstroIntegration['hooks']['astro:config:setup']>>[0],
+ 'command' | 'config' | 'isRestart' | 'logger'
+>;
diff --git a/packages/starlight/vitest.config.ts b/packages/starlight/vitest.config.ts
index fe6acc49..ba20e21a 100644
--- a/packages/starlight/vitest.config.ts
+++ b/packages/starlight/vitest.config.ts
@@ -33,10 +33,10 @@ export default defineConfig({
'index.ts',
],
thresholdAutoUpdate: true,
- lines: 69.21,
- functions: 90.24,
- branches: 90.62,
- statements: 69.21,
+ lines: 80.11,
+ functions: 93.61,
+ branches: 91.23,
+ statements: 80.11,
},
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db414b31..72c2b7bd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1494,6 +1494,7 @@ packages:
/@types/node@18.16.19:
resolution: {integrity: sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==}
+ requiresBuild: true
/@types/normalize-package-data@2.4.1:
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}