@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,108 @@
1
+ import type { RenderedRequest } from '@specbook/core'
2
+ import { useMemo, useState } from 'preact/hooks'
3
+ import { fieldDoc, withNote } from '../fielddoc'
4
+ import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
5
+ import { BodyEditor } from './BodyEditor'
6
+ import { FieldDoc } from './FieldDoc'
7
+
8
+ const pretty = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))
9
+
10
+ /** Repeated keys for arrays, as the transport sends them. */
11
+ export function queryString(query: Record<string, string | string[]>) {
12
+ const p = new URLSearchParams()
13
+ for (const [k, v] of Object.entries(query))
14
+ for (const x of Array.isArray(v) ? v : [v]) p.append(k, x)
15
+ const s = p.toString()
16
+ return s ? `?${s}` : ''
17
+ }
18
+
19
+ /** What the spec says about the fields: the request schema (`{ path, query, headers, data }`). */
20
+ export interface RequestDocs {
21
+ schema?: JsonSchema
22
+ defs?: Defs
23
+ notes?: Map<string, string>
24
+ }
25
+
26
+ /** The body as sent, with the documentation of the field the cursor is on below it. */
27
+ function SentBody({ body, docs }: { body: unknown; docs?: RequestDocs }) {
28
+ const [line, setLine] = useState(0)
29
+ const text = pretty(body)
30
+ const documented = !!docs?.schema && typeof body === 'object' && body !== null
31
+ const paths = useMemo(() => (documented ? linePaths(text) : []), [documented, text])
32
+ const doc = useMemo(() => {
33
+ const p = paths[line]
34
+ return p && docs
35
+ ? withNote(fieldDoc(docs.schema, docs.defs, p, ['data']), docs.notes, 'data', pathString(p))
36
+ : undefined
37
+ }, [paths, line, docs])
38
+ return (
39
+ <>
40
+ <BodyEditor
41
+ label="Body as sent"
42
+ value={text}
43
+ readOnly
44
+ language={typeof body === 'string' ? 'text' : 'json'}
45
+ onLine={setLine}
46
+ />
47
+ {documented && <FieldDoc doc={doc} />}
48
+ </>
49
+ )
50
+ }
51
+
52
+ /** The request exactly as it went out: method, URL, headers and body after placeholders were filled. */
53
+ export function RequestView({
54
+ request,
55
+ hideBar,
56
+ docs,
57
+ }: {
58
+ request?: RenderedRequest
59
+ /** the surrounding view already shows the method and URL */
60
+ hideBar?: boolean
61
+ /** when given, a field's description is shown as the cursor moves through the body */
62
+ docs?: RequestDocs
63
+ }) {
64
+ if (!request)
65
+ return (
66
+ <div class="rs-empty">
67
+ <strong>Nothing sent yet</strong>
68
+ <span class="muted">The request, with every value filled in, appears here after Send.</span>
69
+ </div>
70
+ )
71
+ const headers = Object.entries(request.headers)
72
+ return (
73
+ <div class="sent-view">
74
+ {!hideBar && (
75
+ <div class="rq-bar">
76
+ <span class={`method ${request.method}`}>{request.method}</span>
77
+ <div class="rq-url" title={request.url}>
78
+ <code>
79
+ {request.url}
80
+ {queryString(request.query)}
81
+ </code>
82
+ </div>
83
+ </div>
84
+ )}
85
+ <h3>Headers</h3>
86
+ {headers.length === 0 ? (
87
+ <p class="muted">No headers.</p>
88
+ ) : (
89
+ <table class="headers">
90
+ <tbody>
91
+ {headers.map(([k, v]) => (
92
+ <tr>
93
+ <th>{k}</th>
94
+ <td>{v}</td>
95
+ </tr>
96
+ ))}
97
+ </tbody>
98
+ </table>
99
+ )}
100
+ {request.body !== undefined && (
101
+ <>
102
+ <h3>Body</h3>
103
+ <SentBody body={request.body} docs={docs} />
104
+ </>
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,182 @@
1
+ import type { StepResult } from '@specbook/core'
2
+ import { useMemo, useState } from 'preact/hooks'
3
+ import { fieldDoc } from '../fielddoc'
4
+ import { type Defs, type JsonSchema, linePaths, validate } from '../schema'
5
+ import { statusClass, statusName } from '../status'
6
+ import { BodyEditor } from './BodyEditor'
7
+ import { FieldDoc } from './FieldDoc'
8
+ import { PaperPlane } from './icons'
9
+ import { queryString } from './RequestView'
10
+
11
+ interface Props {
12
+ step?: StepResult
13
+ error?: string
14
+ schemas?: Record<string, JsonSchema>
15
+ defs?: Defs
16
+ /** the request is shown elsewhere */
17
+ hideRequest?: boolean
18
+ }
19
+
20
+ const pretty = (v: unknown) => JSON.stringify(v, null, 2)
21
+ const size = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
22
+
23
+ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props) {
24
+ const [tab, setTab] = useState<'body' | 'headers' | 'checks'>('body')
25
+ const [line, setLine] = useState(0)
26
+ const response = step?.response
27
+ const schema = response ? (schemas?.[String(response.status)] ?? schemas?.default) : undefined
28
+ const issues = useMemo(
29
+ () => (response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []),
30
+ [response, schema, defs],
31
+ )
32
+ const text =
33
+ response === undefined || response.body === undefined || response.body === ''
34
+ ? ''
35
+ : typeof response.body === 'string'
36
+ ? response.body
37
+ : pretty(response.body)
38
+ const paths = useMemo(() => linePaths(text), [text])
39
+ const doc = useMemo(() => {
40
+ const p = paths[line]
41
+ return p ? fieldDoc(schema, defs, p) : undefined
42
+ }, [paths, line, schema, defs])
43
+ const checks = step?.assertions ?? []
44
+ const failed = checks.filter((a) => !a.pass).length
45
+ const headers = Object.entries(response?.headers ?? {})
46
+ const empty = !response && !error && !step?.error
47
+
48
+ return (
49
+ <section class="response" aria-label="Response">
50
+ <div class="rs-head">
51
+ {response && (
52
+ <>
53
+ <span class={`state-badge ${statusClass(response.status)}`}>
54
+ {response.status} {statusName(response.status)}
55
+ </span>
56
+ <span class="rs-stat">{response.durationMs} ms</span>
57
+ <span class="rs-stat">{size(new Blob([text]).size)}</span>
58
+ </>
59
+ )}
60
+ <span class="spacer" />
61
+ {response && (
62
+ <span class={`conf ${schema ? (issues.length ? 'warn' : 'ok') : 'none'}`}>
63
+ {schema
64
+ ? issues.length
65
+ ? `⚠ ${issues.length} difference${issues.length > 1 ? 's' : ''} from spec`
66
+ : '✓ Matches spec'
67
+ : 'No schema for this response'}
68
+ </span>
69
+ )}
70
+ </div>
71
+
72
+ {step?.request && !hideRequest && (
73
+ <p class="sent">
74
+ <span class={`method ${step.request.method}`}>{step.request.method}</span>{' '}
75
+ <code>
76
+ {step.request.url}
77
+ {queryString(step.request.query)}
78
+ </code>
79
+ </p>
80
+ )}
81
+ {error && <p class="error-text">{error}</p>}
82
+ {step?.error && <p class="error-text">{step.error}</p>}
83
+ {empty && (
84
+ <div class="rs-empty">
85
+ <strong>No response yet</strong>
86
+ <span class="muted">
87
+ Press{' '}
88
+ <kbd class="icon-key">
89
+ <PaperPlane size={12} />
90
+ </kbd>{' '}
91
+ or <kbd>⌘</kbd> <kbd>↵</kbd>
92
+ </span>
93
+ </div>
94
+ )}
95
+
96
+ {response && (
97
+ <>
98
+ <div class="rq-tabs" role="tablist">
99
+ {(
100
+ [
101
+ ['body', 'Body', 0],
102
+ ['headers', 'Headers', headers.length],
103
+ ['checks', 'Checks', checks.length],
104
+ ] as const
105
+ ).map(([id, label, n]) => (
106
+ <button
107
+ type="button"
108
+ role="tab"
109
+ aria-selected={tab === id}
110
+ class={`rq-tab ${tab === id ? 'on' : ''}`}
111
+ onClick={() => setTab(id)}
112
+ >
113
+ {label}
114
+ {n > 0 && (
115
+ <span class={`count ${id === 'checks' && failed ? 'bad' : ''}`}>{n}</span>
116
+ )}
117
+ </button>
118
+ ))}
119
+ </div>
120
+ <div class="rs-pane">
121
+ <div hidden={tab !== 'body'}>
122
+ {text === '' ? (
123
+ <p class="muted">No content.</p>
124
+ ) : (
125
+ <BodyEditor
126
+ label="Response body"
127
+ value={text}
128
+ readOnly
129
+ language={typeof response.body === 'string' ? 'text' : 'json'}
130
+ onLine={setLine}
131
+ />
132
+ )}
133
+ {text !== '' && <FieldDoc doc={doc} />}
134
+ {issues.length > 0 && (
135
+ <details class="issues">
136
+ <summary>What differs</summary>
137
+ <ul>
138
+ {issues.map((i) => (
139
+ <li>
140
+ <code>{i.path || '(root)'}</code> {i.message}
141
+ </li>
142
+ ))}
143
+ </ul>
144
+ </details>
145
+ )}
146
+ </div>
147
+ <div hidden={tab !== 'headers'}>
148
+ <table class="headers">
149
+ <tbody>
150
+ {headers.map(([k, v]) => (
151
+ <tr>
152
+ <th>{k}</th>
153
+ <td>{v}</td>
154
+ </tr>
155
+ ))}
156
+ {headers.length === 0 && (
157
+ <tr>
158
+ <td class="muted">No headers recorded</td>
159
+ </tr>
160
+ )}
161
+ </tbody>
162
+ </table>
163
+ </div>
164
+ <div hidden={tab !== 'checks'}>
165
+ {checks.length === 0 ? (
166
+ <p class="muted">This request has no checks.</p>
167
+ ) : (
168
+ <ul class="checks">
169
+ {checks.map((a) => (
170
+ <li class={a.pass ? 'ok' : 'bad'}>
171
+ {a.pass ? '✓' : '✗'} {a.label}
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ )}
176
+ </div>
177
+ </div>
178
+ </>
179
+ )}
180
+ </section>
181
+ )
182
+ }
@@ -0,0 +1,156 @@
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import { absoluteBase } from '../routing'
3
+ import { engine } from '../runtime'
4
+ import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
5
+ import { env, version } from '../store'
6
+
7
+ interface Need {
8
+ service: string
9
+ origin: string
10
+ title: string
11
+ names: string[]
12
+ }
13
+
14
+ const SOURCE: Record<string, string> = {
15
+ session: 'this session',
16
+ process: 'environment variable',
17
+ environment: 'environment file',
18
+ shared: 'shared file',
19
+ }
20
+
21
+ /**
22
+ * Credentials, asked for where they are needed: the request or flow about to run. A value typed here
23
+ * goes into the request, bound to the API's own origin, and stays in this tab. Where the dev server
24
+ * already has one (an environment variable or a secrets file) it is listed as available and only needs
25
+ * a value here to override it; a name nothing supplies is what the bar asks for.
26
+ */
27
+ export function SecretsBar({
28
+ services,
29
+ enabled = true,
30
+ supplied,
31
+ }: {
32
+ services?: string[]
33
+ /** false hides the bar (the caller has its own way to ask) */
34
+ enabled?: boolean
35
+ /** names the executor already holds, by source; read from the engine unless given */
36
+ supplied?: Record<string, string>
37
+ }) {
38
+ const [needs, setNeeds] = useState<Need[]>([])
39
+ const [asked, setManaged] = useState<Record<string, string>>({})
40
+ const managed = supplied ?? asked
41
+ const [, bump] = useState(0)
42
+ // once nothing is missing the prompt folds to one small chip; a tap opens it again
43
+ const [open, setOpen] = useState(false)
44
+ const live = enabled && env.value !== 'mock'
45
+ const wanted = services?.join('\n') ?? '*'
46
+
47
+ useEffect(() => {
48
+ if (!live) {
49
+ setNeeds([])
50
+ return
51
+ }
52
+ let on = true
53
+ if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
54
+ engine.config(version.value).then((cfg) => {
55
+ if (!on) return
56
+ const out: Need[] = []
57
+ for (const [service, s] of Object.entries(cfg.services ?? {})) {
58
+ if (services && !services.includes(service)) continue
59
+ const profile = s.auth ? cfg.auth?.[s.auth] : undefined
60
+ const base = s.baseUrl?.[env.value]
61
+ const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
62
+ if (!profile || !origin) continue
63
+ const names = [
64
+ ...new Set(
65
+ [...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
66
+ (m) => m[1] as string,
67
+ ),
68
+ ),
69
+ ]
70
+ if (names.length) out.push({ service, origin, title: profile.title ?? s.auth ?? '', names })
71
+ }
72
+ setNeeds(out)
73
+ })
74
+ return () => {
75
+ on = false
76
+ }
77
+ }, [live, env.value, wanted, version.value])
78
+
79
+ if (!live || needs.length === 0) return null
80
+ const have = (n: Need, name: string) =>
81
+ secretStore.get(n.origin, name) !== undefined || managed[name] !== undefined
82
+ const missing = needs.some((n) => n.names.some((x) => !have(n, x)))
83
+ if (!missing && !open)
84
+ return (
85
+ <div class="secrets-folded">
86
+ <button
87
+ type="button"
88
+ class="secrets-chip"
89
+ aria-expanded="false"
90
+ title="Credentials are available. Tap to change or clear the ones set in this tab."
91
+ onClick={() => setOpen(true)}
92
+ >
93
+ <span aria-hidden="true">🔑</span> {needs.map((n) => n.title || n.service).join(' · ')} ✓
94
+ </button>
95
+ </div>
96
+ )
97
+ return (
98
+ <fieldset class={`needs-vars ${missing ? '' : 'ok'}`} aria-label="Credentials for this request">
99
+ {needs.map((n) => (
100
+ <>
101
+ <span class="muted" title={`Sent from this browser to ${n.origin}`}>
102
+ {n.service} · {n.title}
103
+ </span>
104
+ {n.names.map((name) => {
105
+ const has = secretStore.get(n.origin, name) !== undefined
106
+ const from = managed[name]
107
+ return (
108
+ <span class="needs-var">
109
+ <code>{name}</code>
110
+ {has ? (
111
+ <>
112
+ <span class="muted">✓ set</span>
113
+ <button
114
+ type="button"
115
+ class="link"
116
+ onClick={() => {
117
+ secretStore.remove(n.origin, name)
118
+ bump((x) => x + 1)
119
+ }}
120
+ >
121
+ Clear
122
+ </button>
123
+ </>
124
+ ) : (
125
+ <>
126
+ {from && <span class="muted">from {SOURCE[from] ?? from}</span>}
127
+ <input
128
+ type="password"
129
+ autocomplete="off"
130
+ aria-label={`${name} for ${n.service}`}
131
+ placeholder={from ? 'use another value' : 'paste value'}
132
+ onChange={(e) => {
133
+ const v = (e.target as HTMLInputElement).value
134
+ if (v === '') return
135
+ secretStore.set(n.origin, name, v)
136
+ bump((x) => x + 1)
137
+ }}
138
+ />
139
+ </>
140
+ )}
141
+ </span>
142
+ )
143
+ })}
144
+ </>
145
+ ))}
146
+ <span class="muted small secrets-note">
147
+ Kept in this tab only; sent from your browser to the API.
148
+ </span>
149
+ {!missing && (
150
+ <button type="button" class="link" onClick={() => setOpen(false)}>
151
+ Hide
152
+ </button>
153
+ )}
154
+ </fieldset>
155
+ )
156
+ }
@@ -0,0 +1,132 @@
1
+ import { useMemo } from 'preact/hooks'
2
+ import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
3
+ import type { SequenceInfo } from '../engine'
4
+ import { go } from '../nav'
5
+ import { useSequenceNotes } from './stepnotes'
6
+
7
+ const KIND: Record<string, string> = {
8
+ step: 'request',
9
+ each: 'for each item',
10
+ input: 'you choose',
11
+ check: 'check',
12
+ }
13
+
14
+ const show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v))
15
+
16
+ /**
17
+ * What a sequence does, as documentation: its description (markdown), an optional markdown page from
18
+ * `docs/*.md` with `sequence: <id>` frontmatter, the controls it takes and the operation behind each step.
19
+ */
20
+ export function SequenceDocs({ info }: { info: SequenceInfo }) {
21
+ const { seq } = info
22
+ const { intro, notes } = useSequenceNotes(info)
23
+
24
+ const params = useMemo(
25
+ () =>
26
+ Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([name, c]) => {
27
+ const spec: FieldSpec = specFromControl(c)
28
+ return { name, spec, initial: defaultFor(spec) }
29
+ }),
30
+ [seq],
31
+ )
32
+
33
+ return (
34
+ <article class="doc" data-seq={seq.meta.id}>
35
+ {(seq.meta.tags?.length ?? 0) > 0 && (
36
+ <p class="doc-tags">
37
+ {seq.meta.tags?.map((t) => (
38
+ <span class="chip">{t}</span>
39
+ ))}
40
+ </p>
41
+ )}
42
+
43
+ {intro && <div class="doc-custom" dangerouslySetInnerHTML={{ __html: intro }} />}
44
+
45
+ {params.length > 0 && (
46
+ <section>
47
+ <h2>Controls</h2>
48
+ <table class="fields">
49
+ <thead>
50
+ <tr>
51
+ <th>Name</th>
52
+ <th>Type</th>
53
+ <th>Default</th>
54
+ <th>Description</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {params.map((p) => (
59
+ <tr>
60
+ <td>
61
+ <code>{p.name}</code>
62
+ </td>
63
+ <td>
64
+ <span class="chip">{p.spec.kind}</span>
65
+ </td>
66
+ <td>
67
+ <code>{show(p.initial)}</code>
68
+ </td>
69
+ <td>{p.spec.description}</td>
70
+ </tr>
71
+ ))}
72
+ </tbody>
73
+ </table>
74
+ </section>
75
+ )}
76
+
77
+ <section>
78
+ <h2>Steps</h2>
79
+ <table class="fields">
80
+ <thead>
81
+ <tr>
82
+ <th>#</th>
83
+ <th>Step</th>
84
+ <th>What it does</th>
85
+ </tr>
86
+ </thead>
87
+ <tbody>
88
+ {seq.nodes.map((n, i) => {
89
+ const def = n.kind === 'step' && typeof n.def !== 'function' ? n.def : undefined
90
+ const op = def?.op
91
+ const note = notes.get(n.name)
92
+ return (
93
+ <>
94
+ <tr class={note ? 'has-note' : ''}>
95
+ <td>{i + 1}</td>
96
+ <td>{n.name}</td>
97
+ <td>
98
+ {op ? (
99
+ <button
100
+ type="button"
101
+ class="link"
102
+ title="Open this operation"
103
+ onClick={() => go({ kind: 'op', key: `${op.service}/${op.id}` })}
104
+ >
105
+ <span class={`method ${op.method}`}>{op.method}</span>{' '}
106
+ <code>{op.path}</code>
107
+ </button>
108
+ ) : (
109
+ <span class="muted">{KIND[n.kind] ?? n.kind}</span>
110
+ )}
111
+ </td>
112
+ </tr>
113
+ {note && (
114
+ <tr class="step-note-row">
115
+ <td />
116
+ <td colSpan={2}>
117
+ <div
118
+ class="doc-custom step-note"
119
+ dangerouslySetInnerHTML={{ __html: note }}
120
+ />
121
+ </td>
122
+ </tr>
123
+ )}
124
+ </>
125
+ )
126
+ })}
127
+ </tbody>
128
+ </table>
129
+ </section>
130
+ </article>
131
+ )
132
+ }