@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,237 @@
1
+ import {
2
+ type AuthProfile,
3
+ type Encoding,
4
+ type RenderedRequest,
5
+ render,
6
+ type SnippetInput,
7
+ type SnippetLang,
8
+ snippetLangs,
9
+ toSnippet,
10
+ } from '@specbook/core'
11
+ import type { ComponentChildren } from 'preact'
12
+ import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
13
+ import { engine } from '../runtime'
14
+ import { version } from '../store'
15
+ import { type Draft, requestParts } from './draft'
16
+
17
+ const LANG_KEY = 'specbook:code-lang'
18
+
19
+ function savedLang(): SnippetLang {
20
+ try {
21
+ const v = localStorage.getItem(LANG_KEY)
22
+ if (v === 'curl' || v === 'fetch') return v
23
+ } catch {
24
+ /* optional */
25
+ }
26
+ return 'curl'
27
+ }
28
+
29
+ type Applied = { headers?: Record<string, string>; query?: Record<string, string> } | undefined
30
+
31
+ interface Props {
32
+ service: string
33
+ /** auth profile to apply: its name, `service` for the service's default, or nothing */
34
+ profile?: string
35
+ /** shown as a hint under the sample */
36
+ secured?: boolean
37
+ /** builds the request; keep it stable (useCallback) so values from faker do not change per render */
38
+ make: (base: string, applied: Applied) => SnippetInput
39
+ /** the request as it actually went out, offered as a third view once there is one */
40
+ sent?: ComponentChildren
41
+ }
42
+
43
+ /** cURL / Node fetch tabs with a copy button, for a request described by `make`. */
44
+ export function CodeSample({ service, profile, secured, make, sent }: Props) {
45
+ const [view, setView] = useState<SnippetLang | 'sent'>(savedLang)
46
+ const lang: SnippetLang = view === 'sent' ? 'curl' : view
47
+ const [copied, setCopied] = useState(false)
48
+ const [base, setBase] = useState<string>()
49
+ const [applied, setApplied] = useState<Applied>()
50
+
51
+ useEffect(() => {
52
+ let live = true
53
+ engine.config(version.value).then((c) => {
54
+ if (!live) return
55
+ const svc = c.services?.[service]
56
+ const urls = svc?.baseUrl ?? {}
57
+ setBase(urls.live ?? Object.values(urls)[0])
58
+ const name = profile === 'service' ? svc?.auth : profile
59
+ setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
60
+ })
61
+ return () => {
62
+ live = false
63
+ }
64
+ }, [service, profile])
65
+
66
+ const built = useMemo<{ text?: string; error?: string }>(() => {
67
+ try {
68
+ return { text: toSnippet(lang, make(base ?? 'https://api.example.com', applied)) }
69
+ } catch (e) {
70
+ return { error: e instanceof Error ? e.message : String(e) }
71
+ }
72
+ }, [make, lang, base, applied])
73
+
74
+ const pick = (l: SnippetLang | 'sent') => {
75
+ setView(l)
76
+ if (l === 'sent') return
77
+ try {
78
+ localStorage.setItem(LANG_KEY, l)
79
+ } catch {
80
+ /* optional */
81
+ }
82
+ }
83
+
84
+ const copy = async () => {
85
+ if (!built.text) return
86
+ try {
87
+ await navigator.clipboard.writeText(built.text)
88
+ setCopied(true)
89
+ setTimeout(() => setCopied(false), 1500)
90
+ } catch {
91
+ /* clipboard blocked: the text is selectable */
92
+ }
93
+ }
94
+
95
+ return (
96
+ <div class="code-sample">
97
+ <div class="code-head">
98
+ <fieldset class="seg" aria-label="Code sample">
99
+ {snippetLangs.map((l) => (
100
+ <button
101
+ type="button"
102
+ aria-pressed={view === l.id}
103
+ class={`seg-btn ${view === l.id ? 'on' : ''}`}
104
+ onClick={() => pick(l.id)}
105
+ >
106
+ {l.label}
107
+ </button>
108
+ ))}
109
+ {sent !== undefined && (
110
+ <button
111
+ type="button"
112
+ aria-pressed={view === 'sent'}
113
+ class={`seg-btn ${view === 'sent' ? 'on' : ''}`}
114
+ onClick={() => pick('sent')}
115
+ >
116
+ Sent
117
+ </button>
118
+ )}
119
+ </fieldset>
120
+ <span class="spacer" />
121
+ {view !== 'sent' && (
122
+ <button type="button" class="btn small" onClick={copy} disabled={!built.text}>
123
+ {copied ? 'Copied' : 'Copy'}
124
+ </button>
125
+ )}
126
+ </div>
127
+ {view === 'sent' ? (
128
+ sent
129
+ ) : built.error ? (
130
+ <p class="muted">{built.error}</p>
131
+ ) : (
132
+ <pre class="code-block" part="code">
133
+ <code>{built.text}</code>
134
+ </pre>
135
+ )}
136
+ {view !== 'sent' && secured && (
137
+ <p class="muted small">Secrets are read from environment variables; set them first.</p>
138
+ )}
139
+ </div>
140
+ )
141
+ }
142
+
143
+ interface DraftProps {
144
+ service: string
145
+ method: string
146
+ /** the operation's path template, e.g. /pets/{petId} */
147
+ path: string
148
+ draft: Draft
149
+ encoding: Encoding
150
+ vars: Record<string, string>
151
+ secured?: boolean
152
+ sent?: ComponentChildren
153
+ }
154
+
155
+ /** The sample for the request as currently edited in the panel. */
156
+ export function DraftCodeSample({
157
+ service,
158
+ method,
159
+ path,
160
+ draft,
161
+ encoding,
162
+ vars,
163
+ secured,
164
+ sent,
165
+ }: DraftProps) {
166
+ const make = useCallback(
167
+ (base: string, applied: Applied): SnippetInput => {
168
+ const parts = requestParts(draft, encoding)
169
+ const raw = {
170
+ path: parts.path ?? {},
171
+ query: parts.query ?? {},
172
+ headers: parts.headers ?? {},
173
+ data: parts.data,
174
+ }
175
+ let r = raw
176
+ try {
177
+ r = render(raw, vars)
178
+ } catch {
179
+ /* a variable is still unset: show the placeholders as they are */
180
+ }
181
+ const url = path.replace(/\{([^}]+)\}/g, (m, n: string) => {
182
+ const v = (r.path as Record<string, unknown>)[n]
183
+ return v === undefined || v === '' ? m : encodeURIComponent(String(v))
184
+ })
185
+ return {
186
+ method,
187
+ url: `${base.replace(/\/+$/, '')}${url}`,
188
+ query: { ...(applied?.query ?? {}), ...(r.query as Record<string, string | string[]>) },
189
+ headers: { ...(applied?.headers ?? {}), ...(r.headers as Record<string, string>) },
190
+ body: r.data,
191
+ encoding,
192
+ }
193
+ },
194
+ [draft, encoding, vars, method, path],
195
+ )
196
+ return (
197
+ <CodeSample
198
+ service={service}
199
+ profile={secured ? 'service' : undefined}
200
+ secured={secured}
201
+ make={make}
202
+ sent={sent}
203
+ />
204
+ )
205
+ }
206
+
207
+ /** The sample for a request a sequence step recorded (placeholders for secrets and session values kept). */
208
+ export function StepCodeSample({
209
+ request,
210
+ encoding,
211
+ sent,
212
+ }: {
213
+ request: RenderedRequest
214
+ encoding: Encoding
215
+ sent?: ComponentChildren
216
+ }) {
217
+ const make = useCallback(
218
+ (base: string, applied: Applied): SnippetInput => ({
219
+ method: request.method,
220
+ url: `${base.replace(/\/+$/, '')}${request.url}`,
221
+ query: { ...(applied?.query ?? {}), ...request.query },
222
+ headers: { ...(applied?.headers ?? {}), ...request.headers },
223
+ body: request.body,
224
+ encoding,
225
+ }),
226
+ [request, encoding],
227
+ )
228
+ return (
229
+ <CodeSample
230
+ service={request.service}
231
+ profile={request.profile}
232
+ secured={!!request.profile}
233
+ make={make}
234
+ sent={sent}
235
+ />
236
+ )
237
+ }
@@ -0,0 +1,124 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks'
2
+
3
+ interface Props {
4
+ value: string
5
+ options: string[]
6
+ onChange: (value: string) => void
7
+ label: string
8
+ placeholder?: string
9
+ /** forwarded so the grid can move between rows with Enter and the arrow keys */
10
+ onKeyDown?: (e: KeyboardEvent) => void
11
+ cell?: string
12
+ }
13
+
14
+ /** A text input with the spec's allowed values as a dropdown; any other value can still be typed. */
15
+ export function Combo({ value, options, onChange, label, placeholder, onKeyDown, cell }: Props) {
16
+ const [open, setOpen] = useState(false)
17
+ const [at, setAt] = useState(-1)
18
+ const [pos, setPos] = useState<Record<string, string>>()
19
+ const box = useRef<HTMLDivElement>(null)
20
+
21
+ /** The list is `fixed` so the grid's rounded, clipped table and the scrolling pane can't cut it off. */
22
+ const place = () => {
23
+ const r = box.current?.getBoundingClientRect()
24
+ if (!r) return
25
+ const below = innerHeight - r.bottom - 8
26
+ const flip = below < 160 && r.top > below
27
+ // strings with units: Preact 11 sets style properties as given
28
+ setPos({
29
+ left: `${r.left}px`,
30
+ width: `${Math.max(r.width, 160)}px`,
31
+ maxHeight: `${Math.min(240, (flip ? r.top : below) - 4)}px`,
32
+ ...(flip ? { bottom: `${innerHeight - r.top + 2}px` } : { top: `${r.bottom + 2}px` }),
33
+ })
34
+ }
35
+
36
+ useEffect(() => {
37
+ if (!open) return
38
+ place()
39
+ const close = (e: Event) => {
40
+ if (!(e.target instanceof Node && box.current?.contains(e.target))) setOpen(false)
41
+ }
42
+ addEventListener('scroll', close, true)
43
+ addEventListener('resize', close)
44
+ return () => {
45
+ removeEventListener('scroll', close, true)
46
+ removeEventListener('resize', close)
47
+ }
48
+ }, [open])
49
+
50
+ useEffect(() => {
51
+ if (!open) return
52
+ const away = (e: MouseEvent) => {
53
+ if (!e.composedPath().includes(box.current as Node)) setOpen(false)
54
+ }
55
+ document.addEventListener('mousedown', away, true)
56
+ return () => document.removeEventListener('mousedown', away, true)
57
+ }, [open])
58
+
59
+ const pick = (v: string) => {
60
+ onChange(v)
61
+ setOpen(false)
62
+ setAt(-1)
63
+ }
64
+
65
+ return (
66
+ <div class="combo" ref={box}>
67
+ <input
68
+ class="cell"
69
+ data-cell={cell}
70
+ value={value}
71
+ placeholder={placeholder}
72
+ aria-label={label}
73
+ aria-expanded={open}
74
+ role="combobox"
75
+ autocomplete="off"
76
+ onFocus={() => setOpen(true)}
77
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
78
+ onKeyDown={(e) => {
79
+ if (open && e.key === 'ArrowDown') {
80
+ e.preventDefault()
81
+ setAt((at + 1) % options.length)
82
+ } else if (open && e.key === 'ArrowUp') {
83
+ e.preventDefault()
84
+ setAt((at - 1 + options.length) % options.length)
85
+ } else if (open && e.key === 'Enter' && at >= 0) {
86
+ e.preventDefault()
87
+ pick(options[at] as string)
88
+ } else if (e.key === 'Escape' && open) {
89
+ e.stopPropagation()
90
+ setOpen(false)
91
+ } else {
92
+ if (e.key === 'Enter' || e.key === 'Tab') setOpen(false)
93
+ onKeyDown?.(e)
94
+ }
95
+ }}
96
+ />
97
+ <button
98
+ type="button"
99
+ class="combo-toggle"
100
+ tabIndex={-1}
101
+ aria-label={`Show ${label} options`}
102
+ onClick={() => setOpen(!open)}
103
+ >
104
+ ▾
105
+ </button>
106
+ {open && (
107
+ <div class="combo-list" role="listbox" style={pos}>
108
+ {options.map((o, i) => (
109
+ <button
110
+ type="button"
111
+ role="option"
112
+ aria-selected={o === value}
113
+ class={`${o === value ? 'on' : ''} ${i === at ? 'at' : ''}`}
114
+ onMouseDown={(e) => e.preventDefault()}
115
+ onClick={() => pick(o)}
116
+ >
117
+ {o}
118
+ </button>
119
+ ))}
120
+ </div>
121
+ )}
122
+ </div>
123
+ )
124
+ }
@@ -0,0 +1,189 @@
1
+ import { useState } from 'preact/hooks'
2
+ import { defaultFor, type FieldSpec } from '../components/Fields'
3
+ import { Combo } from './Combo'
4
+
5
+ interface Props {
6
+ title: string
7
+ specs: Record<string, FieldSpec>
8
+ values: Record<string, unknown>
9
+ onChange: (values: Record<string, unknown>) => void
10
+ }
11
+
12
+ const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
13
+
14
+ function hint(spec: FieldSpec): string {
15
+ const bits: string[] = [spec.kind]
16
+ if (spec.min !== undefined || spec.max !== undefined)
17
+ bits.push(`${spec.min ?? ''}–${spec.max ?? ''}`)
18
+ return bits.join(' · ')
19
+ }
20
+
21
+ /** One editable cell, by control kind. Nested objects and arrays are edited as JSON. */
22
+ function ValueCell({
23
+ name,
24
+ spec,
25
+ value,
26
+ onChange,
27
+ }: {
28
+ name: string
29
+ spec: FieldSpec
30
+ value: unknown
31
+ onChange: (v: unknown) => void
32
+ }) {
33
+ const [raw, setRaw] = useState<string>()
34
+ switch (spec.kind) {
35
+ case 'number':
36
+ return (
37
+ <input
38
+ class="cell"
39
+ type="number"
40
+ aria-label={name}
41
+ value={String(value ?? '')}
42
+ min={spec.min}
43
+ max={spec.max}
44
+ onInput={(e) => {
45
+ const v = (e.target as HTMLInputElement).value
46
+ onChange(v === '' ? undefined : Number(v))
47
+ }}
48
+ />
49
+ )
50
+ case 'boolean':
51
+ return (
52
+ <Combo
53
+ label={name}
54
+ value={String(Boolean(value))}
55
+ options={['true', 'false']}
56
+ onChange={(v) => (v === 'true' || v === 'false' ? onChange(v === 'true') : undefined)}
57
+ />
58
+ )
59
+ case 'date':
60
+ return (
61
+ <input
62
+ class="cell"
63
+ type="date"
64
+ aria-label={name}
65
+ value={String(value ?? '')}
66
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
67
+ />
68
+ )
69
+ case 'select': {
70
+ const options = spec.options ?? []
71
+ const current = options.find((o) => same(o.value, value))
72
+ return (
73
+ <Combo
74
+ label={name}
75
+ value={current?.label ?? String(value ?? '')}
76
+ options={options.map((o) => o.label)}
77
+ onChange={(label) => {
78
+ const hit = options.find((o) => o.label === label)
79
+ if (hit) onChange(hit.value)
80
+ }}
81
+ />
82
+ )
83
+ }
84
+ case 'multiselect': {
85
+ const chosen = (value as unknown[]) ?? []
86
+ return (
87
+ <div class="multi-cell">
88
+ {(spec.options ?? []).map((o) => (
89
+ <label>
90
+ <input
91
+ type="checkbox"
92
+ checked={chosen.some((c) => same(c, o.value))}
93
+ onChange={(e) =>
94
+ onChange(
95
+ (e.target as HTMLInputElement).checked
96
+ ? [...chosen, o.value]
97
+ : chosen.filter((c) => !same(c, o.value)),
98
+ )
99
+ }
100
+ />
101
+ {o.label}
102
+ </label>
103
+ ))}
104
+ </div>
105
+ )
106
+ }
107
+ case 'object':
108
+ case 'array':
109
+ return (
110
+ <input
111
+ class="cell mono"
112
+ aria-label={name}
113
+ value={raw ?? JSON.stringify(value ?? defaultFor(spec))}
114
+ onInput={(e) => {
115
+ const text = (e.target as HTMLInputElement).value
116
+ setRaw(text)
117
+ try {
118
+ onChange(JSON.parse(text))
119
+ } catch {
120
+ /* keep typing; the last valid value stays */
121
+ }
122
+ }}
123
+ onBlur={() => setRaw(undefined)}
124
+ />
125
+ )
126
+ default:
127
+ return (
128
+ <input
129
+ class="cell"
130
+ aria-label={name}
131
+ value={String(value ?? '')}
132
+ onInput={(e) => onChange((e.target as HTMLInputElement).value)}
133
+ />
134
+ )
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A sequence's controls (params, or the choices of an input step) as the same spreadsheet the request
140
+ * parameters use: name, value, description.
141
+ */
142
+ export function ControlsGrid({ title, specs, values, onChange }: Props) {
143
+ const names = Object.keys(specs)
144
+ if (names.length === 0) return null
145
+ return (
146
+ <section class="grid controls-grid" aria-label={title}>
147
+ <h3>{title}</h3>
148
+ <table>
149
+ <colgroup>
150
+ <col class="c-key" />
151
+ <col class="c-val" />
152
+ <col />
153
+ </colgroup>
154
+ <thead>
155
+ <tr>
156
+ <th class="g-key">Name</th>
157
+ <th class="g-val">Value</th>
158
+ <th class="g-doc">Description</th>
159
+ </tr>
160
+ </thead>
161
+ <tbody>
162
+ {names.map((name) => {
163
+ const spec = specs[name] as FieldSpec
164
+ return (
165
+ <tr key={name}>
166
+ <td class="g-key">
167
+ <span class="key">
168
+ <code>{spec.label ?? name}</code>
169
+ </span>
170
+ </td>
171
+ <td class="g-val">
172
+ <ValueCell
173
+ name={name}
174
+ spec={spec}
175
+ value={values[name]}
176
+ onChange={(v) => onChange({ ...values, [name]: v })}
177
+ />
178
+ </td>
179
+ <td class="g-doc" title={spec.description}>
180
+ <span class="chip">{hint(spec)}</span> {spec.description}
181
+ </td>
182
+ </tr>
183
+ )
184
+ })}
185
+ </tbody>
186
+ </table>
187
+ </section>
188
+ )
189
+ }
@@ -0,0 +1,42 @@
1
+ import type { FieldDoc as Doc } from '../fielddoc'
2
+ import { markdownToHtml } from '../markdown'
3
+
4
+ /** The documentation for the field the cursor is on, shown below the editor instead of beside each line. */
5
+ export function FieldDoc({ doc, hint }: { doc?: Doc; hint?: string }) {
6
+ if (!doc)
7
+ return (
8
+ <div class="field-doc empty" part="field-doc" aria-live="polite">
9
+ {hint ?? 'Put the cursor on a field to see what the API says about it.'}
10
+ </div>
11
+ )
12
+ return (
13
+ <div class="field-doc" part="field-doc" aria-live="polite">
14
+ <div class="field-doc-head">
15
+ <code>{doc.path}</code>
16
+ <span class="chip">{doc.type}</span>
17
+ {doc.required && <span class="chip req">required</span>}
18
+ {doc.deprecated && <span class="chip warn">deprecated</span>}
19
+ </div>
20
+ {doc.description ? (
21
+ <div
22
+ class="field-doc-text"
23
+ dangerouslySetInnerHTML={{ __html: markdownToHtml(doc.description) }}
24
+ />
25
+ ) : doc.note ? null : (
26
+ <p class="muted">
27
+ The spec has no description for this field.
28
+ {doc.parent && <> It is part of: {doc.parent}</>}
29
+ </p>
30
+ )}
31
+ {doc.note && <div class="field-note" dangerouslySetInnerHTML={{ __html: doc.note }} />}
32
+ {doc.enum && (
33
+ <p class="field-doc-enum">
34
+ {doc.enum.slice(0, 12).map((v) => (
35
+ <code>{v}</code>
36
+ ))}
37
+ {doc.enum.length > 12 && <span class="muted">+{doc.enum.length - 12} more</span>}
38
+ </p>
39
+ )}
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,113 @@
1
+ import { inlineSummary } from '../markdown'
2
+ import { type Defs, describe, type JsonSchema, kindOf, propsOf, unwrap } from '../schema'
3
+ import { type FieldSection, noteKey } from './fieldnotes-split'
4
+
5
+ interface Row {
6
+ name: string
7
+ depth: number
8
+ type: string
9
+ required: boolean
10
+ description?: string
11
+ enum?: string[]
12
+ }
13
+
14
+ const MAX_ROWS = 60
15
+
16
+ function typeOf(u: JsonSchema, defs: Defs): string {
17
+ const kind = kindOf(u)
18
+ if (kind === 'array') return `${kindOf(unwrap(u.items, defs))}[]`
19
+ return u.format ? `${kind} · ${u.format}` : kind
20
+ }
21
+
22
+ export function flatten(schema: JsonSchema | undefined, defs: Defs, depth = 0, prefix = ''): Row[] {
23
+ if (!schema || depth > 2) return []
24
+ const rows: Row[] = []
25
+ for (const p of propsOf(schema, defs)) {
26
+ const u = unwrap(p.schema, defs)
27
+ const name = prefix ? `${prefix}.${p.name}` : p.name
28
+ rows.push({
29
+ name,
30
+ depth,
31
+ type: typeOf(u, defs),
32
+ required: p.required,
33
+ description: describe(u),
34
+ enum: Array.isArray(u.enum) && u.enum.length <= 10 ? u.enum.map(String) : undefined,
35
+ })
36
+ const nested = kindOf(u) === 'array' ? unwrap(u.items, defs) : u
37
+ if (kindOf(nested) === 'object') rows.push(...flatten(nested, defs, depth + 1, name))
38
+ }
39
+ return rows
40
+ }
41
+
42
+ export function FieldTable({
43
+ schema,
44
+ defs,
45
+ section = 'data',
46
+ notes,
47
+ }: {
48
+ schema?: JsonSchema
49
+ defs: Defs
50
+ /** where these fields sit in the request, for looking up the project's notes */
51
+ section?: FieldSection
52
+ notes?: Map<string, string>
53
+ }) {
54
+ const rows = flatten(schema, defs)
55
+ if (rows.length === 0) return null
56
+ return (
57
+ <table class="fields">
58
+ <thead>
59
+ <tr>
60
+ <th>Field</th>
61
+ <th>Type</th>
62
+ <th>Description</th>
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ {rows.slice(0, MAX_ROWS).map((r) => (
67
+ <tr>
68
+ <td style={{ paddingLeft: `${r.depth * 16 + 8}px` }}>
69
+ <code>{r.name.split('.').pop()}</code>
70
+ {r.required && (
71
+ <span class="req" title="required">
72
+ *
73
+ </span>
74
+ )}
75
+ </td>
76
+ <td>
77
+ <span class="chip">{r.type}</span>
78
+ </td>
79
+ <td>
80
+ <span
81
+ class="doc-inline wrap"
82
+ dangerouslySetInnerHTML={{ __html: inlineSummary(r.description, 220) }}
83
+ />
84
+ {notes?.get(noteKey(section, r.name)) && (
85
+ <div
86
+ class="field-note"
87
+ dangerouslySetInnerHTML={{
88
+ __html: notes.get(noteKey(section, r.name)) as string,
89
+ }}
90
+ />
91
+ )}
92
+ {r.enum && (
93
+ <span class="field-doc-enum">
94
+ {' '}
95
+ {r.enum.map((v) => (
96
+ <code>{v}</code>
97
+ ))}
98
+ </span>
99
+ )}
100
+ </td>
101
+ </tr>
102
+ ))}
103
+ {rows.length > MAX_ROWS && (
104
+ <tr>
105
+ <td colSpan={3} class="muted">
106
+ +{rows.length - MAX_ROWS} more fields in the spec
107
+ </td>
108
+ </tr>
109
+ )}
110
+ </tbody>
111
+ </table>
112
+ )
113
+ }