diff --git a/.changeset/lovely-keys-wash.md b/.changeset/lovely-keys-wash.md new file mode 100644 index 00000000000..9ef587635c1 --- /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 9e546dc51cf..1247f3956d5 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 00000000000..de859cf1053 --- /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; + }; +} +``` + +## `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 00000000000..0b866b1dacd --- /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 00000000000..ba775a34c05 --- /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 00000000000..a6eaa3b296e --- /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 66eebc79913..e0cfe701d74 100644 --- a/packages/starlight/__tests__/test-config.ts +++ b/packages/starlight/__tests__/test-config.ts @@ -1,13 +1,13 @@ /// +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, +export async function defineVitestConfig( + { plugins, ...config }: StarlightUserConfigWithPlugins, opts?: { build?: Pick; 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 00000000000..32b60119b4c --- /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 b40e02234c8..9afb36f46e0 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 8d99451e515..861d1afbb95 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 00000000000..40e449d57f3 --- /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; + +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[] } + >, + /** + * 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>]), + 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, + /** + * The command used to run Starlight. + * + * @see https://docs.astro.build/en/reference/integrations-reference/#command-option + */ + command: z.any() as z.Schema, + /** + * `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, + /** + * 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, + }), + ]), + z.union([z.void(), z.promise(z.void())]) + ), + }), +}); + +const starlightPluginsConfigSchema = z.array(starlightPluginSchema).default([]); + +type StarlightPluginsUserConfig = z.input; + +export type StarlightPlugin = z.input; + +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>[0], + 'command' | 'config' | 'isRestart' | 'logger' +>; diff --git a/packages/starlight/vitest.config.ts b/packages/starlight/vitest.config.ts index fe6acc49c71..ba20e21a2f1 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 db414b3142f..72c2b7bd449 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==}