@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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  4. package/dist-viewer/apple-touch-icon.png +0 -0
  5. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  6. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  9. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  10. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  11. package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
  12. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  13. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  14. package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
  15. package/dist-viewer/favicon.svg +1 -0
  16. package/dist-viewer/index.html +19 -0
  17. package/dist-viewer/specbook.js +1 -0
  18. package/index.html +12 -0
  19. package/package.json +61 -3
  20. package/src/app.tsx +368 -0
  21. package/src/assets/mark.svg +1 -0
  22. package/src/badge.ts +14 -0
  23. package/src/branding.ts +92 -0
  24. package/src/components/Catalog.tsx +324 -0
  25. package/src/components/EnvBadge.tsx +48 -0
  26. package/src/components/EnvironmentMenu.tsx +262 -0
  27. package/src/components/Fields.tsx +220 -0
  28. package/src/components/GitHubStar.tsx +22 -0
  29. package/src/components/ListResizer.tsx +70 -0
  30. package/src/components/ModeHint.tsx +17 -0
  31. package/src/components/Palette.tsx +85 -0
  32. package/src/components/PoweredBy.tsx +15 -0
  33. package/src/components/ShareMenu.tsx +94 -0
  34. package/src/components/ThemeToggle.tsx +52 -0
  35. package/src/embed/panel.tsx +115 -0
  36. package/src/embed/styles.ts +27 -0
  37. package/src/embed.ts +65 -0
  38. package/src/engine.ts +574 -0
  39. package/src/extension.ts +101 -0
  40. package/src/fielddoc.ts +83 -0
  41. package/src/main.tsx +24 -0
  42. package/src/markdown.ts +133 -0
  43. package/src/nav.ts +118 -0
  44. package/src/oauth-callback.ts +87 -0
  45. package/src/oauth.ts +194 -0
  46. package/src/panel/Body.tsx +182 -0
  47. package/src/panel/BodyEditor.tsx +92 -0
  48. package/src/panel/CodeSample.tsx +238 -0
  49. package/src/panel/Combo.tsx +124 -0
  50. package/src/panel/ControlsGrid.tsx +189 -0
  51. package/src/panel/FieldDoc.tsx +42 -0
  52. package/src/panel/FieldTable.tsx +113 -0
  53. package/src/panel/Host.tsx +100 -0
  54. package/src/panel/OAuthSignIn.tsx +163 -0
  55. package/src/panel/OperationDocs.tsx +181 -0
  56. package/src/panel/Panel.tsx +197 -0
  57. package/src/panel/ParamGrid.tsx +396 -0
  58. package/src/panel/RequestPanel.tsx +627 -0
  59. package/src/panel/RequestView.tsx +108 -0
  60. package/src/panel/ResponseView.tsx +182 -0
  61. package/src/panel/SecretsBar.tsx +189 -0
  62. package/src/panel/SequenceDocs.tsx +132 -0
  63. package/src/panel/SequencePanel.tsx +247 -0
  64. package/src/panel/StepExchange.tsx +118 -0
  65. package/src/panel/completions.ts +114 -0
  66. package/src/panel/draft.ts +106 -0
  67. package/src/panel/fieldnotes-split.ts +55 -0
  68. package/src/panel/fieldnotes.ts +40 -0
  69. package/src/panel/graphql.ts +31 -0
  70. package/src/panel/icons.tsx +85 -0
  71. package/src/panel/jsonCursor.ts +115 -0
  72. package/src/panel/stepnotes-split.ts +38 -0
  73. package/src/panel/stepnotes.ts +58 -0
  74. package/src/routing.ts +120 -0
  75. package/src/runtime.ts +32 -0
  76. package/src/schema.ts +359 -0
  77. package/src/secrets.ts +61 -0
  78. package/src/status.ts +27 -0
  79. package/src/store.ts +12 -0
  80. package/src/styles/app.css +821 -0
  81. package/src/styles/panel.css +1939 -0
  82. package/src/styles/tokens.css +122 -0
  83. package/src/theme.ts +37 -0
  84. package/src/viewer/Opening.tsx +43 -0
  85. package/src/viewer/bundles.ts +74 -0
  86. package/src/viewer/client.ts +54 -0
  87. package/src/viewer/embed.ts +83 -0
  88. package/src/viewer/faker-en.ts +3 -0
  89. package/src/viewer/main.tsx +289 -0
  90. package/src/viewer/mount.ts +52 -0
  91. package/src/viewer/protocol.ts +54 -0
  92. package/src/viewer/recent.ts +22 -0
  93. package/src/viewer/registry-data.ts +8 -0
  94. package/src/viewer/registry.ts +163 -0
  95. package/src/viewer/share.ts +134 -0
  96. package/src/viewer/site.ts +3 -0
  97. package/src/viewer/uploads.ts +53 -0
  98. package/src/viewer/viewer.css +184 -0
  99. package/src/viewer/worker-factory.ts +14 -0
  100. package/src/viewer/worker.ts +176 -0
  101. 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="{ &quot;openapi&quot;: &quot;3.0.0&quot;, … }"
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
+ })