@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 CHANGED
@@ -17,7 +17,7 @@
17
17
  "openapi",
18
18
  "swagger"
19
19
  ],
20
- "version": "0.3.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.1.13"
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"
@@ -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 cached = cdnLoads.get(cdn)
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(cdn)
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(cdn, load)
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.