@scalar/astro 0.3.0 → 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 +2 -2
- package/src/ScalarClient.astro +22 -2
- package/src/ScalarComponent.astro +3 -3
- package/src/client.ts +47 -5
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
|
},
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"documentation": "https://scalar.com/products/api-references/integrations/astro"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@scalar/client-side-rendering": "0.
|
|
50
|
+
"@scalar/client-side-rendering": "0.2.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"astro": "^4.0.0 || ^5.0.0 || ^6.0.0"
|
package/src/ScalarClient.astro
CHANGED
|
@@ -4,13 +4,33 @@ import { getConfiguration, type HtmlRenderingConfiguration } from '@scalar/clien
|
|
|
4
4
|
interface Props {
|
|
5
5
|
config: Omit<Partial<HtmlRenderingConfiguration>, 'cdn' | 'pageTitle'>
|
|
6
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
|
|
7
18
|
}
|
|
8
19
|
|
|
9
|
-
const { config, cdn } = Astro.props
|
|
20
|
+
const { config, cdn, nonce } = Astro.props
|
|
10
21
|
---
|
|
11
22
|
|
|
12
|
-
<div data-scalar-client data-configuration={JSON.stringify(getConfiguration(config))} data-cdn={cdn}></div>
|
|
23
|
+
<div data-scalar-client data-configuration={JSON.stringify(getConfiguration(config))} data-cdn={cdn} data-nonce={nonce}></div>
|
|
13
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
|
+
*/}
|
|
14
34
|
<script>
|
|
15
35
|
import { initScalarClient } from './client'
|
|
16
36
|
|
|
@@ -32,13 +32,13 @@ const finalConfiguration = {
|
|
|
32
32
|
...DEFAULT_CONFIGURATION,
|
|
33
33
|
...configuration,
|
|
34
34
|
}
|
|
35
|
-
const { cdn, pageTitle, ...config } = finalConfiguration
|
|
35
|
+
const { cdn, pageTitle, nonce, ...config } = finalConfiguration
|
|
36
36
|
---
|
|
37
37
|
|
|
38
38
|
{
|
|
39
39
|
renderMode === 'client' ? (
|
|
40
|
-
<ScalarClient config={config} cdn={cdn} />
|
|
40
|
+
<ScalarClient config={config} cdn={cdn} nonce={nonce} />
|
|
41
41
|
) : (
|
|
42
|
-
<div set:html={renderApiReference({ config, cdn, pageTitle })} />
|
|
42
|
+
<div set:html={renderApiReference({ config, cdn, pageTitle, nonce })} />
|
|
43
43
|
)
|
|
44
44
|
}
|
package/src/client.ts
CHANGED
|
@@ -81,10 +81,17 @@ const getState = (): ClientState => {
|
|
|
81
81
|
* (Distinct bundles sharing one global is inherently last-one-wins; capturing
|
|
82
82
|
* here keeps the common single-CDN page correct and narrows the window for the
|
|
83
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.
|
|
84
90
|
*/
|
|
85
|
-
const loadCdn = (cdn: string): Promise<ScalarGlobal | undefined> => {
|
|
91
|
+
const loadCdn = (cdn: string, nonce?: string): Promise<ScalarGlobal | undefined> => {
|
|
86
92
|
const { cdnLoads } = getState()
|
|
87
|
-
const
|
|
93
|
+
const key = nonce ? `${cdn}\n${nonce}` : cdn
|
|
94
|
+
const cached = cdnLoads.get(key)
|
|
88
95
|
|
|
89
96
|
if (cached) {
|
|
90
97
|
return cached
|
|
@@ -93,13 +100,18 @@ const loadCdn = (cdn: string): Promise<ScalarGlobal | undefined> => {
|
|
|
93
100
|
const load = new Promise<ScalarGlobal | undefined>((resolve, reject) => {
|
|
94
101
|
const script = document.createElement('script')
|
|
95
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
|
+
}
|
|
96
108
|
script.addEventListener('load', () => resolve((window as ScalarWindow).Scalar), { once: true })
|
|
97
109
|
script.addEventListener(
|
|
98
110
|
'error',
|
|
99
111
|
() => {
|
|
100
112
|
// Drop the cached failure (and the dead tag) so a later mount or
|
|
101
113
|
// navigation can retry, instead of replaying this rejection forever.
|
|
102
|
-
cdnLoads.delete(
|
|
114
|
+
cdnLoads.delete(key)
|
|
103
115
|
script.remove()
|
|
104
116
|
reject(new Error(`[@scalar/astro] Could not load ${cdn}`))
|
|
105
117
|
},
|
|
@@ -108,11 +120,34 @@ const loadCdn = (cdn: string): Promise<ScalarGlobal | undefined> => {
|
|
|
108
120
|
document.head.appendChild(script)
|
|
109
121
|
})
|
|
110
122
|
|
|
111
|
-
cdnLoads.set(
|
|
123
|
+
cdnLoads.set(key, load)
|
|
112
124
|
|
|
113
125
|
return load
|
|
114
126
|
}
|
|
115
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
|
+
|
|
116
151
|
/** Mount a single container, unless it is already mounted or mounting. */
|
|
117
152
|
const mountContainer = (element: HTMLElement): void => {
|
|
118
153
|
const state = getState()
|
|
@@ -136,12 +171,19 @@ const mountContainer = (element: HTMLElement): void => {
|
|
|
136
171
|
}
|
|
137
172
|
|
|
138
173
|
const cdn = element.dataset.cdn || DEFAULT_CDN
|
|
174
|
+
const nonce = element.dataset.nonce
|
|
139
175
|
// Remember which lifecycle this mount belongs to (see `ClientState`).
|
|
140
176
|
const { generation } = state
|
|
141
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
|
+
|
|
142
184
|
state.pending.add(element)
|
|
143
185
|
|
|
144
|
-
void loadCdn(cdn)
|
|
186
|
+
void loadCdn(cdn, nonce)
|
|
145
187
|
.then((Scalar) => {
|
|
146
188
|
// Mount only if this page is still live (no view transition happened
|
|
147
189
|
// while the CDN loaded) and the element is still in the document.
|