@specbook/ui 0.1.0 → 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.
Files changed (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Environments a person makes in the browser, for pages with no project files to write them to (a built
3
+ * site, the hosted viewer). A variable here is not a secret, so it lives in `localStorage`, kept per spec; a
4
+ * document opened from a file has no stable address and keeps them for the tab only.
5
+ */
6
+ import { kv } from './storage'
7
+
8
+ const KEY = 'specbook.environments.v1'
9
+
10
+ export interface BrowserEnvironment {
11
+ /** the environment it was copied from; absent for a built-in environment this browser overrides */
12
+ from?: string
13
+ vars: Record<string, string>
14
+ }
15
+
16
+ export interface EnvironmentStore {
17
+ all(): Record<string, BrowserEnvironment>
18
+ save(name: string, env: BrowserEnvironment): void
19
+ remove(name: string): void
20
+ }
21
+
22
+ type Table = Record<string, Record<string, BrowserEnvironment>>
23
+
24
+ const memory: Record<'local' | 'session', Table> = { local: {}, session: {} }
25
+
26
+ const storageOf = (kind: 'local' | 'session') => (kind === 'local' ? kv.local : kv.session)
27
+
28
+ function read(kind: 'local' | 'session'): Table {
29
+ try {
30
+ const raw = storageOf(kind).getItem(KEY)
31
+ return raw ? (JSON.parse(raw) as Table) : memory[kind]
32
+ } catch {
33
+ return memory[kind]
34
+ }
35
+ }
36
+
37
+ function write(kind: 'local' | 'session', table: Table) {
38
+ memory[kind] = table
39
+ try {
40
+ storageOf(kind).setItem(KEY, JSON.stringify(table))
41
+ } catch {
42
+ /* private mode or blocked storage: the in-memory copy still serves this page */
43
+ }
44
+ }
45
+
46
+ /** The store for one spec (`scope`: its address); `session` keeps it for this tab only. */
47
+ export function createEnvironmentStore(
48
+ scope: string,
49
+ kind: 'local' | 'session' = 'local',
50
+ ): EnvironmentStore {
51
+ return {
52
+ all: () => read(kind)[scope] ?? {},
53
+ save(name, env) {
54
+ const t = read(kind)
55
+ write(kind, { ...t, [scope]: { ...t[scope], [name]: env } })
56
+ },
57
+ remove(name) {
58
+ const t = read(kind)
59
+ const { [name]: _gone, ...rest } = t[scope] ?? {}
60
+ write(kind, { ...t, [scope]: rest })
61
+ },
62
+ }
63
+ }
@@ -1,114 +1,357 @@
1
1
  import { render } from 'preact'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
+ import { brandCss } from '../branding'
3
4
  import type { SequenceInfo, Variant } from '../engine'
5
+ import { setNavigator } from '../nav'
4
6
  import { Host } from '../panel/Host'
7
+ import { Panel } from '../panel/Panel'
5
8
  import { engine, registry } from '../runtime'
6
- import { env, environmentRisk, environments, version } from '../store'
9
+ import { useMemoryStorage } from '../storage'
10
+ import {
11
+ env,
12
+ environmentNote,
13
+ environmentRisk,
14
+ environments,
15
+ features,
16
+ locale,
17
+ version,
18
+ } from '../store'
7
19
  import { panelSheets } from './styles'
20
+ import type { Target } from './target'
21
+ import { followTheme, setMode } from './theme'
8
22
 
9
- export interface Target {
10
- service?: string
11
- operation?: string
12
- sequence?: string
13
- example?: string
14
- /** open on the Docs tab instead of Try */
15
- tab?: 'docs' | 'try'
16
- }
23
+ // A page that embeds the panel owns its storage and its address: what a visitor types stays in memory, nothing is
24
+ // written to the page's `localStorage`, and a link inside the panel opens inside it. There is no dialog for creating an
25
+ // environment or for settings here, so the controls that lead to them are not offered.
26
+ useMemoryStorage()
27
+ features.value = { createEnvironment: false, environmentSettings: false }
28
+
29
+ export type { Target } from './target'
17
30
 
18
31
  interface Loaded {
19
32
  variants: Record<string, Variant[]>
20
33
  }
21
34
 
22
- let loaded: Promise<Loaded> | undefined
23
- const load = () =>
24
- (loaded ??= (async () => {
35
+ /** The environments, locale and accent: shared by everything a page opens, so asked for once. */
36
+ let shared: Promise<void> | undefined
37
+ const loadShared = () =>
38
+ (shared ??= (async () => {
25
39
  environments.value = await engine.environments(version.value)
26
40
  environmentRisk.value = await engine.environmentRisks(version.value)
41
+ environmentNote.value = await engine.environmentNotes(version.value)
27
42
  env.value = environments.value.includes('mock') ? 'mock' : (environments.value[0] ?? 'mock')
28
- return { variants: await engine.loadVariants(registry.requests, version.value) }
43
+ // the project's faker locale and accent, as the app applies them
44
+ const cfg = await engine.config(version.value).catch(() => undefined)
45
+ if (cfg?.faker?.default) locale.value = cfg.faker.default
46
+ if (cfg?.branding) applyBranding(cfg.branding)
29
47
  })())
30
48
 
49
+ /**
50
+ * One request: only the request files that are for its operation are imported (the registry says which from their text),
51
+ * and only its service's list of operations is fetched, not every request and every service of the project.
52
+ */
53
+ async function loadRequest(t: Target): Promise<Loaded & { service?: string; operation?: string }> {
54
+ const wanted = (file: string, key?: string) =>
55
+ t.request
56
+ ? file === t.request
57
+ : registry.requestMeta[file] === key || registry.unresolvedRequests.includes(file)
58
+ let key: string | undefined
59
+ if (!t.request) {
60
+ // a bare operation id names no service: the lists of all services say which has it
61
+ if (!t.service) await registry.loadManifests()
62
+ const service =
63
+ t.service ??
64
+ Object.values(registry.manifests).find((m) => m.operations.some((o) => o.id === t.operation))
65
+ ?.service
66
+ key = service && t.operation ? `${service}/${t.operation}` : undefined
67
+ if (!key) return { variants: {} }
68
+ }
69
+ const entries = registry.requests.filter((e) => wanted(e.file, key))
70
+ const variants = await engine.loadVariants(entries, version.value)
71
+ if (t.request) {
72
+ const hit = Object.keys(variants)[0]
73
+ if (!hit) return { variants }
74
+ key = hit
75
+ }
76
+ const at = (key as string).indexOf('/')
77
+ const service = (key as string).slice(0, at)
78
+ await registry.loadManifests([service])
79
+ return { variants, service, operation: (key as string).slice(at + 1) }
80
+ }
81
+
31
82
  /** Headers are read from the file text, so only the requested module (or the odd unresolved ones) is imported. */
32
83
  async function loadSequence(id: string) {
33
84
  const file = registry.sequenceMeta[id]?.file
34
85
  const wanted = (f: string) => (file ? f === file : registry.unresolvedSequences.includes(f))
35
86
  const entries = registry.sequences.filter((e) => wanted(e.file))
36
87
  const list = await engine.loadSequences(entries, version.value)
37
- return list.find((s) => s.seq.meta.id === id)
88
+ const found = list.find((s) => s.seq.meta.id === id)
89
+ if (found) {
90
+ // the services its steps call; a step built by a function is only known when it runs, so any such step asks for all
91
+ const names = new Set<string>()
92
+ let all = false
93
+ for (const n of found.seq.nodes) {
94
+ if (n.kind === 'step' && typeof n.def !== 'function') names.add(n.def.op.service)
95
+ else if (n.kind === 'step' || n.kind === 'each') all = true
96
+ }
97
+ await registry.loadManifests(all ? undefined : [...names])
98
+ }
99
+ return found
100
+ }
101
+
102
+ function findOperation(t: Target, data: Loaded & { service?: string; operation?: string }) {
103
+ if (!data.service || !data.operation) return undefined
104
+ const op = registry.manifests[data.service]?.operations.find((o) => o.id === data.operation)
105
+ if (!op) return undefined
106
+ return {
107
+ service: data.service,
108
+ op,
109
+ example: t.request ? `saved:${t.request}` : (t.example ?? ''),
110
+ }
38
111
  }
39
112
 
40
- function findOperation(t: Target) {
41
- const services = Object.values(registry.manifests)
42
- const scope = t.service ? services.filter((m) => m.service === t.service) : services
43
- for (const m of scope) {
44
- const op = m.operations.find((o) => o.id === t.operation)
45
- if (op) return { service: m.service, op }
113
+ const isProduction = (name: string) =>
114
+ environmentRisk.value[name] === 'production' ||
115
+ (environmentRisk.value[name] !== 'sandbox' && /prod/i.test(name))
116
+
117
+ /** Applies the element's environment: unknown names fall back with a note, and auto-run is refused for production. */
118
+ function chooseEnvironment(t: Target): string | undefined {
119
+ if (!t.env) return undefined
120
+ if (!environments.value.includes(t.env)) {
121
+ return `Environment "${t.env}" does not exist here (${environments.value.join(', ')}); using "${env.value}".`
46
122
  }
123
+ env.value = t.env
47
124
  return undefined
48
125
  }
49
126
 
127
+ function Problem({ title, text, onClose }: { title: string; text: string; onClose: () => void }) {
128
+ return (
129
+ <Panel title={title} modal variant="popover" onClose={onClose}>
130
+ <p class="embed-problem" role="alert">
131
+ {text}
132
+ </p>
133
+ </Panel>
134
+ )
135
+ }
136
+
50
137
  function Embedded({ target, onClose }: { target: Target; onClose: () => void }) {
51
- const [data, setData] = useState<Loaded>()
138
+ const [data, setData] = useState<Loaded & { service?: string; operation?: string }>()
52
139
  const [seq, setSeq] = useState<SequenceInfo>()
140
+ const [seqMissing, setSeqMissing] = useState(false)
141
+ const [note, setNote] = useState<string>()
53
142
  const [view, setView] = useState<{ tab: 'docs' | 'try'; example: string }>({
54
143
  tab: target.tab ?? 'try',
55
144
  example: target.example ?? '',
56
145
  })
57
146
  const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
58
147
  setView({ tab: to.tab, example: to.example ?? view.example })
148
+
59
149
  useEffect(() => {
60
- load().then(setData)
61
- if (target.sequence) loadSequence(target.sequence).then(setSeq)
150
+ // one navigation inside the panel at a time: a link in the docs opens that operation or flow here
151
+ setNavigator((r) => {
152
+ if (r.kind === 'op') show({ service: r.key.split('/')[0], operation: r.key.split('/')[1] })
153
+ else if (r.kind === 'seq') show({ sequence: r.id })
154
+ })
155
+ return () => setNavigator(undefined)
62
156
  }, [])
63
- if (target.sequence)
64
- return seq ? (
65
- <Host kind="seq" info={seq} tab={view.tab} onNavigate={navigate} onClose={onClose} />
157
+
158
+ useEffect(() => {
159
+ loadShared().then(async () => {
160
+ setNote(chooseEnvironment(target))
161
+ if (target.sequence) {
162
+ const s = await loadSequence(target.sequence)
163
+ if (s) setSeq(s)
164
+ else setSeqMissing(true)
165
+ setData({ variants: {} })
166
+ } else setData(await loadRequest(target))
167
+ })
168
+ }, [])
169
+
170
+ const wantsAutoRun = !!target.autoRun && !isProduction(env.value)
171
+ const autoRunRefused = !!target.autoRun && isProduction(env.value)
172
+ const banner = [target.problem, note, autoRunRefused ? 'Auto-run is off for production.' : '']
173
+ .filter(Boolean)
174
+ .join(' ')
175
+
176
+ if (target.sequence) {
177
+ if (seqMissing)
178
+ return (
179
+ <Problem
180
+ title="Flow not found"
181
+ text={`This build has no flow "${target.sequence}".`}
182
+ onClose={onClose}
183
+ />
184
+ )
185
+ return seq && data ? (
186
+ <>
187
+ <Host
188
+ kind="seq"
189
+ info={seq}
190
+ variant="popover"
191
+ tab={view.tab}
192
+ preset={target.inputs}
193
+ autoRun={wantsAutoRun}
194
+ onNavigate={navigate}
195
+ onClose={onClose}
196
+ />
197
+ {banner && <Banner text={banner} />}
198
+ </>
66
199
  ) : null
200
+ }
67
201
  if (!data) return null
68
- const found = findOperation(target)
69
- if (!found) return null
202
+ const found = findOperation(target, data)
203
+ if (!found)
204
+ return (
205
+ <Problem
206
+ title="Request not found"
207
+ text={`This build has no request "${target.request ?? [target.service, target.operation].filter(Boolean).join('/')}".`}
208
+ onClose={onClose}
209
+ />
210
+ )
211
+ return (
212
+ <>
213
+ <Host
214
+ kind="op"
215
+ service={found.service}
216
+ op={found.op}
217
+ variants={data.variants[`${found.service}/${found.op.id}`] ?? []}
218
+ example={view.example || found.example}
219
+ variant="popover"
220
+ tab={view.tab}
221
+ autoRun={wantsAutoRun}
222
+ onNavigate={navigate}
223
+ onClose={onClose}
224
+ />
225
+ {banner && <Banner text={banner} />}
226
+ </>
227
+ )
228
+ }
229
+
230
+ /** A line over the panel for what the page's attributes asked that could not be done. */
231
+ function Banner({ text }: { text: string }) {
70
232
  return (
71
- <Host
72
- kind="op"
73
- service={found.service}
74
- op={found.op}
75
- variants={data.variants[`${found.service}/${found.op.id}`] ?? []}
76
- example={view.example}
77
- tab={view.tab}
78
- onNavigate={navigate}
79
- onClose={onClose}
80
- />
233
+ <div class="embed-banner" role="status">
234
+ {text}
235
+ </div>
81
236
  )
82
237
  }
83
238
 
239
+ let brandSheet: CSSStyleSheet | undefined
240
+ function applyBranding(b: Parameters<typeof brandCss>[0]) {
241
+ const css = brandCss(b, 'host')
242
+ if (!css) return
243
+ brandSheet = new CSSStyleSheet()
244
+ brandSheet.replaceSync(css)
245
+ const panel = document.querySelector<SpecbookPanel>('specbook-panel')
246
+ panel?.adopt(brandSheet)
247
+ }
248
+
249
+ /** The active element through any number of shadow roots, which is where focus goes back to. */
250
+ function deepActive(): HTMLElement | null {
251
+ let el = document.activeElement as HTMLElement | null
252
+ while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement as HTMLElement
253
+ return el
254
+ }
255
+
84
256
  class SpecbookPanel extends HTMLElement {
85
257
  private mount!: HTMLElement
258
+ private top!: HTMLElement
259
+ private opener: HTMLElement | null = null
260
+ private trigger: HTMLElement | undefined
261
+ private native = false
86
262
 
87
263
  connectedCallback() {
88
264
  if (this.shadowRoot) return
89
265
  const root = this.attachShadow({ mode: 'open' })
90
266
  root.adoptedStyleSheets = panelSheets()
267
+ this.top = document.createElement('div')
268
+ this.top.className = 'sb-top'
269
+ this.native = typeof this.top.showPopover === 'function'
270
+ if (this.native) this.top.setAttribute('popover', 'manual')
271
+ else this.top.className += ' sb-fallback'
272
+ this.top.hidden = !this.native
91
273
  this.mount = document.createElement('div')
92
274
  this.mount.className = 'sb-root'
93
- root.append(this.mount)
275
+ this.top.append(this.mount)
276
+ root.append(this.top)
277
+ if (brandSheet) this.adopt(brandSheet)
278
+ }
279
+
280
+ adopt(sheet: CSSStyleSheet) {
281
+ const root = this.shadowRoot
282
+ if (root && !root.adoptedStyleSheets.includes(sheet))
283
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet]
94
284
  }
95
285
 
96
- show(target: Target | undefined) {
286
+ show(target: Target | undefined, trigger?: HTMLElement) {
287
+ if (!target) return this.close()
288
+ if (trigger) {
289
+ this.trigger = trigger
290
+ this.opener = deepActive()
291
+ setExpanded(trigger, true)
292
+ trigger.dispatchEvent(
293
+ new CustomEvent('specbook:open', { bubbles: true, composed: true, detail: target }),
294
+ )
295
+ }
296
+ if (this.isConnected) {
297
+ if (this.followed) setMode(this, target.theme)
298
+ else {
299
+ followTheme(this, target.theme)
300
+ this.followed = true
301
+ }
302
+ }
303
+ if (this.native) {
304
+ if (!this.top.matches(':popover-open')) this.top.showPopover()
305
+ } else this.top.hidden = false
97
306
  render(
98
- target ? <Embedded target={target} onClose={() => this.show(undefined)} /> : null,
307
+ <Embedded key={JSON.stringify(target)} target={target} onClose={() => this.close()} />,
99
308
  this.mount,
100
309
  )
101
310
  }
311
+
312
+ private followed = false
313
+
314
+ close() {
315
+ render(null, this.mount)
316
+ if (this.native) {
317
+ if (this.top.matches(':popover-open')) this.top.hidePopover()
318
+ } else this.top.hidden = true
319
+ const trigger = this.trigger
320
+ this.trigger = undefined
321
+ if (trigger) {
322
+ setExpanded(trigger, false)
323
+ trigger.dispatchEvent(new CustomEvent('specbook:close', { bubbles: true, composed: true }))
324
+ }
325
+ // back to where the person was, which is the button that opened the panel
326
+ const opener = this.opener
327
+ this.opener = null
328
+ // a programmatic open leaves focus on the page's body, so the trigger itself is where it goes back to
329
+ const back =
330
+ opener && opener !== document.body
331
+ ? opener
332
+ : (trigger?.shadowRoot?.querySelector('button') ?? trigger)
333
+ if (back?.isConnected) back.focus()
334
+ }
335
+ }
336
+
337
+ /** The button that opens the panel says whether it is open. */
338
+ function setExpanded(trigger: HTMLElement, open: boolean) {
339
+ const button = trigger.shadowRoot?.querySelector('button') ?? trigger
340
+ button.setAttribute('aria-expanded', String(open))
102
341
  }
103
342
 
104
343
  customElements.define('specbook-panel', SpecbookPanel)
105
344
 
106
- /** One panel serves every Play button on the page. */
107
- export function show(target: Target) {
345
+ /** One panel serves every trigger on the page. */
346
+ export function show(target: Target, trigger?: HTMLElement) {
108
347
  let panel = document.querySelector<SpecbookPanel>('specbook-panel')
109
348
  if (!panel) {
110
349
  panel = document.createElement('specbook-panel') as SpecbookPanel
111
350
  document.body.append(panel)
112
351
  }
113
- panel.show(target)
352
+ panel.show(target, trigger)
353
+ }
354
+
355
+ export function close() {
356
+ document.querySelector<SpecbookPanel>('specbook-panel')?.close()
114
357
  }
@@ -2,6 +2,7 @@ import complete from 'prism-code-editor/autocomplete.css?inline'
2
2
  import completeIcons from 'prism-code-editor/autocomplete-icons.css?inline'
3
3
  import layout from 'prism-code-editor/layout.css?inline'
4
4
  import app from '../styles/app.css?inline'
5
+ import embed from '../styles/embed.css?inline'
5
6
  import panel from '../styles/panel.css?inline'
6
7
  import tokens from '../styles/tokens.css?inline'
7
8
 
@@ -21,6 +22,7 @@ export const panelSheets = () => [
21
22
  sheet(app),
22
23
  sheet(panel),
23
24
  sheet(root),
25
+ sheet(embed),
24
26
  ]
25
27
 
26
28
  /** Inline `color`/`font` on the host would be beaten by host-page rules, so the root element sets them. */
@@ -0,0 +1,82 @@
1
+ // What a page's data attributes ask the play panel to open. Pure, so it is tested in node.
2
+
3
+ export type EmbedTheme = 'host' | 'auto' | 'light' | 'dark'
4
+
5
+ export interface Target {
6
+ /** `petstore` of `petstore/GetPetById`; empty when the build has one service and the attribute gave a bare id */
7
+ service?: string
8
+ operation?: string
9
+ /** a saved request, by its project file (`requests/petstore/find-pets.req.ts`) */
10
+ request?: string
11
+ sequence?: string
12
+ example?: string
13
+ /** open on the Docs tab instead of Try */
14
+ tab?: 'docs' | 'try'
15
+ /** preset flow inputs, merged over each input's default */
16
+ inputs?: Record<string, unknown>
17
+ /** why `inputs` was not used */
18
+ problem?: string
19
+ env?: string
20
+ autoRun?: boolean
21
+ theme?: EmbedTheme
22
+ }
23
+
24
+ /** The attributes the panel reads, all namespaced so a host page's own `data-theme` or `data-env` cannot collide. */
25
+ export const TRIGGER_SELECTOR = '[data-specbook-try],[data-specbook-flow]'
26
+
27
+ const THEMES = new Set<string>(['host', 'auto', 'light', 'dark'])
28
+
29
+ type Attrs = Pick<Element, 'getAttribute' | 'hasAttribute' | 'closest'>
30
+
31
+ const attr = (el: Attrs, name: string) =>
32
+ el.getAttribute(`data-specbook-${name}`)?.trim() || undefined
33
+
34
+ /** The nearest ancestor (or the element itself) that sets an attribute, for page-wide defaults like the environment. */
35
+ function inherited(el: Attrs, name: string): string | undefined {
36
+ return attr(el.closest(`[data-specbook-${name}]`) ?? el, name)
37
+ }
38
+
39
+ /**
40
+ * Reads one trigger. `data-specbook-try="petstore/GetPetById"` (or a bare `GetPetById`, or a saved request's file)
41
+ * opens a request and `data-specbook-flow="adopt-pet"` a flow; a flow wins when both are given.
42
+ */
43
+ export function parseTarget(el: Attrs): Target | undefined {
44
+ const tryValue = attr(el, 'try')
45
+ const flow = attr(el, 'flow')
46
+ if (!tryValue && !flow) return undefined
47
+
48
+ const target: Target = {
49
+ example: attr(el, 'example'),
50
+ tab: attr(el, 'tab') === 'docs' ? 'docs' : undefined,
51
+ env: inherited(el, 'env'),
52
+ autoRun: el.hasAttribute('data-specbook-auto-run'),
53
+ }
54
+ const theme = inherited(el, 'theme')
55
+ if (theme && THEMES.has(theme)) target.theme = theme as EmbedTheme
56
+
57
+ if (flow) target.sequence = flow
58
+ else if (tryValue) {
59
+ if (/\.req\.ts$/.test(tryValue)) target.request = tryValue
60
+ else {
61
+ const slash = tryValue.indexOf('/')
62
+ if (slash < 0) target.operation = tryValue
63
+ else {
64
+ target.service = tryValue.slice(0, slash)
65
+ target.operation = tryValue.slice(slash + 1)
66
+ }
67
+ }
68
+ }
69
+
70
+ const inputs = attr(el, 'inputs')
71
+ if (inputs) {
72
+ try {
73
+ const parsed: unknown = JSON.parse(inputs)
74
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
75
+ target.inputs = parsed as Record<string, unknown>
76
+ else target.problem = 'data-specbook-inputs must be a JSON object, such as {"quantity":2}.'
77
+ } catch {
78
+ target.problem = `data-specbook-inputs is not valid JSON: ${inputs}`
79
+ }
80
+ }
81
+ return target
82
+ }
@@ -0,0 +1,75 @@
1
+ // Which colors the panel and the Play buttons wear: the host page's, unless the author says otherwise. Tokens react to a
2
+ // `data-theme` attribute on the host element itself, which is all a shadow root can see.
3
+
4
+ import type { EmbedTheme } from './target'
5
+
6
+ export type Scheme = 'light' | 'dark' | undefined
7
+
8
+ export interface PageTheme {
9
+ /** `data-theme` on the root element (Starlight, many docs themes) */
10
+ attr?: string | null
11
+ /** the root element's classes (`dark`, `light`: Tailwind and others) */
12
+ classes?: string
13
+ /** the computed `color-scheme` of the root element */
14
+ colorScheme?: string
15
+ }
16
+
17
+ /** `undefined` is "auto": follow the operating system. Pure, so it is tested in node. */
18
+ export function pageScheme(p: PageTheme): Scheme {
19
+ if (p.attr === 'light' || p.attr === 'dark') return p.attr
20
+ const classes = (p.classes ?? '').split(/\s+/)
21
+ if (classes.includes('dark')) return 'dark'
22
+ if (classes.includes('light')) return 'light'
23
+ const cs = (p.colorScheme ?? '').trim()
24
+ if (cs === 'dark' || cs === 'light') return cs
25
+ return undefined
26
+ }
27
+
28
+ /** What an element asking for `mode` should wear, given the page. */
29
+ export function schemeFor(mode: EmbedTheme | undefined, page: PageTheme): Scheme {
30
+ if (mode === 'light' || mode === 'dark') return mode
31
+ if (mode === 'auto') return undefined
32
+ return pageScheme(page)
33
+ }
34
+
35
+ const readPage = (): PageTheme => {
36
+ const root = document.documentElement
37
+ return {
38
+ attr: root.getAttribute('data-theme'),
39
+ classes: root.className,
40
+ colorScheme: getComputedStyle(root).colorScheme,
41
+ }
42
+ }
43
+
44
+ const themed = new Map<HTMLElement, EmbedTheme | undefined>()
45
+ let watching = false
46
+
47
+ const apply = (el: HTMLElement, mode: EmbedTheme | undefined) => {
48
+ const s = schemeFor(mode, readPage())
49
+ if (s) el.setAttribute('data-theme', s)
50
+ else el.removeAttribute('data-theme')
51
+ }
52
+
53
+ /** Keeps `el` in step with the page: set now, and again when the page changes its theme. */
54
+ export function followTheme(el: HTMLElement, mode: EmbedTheme | undefined): void {
55
+ themed.set(el, mode)
56
+ apply(el, mode)
57
+ if (watching || typeof MutationObserver === 'undefined') return
58
+ watching = true
59
+ const refresh = () => {
60
+ for (const [e, m] of themed) {
61
+ if (e.isConnected) apply(e, m)
62
+ else themed.delete(e)
63
+ }
64
+ }
65
+ new MutationObserver(refresh).observe(document.documentElement, {
66
+ attributes: true,
67
+ attributeFilter: ['data-theme', 'class', 'style'],
68
+ })
69
+ }
70
+
71
+ /** A new mode for an element already followed (a different Play button opens the one panel). */
72
+ export function setMode(el: HTMLElement, mode: EmbedTheme | undefined): void {
73
+ if (themed.has(el)) themed.set(el, mode)
74
+ apply(el, mode)
75
+ }