@scalar/astro 0.2.19 → 0.3.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.3.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.1.13"
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,18 @@
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
+
9
+ const { config, cdn } = Astro.props
10
+ ---
11
+
12
+ <div data-scalar-client data-configuration={JSON.stringify(getConfiguration(config))} data-cdn={cdn}></div>
13
+
14
+ <script>
15
+ import { initScalarClient } from './client'
16
+
17
+ initScalarClient()
18
+ </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
35
  const { cdn, pageTitle, ...config } = finalConfiguration
23
- const html = renderApiReference({
24
- config,
25
- cdn,
26
- pageTitle,
27
- })
28
36
  ---
29
37
 
30
- <div set:html={html}></div>
38
+ {
39
+ renderMode === 'client' ? (
40
+ <ScalarClient config={config} cdn={cdn} />
41
+ ) : (
42
+ <div set:html={renderApiReference({ config, cdn, pageTitle })} />
43
+ )
44
+ }
package/src/client.ts ADDED
@@ -0,0 +1,253 @@
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
+ const loadCdn = (cdn: string): Promise<ScalarGlobal | undefined> => {
86
+ const { cdnLoads } = getState()
87
+ const cached = cdnLoads.get(cdn)
88
+
89
+ if (cached) {
90
+ return cached
91
+ }
92
+
93
+ const load = new Promise<ScalarGlobal | undefined>((resolve, reject) => {
94
+ const script = document.createElement('script')
95
+ script.src = cdn
96
+ script.addEventListener('load', () => resolve((window as ScalarWindow).Scalar), { once: true })
97
+ script.addEventListener(
98
+ 'error',
99
+ () => {
100
+ // Drop the cached failure (and the dead tag) so a later mount or
101
+ // navigation can retry, instead of replaying this rejection forever.
102
+ cdnLoads.delete(cdn)
103
+ script.remove()
104
+ reject(new Error(`[@scalar/astro] Could not load ${cdn}`))
105
+ },
106
+ { once: true },
107
+ )
108
+ document.head.appendChild(script)
109
+ })
110
+
111
+ cdnLoads.set(cdn, load)
112
+
113
+ return load
114
+ }
115
+
116
+ /** Mount a single container, unless it is already mounted or mounting. */
117
+ const mountContainer = (element: HTMLElement): void => {
118
+ const state = getState()
119
+
120
+ // Skip containers that are already mounted, or have a mount in flight. This
121
+ // also dedupes the two `mountAll()` calls the initial page load triggers
122
+ // (our own call in `initScalarClient`, plus `astro:page-load`).
123
+ if (state.instances.has(element) || state.pending.has(element)) {
124
+ return
125
+ }
126
+
127
+ let configuration: unknown
128
+
129
+ try {
130
+ configuration = JSON.parse(element.dataset.configuration || '{}')
131
+ } catch (error) {
132
+ // A bad configuration is left untracked, so a corrected one on a later
133
+ // navigation still gets a chance to mount.
134
+ console.error('[@scalar/astro] Could not parse the configuration.', error)
135
+ return
136
+ }
137
+
138
+ const cdn = element.dataset.cdn || DEFAULT_CDN
139
+ // Remember which lifecycle this mount belongs to (see `ClientState`).
140
+ const { generation } = state
141
+
142
+ state.pending.add(element)
143
+
144
+ void loadCdn(cdn)
145
+ .then((Scalar) => {
146
+ // Mount only if this page is still live (no view transition happened
147
+ // while the CDN loaded) and the element is still in the document.
148
+ if (state.generation === generation && element.isConnected && Scalar?.createApiReference) {
149
+ state.instances.set(element, Scalar.createApiReference(element, configuration))
150
+ }
151
+ })
152
+ .catch((error) => console.error('[@scalar/astro] Could not mount the API reference.', error))
153
+ .finally(() => {
154
+ // Release the pending slot — but only if a view transition has not
155
+ // already cleared it, in which case the slot belongs to a newer attempt.
156
+ if (state.generation === generation) {
157
+ state.pending.delete(element)
158
+ }
159
+ })
160
+ }
161
+
162
+ /** Mount every client-rendered container currently in the document. */
163
+ const mountAll = (): void => {
164
+ document.querySelectorAll<HTMLElement>('[data-scalar-client]').forEach(mountContainer)
165
+ }
166
+
167
+ /** Destroy every mounted instance, e.g. before Astro swaps the page out. */
168
+ const unmountAll = (): void => {
169
+ const state = getState()
170
+
171
+ state.instances.forEach((instance) => {
172
+ try {
173
+ instance.destroy()
174
+ } catch (error) {
175
+ console.error('[@scalar/astro] Could not destroy the API reference.', error)
176
+ }
177
+ })
178
+
179
+ state.instances.clear()
180
+ state.pending.clear()
181
+
182
+ // Invalidate in-flight mounts: their CDN load may still resolve, but it
183
+ // belongs to the page being swapped away. The next `astro:page-load` then
184
+ // mounts every container afresh — including any that survive the swap via
185
+ // `transition:persist`, which are no longer tracked as mounted.
186
+ state.generation += 1
187
+ }
188
+
189
+ /** The `astro:before-swap` event, narrowed to the part this module uses. */
190
+ type BeforeSwapEvent = Event & { newDocument?: Document }
191
+
192
+ /**
193
+ * Carry Scalar's stylesheet into the next page during a view transition.
194
+ *
195
+ * The standalone bundle injects its CSS into `<head>` once, when the CDN
196
+ * script first runs. Astro replaces `<head>` on every navigation, which would
197
+ * drop that `<style>` and leave the reference unstyled after a client-side
198
+ * navigation. Cloning it into the incoming document keeps the styles in place.
199
+ *
200
+ * This runs on every navigation, even to pages without a reference: that
201
+ * `<style>` is the only copy, so a page in between (an "About" page, say) has
202
+ * to carry it along, otherwise it is gone for good once the user navigates on.
203
+ */
204
+ const persistScalarStyles = (newDocument: Document): void => {
205
+ document.head.querySelectorAll('style').forEach((style) => {
206
+ // Scalar's design tokens are namespaced `--scalar-*`, which reliably
207
+ // fingerprints the (otherwise unmarked) `<style>` the bundle injects.
208
+ if (!style.textContent?.includes('--scalar-')) {
209
+ return
210
+ }
211
+
212
+ const alreadyThere = Array.from(newDocument.head.querySelectorAll('style')).some((candidate) =>
213
+ candidate.isEqualNode(style),
214
+ )
215
+
216
+ if (!alreadyThere) {
217
+ newDocument.head.appendChild(style.cloneNode(true))
218
+ }
219
+ })
220
+ }
221
+
222
+ /** Persist styles and tear down instances before Astro swaps the page out. */
223
+ const handleBeforeSwap = (event: Event): void => {
224
+ const { newDocument } = event as BeforeSwapEvent
225
+
226
+ if (newDocument) {
227
+ persistScalarStyles(newDocument)
228
+ }
229
+
230
+ unmountAll()
231
+ }
232
+
233
+ /**
234
+ * Mount client-rendered API references and keep them working across Astro
235
+ * view transitions. Safe to call repeatedly — the listeners register once.
236
+ */
237
+ export const initScalarClient = (): void => {
238
+ // Mount right away. This covers the initial page load, including sites
239
+ // without `<ClientRouter />`, where `astro:page-load` never fires.
240
+ mountAll()
241
+
242
+ const state = getState()
243
+
244
+ if (state.initialized) {
245
+ return
246
+ }
247
+ state.initialized = true
248
+
249
+ // `astro:before-swap` fires before the outgoing page is replaced, and
250
+ // `astro:page-load` once the new page is in place — destroy, then re-mount.
251
+ document.addEventListener('astro:before-swap', handleBeforeSwap)
252
+ document.addEventListener('astro:page-load', mountAll)
253
+ }