@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
package/src/oauth.ts ADDED
@@ -0,0 +1,194 @@
1
+ import {
2
+ authorizeUrl,
3
+ createPkce,
4
+ newState,
5
+ OAuthError,
6
+ parseCallback,
7
+ parseTokenResponse,
8
+ tokenRequestFields,
9
+ } from '@specbook/core'
10
+ import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
11
+
12
+ /**
13
+ * Page side of "Sign in": opens the provider in a popup, waits for the return leg
14
+ * (`oauth-callback.ts`), exchanges the code and hands back the access token. The verifier and the `state`
15
+ * live in this closure only: not in the URL, not in storage, not in a log.
16
+ */
17
+
18
+ /**
19
+ * The address a provider sends the person back to, and the one to register with it.
20
+ *
21
+ * The hosted site declares a stable, unversioned callback page (a `<meta>` its assembly adds to the viewer's
22
+ * `index.html`), so what people register does not change with the viewer's channel (`/v1/`, `/v2/`).
23
+ * Anywhere else (a self-hosted copy, an embed on a host page, the dev server) it is the page's own address.
24
+ */
25
+ export const redirectUri = (): string => {
26
+ const declared = document
27
+ .querySelector<HTMLMetaElement>('meta[name="specbook-oauth-callback"]')
28
+ ?.content.trim()
29
+ if (declared) {
30
+ try {
31
+ return new URL(declared, location.origin).href
32
+ } catch {
33
+ /* not an address: fall back to the page's own */
34
+ }
35
+ }
36
+ return `${location.origin}${location.pathname}`
37
+ }
38
+
39
+ export interface SignInInput {
40
+ authorizationUrl: string
41
+ clientId: string
42
+ scopes: string[]
43
+ /** posts the form to the token endpoint and returns the parsed response body */
44
+ exchange(fields: Record<string, string>, signal?: AbortSignal): Promise<unknown>
45
+ signal?: AbortSignal
46
+ /** how long to wait for the person to finish (default five minutes) */
47
+ timeoutMs?: number
48
+ }
49
+
50
+ /**
51
+ * Call this straight from a click: the popup has to open before anything is awaited or the browser
52
+ * blocks it.
53
+ */
54
+ export async function signIn(
55
+ i: SignInInput,
56
+ ): Promise<{ accessToken: string; expiresIn?: number; scope?: string }> {
57
+ // opened blank and pointed at the provider once the challenge exists, which keeps Safari's
58
+ // user-activation rule happy
59
+ const popup = window.open('', OAUTH_CHANNEL, 'popup,width=520,height=720')
60
+ if (!popup)
61
+ throw new OAuthError(
62
+ 'The browser blocked the sign-in window. Allow pop-ups for this page and try again.',
63
+ )
64
+
65
+ const channel = new BroadcastChannel(OAUTH_CHANNEL)
66
+ let onWindowMessage: ((e: MessageEvent<OAuthMessage>) => void) | undefined
67
+ try {
68
+ const { verifier, challenge } = await createPkce()
69
+ const state = newState()
70
+ const redirect = redirectUri()
71
+
72
+ const returned = new Promise<OAuthMessage & { type: 'callback' }>((resolve, reject) => {
73
+ const timer = setTimeout(
74
+ () => reject(new OAuthError('Sign-in timed out. Try again.')),
75
+ i.timeoutMs ?? 5 * 60_000,
76
+ )
77
+ const stop = () => clearTimeout(timer)
78
+ i.signal?.addEventListener('abort', () => {
79
+ stop()
80
+ reject(new OAuthError('Sign-in was cancelled.'))
81
+ })
82
+ // the return arrives over the channel or from the popup as its opener; both carry the same message,
83
+ // and the one that arrives second is a repeat of the first
84
+ const take = (m: OAuthMessage | undefined, ack: (a: OAuthMessage) => void) => {
85
+ if (m?.type !== 'callback' || m.state !== state) return
86
+ stop()
87
+ ack({ type: 'ack', state })
88
+ resolve(m)
89
+ }
90
+ channel.onmessage = (e: MessageEvent<OAuthMessage>) =>
91
+ take(e.data, (a) => channel.postMessage(a))
92
+ onWindowMessage = (e: MessageEvent<OAuthMessage>) => {
93
+ // only this origin's own callback page: another site cannot hand us a code
94
+ if (e.origin !== location.origin) return
95
+ take(e.data, (a) => (e.source as Window | null)?.postMessage(a, location.origin))
96
+ }
97
+ addEventListener('message', onWindowMessage)
98
+ })
99
+
100
+ popup.location.href = authorizeUrl({
101
+ authorizationUrl: i.authorizationUrl,
102
+ clientId: i.clientId,
103
+ redirectUri: redirect,
104
+ scopes: i.scopes,
105
+ state,
106
+ challenge,
107
+ })
108
+
109
+ const back = await returned
110
+ const { code } = parseCallback(
111
+ {
112
+ state: back.state,
113
+ ...(back.code ? { code: back.code } : {}),
114
+ ...(back.error ? { error: back.error } : {}),
115
+ ...(back.description ? { error_description: back.description } : {}),
116
+ },
117
+ state,
118
+ )
119
+ const body = await i.exchange(
120
+ tokenRequestFields({ code, verifier, clientId: i.clientId, redirectUri: redirect }),
121
+ i.signal,
122
+ )
123
+ return parseTokenResponse(body)
124
+ } finally {
125
+ channel.close()
126
+ if (onWindowMessage) removeEventListener('message', onWindowMessage)
127
+ try {
128
+ popup.close()
129
+ } catch {
130
+ /* a severed popup cannot be closed from here; the callback page closes itself */
131
+ }
132
+ }
133
+ }
134
+
135
+ // ---- small per-browser conveniences: a client id is public, and an expiry note is not a secret ----
136
+
137
+ const CLIENTS = 'specbook.oauth.clients.v1'
138
+ const EXPIRY = 'specbook.oauth.expiry.v1'
139
+
140
+ const readJson = (store: Storage, key: string): Record<string, any> => {
141
+ try {
142
+ return JSON.parse(store.getItem(key) ?? '{}')
143
+ } catch {
144
+ return {}
145
+ }
146
+ }
147
+ const writeJson = (store: Storage, key: string, value: unknown) => {
148
+ try {
149
+ store.setItem(key, JSON.stringify(value))
150
+ } catch {
151
+ /* blocked or full: the page still works without it */
152
+ }
153
+ }
154
+
155
+ export const recallClientId = (authorizationUrl: string): string | undefined => {
156
+ try {
157
+ return readJson(localStorage, CLIENTS)[authorizationUrl]
158
+ } catch {
159
+ return undefined
160
+ }
161
+ }
162
+
163
+ export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
164
+ try {
165
+ writeJson(localStorage, CLIENTS, {
166
+ ...readJson(localStorage, CLIENTS),
167
+ [authorizationUrl]: clientId,
168
+ })
169
+ } catch {
170
+ /* no storage */
171
+ }
172
+ }
173
+
174
+ const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
175
+
176
+ /** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
177
+ export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
178
+ try {
179
+ const t = readJson(sessionStorage, EXPIRY)
180
+ if (expiresIn === undefined) delete t[expiryKey(origin, name)]
181
+ else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
182
+ writeJson(sessionStorage, EXPIRY, t)
183
+ } catch {
184
+ /* no storage */
185
+ }
186
+ }
187
+
188
+ export const expiryOf = (origin: string, name: string): number | undefined => {
189
+ try {
190
+ return readJson(sessionStorage, EXPIRY)[expiryKey(origin, name)]
191
+ } catch {
192
+ return undefined
193
+ }
194
+ }
@@ -0,0 +1,182 @@
1
+ import { type Encoding, fromFormRows, toFormRows } from '@specbook/core'
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
+ import { type FieldDoc as Doc, fieldDoc, keyPath, withNote } from '../fielddoc'
4
+ import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
5
+ import { BodyEditor } from './BodyEditor'
6
+ import { jsonCompletions } from './completions'
7
+ import { type Draft, type FieldRow, gridMode } from './draft'
8
+ import { FieldDoc } from './FieldDoc'
9
+ import { type Graphql, joinGraphql, splitGraphql } from './graphql'
10
+ import { ParamGrid } from './ParamGrid'
11
+
12
+ interface Props {
13
+ encoding: Encoding
14
+ draft: Draft
15
+ onChange: (d: Draft) => void
16
+ /** request schema (`{ path, query, headers, data }`) and shared definitions */
17
+ schema?: JsonSchema
18
+ defs?: Defs
19
+ /** dotted body paths that are uploads */
20
+ files?: string[]
21
+ /** the project's own notes for body fields, by `data.<path>` */
22
+ notes?: Map<string, string>
23
+ }
24
+
25
+ /** Keeps the typed texts locally, so invalid variables JSON isn't rewritten while the user is typing. */
26
+ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
27
+ const [g, setG] = useState(() => splitGraphql(draft.body) as Graphql)
28
+ const emitted = useRef(draft.body)
29
+ useEffect(() => {
30
+ if (draft.body === emitted.current) return
31
+ const next = splitGraphql(draft.body)
32
+ if (next) setG(next)
33
+ emitted.current = draft.body
34
+ }, [draft.body])
35
+
36
+ const set = (part: Partial<Graphql>) => {
37
+ const next = { ...g, ...part }
38
+ setG(next)
39
+ emitted.current = joinGraphql(next)
40
+ onChange({ ...draft, body: emitted.current })
41
+ }
42
+ return (
43
+ <section class="body" aria-label="Body">
44
+ <div class="section-head">
45
+ <h3>Query</h3>
46
+ <span class="chip">graphql</span>
47
+ </div>
48
+ <BodyEditor
49
+ label="GraphQL query"
50
+ language="graphql"
51
+ value={g.query}
52
+ onChange={(query) => set({ query })}
53
+ />
54
+ <div class="section-head">
55
+ <h3>Variables</h3>
56
+ <span class="chip">json</span>
57
+ </div>
58
+ <BodyEditor
59
+ label="GraphQL variables"
60
+ language="json"
61
+ value={g.variables}
62
+ onChange={(variables) => set({ variables })}
63
+ />
64
+ </section>
65
+ )
66
+ }
67
+
68
+ const language = (e: Encoding) => (e === 'graphql' ? 'graphql' : e === 'text' ? 'text' : 'json')
69
+
70
+ export function Body({ encoding, draft, onChange, schema, defs, files = [], notes }: Props) {
71
+ const [line, setLine] = useState(0)
72
+ const paths = useMemo(() => linePaths(draft.body), [draft.body])
73
+ const documented = encoding === 'json' || encoding === 'form'
74
+ const doc: Doc | undefined = useMemo(() => {
75
+ const p = paths[line]
76
+ return p && documented
77
+ ? withNote(fieldDoc(schema, defs, p, ['data']), notes, 'data', pathString(p))
78
+ : undefined
79
+ }, [paths, line, schema, defs, documented, notes])
80
+ const complete = useMemo(
81
+ () =>
82
+ documented && schema
83
+ ? (text: string, pos: number) => jsonCompletions(schema, defs, ['data'], text, pos)
84
+ : undefined,
85
+ [schema, defs, documented],
86
+ )
87
+
88
+ /** Form bodies convert between the nested JSON and the bracket-key rows when the view is switched. */
89
+ const switchView = (view: 'json' | 'grid') => {
90
+ if (view === (draft.formView ?? 'grid')) return
91
+ if (view === 'grid') {
92
+ try {
93
+ onChange({ ...draft, formView: 'grid', fields: toFormRows(JSON.parse(draft.body || '{}')) })
94
+ } catch {
95
+ onChange({ ...draft, formView: 'grid' })
96
+ }
97
+ } else {
98
+ onChange({
99
+ ...draft,
100
+ formView: 'json',
101
+ body: JSON.stringify(fromFormRows(draft.fields.filter((r) => !r.file)), null, 2),
102
+ })
103
+ }
104
+ }
105
+ const viewToggle =
106
+ encoding === 'form' ? (
107
+ <fieldset class="seg" aria-label="Body view">
108
+ {(['json', 'grid'] as const).map((v) => (
109
+ <button
110
+ type="button"
111
+ class={`seg-btn ${(draft.formView ?? 'grid') === v ? 'on' : ''}`}
112
+ aria-pressed={(draft.formView ?? 'grid') === v}
113
+ onClick={() => switchView(v)}
114
+ >
115
+ {v === 'json' ? 'JSON' : 'Grid'}
116
+ </button>
117
+ ))}
118
+ </fieldset>
119
+ ) : null
120
+
121
+ if (gridMode(draft, encoding) || encoding === 'binary') {
122
+ const isFile = (k: string) => encoding === 'binary' || files.includes(k.replace(/\[\d+\]/g, ''))
123
+ const set = (fields: FieldRow[]) => onChange({ ...draft, fields })
124
+ const fileKeys = (draft.fields.length ? draft.fields.map((r) => r.key) : []).filter(isFile)
125
+ return (
126
+ <>
127
+ {viewToggle && (
128
+ <div class="section-head">
129
+ <h3>Body</h3>
130
+ <span class="chip">{encoding}</span>
131
+ <span class="spacer" />
132
+ {viewToggle}
133
+ </div>
134
+ )}
135
+ <ParamGrid
136
+ title={encoding === 'binary' ? 'File' : 'Body'}
137
+ bare={!!viewToggle}
138
+ rows={draft.fields}
139
+ onChange={set}
140
+ documented={[...new Set(fileKeys)].map((name) => ({ name, file: true }))}
141
+ docFor={(k) => withNote(fieldDoc(schema, defs, keyPath(k), ['data']), notes, 'data', k)}
142
+ />
143
+ </>
144
+ )
145
+ }
146
+
147
+ const format = () => {
148
+ try {
149
+ onChange({ ...draft, body: JSON.stringify(JSON.parse(draft.body), null, 2) })
150
+ } catch {
151
+ /* the invalid-JSON message is shown by the panel */
152
+ }
153
+ }
154
+
155
+ if (encoding === 'graphql' && splitGraphql(draft.body))
156
+ return <GraphqlBody draft={draft} onChange={onChange} />
157
+
158
+ return (
159
+ <section class="body" aria-label="Body">
160
+ <div class="section-head">
161
+ <h3>Body</h3>
162
+ <span class="chip">{encoding}</span>
163
+ <span class="spacer" />
164
+ {encoding !== 'text' && (
165
+ <button type="button" class="link" onClick={format}>
166
+ Format
167
+ </button>
168
+ )}
169
+ {viewToggle}
170
+ </div>
171
+ <BodyEditor
172
+ label="Request body"
173
+ language={language(encoding === 'form' ? 'json' : encoding)}
174
+ value={draft.body}
175
+ onChange={(body) => onChange({ ...draft, body })}
176
+ onLine={setLine}
177
+ complete={complete}
178
+ />
179
+ {documented && <FieldDoc doc={doc} />}
180
+ </section>
181
+ )
182
+ }
@@ -0,0 +1,92 @@
1
+ import { createEditor, type PrismEditor } from 'prism-code-editor'
2
+ import { autoComplete, fuzzyFilter, registerCompletions } from 'prism-code-editor/autocomplete'
3
+ import { defaultKeymap, editHistory, editorCommands } from 'prism-code-editor/commands'
4
+ import { cursorPosition } from 'prism-code-editor/cursor'
5
+ import 'prism-code-editor/languages/json'
6
+ import 'prism-code-editor/languages/graphql'
7
+ import 'prism-code-editor/prism/languages/json'
8
+ import 'prism-code-editor/prism/languages/graphql'
9
+ import { useEffect, useRef } from 'preact/hooks'
10
+ import type { Completions } from './completions'
11
+
12
+ /** Per-editor completion providers; one global source dispatches to the editor it was asked about. */
13
+ const providers = new WeakMap<PrismEditor, (text: string, pos: number) => Completions | undefined>()
14
+
15
+ registerCompletions(['json'], {
16
+ sources: [
17
+ (ctx, editor) => {
18
+ const found = providers.get(editor)?.(editor.value, ctx.pos)
19
+ if (!found) return null
20
+ return {
21
+ from: found.from,
22
+ to: found.to,
23
+ options: found.options.map((o) => ({
24
+ ...o,
25
+ renderDocs: o.doc ? () => [o.doc as string] : undefined,
26
+ })),
27
+ }
28
+ },
29
+ ],
30
+ })
31
+
32
+ interface Props {
33
+ value: string
34
+ language?: 'json' | 'graphql' | 'text'
35
+ readOnly?: boolean
36
+ label: string
37
+ onChange?: (value: string) => void
38
+ /** 0-based line the cursor is on */
39
+ onLine?: (line: number) => void
40
+ /** schema-driven suggestions for keys and values (JSON bodies only) */
41
+ complete?: (text: string, pos: number) => Completions | undefined
42
+ }
43
+
44
+ export function BodyEditor({
45
+ value,
46
+ language = 'json',
47
+ readOnly,
48
+ label,
49
+ onChange,
50
+ onLine,
51
+ complete,
52
+ }: Props) {
53
+ const host = useRef<HTMLDivElement>(null)
54
+ const editor = useRef<PrismEditor | undefined>(undefined)
55
+ const on = useRef({ onChange, onLine })
56
+ on.current = { onChange, onLine }
57
+ const completer = useRef(complete)
58
+ completer.current = complete
59
+
60
+ useEffect(() => {
61
+ const e = createEditor(
62
+ host.current,
63
+ {
64
+ value,
65
+ language,
66
+ readOnly,
67
+ lineNumbers: false,
68
+ wordWrap: true,
69
+ onUpdate: (v) => on.current.onChange?.(v),
70
+ onSelectionChange: (_s, _v, ed) => on.current.onLine?.(ed.activeLine - 1),
71
+ },
72
+ editorCommands(defaultKeymap),
73
+ editHistory(),
74
+ ...(complete ? [cursorPosition(), autoComplete({ filter: fuzzyFilter })] : []),
75
+ )
76
+ if (complete) providers.set(e, (text, pos) => completer.current?.(text, pos))
77
+ e.textarea.setAttribute('aria-label', label)
78
+ editor.current = e
79
+ return () => e.remove()
80
+ }, [])
81
+
82
+ useEffect(() => {
83
+ const e = editor.current
84
+ if (e && e.value !== value) e.setOptions({ value })
85
+ }, [value])
86
+
87
+ useEffect(() => {
88
+ editor.current?.setOptions({ readOnly, language })
89
+ }, [readOnly, language])
90
+
91
+ return <div class="body-editor" part="editor" ref={host} />
92
+ }
@@ -0,0 +1,238 @@
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 { env, 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
+ // the environment being used, so a sample for the sandbox points at the sandbox
58
+ setBase(urls[env.value] ?? urls.live ?? Object.values(urls)[0])
59
+ const name = profile === 'service' ? svc?.auth : profile
60
+ setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
61
+ })
62
+ return () => {
63
+ live = false
64
+ }
65
+ }, [service, profile, env.value])
66
+
67
+ const built = useMemo<{ text?: string; error?: string }>(() => {
68
+ try {
69
+ return { text: toSnippet(lang, make(base ?? 'https://api.example.com', applied)) }
70
+ } catch (e) {
71
+ return { error: e instanceof Error ? e.message : String(e) }
72
+ }
73
+ }, [make, lang, base, applied])
74
+
75
+ const pick = (l: SnippetLang | 'sent') => {
76
+ setView(l)
77
+ if (l === 'sent') return
78
+ try {
79
+ localStorage.setItem(LANG_KEY, l)
80
+ } catch {
81
+ /* optional */
82
+ }
83
+ }
84
+
85
+ const copy = async () => {
86
+ if (!built.text) return
87
+ try {
88
+ await navigator.clipboard.writeText(built.text)
89
+ setCopied(true)
90
+ setTimeout(() => setCopied(false), 1500)
91
+ } catch {
92
+ /* clipboard blocked: the text is selectable */
93
+ }
94
+ }
95
+
96
+ return (
97
+ <div class="code-sample">
98
+ <div class="code-head">
99
+ <fieldset class="seg" aria-label="Code sample">
100
+ {snippetLangs.map((l) => (
101
+ <button
102
+ type="button"
103
+ aria-pressed={view === l.id}
104
+ class={`seg-btn ${view === l.id ? 'on' : ''}`}
105
+ onClick={() => pick(l.id)}
106
+ >
107
+ {l.label}
108
+ </button>
109
+ ))}
110
+ {sent !== undefined && (
111
+ <button
112
+ type="button"
113
+ aria-pressed={view === 'sent'}
114
+ class={`seg-btn ${view === 'sent' ? 'on' : ''}`}
115
+ onClick={() => pick('sent')}
116
+ >
117
+ Sent
118
+ </button>
119
+ )}
120
+ </fieldset>
121
+ <span class="spacer" />
122
+ {view !== 'sent' && (
123
+ <button type="button" class="btn small" onClick={copy} disabled={!built.text}>
124
+ {copied ? 'Copied' : 'Copy'}
125
+ </button>
126
+ )}
127
+ </div>
128
+ {view === 'sent' ? (
129
+ sent
130
+ ) : built.error ? (
131
+ <p class="muted">{built.error}</p>
132
+ ) : (
133
+ <pre class="code-block" part="code">
134
+ <code>{built.text}</code>
135
+ </pre>
136
+ )}
137
+ {view !== 'sent' && secured && (
138
+ <p class="muted small">Secrets are read from environment variables; set them first.</p>
139
+ )}
140
+ </div>
141
+ )
142
+ }
143
+
144
+ interface DraftProps {
145
+ service: string
146
+ method: string
147
+ /** the operation's path template, e.g. /pets/{petId} */
148
+ path: string
149
+ draft: Draft
150
+ encoding: Encoding
151
+ vars: Record<string, string>
152
+ secured?: boolean
153
+ sent?: ComponentChildren
154
+ }
155
+
156
+ /** The sample for the request as currently edited in the panel. */
157
+ export function DraftCodeSample({
158
+ service,
159
+ method,
160
+ path,
161
+ draft,
162
+ encoding,
163
+ vars,
164
+ secured,
165
+ sent,
166
+ }: DraftProps) {
167
+ const make = useCallback(
168
+ (base: string, applied: Applied): SnippetInput => {
169
+ const parts = requestParts(draft, encoding)
170
+ const raw = {
171
+ path: parts.path ?? {},
172
+ query: parts.query ?? {},
173
+ headers: parts.headers ?? {},
174
+ data: parts.data,
175
+ }
176
+ let r = raw
177
+ try {
178
+ r = render(raw, vars)
179
+ } catch {
180
+ /* a variable is still unset: show the placeholders as they are */
181
+ }
182
+ const url = path.replace(/\{([^}]+)\}/g, (m, n: string) => {
183
+ const v = (r.path as Record<string, unknown>)[n]
184
+ return v === undefined || v === '' ? m : encodeURIComponent(String(v))
185
+ })
186
+ return {
187
+ method,
188
+ url: `${base.replace(/\/+$/, '')}${url}`,
189
+ query: { ...(applied?.query ?? {}), ...(r.query as Record<string, string | string[]>) },
190
+ headers: { ...(applied?.headers ?? {}), ...(r.headers as Record<string, string>) },
191
+ body: r.data,
192
+ encoding,
193
+ }
194
+ },
195
+ [draft, encoding, vars, method, path],
196
+ )
197
+ return (
198
+ <CodeSample
199
+ service={service}
200
+ profile={secured ? 'service' : undefined}
201
+ secured={secured}
202
+ make={make}
203
+ sent={sent}
204
+ />
205
+ )
206
+ }
207
+
208
+ /** The sample for a request a sequence step recorded (placeholders for secrets and session values kept). */
209
+ export function StepCodeSample({
210
+ request,
211
+ encoding,
212
+ sent,
213
+ }: {
214
+ request: RenderedRequest
215
+ encoding: Encoding
216
+ sent?: ComponentChildren
217
+ }) {
218
+ const make = useCallback(
219
+ (base: string, applied: Applied): SnippetInput => ({
220
+ method: request.method,
221
+ url: `${base.replace(/\/+$/, '')}${request.url}`,
222
+ query: { ...(applied?.query ?? {}), ...request.query },
223
+ headers: { ...(applied?.headers ?? {}), ...request.headers },
224
+ body: request.body,
225
+ encoding,
226
+ }),
227
+ [request, encoding],
228
+ )
229
+ return (
230
+ <CodeSample
231
+ service={request.service}
232
+ profile={request.profile}
233
+ secured={!!request.profile}
234
+ make={make}
235
+ sent={sent}
236
+ />
237
+ )
238
+ }