@specbook/ui 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +61 -3
- package/src/app.tsx +368 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +48 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +176 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { render } from 'preact'
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
4
|
+
import { applyTheme } from '../theme'
|
|
5
|
+
import { type OpenFailure, SpecWorker } from './client'
|
|
6
|
+
import { mountOpened } from './mount'
|
|
7
|
+
import { Opening } from './Opening'
|
|
8
|
+
import type { OpenResult, Source } from './protocol'
|
|
9
|
+
import { recentSpecs, rememberSpec } from './recent'
|
|
10
|
+
import { rawGithub, softEncode } from './share'
|
|
11
|
+
import { loadUpload, saveUpload } from './uploads'
|
|
12
|
+
import 'prism-code-editor/layout.css'
|
|
13
|
+
import 'prism-code-editor/autocomplete.css'
|
|
14
|
+
import 'prism-code-editor/autocomplete-icons.css'
|
|
15
|
+
import '../styles/tokens.css'
|
|
16
|
+
import '../styles/app.css'
|
|
17
|
+
import '../styles/panel.css'
|
|
18
|
+
import './viewer.css'
|
|
19
|
+
|
|
20
|
+
const EXAMPLES: { label: string; url: string }[] = [
|
|
21
|
+
{ label: 'Petstore', url: 'https://petstore3.swagger.io/api/v3/openapi.json' },
|
|
22
|
+
{ label: 'httpbin (live requests work)', url: 'https://httpbin.org/spec.json' },
|
|
23
|
+
{
|
|
24
|
+
label: 'Stripe (form-encoded requests)',
|
|
25
|
+
url: 'https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: 'OpenAI',
|
|
29
|
+
url: 'https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: 'GitHub (large)',
|
|
33
|
+
url: 'https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json',
|
|
34
|
+
},
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
// a stored light or dark choice applies before anything is drawn, as the app itself would
|
|
38
|
+
applyTheme()
|
|
39
|
+
|
|
40
|
+
const root = document.getElementById('app') as HTMLElement
|
|
41
|
+
const params = new URLSearchParams(location.search)
|
|
42
|
+
// a link that names its document goes straight to opening it: show the app's shape, not the picker
|
|
43
|
+
const autostart = params.has('spec') || params.has('upload')
|
|
44
|
+
|
|
45
|
+
const specUrl = (u: string) => `${location.origin}${location.pathname}?spec=${softEncode(u)}`
|
|
46
|
+
|
|
47
|
+
type Phase =
|
|
48
|
+
| { kind: 'idle' }
|
|
49
|
+
| { kind: 'loading'; what: string; progress?: string }
|
|
50
|
+
| { kind: 'error'; message: string; code?: OpenFailure['code'] }
|
|
51
|
+
| { kind: 'base'; open: OpenResult; apply(base: string | undefined): void }
|
|
52
|
+
|
|
53
|
+
function Picker({ initial }: { initial: Phase }) {
|
|
54
|
+
const [phase, setPhase] = useState<Phase>(initial)
|
|
55
|
+
const [url, setUrl] = useState(params.get('spec') ?? '')
|
|
56
|
+
const [text, setText] = useState('')
|
|
57
|
+
const [over, setOver] = useState(false)
|
|
58
|
+
const worker = useRef<SpecWorker | undefined>(undefined)
|
|
59
|
+
|
|
60
|
+
const start = async (source: Source, label: string, remember?: string) => {
|
|
61
|
+
worker.current ??= new SpecWorker()
|
|
62
|
+
setPhase({ kind: 'loading', what: label })
|
|
63
|
+
const { promise } = worker.current.open(source, (progress) =>
|
|
64
|
+
setPhase({ kind: 'loading', what: label, progress }),
|
|
65
|
+
)
|
|
66
|
+
try {
|
|
67
|
+
const result = await promise
|
|
68
|
+
const finish = async (baseUrl?: string) => {
|
|
69
|
+
if (remember) rememberSpec(remember)
|
|
70
|
+
// the app renders over this one: clearing first would leave a blank page while its code loads
|
|
71
|
+
await mountOpened({ result, worker: worker.current as SpecWorker, source, baseUrl })
|
|
72
|
+
}
|
|
73
|
+
if (result.needsBaseUrl)
|
|
74
|
+
setPhase({ kind: 'base', open: result, apply: (b) => void finish(b) })
|
|
75
|
+
else await finish()
|
|
76
|
+
} catch (e) {
|
|
77
|
+
const f = e as OpenFailure
|
|
78
|
+
setPhase({ kind: 'error', message: f.message, code: f.code })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const openUrl = (u: string) => {
|
|
83
|
+
const clean = rawGithub(u.trim())
|
|
84
|
+
if (!clean) return
|
|
85
|
+
if (!/^https?:\/\//i.test(clean))
|
|
86
|
+
return setPhase({
|
|
87
|
+
kind: 'error',
|
|
88
|
+
message: 'Enter an address starting with http:// or https://',
|
|
89
|
+
})
|
|
90
|
+
history.replaceState(null, '', specUrl(clean))
|
|
91
|
+
void start({ kind: 'url', url: clean }, clean, clean)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const openText = async (name: string, body: string) => {
|
|
95
|
+
const id = await saveUpload(name, body)
|
|
96
|
+
if (id) history.replaceState(null, '', `${location.pathname}?upload=${id}`)
|
|
97
|
+
void start({ kind: 'text', text: body, name }, name)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const s = params.get('spec')
|
|
102
|
+
const up = params.get('upload')
|
|
103
|
+
if (s) openUrl(s)
|
|
104
|
+
else if (up)
|
|
105
|
+
void loadUpload(up).then((u) =>
|
|
106
|
+
u
|
|
107
|
+
? start({ kind: 'text', text: u.text, name: u.name }, u.name)
|
|
108
|
+
: setPhase({
|
|
109
|
+
kind: 'error',
|
|
110
|
+
message: 'That uploaded document is no longer stored in this browser.',
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
const busy = phase.kind === 'loading'
|
|
116
|
+
if (autostart && (phase.kind === 'idle' || phase.kind === 'loading'))
|
|
117
|
+
return (
|
|
118
|
+
<Opening
|
|
119
|
+
what={params.get('spec') ?? 'your uploaded document'}
|
|
120
|
+
progress={phase.kind === 'loading' ? phase.progress : undefined}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
return (
|
|
124
|
+
<main class="vw">
|
|
125
|
+
<div class="vw-card">
|
|
126
|
+
<h1>Specbook</h1>
|
|
127
|
+
<p>
|
|
128
|
+
Open any OpenAPI or Swagger document. It is read in your browser; nothing is uploaded.
|
|
129
|
+
</p>
|
|
130
|
+
|
|
131
|
+
<form
|
|
132
|
+
onSubmit={(e) => {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
openUrl(url)
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<input
|
|
138
|
+
type="url"
|
|
139
|
+
placeholder="https://example.com/openapi.json"
|
|
140
|
+
aria-label="Address of an OpenAPI or Swagger document"
|
|
141
|
+
value={url}
|
|
142
|
+
onInput={(e) => setUrl((e.target as HTMLInputElement).value)}
|
|
143
|
+
disabled={busy}
|
|
144
|
+
/>
|
|
145
|
+
<button type="submit" class="btn primary" disabled={busy}>
|
|
146
|
+
Open
|
|
147
|
+
</button>
|
|
148
|
+
</form>
|
|
149
|
+
|
|
150
|
+
{phase.kind === 'loading' && (
|
|
151
|
+
<p class="vw-progress" role="status">
|
|
152
|
+
{phase.progress ?? 'Opening…'}
|
|
153
|
+
</p>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{phase.kind === 'error' && (
|
|
157
|
+
<div class="vw-error" role="alert">
|
|
158
|
+
{phase.message}
|
|
159
|
+
{phase.code === 'cors' && (
|
|
160
|
+
<> Paste the document below or drop the file instead; it works the same.</>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
{phase.kind === 'base' && <BasePrompt onApply={phase.apply} title={phase.open.title} />}
|
|
166
|
+
|
|
167
|
+
<section
|
|
168
|
+
class={`vw-drop ${over ? 'over' : ''}`}
|
|
169
|
+
aria-label="Drop a document here"
|
|
170
|
+
onDragOver={(e) => {
|
|
171
|
+
e.preventDefault()
|
|
172
|
+
setOver(true)
|
|
173
|
+
}}
|
|
174
|
+
onDragLeave={() => setOver(false)}
|
|
175
|
+
onDrop={async (e) => {
|
|
176
|
+
e.preventDefault()
|
|
177
|
+
setOver(false)
|
|
178
|
+
const f = e.dataTransfer?.files?.[0]
|
|
179
|
+
if (f) await openText(f.name, await f.text())
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
Drop a file here, or{' '}
|
|
183
|
+
<label class="link">
|
|
184
|
+
choose one
|
|
185
|
+
<input
|
|
186
|
+
type="file"
|
|
187
|
+
accept=".json,.yaml,.yml,application/json"
|
|
188
|
+
hidden
|
|
189
|
+
onChange={async (e) => {
|
|
190
|
+
const f = (e.target as HTMLInputElement).files?.[0]
|
|
191
|
+
if (f) await openText(f.name, await f.text())
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
</label>
|
|
195
|
+
</section>
|
|
196
|
+
|
|
197
|
+
<details>
|
|
198
|
+
<summary>Paste the document</summary>
|
|
199
|
+
<p />
|
|
200
|
+
<textarea
|
|
201
|
+
aria-label="OpenAPI or Swagger document text"
|
|
202
|
+
placeholder="{ "openapi": "3.0.0", … }"
|
|
203
|
+
value={text}
|
|
204
|
+
onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
|
|
205
|
+
/>
|
|
206
|
+
<p />
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
class="btn primary"
|
|
210
|
+
disabled={busy || text.trim() === ''}
|
|
211
|
+
onClick={() => void openText('pasted document', text)}
|
|
212
|
+
>
|
|
213
|
+
Open pasted document
|
|
214
|
+
</button>
|
|
215
|
+
</details>
|
|
216
|
+
|
|
217
|
+
<section>
|
|
218
|
+
<p>Try one</p>
|
|
219
|
+
<ul>
|
|
220
|
+
{EXAMPLES.map((x) => (
|
|
221
|
+
<li>
|
|
222
|
+
<button type="button" class="link" onClick={() => openUrl(x.url)}>
|
|
223
|
+
{x.label}
|
|
224
|
+
</button>
|
|
225
|
+
</li>
|
|
226
|
+
))}
|
|
227
|
+
</ul>
|
|
228
|
+
</section>
|
|
229
|
+
|
|
230
|
+
{recentSpecs().length > 0 && (
|
|
231
|
+
<section>
|
|
232
|
+
<p>Recent</p>
|
|
233
|
+
<ul>
|
|
234
|
+
{recentSpecs().map((u) => (
|
|
235
|
+
<li>
|
|
236
|
+
<button type="button" class="link" title={u} onClick={() => openUrl(u)}>
|
|
237
|
+
{u}
|
|
238
|
+
</button>
|
|
239
|
+
</li>
|
|
240
|
+
))}
|
|
241
|
+
</ul>
|
|
242
|
+
</section>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
</main>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function BasePrompt({
|
|
250
|
+
onApply,
|
|
251
|
+
title,
|
|
252
|
+
}: {
|
|
253
|
+
onApply(base: string | undefined): void
|
|
254
|
+
title: string
|
|
255
|
+
}) {
|
|
256
|
+
const [base, setBase] = useState('')
|
|
257
|
+
return (
|
|
258
|
+
<form
|
|
259
|
+
class="vw-base"
|
|
260
|
+
onSubmit={(e) => {
|
|
261
|
+
e.preventDefault()
|
|
262
|
+
if (/^https?:\/\//i.test(base.trim())) onApply(base.trim().replace(/\/$/, ''))
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<p>
|
|
266
|
+
This document gives its server as a relative address. Enter where the API is to make live
|
|
267
|
+
calls, or continue with mock data only.
|
|
268
|
+
</p>
|
|
269
|
+
<input
|
|
270
|
+
type="url"
|
|
271
|
+
placeholder="https://api.example.com"
|
|
272
|
+
aria-label={`Base URL for ${title}`}
|
|
273
|
+
value={base}
|
|
274
|
+
onInput={(e) => setBase((e.target as HTMLInputElement).value)}
|
|
275
|
+
/>
|
|
276
|
+
<button type="submit" class="btn primary">
|
|
277
|
+
Use this base URL
|
|
278
|
+
</button>
|
|
279
|
+
<button type="button" class="link" onClick={() => onApply(undefined)}>
|
|
280
|
+
Continue with mock data
|
|
281
|
+
</button>
|
|
282
|
+
</form>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// a sign-in's return leg lands on this page too: hand it to the tab that started it, draw nothing here
|
|
287
|
+
void handleOAuthCallback().then((handled) => {
|
|
288
|
+
if (!handled) render(<Picker initial={{ kind: 'idle' }} />, root)
|
|
289
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SpecWorker } from './client'
|
|
2
|
+
import type { OpenResult, Source } from './protocol'
|
|
3
|
+
import type { ViewerGlobal } from './registry'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hands an opened spec to the app and starts it. The app reads the registry module when it loads, so the
|
|
7
|
+
* global is set first. `shareable` is false for a page that is not the viewer's own (an embed), where a
|
|
8
|
+
* link back to `?spec=` would point at a page that does not read it.
|
|
9
|
+
*/
|
|
10
|
+
export async function mountOpened(o: {
|
|
11
|
+
result: OpenResult
|
|
12
|
+
worker: SpecWorker
|
|
13
|
+
source: Source
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
shareable?: boolean
|
|
16
|
+
setTitle?: boolean
|
|
17
|
+
}): Promise<void> {
|
|
18
|
+
const { worker: w } = o
|
|
19
|
+
const g: ViewerGlobal = {
|
|
20
|
+
open: o.result,
|
|
21
|
+
baseUrl: o.baseUrl,
|
|
22
|
+
source:
|
|
23
|
+
o.shareable === false
|
|
24
|
+
? undefined
|
|
25
|
+
: o.source.kind === 'url'
|
|
26
|
+
? { kind: 'url', url: o.source.url }
|
|
27
|
+
: { kind: 'text' },
|
|
28
|
+
bundle: (id) => w.bundle(id) as Promise<Record<string, any>>,
|
|
29
|
+
definitions: () => w.definitions() as Promise<Record<string, any>>,
|
|
30
|
+
}
|
|
31
|
+
globalThis.__SPECBOOK_VIEWER__ = g
|
|
32
|
+
if (o.setTitle !== false) document.title = `${o.result.title} · Specbook`
|
|
33
|
+
await import('../main')
|
|
34
|
+
warmFaker()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mock responses are generated with the spec, so the first mock request needs no faker. Faker (a
|
|
39
|
+
* sizeable chunk) is only used by "Dynamic" examples and `{{faker.*}}` values, so it is fetched when the
|
|
40
|
+
* page has been idle for a moment instead of up front: by the time someone picks Dynamic it is cached.
|
|
41
|
+
*/
|
|
42
|
+
function warmFaker() {
|
|
43
|
+
if ((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData)
|
|
44
|
+
return
|
|
45
|
+
const idle: (f: () => void) => void =
|
|
46
|
+
(window as unknown as { requestIdleCallback?: (f: () => void) => void }).requestIdleCallback ??
|
|
47
|
+
((f) => setTimeout(f, 1500))
|
|
48
|
+
idle(() => {
|
|
49
|
+
// the registry's loader is the same import the run would make
|
|
50
|
+
void import('./registry').then((r) => r.loadFaker()).catch(() => {})
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ServerEntry } from '@specbook/core'
|
|
2
|
+
import type { AuthProfileLike } from './registry-data'
|
|
3
|
+
|
|
4
|
+
/** Where a spec comes from. */
|
|
5
|
+
export type Source = { kind: 'url'; url: string } | { kind: 'text'; text: string; name?: string }
|
|
6
|
+
|
|
7
|
+
export interface Timings {
|
|
8
|
+
/** fetching the document (url sources) */
|
|
9
|
+
fetch?: number
|
|
10
|
+
parse: number
|
|
11
|
+
generate: number
|
|
12
|
+
total: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OpenResult {
|
|
16
|
+
/** slug of the API title: the service key the UI uses */
|
|
17
|
+
service: string
|
|
18
|
+
title: string
|
|
19
|
+
/** the manifest's operation list, as the UI's registry wants it */
|
|
20
|
+
manifest: Record<string, unknown>
|
|
21
|
+
/** per operation, what `defineOperation` needs */
|
|
22
|
+
metas: Record<string, Record<string, unknown>>
|
|
23
|
+
/** operation id -> the example names offered (spec ones and generated) */
|
|
24
|
+
exampleNames: Record<string, string[]>
|
|
25
|
+
auth: Record<string, AuthProfileLike>
|
|
26
|
+
secrets: Record<string, Record<string, never>>
|
|
27
|
+
defaultProfile?: string
|
|
28
|
+
/** absolute API address, from `servers` (resolved against the spec URL) or `host` */
|
|
29
|
+
baseUrl?: string
|
|
30
|
+
/** every usable server the document lists, as environments; the first is `live` */
|
|
31
|
+
servers: ServerEntry[]
|
|
32
|
+
/** a relative `servers` entry the person has to give a base URL for */
|
|
33
|
+
needsBaseUrl: boolean
|
|
34
|
+
warnings: string[]
|
|
35
|
+
timings: Timings
|
|
36
|
+
/** bytes of the document, and of the shared definitions */
|
|
37
|
+
bytes: { spec: number; definitions: number }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Request =
|
|
41
|
+
| { id: number; type: 'open'; source: Source }
|
|
42
|
+
| { id: number; type: 'bundle'; opId: string }
|
|
43
|
+
| { id: number; type: 'definitions' }
|
|
44
|
+
| { id: number; type: 'cancel' }
|
|
45
|
+
|
|
46
|
+
export type Response =
|
|
47
|
+
| { id: number; ok: true; result: OpenResult | string | null }
|
|
48
|
+
| {
|
|
49
|
+
id: number
|
|
50
|
+
ok: false
|
|
51
|
+
error: string
|
|
52
|
+
code?: 'cors' | 'http' | 'too-large' | 'timeout' | 'parse'
|
|
53
|
+
}
|
|
54
|
+
| { id: number; progress: string }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const KEY = 'specbook:recent-specs'
|
|
2
|
+
const MAX = 10
|
|
3
|
+
|
|
4
|
+
export function recentSpecs(): string[] {
|
|
5
|
+
try {
|
|
6
|
+
const v = JSON.parse(localStorage.getItem(KEY) ?? '[]')
|
|
7
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, MAX) : []
|
|
8
|
+
} catch {
|
|
9
|
+
return []
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function rememberSpec(url: string) {
|
|
14
|
+
try {
|
|
15
|
+
localStorage.setItem(
|
|
16
|
+
KEY,
|
|
17
|
+
JSON.stringify([url, ...recentSpecs().filter((u) => u !== url)].slice(0, MAX)),
|
|
18
|
+
)
|
|
19
|
+
} catch {
|
|
20
|
+
/* private mode: nothing to remember with */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OAuthSettings } from '@specbook/core'
|
|
2
|
+
|
|
3
|
+
export interface AuthProfileLike {
|
|
4
|
+
title: string
|
|
5
|
+
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
6
|
+
/** the scheme's authorization code flow, when it has one the page can sign in to */
|
|
7
|
+
oauth?: OAuthSettings
|
|
8
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry the UI reads (`virtual:specbook/registry`) for a spec opened at run time. The shell opens
|
|
3
|
+
* the document in a worker and puts the result on `globalThis.__SPECBOOK_VIEWER__` before the app is
|
|
4
|
+
* imported; this module turns that into the same named exports the dev server and the static build give.
|
|
5
|
+
*/
|
|
6
|
+
import { defineOperation, type OpMeta } from '@specbook/core'
|
|
7
|
+
import type { OpenResult } from './protocol'
|
|
8
|
+
|
|
9
|
+
export interface ViewerGlobal {
|
|
10
|
+
open: OpenResult
|
|
11
|
+
bundle(opId: string): Promise<Record<string, any>>
|
|
12
|
+
definitions(): Promise<Record<string, any>>
|
|
13
|
+
/** the base URL the person gave, when the document has none of its own */
|
|
14
|
+
baseUrl?: string
|
|
15
|
+
/** where the document came from, which decides whether a link to this view can be shared */
|
|
16
|
+
source?: import('./share').ViewerSource
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
// set by the shell
|
|
21
|
+
var __SPECBOOK_VIEWER__: ViewerGlobal | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const v = globalThis.__SPECBOOK_VIEWER__
|
|
25
|
+
if (!v) throw new Error('The viewer registry was imported before a spec was opened')
|
|
26
|
+
|
|
27
|
+
const { open } = v
|
|
28
|
+
const service = open.service
|
|
29
|
+
const keyOf = (id: string) => `${service}/${id}`
|
|
30
|
+
const ids = Object.keys(open.metas)
|
|
31
|
+
|
|
32
|
+
export const mode = 'static' as const
|
|
33
|
+
export const project = { root: '', outDir: '', requestsDir: 'requests' }
|
|
34
|
+
export const manifests = { [service]: open.manifest }
|
|
35
|
+
export const requests: never[] = []
|
|
36
|
+
export const sequences: never[] = []
|
|
37
|
+
export const sequenceMeta = {}
|
|
38
|
+
export const unresolvedSequences: string[] = []
|
|
39
|
+
export const brandingAssets = {}
|
|
40
|
+
export const environmentVars = {}
|
|
41
|
+
export const docs: never[] = []
|
|
42
|
+
|
|
43
|
+
/** Per operation bundles are fetched from the worker once and kept. */
|
|
44
|
+
const bundles = new Map<string, Promise<Record<string, any>>>()
|
|
45
|
+
const bundle = (key: string) => {
|
|
46
|
+
const id = key.slice(service.length + 1)
|
|
47
|
+
const have = bundles.get(key)
|
|
48
|
+
if (have) return have
|
|
49
|
+
const fresh: Promise<Record<string, any>> = v.bundle(id).catch(() => ({}))
|
|
50
|
+
bundles.set(key, fresh)
|
|
51
|
+
return fresh
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A lookup that answers only for `service/Operation` keys. */
|
|
55
|
+
const byKey = <T>(make: (key: string) => T) =>
|
|
56
|
+
new Proxy({} as Record<string, T>, {
|
|
57
|
+
get: (_, k) =>
|
|
58
|
+
typeof k === 'string' &&
|
|
59
|
+
k.startsWith(`${service}/`) &&
|
|
60
|
+
ids.includes(k.slice(service.length + 1))
|
|
61
|
+
? make(k)
|
|
62
|
+
: undefined,
|
|
63
|
+
has: (_, k) =>
|
|
64
|
+
typeof k === 'string' &&
|
|
65
|
+
k.startsWith(`${service}/`) &&
|
|
66
|
+
ids.includes(k.slice(service.length + 1)),
|
|
67
|
+
ownKeys: () => ids.map(keyOf),
|
|
68
|
+
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true }),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const part = (pick: (b: Record<string, any>) => unknown) =>
|
|
72
|
+
byKey((key) => () => bundle(key).then((b) => ({ default: pick(b) })))
|
|
73
|
+
|
|
74
|
+
export const schemas = part((b) => b.schema)
|
|
75
|
+
export const responseSchemas = part((b) => b.responses)
|
|
76
|
+
export const operationDocs = part((b) => b.docs)
|
|
77
|
+
|
|
78
|
+
export const definitions = {
|
|
79
|
+
[service]: () => v.definitions().then((d) => ({ default: d })),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Mock responses are keyed `service/Operation.<status|default>`. */
|
|
83
|
+
export const examples = new Proxy({} as Record<string, () => Promise<{ default: unknown }>>, {
|
|
84
|
+
get: (_, k) => {
|
|
85
|
+
if (typeof k !== 'string' || !k.includes('.')) return undefined
|
|
86
|
+
const at = k.lastIndexOf('.')
|
|
87
|
+
const key = k.slice(0, at)
|
|
88
|
+
if (!ids.includes(key.slice(service.length + 1))) return undefined
|
|
89
|
+
return () => bundle(key).then((b) => ({ default: b.mock?.[k.slice(at + 1)] }))
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export const requestExamples = byKey((key) =>
|
|
94
|
+
(open.exampleNames[key.slice(service.length + 1)] ?? []).map((name) => ({
|
|
95
|
+
name,
|
|
96
|
+
source: 'generated' as const,
|
|
97
|
+
dir: `${key}@generated`,
|
|
98
|
+
load: () => bundle(key).then((b) => ({ default: b.examples?.[name] })),
|
|
99
|
+
})),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
/** Upload fields are filled with a small sample file, served from memory. */
|
|
103
|
+
let sample: string | undefined
|
|
104
|
+
export const assets = new Proxy({} as Record<string, () => Promise<string>>, {
|
|
105
|
+
get: (_, k) =>
|
|
106
|
+
typeof k === 'string' && k.endsWith('/sample.txt')
|
|
107
|
+
? () => {
|
|
108
|
+
sample ??= URL.createObjectURL(
|
|
109
|
+
new Blob(['specbook sample file\n'], { type: 'text/plain' }),
|
|
110
|
+
)
|
|
111
|
+
return Promise.resolve(sample)
|
|
112
|
+
}
|
|
113
|
+
: undefined,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
export const operations = Object.fromEntries(
|
|
117
|
+
ids.map((id) => [
|
|
118
|
+
keyOf(id),
|
|
119
|
+
async () => ({ [id]: defineOperation(open.metas[id] as unknown as OpMeta) }),
|
|
120
|
+
]),
|
|
121
|
+
) as Record<string, () => Promise<Record<string, any>>>
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Faker, English only: a locale is its own chunk in a project build, but a viewer opening a stranger's
|
|
125
|
+
* spec has no reason to ship the others. Loaded only when a run uses `{{faker.*}}`.
|
|
126
|
+
*/
|
|
127
|
+
export const loadFaker = () =>
|
|
128
|
+
import('./faker-en').then((m) => ({
|
|
129
|
+
Faker: m.Faker,
|
|
130
|
+
// a run asks for its locale and `base`, the fallback every locale chain ends in
|
|
131
|
+
locales: {
|
|
132
|
+
en: () => Promise.resolve(m.en),
|
|
133
|
+
base: () => Promise.resolve(m.base),
|
|
134
|
+
},
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
// `live` is the document's first server, or the address the person typed for it; the rest keep their own names
|
|
138
|
+
const live = v.baseUrl ?? open.baseUrl
|
|
139
|
+
const baseUrl: Record<string, string> = {
|
|
140
|
+
...(live ? { live } : {}),
|
|
141
|
+
...Object.fromEntries(open.servers.filter((s) => s.name !== 'live').map((s) => [s.name, s.url])),
|
|
142
|
+
}
|
|
143
|
+
const described = Object.fromEntries(
|
|
144
|
+
open.servers.filter((s) => s.description).map((s) => [s.name, { description: s.description }]),
|
|
145
|
+
)
|
|
146
|
+
export const loadConfig = () =>
|
|
147
|
+
Promise.resolve({
|
|
148
|
+
default: {
|
|
149
|
+
project: service,
|
|
150
|
+
secrets: open.secrets,
|
|
151
|
+
auth: open.auth,
|
|
152
|
+
...(Object.keys(described).length ? { environments: described } : {}),
|
|
153
|
+
// one locale, so the header offers no locale choice
|
|
154
|
+
faker: { default: 'en', locales: { en: ['en', 'base'] } },
|
|
155
|
+
services: {
|
|
156
|
+
[service]: {
|
|
157
|
+
spec: '',
|
|
158
|
+
baseUrl,
|
|
159
|
+
...(open.defaultProfile ? { auth: open.defaultProfile } : {}),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
})
|