@specbook/ui 0.0.0-stage → 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/apple-touch-icon.png +0 -0
  4. package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
  5. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  6. package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
  9. package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
  10. package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
  11. package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
  12. package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
  13. package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
  14. package/dist-viewer/favicon.svg +1 -0
  15. package/dist-viewer/index.html +19 -0
  16. package/dist-viewer/specbook.js +1 -0
  17. package/index.html +12 -0
  18. package/package.json +60 -3
  19. package/src/app.tsx +363 -0
  20. package/src/assets/mark.svg +1 -0
  21. package/src/badge.ts +14 -0
  22. package/src/branding.ts +92 -0
  23. package/src/components/Catalog.tsx +324 -0
  24. package/src/components/EnvBadge.tsx +46 -0
  25. package/src/components/EnvironmentMenu.tsx +262 -0
  26. package/src/components/Fields.tsx +220 -0
  27. package/src/components/GitHubStar.tsx +22 -0
  28. package/src/components/ListResizer.tsx +70 -0
  29. package/src/components/ModeHint.tsx +17 -0
  30. package/src/components/Palette.tsx +85 -0
  31. package/src/components/PoweredBy.tsx +15 -0
  32. package/src/components/ShareMenu.tsx +94 -0
  33. package/src/components/ThemeToggle.tsx +52 -0
  34. package/src/embed/panel.tsx +114 -0
  35. package/src/embed/styles.ts +27 -0
  36. package/src/embed.ts +65 -0
  37. package/src/engine.ts +432 -0
  38. package/src/extension.ts +101 -0
  39. package/src/fielddoc.ts +83 -0
  40. package/src/main.tsx +16 -0
  41. package/src/markdown.ts +133 -0
  42. package/src/nav.ts +118 -0
  43. package/src/panel/Body.tsx +182 -0
  44. package/src/panel/BodyEditor.tsx +92 -0
  45. package/src/panel/CodeSample.tsx +237 -0
  46. package/src/panel/Combo.tsx +124 -0
  47. package/src/panel/ControlsGrid.tsx +189 -0
  48. package/src/panel/FieldDoc.tsx +42 -0
  49. package/src/panel/FieldTable.tsx +113 -0
  50. package/src/panel/Host.tsx +100 -0
  51. package/src/panel/OperationDocs.tsx +181 -0
  52. package/src/panel/Panel.tsx +193 -0
  53. package/src/panel/ParamGrid.tsx +396 -0
  54. package/src/panel/RequestPanel.tsx +625 -0
  55. package/src/panel/RequestView.tsx +108 -0
  56. package/src/panel/ResponseView.tsx +182 -0
  57. package/src/panel/SecretsBar.tsx +156 -0
  58. package/src/panel/SequenceDocs.tsx +132 -0
  59. package/src/panel/SequencePanel.tsx +247 -0
  60. package/src/panel/StepExchange.tsx +118 -0
  61. package/src/panel/completions.ts +114 -0
  62. package/src/panel/draft.ts +106 -0
  63. package/src/panel/fieldnotes-split.ts +55 -0
  64. package/src/panel/fieldnotes.ts +40 -0
  65. package/src/panel/graphql.ts +31 -0
  66. package/src/panel/icons.tsx +85 -0
  67. package/src/panel/jsonCursor.ts +115 -0
  68. package/src/panel/stepnotes-split.ts +38 -0
  69. package/src/panel/stepnotes.ts +58 -0
  70. package/src/routing.ts +120 -0
  71. package/src/runtime.ts +32 -0
  72. package/src/schema.ts +359 -0
  73. package/src/secrets.ts +61 -0
  74. package/src/status.ts +27 -0
  75. package/src/store.ts +10 -0
  76. package/src/styles/app.css +821 -0
  77. package/src/styles/panel.css +1897 -0
  78. package/src/styles/tokens.css +122 -0
  79. package/src/theme.ts +37 -0
  80. package/src/viewer/Opening.tsx +43 -0
  81. package/src/viewer/bundles.ts +74 -0
  82. package/src/viewer/client.ts +54 -0
  83. package/src/viewer/embed.ts +78 -0
  84. package/src/viewer/faker-en.ts +3 -0
  85. package/src/viewer/main.tsx +285 -0
  86. package/src/viewer/mount.ts +52 -0
  87. package/src/viewer/protocol.ts +51 -0
  88. package/src/viewer/recent.ts +22 -0
  89. package/src/viewer/registry-data.ts +4 -0
  90. package/src/viewer/registry.ts +154 -0
  91. package/src/viewer/share.ts +134 -0
  92. package/src/viewer/site.ts +3 -0
  93. package/src/viewer/uploads.ts +53 -0
  94. package/src/viewer/viewer.css +184 -0
  95. package/src/viewer/worker-factory.ts +14 -0
  96. package/src/viewer/worker.ts +171 -0
  97. package/src/virtual.d.ts +104 -0
