Skip to content

Commit

Permalink
Initial Starlight Plugins support (#942)
Browse files Browse the repository at this point in the history
Co-authored-by: Chris Swithinbank <357379+delucis@users.noreply.github.com>
  • Loading branch information
HiDeoo and delucis authored Nov 29, 2023
1 parent 00d101b commit efd7fdc
Show file tree
Hide file tree
Showing 13 changed files with 736 additions and 50 deletions.
7 changes: 7 additions & 0 deletions .changeset/lovely-keys-wash.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
163 changes: 163 additions & 0 deletions docs/src/content/docs/reference/plugins.md
Original file line number Diff line number Diff line change
@@ -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…
```
126 changes: 126 additions & 0 deletions packages/starlight/__tests__/plugins/config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
77 changes: 77 additions & 0 deletions packages/starlight/__tests__/plugins/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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()
);
});
Loading

0 comments on commit efd7fdc

Please sign in to comment.