@specbook/ui 0.2.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.
- package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +6 -5
- package/src/app.tsx +65 -12
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +21 -3
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +286 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +274 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +7 -2
- package/src/nav.ts +12 -1
- package/src/oauth.ts +9 -8
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -9
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +6 -2
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +91 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +10 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +8 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +233 -0
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +46 -10
- package/src/viewer/worker.ts +9 -1
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
- package/dist-viewer/assets/index-ez9c20v3.js +0 -1
- package/dist-viewer/assets/main-38QGGpNT.js +0 -14
- package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
- package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
- package/dist-viewer/assets/worker-ClGb0tE1.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
|
+
}
|
package/src/embed/panel.tsx
CHANGED
|
@@ -1,115 +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 {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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)
|
|
27
41
|
environmentNote.value = await engine.environmentNotes(version.value)
|
|
28
42
|
env.value = environments.value.includes('mock') ? 'mock' : (environments.value[0] ?? 'mock')
|
|
29
|
-
|
|
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)
|
|
30
47
|
})())
|
|
31
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
|
+
|
|
32
82
|
/** Headers are read from the file text, so only the requested module (or the odd unresolved ones) is imported. */
|
|
33
83
|
async function loadSequence(id: string) {
|
|
34
84
|
const file = registry.sequenceMeta[id]?.file
|
|
35
85
|
const wanted = (f: string) => (file ? f === file : registry.unresolvedSequences.includes(f))
|
|
36
86
|
const entries = registry.sequences.filter((e) => wanted(e.file))
|
|
37
87
|
const list = await engine.loadSequences(entries, version.value)
|
|
38
|
-
|
|
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
|
+
}
|
|
39
111
|
}
|
|
40
112
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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}".`
|
|
47
122
|
}
|
|
123
|
+
env.value = t.env
|
|
48
124
|
return undefined
|
|
49
125
|
}
|
|
50
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
|
+
|
|
51
137
|
function Embedded({ target, onClose }: { target: Target; onClose: () => void }) {
|
|
52
|
-
const [data, setData] = useState<Loaded>()
|
|
138
|
+
const [data, setData] = useState<Loaded & { service?: string; operation?: string }>()
|
|
53
139
|
const [seq, setSeq] = useState<SequenceInfo>()
|
|
140
|
+
const [seqMissing, setSeqMissing] = useState(false)
|
|
141
|
+
const [note, setNote] = useState<string>()
|
|
54
142
|
const [view, setView] = useState<{ tab: 'docs' | 'try'; example: string }>({
|
|
55
143
|
tab: target.tab ?? 'try',
|
|
56
144
|
example: target.example ?? '',
|
|
57
145
|
})
|
|
58
146
|
const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
|
|
59
147
|
setView({ tab: to.tab, example: to.example ?? view.example })
|
|
148
|
+
|
|
60
149
|
useEffect(() => {
|
|
61
|
-
|
|
62
|
-
|
|
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)
|
|
63
156
|
}, [])
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
</>
|
|
67
199
|
) : null
|
|
200
|
+
}
|
|
68
201
|
if (!data) return null
|
|
69
|
-
const found = findOperation(target)
|
|
70
|
-
if (!found)
|
|
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 }) {
|
|
71
232
|
return (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
op={found.op}
|
|
76
|
-
variants={data.variants[`${found.service}/${found.op.id}`] ?? []}
|
|
77
|
-
example={view.example}
|
|
78
|
-
tab={view.tab}
|
|
79
|
-
onNavigate={navigate}
|
|
80
|
-
onClose={onClose}
|
|
81
|
-
/>
|
|
233
|
+
<div class="embed-banner" role="status">
|
|
234
|
+
{text}
|
|
235
|
+
</div>
|
|
82
236
|
)
|
|
83
237
|
}
|
|
84
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
|
+
|
|
85
256
|
class SpecbookPanel extends HTMLElement {
|
|
86
257
|
private mount!: HTMLElement
|
|
258
|
+
private top!: HTMLElement
|
|
259
|
+
private opener: HTMLElement | null = null
|
|
260
|
+
private trigger: HTMLElement | undefined
|
|
261
|
+
private native = false
|
|
87
262
|
|
|
88
263
|
connectedCallback() {
|
|
89
264
|
if (this.shadowRoot) return
|
|
90
265
|
const root = this.attachShadow({ mode: 'open' })
|
|
91
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
|
|
92
273
|
this.mount = document.createElement('div')
|
|
93
274
|
this.mount.className = 'sb-root'
|
|
94
|
-
|
|
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]
|
|
95
284
|
}
|
|
96
285
|
|
|
97
|
-
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
|
|
98
306
|
render(
|
|
99
|
-
|
|
307
|
+
<Embedded key={JSON.stringify(target)} target={target} onClose={() => this.close()} />,
|
|
100
308
|
this.mount,
|
|
101
309
|
)
|
|
102
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))
|
|
103
341
|
}
|
|
104
342
|
|
|
105
343
|
customElements.define('specbook-panel', SpecbookPanel)
|
|
106
344
|
|
|
107
|
-
/** One panel serves every
|
|
108
|
-
export function show(target: Target) {
|
|
345
|
+
/** One panel serves every trigger on the page. */
|
|
346
|
+
export function show(target: Target, trigger?: HTMLElement) {
|
|
109
347
|
let panel = document.querySelector<SpecbookPanel>('specbook-panel')
|
|
110
348
|
if (!panel) {
|
|
111
349
|
panel = document.createElement('specbook-panel') as SpecbookPanel
|
|
112
350
|
document.body.append(panel)
|
|
113
351
|
}
|
|
114
|
-
panel.show(target)
|
|
352
|
+
panel.show(target, trigger)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function close() {
|
|
356
|
+
document.querySelector<SpecbookPanel>('specbook-panel')?.close()
|
|
115
357
|
}
|
package/src/embed/styles.ts
CHANGED
|
@@ -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
|
+
}
|