@@ -0,0 +1,285 @@
1
+ import { render } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import { applyTheme } from '../theme'
4
+ import { type OpenFailure, SpecWorker } from './client'
5
+ import { mountOpened } from './mount'
6
+ import { Opening } from './Opening'
7
+ import type { OpenResult, Source } from './protocol'
8
+ import { recentSpecs, rememberSpec } from './recent'
9
+ import { rawGithub, softEncode } from './share'
10
+ import { loadUpload, saveUpload } from './uploads'
11
+ import 'prism-code-editor/layout.css'
12
+ import 'prism-code-editor/autocomplete.css'
13
+ import 'prism-code-editor/autocomplete-icons.css'
14
+ import '../styles/tokens.css'
15
+ import '../styles/app.css'
16
+ import '../styles/panel.css'
17
+ import './viewer.css'
18
+
19
+ const EXAMPLES: { label: string; url: string }[] = [
20
+ { label: 'Petstore', url: 'https://petstore3.swagger.io/api/v3/openapi.json' },
21
+ { label: 'httpbin (live requests work)', url: 'https://httpbin.org/spec.json' },
22
+ {
23
+ label: 'Stripe (form-encoded requests)',
24
+ url: 'https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json',
25
+ },
26
+ {
27
+ label: 'OpenAI',
28
+ url: 'https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml',
29
+ },
30
+ {
31
+ label: 'GitHub (large)',
32
+ url: 'https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json',
33
+ },
34
+ ]
35
+
36
+ // a stored light or dark choice applies before anything is drawn, as the app itself would
37
+ applyTheme()
38
+
39
+ const root = document.getElementById('app') as HTMLElement
40
+ const params = new URLSearchParams(location.search)
41
+ // a link that names its document goes straight to opening it: show the app's shape, not the picker
42
+ const autostart = params.has('spec') || params.has('upload')
43
+
44
+ const specUrl = (u: string) => `${location.origin}${location.pathname}?spec=${softEncode(u)}`
45
+
46
+ type Phase =
47
+ | { kind: 'idle' }
48
+ | { kind: 'loading'; what: string; progress?: string }
49
+ | { kind: 'error'; message: string; code?: OpenFailure['code'] }
50
+ | { kind: 'base'; open: OpenResult; apply(base: string | undefined): void }
51
+
52
+ function Picker({ initial }: { initial: Phase }) {
53
+ const [phase, setPhase] = useState<Phase>(initial)
54
+ const [url, setUrl] = useState(params.get('spec') ?? '')
55
+ const [text, setText] = useState('')
56
+ const [over, setOver] = useState(false)
57
+ const worker = useRef<SpecWorker | undefined>(undefined)
58
+
59
+ const start = async (source: Source, label: string, remember?: string) => {
60
+ worker.current ??= new SpecWorker()
61
+ setPhase({ kind: 'loading', what: label })
62
+ const { promise } = worker.current.open(source, (progress) =>
63
+ setPhase({ kind: 'loading', what: label, progress }),
64
+ )
65
+ try {
66
+ const result = await promise
67
+ const finish = async (baseUrl?: string) => {
68
+ if (remember) rememberSpec(remember)
69
+ // the app renders over this one: clearing first would leave a blank page while its code loads
70
+ await mountOpened({ result, worker: worker.current as SpecWorker, source, baseUrl })
71
+ }
72
+ if (result.needsBaseUrl)
73
+ setPhase({ kind: 'base', open: result, apply: (b) => void finish(b) })
74
+ else await finish()
75
+ } catch (e) {
76
+ const f = e as OpenFailure
77
+ setPhase({ kind: 'error', message: f.message, code: f.code })
78
+ }
79
+ }
80
+
81
+ const openUrl = (u: string) => {
82
+ const clean = rawGithub(u.trim())
83
+ if (!clean) return
84
+ if (!/^https?:\/\//i.test(clean))
85
+ return setPhase({
86
+ kind: 'error',
87
+ message: 'Enter an address starting with http:// or https://',
88
+ })
89
+ history.replaceState(null, '', specUrl(clean))
90
+ void start({ kind: 'url', url: clean }, clean, clean)
91
+ }
92
+
93
+ const openText = async (name: string, body: string) => {
94
+ const id = await saveUpload(name, body)
95
+ if (id) history.replaceState(null, '', `${location.pathname}?upload=${id}`)
96
+ void start({ kind: 'text', text: body, name }, name)
97
+ }
98
+
99
+ useEffect(() => {
100
+ const s = params.get('spec')
101
+ const up = params.get('upload')
102
+ if (s) openUrl(s)
103
+ else if (up)
104
+ void loadUpload(up).then((u) =>
105
+ u
106
+ ? start({ kind: 'text', text: u.text, name: u.name }, u.name)
107
+ : setPhase({
108
+ kind: 'error',
109
+ message: 'That uploaded document is no longer stored in this browser.',
110
+ }),
111
+ )
112
+ }, [])
113
+
114
+ const busy = phase.kind === 'loading'
115
+ if (autostart && (phase.kind === 'idle' || phase.kind === 'loading'))
116
+ return (
117
+ <Opening
118
+ what={params.get('spec') ?? 'your uploaded document'}
119
+ progress={phase.kind === 'loading' ? phase.progress : undefined}
120
+ />
121
+ )
122
+ return (
123
+ <main class="vw">
124
+ <div class="vw-card">
125
+ <h1>Specbook</h1>
126
+ <p>
127
+ Open any OpenAPI or Swagger document. It is read in your browser; nothing is uploaded.
128
+ </p>
129
+
130
+ <form
131
+ onSubmit={(e) => {
132
+ e.preventDefault()
133
+ openUrl(url)
134
+ }}
135
+ >
136
+ <input
137
+ type="url"
138
+ placeholder="https://example.com/openapi.json"
139
+ aria-label="Address of an OpenAPI or Swagger document"
140
+ value={url}
141
+ onInput={(e) => setUrl((e.target as HTMLInputElement).value)}
142
+ disabled={busy}
143
+ />
144
+ <button type="submit" class="btn primary" disabled={busy}>
145
+ Open
146
+ </button>
147
+ </form>
148
+
149
+ {phase.kind === 'loading' && (
150
+ <p class="vw-progress" role="status">
151
+ {phase.progress ?? 'Opening…'}
152
+ </p>
153
+ )}
154
+
155
+ {phase.kind === 'error' && (
156
+ <div class="vw-error" role="alert">
157
+ {phase.message}
158
+ {phase.code === 'cors' && (
159
+ <> Paste the document below or drop the file instead; it works the same.</>
160
+ )}
161
+ </div>
162
+ )}
163
+
164
+ {phase.kind === 'base' && <BasePrompt onApply={phase.apply} title={phase.open.title} />}
165
+
166
+ <section
167
+ class={`vw-drop ${over ? 'over' : ''}`}
168
+ aria-label="Drop a document here"
169
+ onDragOver={(e) => {
170
+ e.preventDefault()
171
+ setOver(true)
172
+ }}
173
+ onDragLeave={() => setOver(false)}
174
+ onDrop={async (e) => {
175
+ e.preventDefault()
176
+ setOver(false)
177
+ const f = e.dataTransfer?.files?.[0]
178
+ if (f) await openText(f.name, await f.text())
179
+ }}
180
+ >
181
+ Drop a file here, or{' '}
182
+ <label class="link">
183
+ choose one
184
+ <input
185
+ type="file"
186
+ accept=".json,.yaml,.yml,application/json"
187
+ hidden
188
+ onChange={async (e) => {
189
+ const f = (e.target as HTMLInputElement).files?.[0]
190
+ if (f) await openText(f.name, await f.text())
191
+ }}
192
+ />
193
+ </label>
194
+ </section>
195
+
196
+ <details>
197
+ <summary>Paste the document</summary>
198
+ <p />
199
+ <textarea
200
+ aria-label="OpenAPI or Swagger document text"
201
+ placeholder="{ &quot;openapi&quot;: &quot;3.0.0&quot;, … }"
202
+ value={text}
203
+ onInput={(e) => setText((e.target as HTMLTextAreaElement).value)}
204
+ />
205
+ <p />
206
+ <button
207
+ type="button"
208
+ class="btn primary"
209
+ disabled={busy || text.trim() === ''}
210
+ onClick={() => void openText('pasted document', text)}
211
+ >
212
+ Open pasted document
213
+ </button>
214
+ </details>
215
+
216
+ <section>
217
+ <p>Try one</p>
218
+ <ul>
219
+ {EXAMPLES.map((x) => (
220
+ <li>
221
+ <button type="button" class="link" onClick={() => openUrl(x.url)}>
222
+ {x.label}
223
+ </button>
224
+ </li>
225
+ ))}
226
+ </ul>
227
+ </section>
228
+
229
+ {recentSpecs().length > 0 && (
230
+ <section>
231
+ <p>Recent</p>
232
+ <ul>
233
+ {recentSpecs().map((u) => (
234
+ <li>
235
+ <button type="button" class="link" title={u} onClick={() => openUrl(u)}>
236
+ {u}
237
+ </button>
238
+ </li>
239
+ ))}
240
+ </ul>
241
+ </section>
242
+ )}
243
+ </div>
244
+ </main>
245
+ )
246
+ }
247
+
248
+ function BasePrompt({
249
+ onApply,
250
+ title,
251
+ }: {
252
+ onApply(base: string | undefined): void
253
+ title: string
254
+ }) {
255
+ const [base, setBase] = useState('')
256
+ return (
257
+ <form
258
+ class="vw-base"
259
+ onSubmit={(e) => {
260
+ e.preventDefault()
261
+ if (/^https?:\/\//i.test(base.trim())) onApply(base.trim().replace(/\/$/, ''))
262
+ }}
263
+ >
264
+ <p>
265
+ This document gives its server as a relative address. Enter where the API is to make live
266
+ calls, or continue with mock data only.
267
+ </p>
268
+ <input
269
+ type="url"
270
+ placeholder="https://api.example.com"
271
+ aria-label={`Base URL for ${title}`}
272
+ value={base}
273
+ onInput={(e) => setBase((e.target as HTMLInputElement).value)}
274
+ />
275
+ <button type="submit" class="btn primary">
276
+ Use this base URL
277
+ </button>
278
+ <button type="button" class="link" onClick={() => onApply(undefined)}>
279
+ Continue with mock data
280
+ </button>
281
+ </form>
282
+ )
283
+ }
284
+
285
+ render(<Picker initial={{ kind: 'idle' }} />, root)
@@ -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,51 @@
1
+ import type { AuthProfileLike } from './registry-data'
2
+
3
+ /** Where a spec comes from. */
4
+ export type Source = { kind: 'url'; url: string } | { kind: 'text'; text: string; name?: string }
5
+
6
+ export interface Timings {
7
+ /** fetching the document (url sources) */
8
+ fetch?: number
9
+ parse: number
10
+ generate: number
11
+ total: number
12
+ }
13
+
14
+ export interface OpenResult {
15
+ /** slug of the API title: the service key the UI uses */
16
+ service: string
17
+ title: string
18
+ /** the manifest's operation list, as the UI's registry wants it */
19
+ manifest: Record<string, unknown>
20
+ /** per operation, what `defineOperation` needs */
21
+ metas: Record<string, Record<string, unknown>>
22
+ /** operation id -> the example names offered (spec ones and generated) */
23
+ exampleNames: Record<string, string[]>
24
+ auth: Record<string, AuthProfileLike>
25
+ secrets: Record<string, Record<string, never>>
26
+ defaultProfile?: string
27
+ /** absolute API address, from `servers` (resolved against the spec URL) or `host` */
28
+ baseUrl?: string
29
+ /** a relative `servers` entry the person has to give a base URL for */
30
+ needsBaseUrl: boolean
31
+ warnings: string[]
32
+ timings: Timings
33
+ /** bytes of the document, and of the shared definitions */
34
+ bytes: { spec: number; definitions: number }
35
+ }
36
+
37
+ export type Request =
38
+ | { id: number; type: 'open'; source: Source }
39
+ | { id: number; type: 'bundle'; opId: string }
40
+ | { id: number; type: 'definitions' }
41
+ | { id: number; type: 'cancel' }
42
+
43
+ export type Response =
44
+ | { id: number; ok: true; result: OpenResult | string | null }
45
+ | {
46
+ id: number
47
+ ok: false
48
+ error: string
49
+ code?: 'cors' | 'http' | 'too-large' | 'timeout' | 'parse'
50
+ }
51
+ | { 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,4 @@
1
+ export interface AuthProfileLike {
2
+ title: string
3
+ apply: { headers?: Record<string, string>; query?: Record<string, string> }
4
+ }
@@ -0,0 +1,154 @@
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
+ const baseUrl = v.baseUrl ?? open.baseUrl
138
+ export const loadConfig = () =>
139
+ Promise.resolve({
140
+ default: {
141
+ project: service,
142
+ secrets: open.secrets,
143
+ auth: open.auth,
144
+ // one locale, so the header offers no locale choice
145
+ faker: { default: 'en', locales: { en: ['en', 'base'] } },
146
+ services: {
147
+ [service]: {
148
+ spec: '',
149
+ baseUrl: baseUrl ? { live: baseUrl } : {},
150
+ ...(open.defaultProfile ? { auth: open.defaultProfile } : {}),
151
+ },
152
+ },
153
+ },
154
+ })