-
-
Notifications
You must be signed in to change notification settings - Fork 550
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial Starlight Plugins support (#942)
Co-authored-by: Chris Swithinbank <[email protected]>
- Loading branch information
Showing
13 changed files
with
736 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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… | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
); | ||
}); |
Oops, something went wrong.