@scalar/starlight 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # Scalar API Reference for Astro Starlight
2
+
3
+ [![Version](https://img.shields.io/npm/v/@scalar/starlight)](https://www.npmjs.com/package/@scalar/starlight)
4
+ [![Downloads](https://img.shields.io/npm/dm/@scalar/starlight)](https://www.npmjs.com/package/@scalar/starlight)
5
+ [![License](https://img.shields.io/npm/l/@scalar/starlight)](https://www.npmjs.com/package/@scalar/starlight)
6
+ [![Discord](https://img.shields.io/discord/1135330207960678410?style=flat&color=5865F2)](https://discord.gg/scalar)
7
+
8
+ A [Starlight](https://starlight.astro.build) plugin that renders a beautiful Scalar API reference from an OpenAPI document, inside your Starlight docs.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @scalar/starlight
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Add the plugin to your Starlight configuration and point it at an OpenAPI document. It injects a route that renders the API reference inside the Starlight layout and adds a sidebar entry that links to it — no need to hand-create a page.
19
+
20
+ ```js
21
+ // astro.config.mjs
22
+ import { defineConfig } from 'astro/config'
23
+ import starlight from '@astrojs/starlight'
24
+ import { scalarStarlight } from '@scalar/starlight'
25
+
26
+ export default defineConfig({
27
+ integrations: [
28
+ starlight({
29
+ title: 'My Docs',
30
+ plugins: [
31
+ scalarStarlight({
32
+ // Scalar's universal configuration object:
33
+ // https://scalar.com/products/api-references/configuration
34
+ configuration: {
35
+ url: '/openapi.json',
36
+ },
37
+ }),
38
+ ],
39
+ }),
40
+ ],
41
+ })
42
+ ```
43
+
44
+ The reference is served from `/api-reference` by default.
45
+
46
+ > [!NOTE]
47
+ > If you do not define a `sidebar` in your Starlight config, Starlight auto-generates it from your docs. In that case the plugin does not add the entry (doing so would replace the auto-generated sidebar and hide your other pages) and logs a note instead — add the link yourself, e.g. `sidebar: [{ label: 'API Reference', link: '/api-reference' }]`.
48
+
49
+ ## Options
50
+
51
+ | Option | Default | Description |
52
+ | --------------- | ------------------ | ----------------------------------------------------------------------------------------------- |
53
+ | `configuration` | — | Scalar's universal [configuration object](https://scalar.com/products/api-references/configuration). |
54
+ | `pathname` | `'/api-reference'` | The path the API reference is served from. |
55
+ | `label` | `'API Reference'` | The label of the sidebar entry. |
56
+ | `title` | the `label` | The title of the API reference page. |
57
+
58
+ ## Multiple references
59
+
60
+ Add the plugin more than once, each with its own `pathname`, to serve several API references from one site:
61
+
62
+ ```js
63
+ plugins: [
64
+ scalarStarlight({ pathname: '/reference/payments', label: 'Payments', configuration: { url: '/payments.json' } }),
65
+ scalarStarlight({ pathname: '/reference/billing', label: 'Billing', configuration: { url: '/billing.json' } }),
66
+ ]
67
+ ```
68
+
69
+ > [!NOTE]
70
+ > The configuration is serialized into the page as JSON, so function-valued options (a custom `fetch`, `onLoaded`, plugins, …) are not carried over. This mirrors the `renderMode="client"` behavior of [`@scalar/astro`](https://www.npmjs.com/package/@scalar/astro), which this plugin builds on so the reference keeps working across Starlight's client-side navigation.
71
+
72
+ ## License
73
+
74
+ The source code in this repository is licensed under [MIT](https://github.com/scalar/scalar/blob/main/LICENSE).
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Do not write code directly here, instead use the `src` folder!
2
+ // Then, use this file to export everything you want your user to access.
3
+
4
+ import { scalarStarlight } from './src/plugin'
5
+
6
+ export { scalarStarlight }
7
+
8
+ export type { ScalarStarlightOptions } from './src/plugin'
9
+
10
+ export default scalarStarlight
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@scalar/starlight",
3
+ "description": "Scalar API Reference plugin for Astro Starlight",
4
+ "license": "MIT",
5
+ "author": "Scalar (https://github.com/scalar)",
6
+ "homepage": "https://github.com/scalar/scalar",
7
+ "bugs": "https://github.com/scalar/scalar/issues/new/choose",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/scalar/scalar.git",
11
+ "directory": "integrations/starlight"
12
+ },
13
+ "keywords": [
14
+ "astro-component",
15
+ "starlight",
16
+ "starlight-plugin",
17
+ "scalar",
18
+ "openapi",
19
+ "swagger"
20
+ ],
21
+ "version": "0.2.0",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "scripts": {
26
+ "dev": "cd playground && pnpm dev",
27
+ "test": "vitest --run"
28
+ },
29
+ "type": "module",
30
+ "exports": {
31
+ ".": "./index.ts"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.*",
36
+ "!src/**/*.spec.*",
37
+ "index.ts"
38
+ ],
39
+ "scalarReadme": {
40
+ "title": "Scalar API Reference for Astro Starlight",
41
+ "badges": [
42
+ {
43
+ "type": "npm-version"
44
+ },
45
+ {
46
+ "type": "npm-downloads"
47
+ },
48
+ {
49
+ "type": "npm-license"
50
+ }
51
+ ],
52
+ "documentation": "https://scalar.com/products/api-references/integrations/starlight"
53
+ },
54
+ "dependencies": {
55
+ "@scalar/astro": "workspace:*",
56
+ "@scalar/client-side-rendering": "workspace:*"
57
+ },
58
+ "devDependencies": {
59
+ "@astrojs/starlight": "catalog:*",
60
+ "astro": "catalog:*"
61
+ },
62
+ "peerDependencies": {
63
+ "@astrojs/starlight": ">=0.32.0",
64
+ "astro": "^5.0.0 || ^6.0.0"
65
+ }
66
+ }
@@ -0,0 +1,43 @@
1
+ ---
2
+ import { ScalarComponent } from '@scalar/astro'
3
+ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'
4
+
5
+ import { references } from 'virtual:scalar-starlight'
6
+
7
+ import { normalizePathname } from './normalize-pathname'
8
+
9
+ // One route module renders every configured reference, so pick the entry that
10
+ // matches this page. Strip Astro's `base` from the request path and normalize it
11
+ // with the same helper the plugin used for each `pathname`, so the key lines up
12
+ // regardless of `base` or trailing-slash settings.
13
+ const base = import.meta.env.BASE_URL
14
+ const relative = Astro.url.pathname.startsWith(base) ? Astro.url.pathname.slice(base.length) : Astro.url.pathname
15
+ const key = normalizePathname(relative)
16
+
17
+ // Fall back to the only entry when there is a single reference (the common
18
+ // case), which keeps things working even if the path ever fails to match. With
19
+ // several references a miss cannot be resolved safely — silently rendering the
20
+ // first one would show the wrong API — so fail loudly instead.
21
+ const entries = Object.values(references)
22
+ const reference = references[key] ?? (entries.length === 1 ? entries[0] : undefined)
23
+ if (!reference) {
24
+ throw new Error(
25
+ `[@scalar/starlight] No API reference is registered for "${key}". ` +
26
+ `Registered references: ${Object.keys(references).join(', ')}.`,
27
+ )
28
+ }
29
+ const { configuration, title } = reference
30
+ ---
31
+
32
+ {/*
33
+ Render the API reference inside the Starlight layout, so it keeps the site
34
+ header and sidebar. `template: 'splash'` gives the reference the full content
35
+ width (Scalar brings its own operations sidebar).
36
+
37
+ `renderMode="client"` is required here: Starlight ships `<ClientRouter />`, so
38
+ navigation happens client-side and the static render script would otherwise
39
+ only run after a manual refresh.
40
+ */}
41
+ <StarlightPage frontmatter={{ title, template: 'splash' }}>
42
+ <ScalarComponent renderMode="client" configuration={configuration} />
43
+ </StarlightPage>
@@ -0,0 +1,115 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ import type { HtmlRenderingConfiguration } from '@scalar/client-side-rendering'
4
+ import type { AstroIntegration } from 'astro'
5
+
6
+ type ScalarReference = {
7
+ /** The title of the API reference page. */
8
+ title: string
9
+ /** The Scalar configuration, passed to the reference. */
10
+ configuration: Partial<HtmlRenderingConfiguration>
11
+ }
12
+
13
+ type ScalarRouteOptions = ScalarReference & {
14
+ /** The normalized path the API reference is served from. */
15
+ pathname: string
16
+ }
17
+
18
+ /** The virtual module the injected route reads its configuration from. */
19
+ const VIRTUAL_ID = 'virtual:scalar-starlight'
20
+ const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_ID}`
21
+
22
+ /**
23
+ * Every reference on the site, keyed by its normalized `pathname`.
24
+ *
25
+ * A single bundled `.astro` component renders all of them, and a bundled
26
+ * component cannot receive per-instance props. So instead of one virtual module
27
+ * per reference (they would all resolve the same id and only the first would
28
+ * win), we collect every reference here and let the component pick its own by
29
+ * matching the request path at render time. This is what lets one site expose
30
+ * several references at once.
31
+ *
32
+ * The registry lives at module scope so it is shared across every
33
+ * `scalarStarlight()` instance in a single Astro config.
34
+ */
35
+ const references = new Map<string, ScalarReference>()
36
+
37
+ /**
38
+ * The subset of a Vite plugin this integration uses.
39
+ *
40
+ * Astro bundles its own Vite (currently v6), while the workspace catalog pins a
41
+ * newer Vite whose `Plugin` type is not structurally identical. Describing just
42
+ * the hooks used here keeps the plugin assignable to whichever Vite Astro ships,
43
+ * without importing (and thereby version-locking) Vite's `Plugin` type.
44
+ */
45
+ type VirtualModulePlugin = {
46
+ name: string
47
+ resolveId: (id: string) => string | undefined
48
+ load: (id: string) => string | undefined
49
+ }
50
+
51
+ /**
52
+ * A Vite plugin that exposes every reference to the injected `.astro` route.
53
+ *
54
+ * The route is a bundled component, so it cannot receive props from the plugin
55
+ * directly. Serializing the registry into a virtual module is the standard way
56
+ * to hand data to injected routes. `load` reads the registry lazily — it runs at
57
+ * build time, after every instance has registered, so it sees them all.
58
+ */
59
+ const virtualConfigurationPlugin = (): VirtualModulePlugin => ({
60
+ name: '@scalar/starlight:virtual-configuration',
61
+ resolveId: (id: string): string | undefined => (id === VIRTUAL_ID ? RESOLVED_VIRTUAL_ID : undefined),
62
+ load: (id: string): string | undefined =>
63
+ id === RESOLVED_VIRTUAL_ID
64
+ ? `export const references = ${JSON.stringify(Object.fromEntries(references))}`
65
+ : undefined,
66
+ })
67
+
68
+ /**
69
+ * The Astro integration that renders the API reference.
70
+ *
71
+ * It registers the reference, injects a route at `pathname` pointing at the
72
+ * bundled `ScalarReference` component, and exposes the registry through a
73
+ * virtual module that feeds every reference its configuration.
74
+ */
75
+ export const scalarRouteIntegration = (options: ScalarRouteOptions): AstroIntegration => {
76
+ const reference = { title: options.title, configuration: options.configuration }
77
+
78
+ // Two references sharing a `pathname` would inject the same route twice and
79
+ // surface only as an opaque Astro duplicate-route error, so fail early with a
80
+ // message that points at the cause. Registering the *same* reference again is
81
+ // fine (a dev-server config reload re-runs this), so only reject a genuine
82
+ // collision — a different reference on a pathname already taken.
83
+ const existing = references.get(options.pathname)
84
+ if (existing && JSON.stringify(existing) !== JSON.stringify(reference)) {
85
+ throw new Error(
86
+ `[@scalar/starlight] Two different API references are configured for "${options.pathname}". ` +
87
+ 'Give each reference a distinct `pathname`.',
88
+ )
89
+ }
90
+
91
+ references.set(options.pathname, reference)
92
+
93
+ return {
94
+ // Unique per `pathname` so Astro treats multiple references as distinct
95
+ // integrations. A shared name risks Astro skipping all but the first, which
96
+ // would drop every reference except one.
97
+ name: `@scalar/starlight:${options.pathname}`,
98
+ hooks: {
99
+ 'astro:config:setup': ({ injectRoute, updateConfig }) => {
100
+ updateConfig({
101
+ vite: {
102
+ plugins: [virtualConfigurationPlugin()],
103
+ },
104
+ })
105
+
106
+ injectRoute({
107
+ pattern: options.pathname,
108
+ // The package ships its source, so this resolves to the `.astro` file
109
+ // inside `node_modules`.
110
+ entrypoint: fileURLToPath(new URL('./ScalarReference.astro', import.meta.url)),
111
+ })
112
+ },
113
+ },
114
+ }
115
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Ensure the path starts with a single leading slash and has no trailing slash,
3
+ * so it works both as an Astro route pattern and as a Starlight sidebar link.
4
+ *
5
+ * Splitting on `/` rather than trimming with a regex keeps this linear and
6
+ * sidesteps the backtracking a `/+` pattern can cause on adversarial input. It
7
+ * also collapses empty segments (e.g. from `//`), which Astro route patterns
8
+ * would reject anyway.
9
+ *
10
+ * This lives in its own module because both the plugin (when registering a
11
+ * reference) and the route component (when matching the request path back to a
12
+ * reference) must normalize identically — if the two ever drifted, lookups
13
+ * would silently miss and render the wrong reference.
14
+ */
15
+ export const normalizePathname = (pathname: string): string => `/${pathname.split('/').filter(Boolean).join('/')}`
package/src/plugin.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { StarlightPlugin } from '@astrojs/starlight/types'
2
+ import type { HtmlRenderingConfiguration } from '@scalar/client-side-rendering'
3
+
4
+ import { scalarRouteIntegration } from './integration'
5
+ import { normalizePathname } from './normalize-pathname'
6
+
7
+ export type ScalarStarlightOptions = {
8
+ /**
9
+ * The Scalar configuration.
10
+ *
11
+ * This is Scalar's universal configuration object, most importantly the
12
+ * `url` (or `content`) of the OpenAPI document to render. See
13
+ * https://scalar.com/products/api-references/configuration for the full list.
14
+ *
15
+ * The configuration is serialized into the page as JSON, so function-valued
16
+ * options (a custom `fetch`, `onLoaded`, plugins, …) are not carried over.
17
+ */
18
+ configuration: Partial<HtmlRenderingConfiguration>
19
+ /**
20
+ * The path the API reference is served from.
21
+ *
22
+ * @default '/api-reference'
23
+ */
24
+ pathname?: string
25
+ /**
26
+ * The label of the sidebar entry that links to the API reference.
27
+ *
28
+ * @default 'API Reference'
29
+ */
30
+ label?: string
31
+ /**
32
+ * The title of the API reference page.
33
+ *
34
+ * @default the `label`
35
+ */
36
+ title?: string
37
+ }
38
+
39
+ /**
40
+ * A Starlight plugin that renders a Scalar API reference.
41
+ *
42
+ * It injects a route that renders the API reference inside the Starlight layout
43
+ * and adds a sidebar entry that links to it, so you do not have to hand-create a
44
+ * page and embed the component yourself.
45
+ *
46
+ * @example
47
+ * ```js
48
+ * // astro.config.mjs
49
+ * import starlight from '@astrojs/starlight'
50
+ * import { scalarStarlight } from '@scalar/starlight'
51
+ *
52
+ * export default defineConfig({
53
+ * integrations: [
54
+ * starlight({
55
+ * title: 'My Docs',
56
+ * plugins: [scalarStarlight({ configuration: { url: '/openapi.json' } })],
57
+ * }),
58
+ * ],
59
+ * })
60
+ * ```
61
+ */
62
+ export const scalarStarlight = (options: ScalarStarlightOptions): StarlightPlugin => {
63
+ const pathname = normalizePathname(options.pathname ?? '/api-reference')
64
+
65
+ // A `pathname` that normalizes to the site root would collide with the
66
+ // homepage and only surface as an opaque Astro duplicate-route error, so fail
67
+ // early with a message that points at the actual cause.
68
+ if (pathname === '/') {
69
+ throw new Error(
70
+ '[@scalar/starlight] `pathname` must not resolve to "/", which would collide with your homepage. ' +
71
+ 'Use a subpath like "/api-reference".',
72
+ )
73
+ }
74
+
75
+ const label = options.label ?? 'API Reference'
76
+ const title = options.title ?? label
77
+
78
+ return {
79
+ name: '@scalar/starlight',
80
+ hooks: {
81
+ 'config:setup': ({ config, updateConfig, addIntegration, logger }) => {
82
+ // Inject the route that renders the reference. Starlight plugins cannot
83
+ // inject routes directly, so this goes through an Astro integration.
84
+ addIntegration(scalarRouteIntegration({ pathname, title, configuration: options.configuration }))
85
+
86
+ // Only touch the sidebar when the user already defines one. If it is
87
+ // left undefined, Starlight auto-generates the sidebar from the docs
88
+ // directory — replacing it with a single entry would hide every other
89
+ // page, so we leave it alone and tell the user how to add the link.
90
+ if (config.sidebar) {
91
+ updateConfig({
92
+ sidebar: [...config.sidebar, { label, link: pathname }],
93
+ })
94
+ } else {
95
+ logger.warn(
96
+ `No \`sidebar\` is configured, so Starlight auto-generates it from your docs and the "${label}" ` +
97
+ 'entry was not added (adding it would hide your other pages). Add it yourself, e.g. ' +
98
+ `\`sidebar: [{ label: '${label}', link: '${pathname}' }]\`, or link to ${pathname} from your content.`,
99
+ )
100
+ }
101
+ },
102
+ },
103
+ }
104
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The virtual module the injected `ScalarReference.astro` route reads its
3
+ * configuration from. It is generated at build time by `integration.ts`.
4
+ *
5
+ * `references` holds every configured reference keyed by its normalized
6
+ * `pathname`, so the shared route component can pick the one that matches the
7
+ * page being rendered.
8
+ */
9
+ declare module 'virtual:scalar-starlight' {
10
+ import type { HtmlRenderingConfiguration } from '@scalar/client-side-rendering'
11
+
12
+ export const references: Record<string, { configuration: Partial<HtmlRenderingConfiguration>; title: string }>
13
+ }