@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 CHANGED
@@ -17,7 +17,7 @@
17
17
  "openapi",
18
18
  "swagger"
19
19
  ],
20
- "version": "0.2.19",
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.1.12"
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
- <div set:html={html}></div>
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
+ }