@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,247 @@
1
+ import type { RunResult, StepResult } from '@specbook/core'
2
+ import { useEffect, useMemo, useState } from 'preact/hooks'
3
+ import { EnvBadge } from '../components/EnvBadge'
4
+ import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
5
+ import type { SequenceInfo } from '../engine'
6
+ import { engine } from '../runtime'
7
+ import { env, locale, seed, version } from '../store'
8
+ import { ControlsGrid } from './ControlsGrid'
9
+ import { SecretsBar } from './SecretsBar'
10
+ import { StepExchange } from './StepExchange'
11
+ import { useSequenceNotes } from './stepnotes'
12
+
13
+ /** `Check items #2` belongs to the step `Check items`. */
14
+ const baseName = (n: string) => n.replace(/ #\d+$/, '')
15
+
16
+ type Live = Record<string, 'running' | 'pass' | 'fail' | 'error' | 'skipped' | 'paused'>
17
+
18
+ const ICON = {
19
+ pass: '✓',
20
+ fail: '✗',
21
+ error: '!',
22
+ skipped: '–',
23
+ running: '…',
24
+ paused: '⏸',
25
+ pending: '',
26
+ }
27
+ const short = (v: unknown) => {
28
+ const s = typeof v === 'string' ? v : JSON.stringify(v)
29
+ return s.length > 28 ? `${s.slice(0, 27)}…` : s
30
+ }
31
+
32
+ /** |timeline|request|: the journey on the left, the selected step's exchange on the right. */
33
+ export function SequencePanel({ info }: { info: SequenceInfo }) {
34
+ const { seq } = info
35
+ const specs = useMemo(
36
+ () =>
37
+ Object.fromEntries(
38
+ Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([k, c]) => [
39
+ k,
40
+ specFromControl(c),
41
+ ]),
42
+ ) as Record<string, FieldSpec>,
43
+ [seq],
44
+ )
45
+ const notes = useSequenceNotes(info)
46
+ // the services the flow calls; steps built by functions are only known when they run, so any such step asks for all
47
+ const serviceNames = useMemo(() => {
48
+ const names = new Set<string>()
49
+ for (const n of seq.nodes) {
50
+ if (n.kind === 'step' && typeof n.def !== 'function') names.add(n.def.op.service)
51
+ else if (n.kind === 'step' || n.kind === 'each') return undefined
52
+ }
53
+ return [...names]
54
+ }, [seq])
55
+ const [params, setParams] = useState<Record<string, unknown>>({})
56
+ const [result, setResult] = useState<RunResult>()
57
+ const [live, setLive] = useState<Live>({})
58
+ const [running, setRunning] = useState(false)
59
+ const [pause, setPause] = useState<{
60
+ name: string
61
+ controls: Record<string, FieldSpec>
62
+ resolve: (v: Record<string, unknown>) => void
63
+ }>()
64
+ const [choice, setChoice] = useState<Record<string, unknown>>({})
65
+ const [picked, setPicked] = useState<number>(0)
66
+ // a phone shows the selected step's request and response as a sheet over the timeline
67
+ const [sheet, setSheet] = useState(false)
68
+ const [error, setError] = useState<string>()
69
+
70
+ useEffect(() => {
71
+ setParams(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])))
72
+ setResult(undefined)
73
+ setLive({})
74
+ setPause(undefined)
75
+ }, [info.file])
76
+
77
+ const planned = seq.nodes.map((n) => n.name)
78
+ const rows: { name: string; step?: StepResult }[] = result
79
+ ? result.steps.filter((s) => s.kind !== 'auth').map((s) => ({ name: s.name, step: s }))
80
+ : planned.map((n) => ({ name: n }))
81
+ const current = rows[picked]?.step
82
+
83
+ const run = async () => {
84
+ setRunning(true)
85
+ setResult(undefined)
86
+ setError(undefined)
87
+ setLive({})
88
+ setPicked(0)
89
+ setSheet(false)
90
+ try {
91
+ const r = await engine.run(seq, {
92
+ env: env.value,
93
+ version: version.value,
94
+ params,
95
+ locale: locale.value,
96
+ seed: seed.value,
97
+ onEvent: (e) => {
98
+ if (e.type === 'step:start') setLive((l) => ({ ...l, [e.name]: 'running' }))
99
+ if (e.type === 'step:end') setLive((l) => ({ ...l, [e.name]: e.status }))
100
+ },
101
+ onInput: ({ name, controls }) =>
102
+ new Promise((resolve) => {
103
+ const c = controls as Record<string, FieldSpec>
104
+ setLive((l) => ({ ...l, [name]: 'paused' }))
105
+ setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
106
+ setPause({
107
+ name,
108
+ controls: c,
109
+ resolve: (values) => {
110
+ setPause(undefined)
111
+ resolve(values)
112
+ },
113
+ })
114
+ }),
115
+ })
116
+ setResult(r)
117
+ } catch (e) {
118
+ setError(e instanceof Error ? e.message : String(e))
119
+ } finally {
120
+ setRunning(false)
121
+ setPause(undefined)
122
+ }
123
+ }
124
+
125
+ const overall = result?.status
126
+
127
+ return (
128
+ <div class={`sequence-panel ${sheet && current ? 'sheet-open' : ''}`} data-seq={seq.meta.id}>
129
+ <div class="seq-left">
130
+ <SecretsBar services={serviceNames} />
131
+ <ControlsGrid title="Controls" specs={specs} values={params} onChange={setParams} />
132
+ <div class="seq-run">
133
+ <button type="button" class="btn primary" disabled={running} onClick={run}>
134
+ {running ? 'Running…' : '▶ Run'}
135
+ </button>
136
+ <EnvBadge />
137
+ {result && (
138
+ <span class={`badge ${overall}`}>
139
+ {ICON[overall as 'pass']}{' '}
140
+ {overall === 'pass' ? 'Passed' : overall === 'fail' ? 'Failed' : 'Error'} ·{' '}
141
+ {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
142
+ </span>
143
+ )}
144
+ </div>
145
+ {error && <p class="error-text">{error}</p>}
146
+ <ol class="journey" part="timeline" aria-label="Timeline">
147
+ {rows.map((row, i) => {
148
+ const step = row.step
149
+ const status = step?.status ?? live[row.name] ?? 'pending'
150
+ const failed = step?.assertions.filter((a) => !a.pass) ?? []
151
+ return (
152
+ <li class={`jstep ${status} ${picked === i ? 'picked' : ''}`}>
153
+ <div class="jmark">{ICON[status as keyof typeof ICON] || i + 1}</div>
154
+ <div class="jbody">
155
+ <button
156
+ type="button"
157
+ class="jhead"
158
+ disabled={!step}
159
+ onClick={(e) => {
160
+ const row = e.currentTarget
161
+ setPicked(i)
162
+ setSheet(true)
163
+ // keep the tapped step visible above the sheet that is about to cover the bottom
164
+ requestAnimationFrame(() =>
165
+ row.scrollIntoView?.({ block: 'start', behavior: 'smooth' }),
166
+ )
167
+ }}
168
+ >
169
+ <span class="jname">{row.name}</span>
170
+ {step?.response && (
171
+ <span class="muted">
172
+ {step.response.status} · {step.durationMs}ms
173
+ </span>
174
+ )}
175
+ {step && step.assertions.length > 0 && (
176
+ <span class={failed.length ? 'error-text' : 'muted'}>
177
+ {step.assertions.length - failed.length}/{step.assertions.length} checks
178
+ </span>
179
+ )}
180
+ </button>
181
+ {step?.uses
182
+ ?.filter((u) => u.source === 'capture')
183
+ .map((u) => (
184
+ <span class="cap use" title={`from step "${u.step}"`}>
185
+ ← {u.key} <em>from {u.step}</em>
186
+ </span>
187
+ ))}
188
+ {step &&
189
+ Object.entries(step.captured).map(([k, v]) => (
190
+ <span class="cap make" title={JSON.stringify(v)}>
191
+ → {k} <b>{short(v)}</b>
192
+ </span>
193
+ ))}
194
+ {step?.error && <div class="error-text">{step.error}</div>}
195
+ {failed.map((a) => (
196
+ <div class="error-text">✗ {a.label}</div>
197
+ ))}
198
+ {status === 'skipped' && (
199
+ <div class="muted">Skipped: an earlier step did not pass.</div>
200
+ )}
201
+ {pause && live[row.name] === 'paused' && (
202
+ <div class="pause">
203
+ <strong>Your turn: {pause.name}</strong>
204
+ <ControlsGrid
205
+ title="Choose"
206
+ specs={pause.controls}
207
+ values={choice}
208
+ onChange={setChoice}
209
+ />
210
+ <button
211
+ type="button"
212
+ class="btn primary"
213
+ onClick={() => pause.resolve(choice)}
214
+ >
215
+ Continue ▶
216
+ </button>
217
+ </div>
218
+ )}
219
+ </div>
220
+ </li>
221
+ )
222
+ })}
223
+ </ol>
224
+ </div>
225
+
226
+ <div class={`seq-right ${sheet && current ? 'sheet-open' : ''}`}>
227
+ {current && (
228
+ <button
229
+ type="button"
230
+ class="sheet-close"
231
+ aria-label="Hide step details"
232
+ onClick={() => setSheet(false)}
233
+ >
234
+ <span aria-hidden="true">⌄</span> {current.name}
235
+ </button>
236
+ )}
237
+ {!current && (
238
+ <div class="rs-empty">
239
+ <strong>Nothing run yet</strong>
240
+ <span class="muted">Press Run, then pick a step to see its request and response.</span>
241
+ </div>
242
+ )}
243
+ {current && <StepExchange step={current} note={notes.notes.get(baseName(current.name))} />}
244
+ </div>
245
+ </div>
246
+ )
247
+ }
@@ -0,0 +1,118 @@
1
+ import type { Encoding, StepResult } from '@specbook/core'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import { engine, registry } from '../runtime'
4
+ import type { Defs, JsonSchema } from '../schema'
5
+ import { statusClass, statusName } from '../status'
6
+ import { version } from '../store'
7
+ import { StepCodeSample } from './CodeSample'
8
+ import { queryString, RequestView } from './RequestView'
9
+ import { ResponseView } from './ResponseView'
10
+
11
+ /**
12
+ * The selected step of a run, laid out like an operation: a bar with method, URL and result, then
13
+ * Request and Response tabs (the response checked against the operation's schema).
14
+ */
15
+ export function StepExchange({ step, note }: { step: StepResult; note?: string }) {
16
+ const [tab, setTab] = useState<'request' | 'response'>('response')
17
+ const [schemas, setSchemas] = useState<Record<string, JsonSchema>>()
18
+ const [defs, setDefs] = useState<Defs>()
19
+ const [reqSchema, setReqSchema] = useState<JsonSchema>()
20
+ const [encoding, setEncoding] = useState<Encoding>('json')
21
+ const req = step.request
22
+ const key = req?.opId ? `${req.service}/${req.opId}` : undefined
23
+
24
+ useEffect(() => {
25
+ setTab('response')
26
+ }, [step.name])
27
+
28
+ useEffect(() => {
29
+ let live = true
30
+ setSchemas(undefined)
31
+ setDefs(undefined)
32
+ setReqSchema(undefined)
33
+ if (key && req) {
34
+ registry.schemas[key]?.().then(
35
+ (m: { default: JsonSchema }) => live && setReqSchema(m.default),
36
+ )
37
+ engine
38
+ .operationMeta(key, version.value)
39
+ .then((m) => live && setEncoding(m?.encoding ?? 'json'))
40
+ registry.responseSchemas[key]?.().then(
41
+ (m: { default: Record<string, JsonSchema> }) => live && setSchemas(m.default),
42
+ )
43
+ registry.definitions[req.service]?.().then(
44
+ (m: { default: Defs }) => live && setDefs(m.default),
45
+ )
46
+ }
47
+ return () => {
48
+ live = false
49
+ }
50
+ }, [key])
51
+
52
+ const response = step.response
53
+ const url = req ? `${req.url}${queryString(req.query)}` : ''
54
+
55
+ return (
56
+ <div class="step-exchange">
57
+ {req && (
58
+ <div class="rq-bar">
59
+ <span class={`method ${req.method}`}>{req.method}</span>
60
+ <div class="rq-url" title={url}>
61
+ <code>{url}</code>
62
+ </div>
63
+ {response && (
64
+ <span class={`result-chip ${statusClass(response.status)}`}>
65
+ {response.status} {statusName(response.status)} · {response.durationMs} ms
66
+ </span>
67
+ )}
68
+ </div>
69
+ )}
70
+
71
+ {note && (
72
+ <details class="step-about" key={step.name}>
73
+ <summary>About this step</summary>
74
+ <div class="doc-custom" dangerouslySetInnerHTML={{ __html: note }} />
75
+ </details>
76
+ )}
77
+
78
+ <div class="rq-tabs" role="tablist">
79
+ {(
80
+ [
81
+ ['request', 'Request'],
82
+ ['response', 'Response'],
83
+ ] as const
84
+ ).map(([id, label]) => (
85
+ <button
86
+ type="button"
87
+ role="tab"
88
+ aria-selected={tab === id}
89
+ class={`rq-tab ${tab === id ? 'on' : ''}`}
90
+ onClick={() => setTab(id)}
91
+ >
92
+ {label}
93
+ {id === 'response' && response && (
94
+ <span class={`state-dot ${statusClass(response.status)}`}>{response.status}</span>
95
+ )}
96
+ </button>
97
+ ))}
98
+ </div>
99
+
100
+ <div class="rq-pane">
101
+ <div class="tab-pane" hidden={tab !== 'request'}>
102
+ {req ? (
103
+ <StepCodeSample
104
+ request={req}
105
+ encoding={encoding}
106
+ sent={<RequestView request={req} hideBar docs={{ schema: reqSchema, defs }} />}
107
+ />
108
+ ) : (
109
+ <RequestView request={req} hideBar docs={{ schema: reqSchema, defs }} />
110
+ )}
111
+ </div>
112
+ <div class="tab-pane" hidden={tab !== 'response'}>
113
+ <ResponseView step={step} schemas={schemas} defs={defs} hideRequest />
114
+ </div>
115
+ </div>
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,114 @@
1
+ import {
2
+ type Defs,
3
+ describe,
4
+ type JsonSchema,
5
+ kindOf,
6
+ propsOf,
7
+ schemaAt,
8
+ summary,
9
+ unwrap,
10
+ } from '../schema'
11
+ import { jsonCursor } from './jsonCursor'
12
+
13
+ /** The shape the editor's autocomplete extension consumes (see prism-code-editor's `CompletionResult`). */
14
+ export interface Option {
15
+ label: string
16
+ detail?: string
17
+ icon?: string
18
+ insert?: string
19
+ tabStops?: number[]
20
+ boost?: number
21
+ from?: number
22
+ /** a longer description for the documentation popup */
23
+ doc?: string
24
+ }
25
+ export interface Completions {
26
+ from: number
27
+ to: number
28
+ options: Option[]
29
+ }
30
+
31
+ /** Descriptions are markdown; the popup shows plain text, so links keep just their label. */
32
+ const plain = (s: string | undefined) =>
33
+ s?.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*_]{1,2}/g, '')
34
+
35
+ const typeLabel = (u: JsonSchema, defs: Defs) => {
36
+ const kind = kindOf(u)
37
+ if (kind === 'array') return `${kindOf(unwrap(u.items, defs))}[]`
38
+ return u.format ? `${kind} · ${u.format}` : kind
39
+ }
40
+
41
+ /** The empty value of the right kind that goes after `"key": `, with the cursor placed inside it. */
42
+ function skeleton(u: JsonSchema): { text: string; stop: number } {
43
+ const kind = kindOf(u)
44
+ if (kind === 'object') return { text: '{}', stop: 1 }
45
+ if (kind === 'array') return { text: '[]', stop: 1 }
46
+ if (kind === 'boolean') return { text: 'false', stop: 5 }
47
+ if (kind === 'number' || kind === 'integer') return { text: '0', stop: 1 }
48
+ return { text: '""', stop: 1 }
49
+ }
50
+
51
+ /**
52
+ * Completions for the JSON body editor: the properties the schema allows at the cursor (minus the ones
53
+ * already there), or the allowed values for the property being written. `root` is the request schema and
54
+ * `prefix` the path of the body inside it (`['data']`).
55
+ */
56
+ export function jsonCompletions(
57
+ root: JsonSchema | undefined,
58
+ defs: Defs,
59
+ prefix: (string | number)[],
60
+ text: string,
61
+ pos: number,
62
+ ): Completions | undefined {
63
+ if (!root) return undefined
64
+ const cur = jsonCursor(text, pos)
65
+ if (!cur) return undefined
66
+ const at = schemaAt(root, [...prefix, ...cur.path], defs)
67
+ if (!at) return undefined
68
+ const u = unwrap(at, defs)
69
+
70
+ if (cur.mode === 'key') {
71
+ if (kindOf(u) !== 'object' && !u.properties) return undefined
72
+ const props = propsOf(u, defs).filter((p) => !cur.existing.has(p.name))
73
+ if (props.length === 0) return undefined
74
+ const options: Option[] = props.map((p) => {
75
+ const pu = unwrap(p.schema, defs)
76
+ const sk = skeleton(pu)
77
+ const key = JSON.stringify(p.name)
78
+ const head = cur.hasColon ? key : `${key}: `
79
+ const insert = cur.hasColon ? key : `${head}${sk.text}`
80
+ return {
81
+ label: p.name,
82
+ detail: `${typeLabel(pu, defs)}${p.required ? ' · required' : ''}`,
83
+ icon: pu.deprecated ? 'text' : 'property',
84
+ insert,
85
+ tabStops: cur.hasColon ? undefined : [head.length + sk.stop],
86
+ boost: p.required ? 10 : 0,
87
+ // inside a string the filter should match the letters typed after the opening quote
88
+ from: cur.inString ? cur.from + 1 : undefined,
89
+ doc: plain(summary(describe(pu), 400)),
90
+ }
91
+ })
92
+ return { from: cur.from, to: cur.to, options }
93
+ }
94
+
95
+ // value mode
96
+ const values: Option[] = []
97
+ const add = (v: unknown, detail?: string) => {
98
+ const json = JSON.stringify(v)
99
+ values.push({
100
+ label: typeof v === 'string' ? v : json,
101
+ insert: json,
102
+ icon: 'enum',
103
+ detail,
104
+ from: cur.inString ? cur.from + 1 : undefined,
105
+ })
106
+ }
107
+ if (Array.isArray(u.enum)) for (const v of u.enum) add(v)
108
+ else if (kindOf(u) === 'boolean') {
109
+ add(true)
110
+ add(false)
111
+ } else return undefined
112
+ if (u.nullable === true) add(null, 'null')
113
+ return { from: cur.from, to: cur.to, options: values }
114
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ type Encoding,
3
+ type Example,
4
+ fromFormRows,
5
+ fromRows,
6
+ type Row,
7
+ toFormRows,
8
+ toRows,
9
+ } from '@specbook/core'
10
+
11
+ export type FieldRow = Row & { file?: File }
12
+
13
+ /** The request as the user sees and edits it. Path/query/headers are grid rows; the body depends on encoding. */
14
+ export interface Draft {
15
+ path: Row[]
16
+ query: Row[]
17
+ headers: Row[]
18
+ /** json, graphql and text bodies */
19
+ body: string
20
+ /** form, multipart and binary bodies */
21
+ fields: FieldRow[]
22
+ /**
23
+ * How a form body is edited: nested JSON (the default; flattened to brackets when sent) or the
24
+ * key/value grid. Drafts stored before this existed have only a grid, so they keep it.
25
+ */
26
+ formView?: 'json' | 'grid'
27
+ }
28
+
29
+ /** Whether the body is edited as rows: multipart always, form when its grid view is chosen. */
30
+ export const gridMode = (d: Draft, e: Encoding) =>
31
+ e === 'multipart' || (e === 'form' && (d.formView ?? 'grid') === 'grid')
32
+
33
+ export function draftFrom(ex: Example, encoding: Encoding): Draft {
34
+ const body = ex.body
35
+ const rows = encoding === 'multipart' || encoding === 'form'
36
+ return {
37
+ path: toRows(ex.path),
38
+ query: toRows(ex.query),
39
+ headers: toRows(ex.headers),
40
+ body:
41
+ body === undefined || encoding === 'multipart' || encoding === 'binary'
42
+ ? ''
43
+ : typeof body === 'string' && encoding === 'text'
44
+ ? body
45
+ : JSON.stringify(body, null, 2),
46
+ fields: rows
47
+ ? toFormRows(body)
48
+ : encoding === 'binary' && typeof body === 'string'
49
+ ? [{ key: 'file', value: body, enabled: true }]
50
+ : [],
51
+ formView: encoding === 'form' ? 'json' : undefined,
52
+ }
53
+ }
54
+
55
+ export interface RequestParts {
56
+ path?: Record<string, unknown>
57
+ query?: Record<string, unknown>
58
+ headers?: Record<string, string>
59
+ data?: unknown
60
+ }
61
+
62
+ const nonEmpty = <T extends object>(o: T) => (Object.keys(o).length ? o : undefined)
63
+
64
+ /** Throws a readable message when the JSON body does not parse. */
65
+ export function requestParts(d: Draft, encoding: Encoding): RequestParts {
66
+ let data: unknown
67
+ if (gridMode(d, encoding)) data = fromFormRows(d.fields)
68
+ else if (encoding === 'binary') {
69
+ const f = d.fields.find((r) => r.enabled)
70
+ data = f?.file ?? f?.value
71
+ } else if (d.body.trim() !== '') {
72
+ if (encoding === 'text') data = d.body
73
+ else
74
+ try {
75
+ data = JSON.parse(d.body)
76
+ } catch (e) {
77
+ throw new Error(`Invalid JSON: ${(e as Error).message}`)
78
+ }
79
+ }
80
+ return {
81
+ path: nonEmpty(fromRows(d.path)),
82
+ query: nonEmpty(fromRows(d.query)),
83
+ headers: nonEmpty(fromRows(d.headers)) as Record<string, string> | undefined,
84
+ data,
85
+ }
86
+ }
87
+
88
+ /** The editable state for storage; picked files are never persisted. */
89
+ export const forStorage = (d: Draft) => ({
90
+ ...d,
91
+ fields: d.fields.map(({ file: _file, ...r }) => r),
92
+ })
93
+
94
+ /** What the request will be called with, for the URL bar: path params filled in, enabled query rows appended. */
95
+ export function previewUrl(template: string, path: Row[], query: Row[], base = ''): string {
96
+ const enc = (v: string) => (v.includes('{{') ? v : encodeURIComponent(v))
97
+ const p = template.replace(/\{([^}]+)\}/g, (m, name: string) => {
98
+ const r = path.find((x) => x.key === name && x.enabled && x.value !== '')
99
+ return r ? enc(r.value) : m
100
+ })
101
+ const qs = query
102
+ .filter((r) => r.enabled && r.key !== '')
103
+ .map((r) => `${enc(r.key)}=${enc(r.value)}`)
104
+ .join('&')
105
+ return `${base.replace(/\/+$/, '')}${p}${qs ? `?${qs}` : ''}`
106
+ }
@@ -0,0 +1,55 @@
1
+ /** Section a field belongs to in a request schema (`{ path, query, headers, data }`). */
2
+ export type FieldSection = 'path' | 'query' | 'headers' | 'data'
3
+
4
+ const SECTIONS = new Set<string>(['path', 'query', 'headers', 'data'])
5
+
6
+ export interface FieldNotes {
7
+ /** the page without the field sections */
8
+ intro: string
9
+ /** `section.path` -> HTML, e.g. `data.address.city` */
10
+ notes: Map<string, string>
11
+ }
12
+
13
+ /** `items[0].price` and `items.price` are the same field. */
14
+ const segments = (s: string) => (s.match(/[^.[\]]+/g) ?? []).filter((p) => !/^\d+$/.test(p))
15
+
16
+ /** The key notes are stored and looked up by. */
17
+ export function noteKey(section: FieldSection, path: string): string {
18
+ return [section, ...segments(path)].join('.')
19
+ }
20
+
21
+ /** A heading's text becomes a key when it names a field: `name`, `data.name`, `query.status`. */
22
+ function headingKey(text: string): string | undefined {
23
+ const parts = segments(text.trim())
24
+ if (parts.length === 0) return undefined
25
+ const first = parts[0] as string
26
+ if (parts.length >= 2 && SECTIONS.has(first)) return parts.join('.')
27
+ return ['data', ...parts].join('.')
28
+ }
29
+
30
+ const plain = (h: string) =>
31
+ h
32
+ .replace(/<[^>]+>/g, '')
33
+ .replace(/&amp;/g, '&')
34
+ .replace(/&lt;/g, '<')
35
+ .replace(/&gt;/g, '>')
36
+
37
+ /**
38
+ * A heading made of one inline code span (`## \`data.name\``) attaches the text under it, up to the next
39
+ * heading of that kind, to that field. Everything else stays in the intro.
40
+ */
41
+ export function splitFieldNotes(html: string): FieldNotes {
42
+ const notes = new Map<string, string>()
43
+ if (!html) return { intro: html, notes }
44
+ const intro: string[] = []
45
+ for (const chunk of html.split(/(?=<h[1-6][ >])/)) {
46
+ const m = /^<h([1-6])[^>]*>\s*<code>([^<]+)<\/code>\s*<\/h\1>/.exec(chunk)
47
+ const key = m ? headingKey(plain(m[2] as string)) : undefined
48
+ if (m && key) {
49
+ const prev = notes.get(key)
50
+ const body = chunk.slice(m[0].length)
51
+ notes.set(key, prev ? prev + body : body)
52
+ } else intro.push(chunk)
53
+ }
54
+ return { intro: intro.join(''), notes }
55
+ }
@@ -0,0 +1,40 @@
1
+ import { useEffect, useMemo, useState } from 'preact/hooks'
2
+ import { registry } from '../runtime'
3
+ import { type FieldNotes, splitFieldNotes } from './fieldnotes-split'
4
+
5
+ export { type FieldNotes, type FieldSection, noteKey, splitFieldNotes } from './fieldnotes-split'
6
+
7
+ export interface OperationNotes extends FieldNotes {
8
+ /** the page replaces the spec's description instead of following it */
9
+ replace: boolean
10
+ /** an operation page exists */
11
+ found: boolean
12
+ /** the page has finished loading (or there is none), so what follows it will not move */
13
+ loaded: boolean
14
+ }
15
+
16
+ const NONE = { intro: '', notes: new Map(), replace: false, found: false }
17
+
18
+ /** The markdown that sits with an operation (`.../<Operation>/README.md` or `requests/<service>/docs/<Operation>.md`), split into intro and field notes. */
19
+ export function useOperationNotes(key: string): OperationNotes {
20
+ const [html, setHtml] = useState<string>()
21
+ const [loaded, setLoaded] = useState(false)
22
+ const page = registry.docs.find((d) => d.operation === key)
23
+ useEffect(() => {
24
+ let live = true
25
+ setHtml(undefined)
26
+ setLoaded(!page)
27
+ page
28
+ ?.load()
29
+ .then((h: string) => live && setHtml(h))
30
+ .catch(() => {})
31
+ .finally(() => live && setLoaded(true))
32
+ return () => {
33
+ live = false
34
+ }
35
+ }, [key, page])
36
+ return useMemo(() => {
37
+ if (!page || html === undefined) return { ...NONE, loaded }
38
+ return { ...splitFieldNotes(html), replace: !!page.replace, found: true, loaded }
39
+ }, [html, page, loaded])
40
+ }