@scalar/astro 0.2.19 → 0.4.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/package.json +6 -3
- package/src/ScalarClient.astro +38 -0
- package/src/ScalarComponent.astro +22 -8
- package/src/client.ts +295 -0
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"openapi",
|
|
18
18
|
"swagger"
|
|
19
19
|
],
|
|
20
|
-
"version": "0.
|
|
20
|
+
"version": "0.4.0",
|
|
21
21
|
"engines": {
|
|
22
22
|
"node": ">=22"
|
|
23
23
|
},
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"src",
|
|
30
|
+
"!src/**/*.test.*",
|
|
31
|
+
"!src/**/*.spec.*",
|
|
30
32
|
"index.ts"
|
|
31
33
|
],
|
|
32
34
|
"readme": {
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"documentation": "https://scalar.com/products/api-references/integrations/astro"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
|
-
"@scalar/client-side-rendering": "0.
|
|
50
|
+
"@scalar/client-side-rendering": "0.2.0"
|
|
49
51
|
},
|
|
50
52
|
"devDependencies": {
|
|
51
53
|
"astro": "^4.0.0 || ^5.0.0 || ^6.0.0"
|
|
@@ -54,6 +56,7 @@
|
|
|
54
56
|
"astro": "^4.0.0 || ^5.0.0"
|
|
55
57
|
},
|
|
56
58
|
"scripts": {
|
|
57
|
-
"dev": "cd playground && pnpm dev"
|
|
59
|
+
"dev": "cd playground && pnpm dev",
|
|
60
|
+
"test": "vitest --run"
|
|
58
61
|
}
|
|
59
62
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getConfiguration, type HtmlRenderingConfiguration } from '@scalar/client-side-rendering'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
config: Omit<Partial<HtmlRenderingConfiguration>, 'cdn' | 'pageTitle'>
|
|
6
|
+
cdn?: string
|
|
7
|
+
/**
|
|
8
|
+
* A Content Security Policy nonce, forwarded to the client so it can stamp
|
|
9
|
+
* the dynamically injected CDN `<script>` and the `csp-nonce` meta tag that
|
|
10
|
+
* the standalone bundle reads when nonce-ing its runtime styles.
|
|
11
|
+
*
|
|
12
|
+
* Note: this does not nonce the bootstrap `<script>` below — Astro bundles
|
|
13
|
+
* and emits that tag itself, so its CSP handling is Astro's (see the comment
|
|
14
|
+
* on the script). Under a strict `script-src`, allow Astro's own scripts via
|
|
15
|
+
* its `experimental.csp` flag or `'self'`.
|
|
16
|
+
*/
|
|
17
|
+
nonce?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { config, cdn, nonce } = Astro.props
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<div data-scalar-client data-configuration={JSON.stringify(getConfiguration(config))} data-cdn={cdn} data-nonce={nonce}></div>
|
|
24
|
+
|
|
25
|
+
{/*
|
|
26
|
+
Astro bundles this script and resolves the `./client` import, but that also
|
|
27
|
+
means Astro — not Scalar — emits the final `<script>` tag, so we cannot stamp
|
|
28
|
+
the user's `nonce` on it: adding a `nonce` attribute opts the script out of
|
|
29
|
+
bundling and leaves the import unresolved. Under a strict `script-src`, allow
|
|
30
|
+
Astro's own scripts via its `experimental.csp` flag (which nonces them) or
|
|
31
|
+
`'self'`. The nonce still reaches what Scalar controls — the CDN script and
|
|
32
|
+
the runtime styles — applied from `client.ts` via `data-nonce`.
|
|
33
|
+
*/}
|
|
34
|
+
<script>
|
|
35
|
+
import { initScalarClient } from './client'
|
|
36
|
+
|
|
37
|
+
initScalarClient()
|
|
38
|
+
</script>
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { renderApiReference, type HtmlRenderingConfiguration } from '@scalar/client-side-rendering'
|
|
3
3
|
|
|
4
|
+
import ScalarClient from './ScalarClient.astro'
|
|
5
|
+
|
|
4
6
|
export interface Props {
|
|
5
7
|
configuration: Partial<HtmlRenderingConfiguration>
|
|
8
|
+
/**
|
|
9
|
+
* How the API reference is rendered.
|
|
10
|
+
*
|
|
11
|
+
* - `'static'` (default) renders a full HTML document ahead of time. Its
|
|
12
|
+
* embedded script only runs on a hard page load.
|
|
13
|
+
* - `'client'` renders an empty container and mounts Scalar in the browser,
|
|
14
|
+
* re-mounting around Astro view-transition events. Use this on Starlight
|
|
15
|
+
* pages and other Astro sites with client-side navigation, where the
|
|
16
|
+
* static script would otherwise only render after a manual refresh.
|
|
17
|
+
*/
|
|
18
|
+
renderMode?: 'static' | 'client'
|
|
6
19
|
}
|
|
7
20
|
|
|
8
21
|
/**
|
|
@@ -13,18 +26,19 @@ const DEFAULT_CONFIGURATION: Partial<HtmlRenderingConfiguration> = {
|
|
|
13
26
|
// _integration: 'astro',
|
|
14
27
|
}
|
|
15
28
|
|
|
16
|
-
const { configuration } = Astro.props
|
|
29
|
+
const { configuration, renderMode = 'static' } = Astro.props
|
|
17
30
|
|
|
18
31
|
const finalConfiguration = {
|
|
19
32
|
...DEFAULT_CONFIGURATION,
|
|
20
33
|
...configuration,
|
|
21
34
|
}
|
|
22
|
-
const { cdn, pageTitle, ...config } = finalConfiguration
|
|
23
|
-
const html = renderApiReference({
|
|
24
|
-
config,
|
|
25
|
-
cdn,
|
|
26
|
-
pageTitle,
|
|
27
|
-
})
|
|
35
|
+
const { cdn, pageTitle, nonce, ...config } = finalConfiguration
|
|
28
36
|
---
|
|
29
37
|
|
|
30
|
-
|
|
38
|
+
{
|
|
39
|
+
renderMode === 'client' ? (
|
|
40
|
+
<ScalarClient config={config} cdn={cdn} nonce={nonce} />
|
|
41
|
+
) : (
|
|
42
|
+
<div set:html={renderApiReference({ config, cdn, pageTitle, nonce })} />
|
|
43
|
+
)
|
|
44
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side mounting for `<ScalarComponent renderMode="client" />`.
|
|
3
|
+
*
|
|
4
|
+
* Instead of a pre-rendered HTML document, the component renders an empty
|
|
5
|
+
* `[data-scalar-client]` container. This module loads the Scalar standalone
|
|
6
|
+
* bundle from the CDN and mounts the API reference into that container in the
|
|
7
|
+
* browser.
|
|
8
|
+
*
|
|
9
|
+
* Most importantly, it re-mounts around Astro's view-transition events. A
|
|
10
|
+
* server-rendered `<script>` runs on a hard page load but not after a
|
|
11
|
+
* client-side navigation, which is why the API reference used to appear only
|
|
12
|
+
* after a manual refresh on Starlight pages and other Astro sites that use
|
|
13
|
+
* `<ClientRouter />`.
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_CDN } from '@scalar/client-side-rendering'
|
|
16
|
+
|
|
17
|
+
/** A mounted API reference, as returned by `window.Scalar.createApiReference`. */
|
|
18
|
+
type ScalarInstance = {
|
|
19
|
+
destroy: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ScalarGlobal = {
|
|
23
|
+
createApiReference: (element: Element, configuration: unknown) => ScalarInstance
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Shared, page-wide state. */
|
|
27
|
+
type ClientState = {
|
|
28
|
+
/** Whether the view-transition listeners have been registered. */
|
|
29
|
+
initialized: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Bumped by every `unmountAll`. A mount started before the bump belongs to
|
|
32
|
+
* a page that has since been swapped away, so its (async) CDN load must not
|
|
33
|
+
* create an instance once it finally resolves.
|
|
34
|
+
*/
|
|
35
|
+
generation: number
|
|
36
|
+
/** Live instances, keyed by their container element. */
|
|
37
|
+
instances: Map<HTMLElement, ScalarInstance>
|
|
38
|
+
/** Containers with a mount currently in flight, so it is not started twice. */
|
|
39
|
+
pending: Set<HTMLElement>
|
|
40
|
+
/**
|
|
41
|
+
* CDN script loads in flight (or settled), keyed by their resolved URL. Each
|
|
42
|
+
* resolves with the `Scalar` global that bundle installed (see `loadCdn`).
|
|
43
|
+
*/
|
|
44
|
+
cdnLoads: Map<string, Promise<ScalarGlobal | undefined>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ScalarWindow = Window & {
|
|
48
|
+
Scalar?: ScalarGlobal
|
|
49
|
+
__scalarAstroClient?: ClientState
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the shared state from `window`, creating it on first access.
|
|
54
|
+
*
|
|
55
|
+
* Astro may bundle this module into more than one page chunk, so module-level
|
|
56
|
+
* variables are not guaranteed to be shared across navigations. `window` is.
|
|
57
|
+
*/
|
|
58
|
+
const getState = (): ClientState => {
|
|
59
|
+
const win = window as ScalarWindow
|
|
60
|
+
|
|
61
|
+
win.__scalarAstroClient ??= {
|
|
62
|
+
initialized: false,
|
|
63
|
+
generation: 0,
|
|
64
|
+
instances: new Map(),
|
|
65
|
+
pending: new Set(),
|
|
66
|
+
cdnLoads: new Map(),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return win.__scalarAstroClient
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inject the Scalar standalone bundle, loading each CDN URL at most once, and
|
|
74
|
+
* resolve with the global that bundle installs.
|
|
75
|
+
*
|
|
76
|
+
* The global is captured synchronously inside the `load` handler — the instant
|
|
77
|
+
* this bundle's script has run — rather than read back from `window.Scalar`
|
|
78
|
+
* later. A page that mixes containers with distinct `data-cdn` URLs loads
|
|
79
|
+
* several bundles that all claim the single `window.Scalar` global, so reading
|
|
80
|
+
* it after an `await` could pick up whichever bundle happened to finish last.
|
|
81
|
+
* (Distinct bundles sharing one global is inherently last-one-wins; capturing
|
|
82
|
+
* here keeps the common single-CDN page correct and narrows the window for the
|
|
83
|
+
* rest.)
|
|
84
|
+
*
|
|
85
|
+
* The cache key also includes the nonce. A strict `script-src 'nonce-...'` only
|
|
86
|
+
* allows the `<script>` whose nonce matches the policy, so two mounts that want
|
|
87
|
+
* the same bundle under different nonces (or one with and one without) each need
|
|
88
|
+
* their own correctly-stamped tag — reusing the first load would leave the
|
|
89
|
+
* second blocked.
|
|
90
|
+
*/
|
|
91
|
+
const loadCdn = (cdn: string, nonce?: string): Promise<ScalarGlobal | undefined> => {
|
|
92
|
+
const { cdnLoads } = getState()
|
|
93
|
+
const key = nonce ? `${cdn}\n${nonce}` : cdn
|
|
94
|
+
const cached = cdnLoads.get(key)
|
|
95
|
+
|
|
96
|
+
if (cached) {
|
|
97
|
+
return cached
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const load = new Promise<ScalarGlobal | undefined>((resolve, reject) => {
|
|
101
|
+
const script = document.createElement('script')
|
|
102
|
+
script.src = cdn
|
|
103
|
+
// Stamp the nonce so the injected bundle is allowed under a strict
|
|
104
|
+
// `script-src 'nonce-...'` policy.
|
|
105
|
+
if (nonce) {
|
|
106
|
+
script.nonce = nonce
|
|
107
|
+
}
|
|
108
|
+
script.addEventListener('load', () => resolve((window as ScalarWindow).Scalar), { once: true })
|
|
109
|
+
script.addEventListener(
|
|
110
|
+
'error',
|
|
111
|
+
() => {
|
|
112
|
+
// Drop the cached failure (and the dead tag) so a later mount or
|
|
113
|
+
// navigation can retry, instead of replaying this rejection forever.
|
|
114
|
+
cdnLoads.delete(key)
|
|
115
|
+
script.remove()
|
|
116
|
+
reject(new Error(`[@scalar/astro] Could not load ${cdn}`))
|
|
117
|
+
},
|
|
118
|
+
{ once: true },
|
|
119
|
+
)
|
|
120
|
+
document.head.appendChild(script)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
cdnLoads.set(key, load)
|
|
124
|
+
|
|
125
|
+
return load
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Ensure a `<meta property="csp-nonce">` is present in `<head>`.
|
|
130
|
+
*
|
|
131
|
+
* The standalone bundle reads this meta tag (it is built with `useStrictCSP`)
|
|
132
|
+
* to nonce the stylesheet it injects at runtime, so a strict `style-src` lets
|
|
133
|
+
* the bundle's `<style>` through. The static render path emits this tag in the
|
|
134
|
+
* server-rendered HTML; in client mode we add it here instead. Astro replaces
|
|
135
|
+
* `<head>` on every view transition, so this is re-checked on each mount.
|
|
136
|
+
*/
|
|
137
|
+
const ensureCspNonceMeta = (nonce: string): void => {
|
|
138
|
+
const existing = document.head.querySelector('meta[property="csp-nonce"]')
|
|
139
|
+
|
|
140
|
+
if (existing) {
|
|
141
|
+
existing.setAttribute('content', nonce)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const meta = document.createElement('meta')
|
|
146
|
+
meta.setAttribute('property', 'csp-nonce')
|
|
147
|
+
meta.setAttribute('content', nonce)
|
|
148
|
+
document.head.appendChild(meta)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Mount a single container, unless it is already mounted or mounting. */
|
|
152
|
+
const mountContainer = (element: HTMLElement): void => {
|
|
153
|
+
const state = getState()
|
|
154
|
+
|
|
155
|
+
// Skip containers that are already mounted, or have a mount in flight. This
|
|
156
|
+
// also dedupes the two `mountAll()` calls the initial page load triggers
|
|
157
|
+
// (our own call in `initScalarClient`, plus `astro:page-load`).
|
|
158
|
+
if (state.instances.has(element) || state.pending.has(element)) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let configuration: unknown
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
configuration = JSON.parse(element.dataset.configuration || '{}')
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// A bad configuration is left untracked, so a corrected one on a later
|
|
168
|
+
// navigation still gets a chance to mount.
|
|
169
|
+
console.error('[@scalar/astro] Could not parse the configuration.', error)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const cdn = element.dataset.cdn || DEFAULT_CDN
|
|
174
|
+
const nonce = element.dataset.nonce
|
|
175
|
+
// Remember which lifecycle this mount belongs to (see `ClientState`).
|
|
176
|
+
const { generation } = state
|
|
177
|
+
|
|
178
|
+
// Expose the nonce to the bundle before it loads, so the styles it injects at
|
|
179
|
+
// runtime carry the nonce too.
|
|
180
|
+
if (nonce) {
|
|
181
|
+
ensureCspNonceMeta(nonce)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
state.pending.add(element)
|
|
185
|
+
|
|
186
|
+
void loadCdn(cdn, nonce)
|
|
187
|
+
.then((Scalar) => {
|
|
188
|
+
// Mount only if this page is still live (no view transition happened
|
|
189
|
+
// while the CDN loaded) and the element is still in the document.
|
|
190
|
+
if (state.generation === generation && element.isConnected && Scalar?.createApiReference) {
|
|
191
|
+
state.instances.set(element, Scalar.createApiReference(element, configuration))
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
.catch((error) => console.error('[@scalar/astro] Could not mount the API reference.', error))
|
|
195
|
+
.finally(() => {
|
|
196
|
+
// Release the pending slot — but only if a view transition has not
|
|
197
|
+
// already cleared it, in which case the slot belongs to a newer attempt.
|
|
198
|
+
if (state.generation === generation) {
|
|
199
|
+
state.pending.delete(element)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Mount every client-rendered container currently in the document. */
|
|
205
|
+
const mountAll = (): void => {
|
|
206
|
+
document.querySelectorAll<HTMLElement>('[data-scalar-client]').forEach(mountContainer)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Destroy every mounted instance, e.g. before Astro swaps the page out. */
|
|
210
|
+
const unmountAll = (): void => {
|
|
211
|
+
const state = getState()
|
|
212
|
+
|
|
213
|
+
state.instances.forEach((instance) => {
|
|
214
|
+
try {
|
|
215
|
+
instance.destroy()
|
|
216
|
+
} catch (error) {
|
|
217
|
+
console.error('[@scalar/astro] Could not destroy the API reference.', error)
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
state.instances.clear()
|
|
222
|
+
state.pending.clear()
|
|
223
|
+
|
|
224
|
+
// Invalidate in-flight mounts: their CDN load may still resolve, but it
|
|
225
|
+
// belongs to the page being swapped away. The next `astro:page-load` then
|
|
226
|
+
// mounts every container afresh — including any that survive the swap via
|
|
227
|
+
// `transition:persist`, which are no longer tracked as mounted.
|
|
228
|
+
state.generation += 1
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The `astro:before-swap` event, narrowed to the part this module uses. */
|
|
232
|
+
type BeforeSwapEvent = Event & { newDocument?: Document }
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Carry Scalar's stylesheet into the next page during a view transition.
|
|
236
|
+
*
|
|
237
|
+
* The standalone bundle injects its CSS into `<head>` once, when the CDN
|
|
238
|
+
* script first runs. Astro replaces `<head>` on every navigation, which would
|
|
239
|
+
* drop that `<style>` and leave the reference unstyled after a client-side
|
|
240
|
+
* navigation. Cloning it into the incoming document keeps the styles in place.
|
|
241
|
+
*
|
|
242
|
+
* This runs on every navigation, even to pages without a reference: that
|
|
243
|
+
* `<style>` is the only copy, so a page in between (an "About" page, say) has
|
|
244
|
+
* to carry it along, otherwise it is gone for good once the user navigates on.
|
|
245
|
+
*/
|
|
246
|
+
const persistScalarStyles = (newDocument: Document): void => {
|
|
247
|
+
document.head.querySelectorAll('style').forEach((style) => {
|
|
248
|
+
// Scalar's design tokens are namespaced `--scalar-*`, which reliably
|
|
249
|
+
// fingerprints the (otherwise unmarked) `<style>` the bundle injects.
|
|
250
|
+
if (!style.textContent?.includes('--scalar-')) {
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const alreadyThere = Array.from(newDocument.head.querySelectorAll('style')).some((candidate) =>
|
|
255
|
+
candidate.isEqualNode(style),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if (!alreadyThere) {
|
|
259
|
+
newDocument.head.appendChild(style.cloneNode(true))
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Persist styles and tear down instances before Astro swaps the page out. */
|
|
265
|
+
const handleBeforeSwap = (event: Event): void => {
|
|
266
|
+
const { newDocument } = event as BeforeSwapEvent
|
|
267
|
+
|
|
268
|
+
if (newDocument) {
|
|
269
|
+
persistScalarStyles(newDocument)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
unmountAll()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Mount client-rendered API references and keep them working across Astro
|
|
277
|
+
* view transitions. Safe to call repeatedly — the listeners register once.
|
|
278
|
+
*/
|
|
279
|
+
export const initScalarClient = (): void => {
|
|
280
|
+
// Mount right away. This covers the initial page load, including sites
|
|
281
|
+
// without `<ClientRouter />`, where `astro:page-load` never fires.
|
|
282
|
+
mountAll()
|
|
283
|
+
|
|
284
|
+
const state = getState()
|
|
285
|
+
|
|
286
|
+
if (state.initialized) {
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
state.initialized = true
|
|
290
|
+
|
|
291
|
+
// `astro:before-swap` fires before the outgoing page is replaced, and
|
|
292
|
+
// `astro:page-load` once the new page is in place — destroy, then re-mount.
|
|
293
|
+
document.addEventListener('astro:before-swap', handleBeforeSwap)
|
|
294
|
+
document.addEventListener('astro:page-load', mountAll)
|
|
295
|
+
}
|