@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,83 @@
1
+ import { type FieldSection, noteKey } from './panel/fieldnotes-split'
2
+ import {
3
+ type Defs,
4
+ describe,
5
+ type JsonSchema,
6
+ type KeyPath,
7
+ kindOf,
8
+ pathString,
9
+ propsOf,
10
+ schemaAt,
11
+ unwrap,
12
+ } from './schema'
13
+
14
+ export interface FieldDoc {
15
+ path: string
16
+ type: string
17
+ required: boolean
18
+ enum?: string[]
19
+ description?: string
20
+ /** the project's own note for this field (HTML), shown after the spec's description */
21
+ note?: string
22
+ /** what the enclosing object says about itself, shown when the field has no description */
23
+ parent?: string
24
+ deprecated?: boolean
25
+ }
26
+
27
+ /** What the API documents about the field at `path` (relative to `prefix`, e.g. `['data']` for a body). */
28
+ export function fieldDoc(
29
+ root: JsonSchema | undefined,
30
+ defs: Defs,
31
+ path: KeyPath,
32
+ prefix: KeyPath = [],
33
+ ): FieldDoc | undefined {
34
+ if (!root) return undefined
35
+ const full = [...prefix, ...path]
36
+ const schema = full.length === 0 ? root : schemaAt(root, full, defs)
37
+ if (!schema) return undefined
38
+ const u = unwrap(schema, defs)
39
+ // the cursor is on the outermost brace: say what the whole body is, when the spec does
40
+ if (path.length === 0) {
41
+ const text = typeof u.description === 'string' ? u.description.trim() : ''
42
+ if (!text) return undefined
43
+ return {
44
+ path: typeof u.title === 'string' && u.title.trim() ? u.title.trim() : 'body',
45
+ type: kindOf(u),
46
+ required: false,
47
+ description: text,
48
+ deprecated: u.deprecated === true || undefined,
49
+ }
50
+ }
51
+ const key = path[path.length - 1]
52
+ const parent = schemaAt(root, full.slice(0, -1), defs)
53
+ const kind = kindOf(u)
54
+ const item = kind === 'array' ? kindOf(unwrap(u.items, defs)) : undefined
55
+ return {
56
+ path: pathString(path),
57
+ type: item ? `${item}[]` : u.format ? `${kind} · ${u.format}` : kind,
58
+ required:
59
+ typeof key === 'string' &&
60
+ !!parent &&
61
+ propsOf(parent, defs).some((p) => p.name === key && p.required),
62
+ enum: Array.isArray(u.enum) ? u.enum.map(String) : undefined,
63
+ description: describe(u),
64
+ parent: parent ? describe(unwrap(parent, defs)) : undefined,
65
+ deprecated: u.deprecated === true || undefined,
66
+ }
67
+ }
68
+
69
+ /** `metadata[order]` / `items[0][price]` -> ['metadata', 'order'] / ['items', 0, 'price'] */
70
+ export function keyPath(key: string): KeyPath {
71
+ return (key.match(/[^[\]]+/g) ?? [key]).map((s) => (/^\d+$/.test(s) ? Number(s) : s))
72
+ }
73
+
74
+ /** Adds the project's note for `path` in `section` (see `README.md` field sections), when there is one. */
75
+ export function withNote(
76
+ doc: FieldDoc | undefined,
77
+ notes: Map<string, string> | undefined,
78
+ section: FieldSection,
79
+ path: string,
80
+ ): FieldDoc | undefined {
81
+ const note = doc && notes?.get(noteKey(section, path))
82
+ return doc && note ? { ...doc, note } : doc
83
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,16 @@
1
+ import { render } from 'preact'
2
+ import { App } from './app'
3
+ import { applyTheme } from './theme'
4
+ import 'prism-code-editor/layout.css'
5
+ import 'prism-code-editor/autocomplete.css'
6
+ import 'prism-code-editor/autocomplete-icons.css'
7
+ import './styles/tokens.css'
8
+ import './styles/app.css'
9
+ import './styles/panel.css'
10
+
11
+ applyTheme()
12
+ render(<App />, document.getElementById('app') as HTMLElement)
13
+
14
+ if (import.meta.hot) {
15
+ import.meta.hot.on('specbook:registry-changed', () => location.reload())
16
+ }
@@ -0,0 +1,133 @@
1
+ const esc = (s: string) =>
2
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
3
+
4
+ /** Inline markup on already-escaped text: `code`, **bold**, *italic*, and http(s)/mailto links. */
5
+ function inline(text: string): string {
6
+ const codes: string[] = []
7
+ let s = text.replace(/`([^`]+)`/g, (_m, c: string) => {
8
+ codes.push(`<code>${c}</code>`)
9
+ return `\uE000${codes.length - 1}\uE000`
10
+ })
11
+ s = s
12
+ .replace(
13
+ /\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)\s]+)\)/g,
14
+ (_m, label: string, href: string) => {
15
+ return `<a href="${href}" target="_blank" rel="noreferrer">${label}</a>`
16
+ },
17
+ )
18
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
19
+ .replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
20
+ .replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
21
+ return s.replace(/\uE000(\d+)\uE000/g, (_m, i: string) => codes[Number(i)] ?? '')
22
+ }
23
+
24
+ /**
25
+ * A small, safe markdown subset for descriptions: paragraphs, headings, bullet and numbered lists, fenced
26
+ * code, and inline code, bold, italics and links. Everything is HTML-escaped first, and only http(s) and
27
+ * mailto links are linked, so descriptions from a spec or a sequence can be rendered without trusting them.
28
+ */
29
+ export function markdownToHtml(src: string | undefined): string {
30
+ if (!src) return ''
31
+ const lines = src.replace(/\r\n?/g, '\n').split('\n')
32
+ const out: string[] = []
33
+ let para: string[] = []
34
+ let list: { tag: 'ul' | 'ol'; items: string[] } | undefined
35
+
36
+ const flushPara = () => {
37
+ if (para.length) out.push(`<p>${inline(esc(para.join(' ')))}</p>`)
38
+ para = []
39
+ }
40
+ const flushList = () => {
41
+ if (list)
42
+ out.push(
43
+ `<${list.tag}>${list.items.map((i) => `<li>${inline(esc(i))}</li>`).join('')}</${list.tag}>`,
44
+ )
45
+ list = undefined
46
+ }
47
+
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = lines[i] as string
50
+ if (/^```/.test(line)) {
51
+ flushPara()
52
+ flushList()
53
+ const code: string[] = []
54
+ i++
55
+ while (i < lines.length && !/^```/.test(lines[i] as string)) code.push(lines[i++] as string)
56
+ out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`)
57
+ continue
58
+ }
59
+ const heading = /^(#{1,4})\s+(.*)$/.exec(line)
60
+ if (heading) {
61
+ flushPara()
62
+ flushList()
63
+ const level = Math.min(4, (heading[1] as string).length + 1)
64
+ out.push(`<h${level}>${inline(esc(heading[2] as string))}</h${level}>`)
65
+ continue
66
+ }
67
+ const quote = /^>\s?(.*)$/.exec(line)
68
+ if (quote) {
69
+ flushPara()
70
+ flushList()
71
+ const text: string[] = [quote[1] as string]
72
+ while (i + 1 < lines.length && /^>\s?/.test(lines[i + 1] as string))
73
+ text.push((lines[++i] as string).replace(/^>\s?/, ''))
74
+ out.push(`<blockquote>${inline(esc(text.join(' ')))}</blockquote>`)
75
+ continue
76
+ }
77
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line)
78
+ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line)
79
+ if (bullet || numbered) {
80
+ flushPara()
81
+ const tag = bullet ? 'ul' : 'ol'
82
+ if (list && list.tag !== tag) flushList()
83
+ list ??= { tag, items: [] }
84
+ list.items.push((bullet ?? numbered)?.[1] as string)
85
+ continue
86
+ }
87
+ if (line.trim() === '') {
88
+ flushPara()
89
+ flushList()
90
+ continue
91
+ }
92
+ flushList()
93
+ para.push(line.trim())
94
+ }
95
+ flushPara()
96
+ flushList()
97
+ return out.join('')
98
+ }
99
+
100
+ /** Markdown reduced to readable plain text: links keep their label, markers and fences disappear. */
101
+ export function plainText(src: string | undefined): string {
102
+ if (!src) return ''
103
+ return src
104
+ .replace(/```[\s\S]*?```/g, ' ')
105
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
106
+ .replace(/^#{1,6}\s+/gm, '')
107
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '')
108
+ .replace(/^>\s?/gm, '')
109
+ .replace(/[`*]|(^|\s)_|_(?=\s|$)/g, '$1')
110
+ .replace(/\s+/g, ' ')
111
+ .trim()
112
+ }
113
+
114
+ export function firstParagraph(src: string | undefined): string {
115
+ if (!src) return ''
116
+ return (src.trim().split(/\n\s*\n/)[0] ?? '').replace(/\s*\n\s*/g, ' ').trim()
117
+ }
118
+
119
+ /** Inline markup only (code, bold, italics, links) in already-safe HTML, for a single line or cell. */
120
+ export function inlineMarkdown(src: string | undefined): string {
121
+ return src ? inline(esc(src)) : ''
122
+ }
123
+
124
+ /**
125
+ * A one-paragraph summary for table cells: inline markdown when it is short, plain text with an ellipsis
126
+ * when it is long (cutting markdown mid-link would break it).
127
+ */
128
+ export function inlineSummary(src: string | undefined, max = 300): string {
129
+ const para = firstParagraph(src)
130
+ if (!para) return ''
131
+ const text = plainText(para)
132
+ return text.length > max ? esc(`${text.slice(0, max - 1).trimEnd()}…`) : inlineMarkdown(para)
133
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { signal } from '@preact/signals'
2
+
3
+ export type PanelTab = 'docs' | 'try'
4
+
5
+ /**
6
+ * The page is always the catalog; an operation or sequence route opens the side panel on top of it.
7
+ * `play` is the example to open ('' = the first), `tab` picks Docs or Try (default Try).
8
+ */
9
+ export type Route =
10
+ | { kind: 'home' }
11
+ /** the catalog itself, without the overview: what a phone shows after its splash screen */
12
+ | { kind: 'browse' }
13
+ | { kind: 'op'; key: string; play?: string; tab?: PanelTab }
14
+ | { kind: 'seq'; id: string; play?: string; tab?: PanelTab }
15
+
16
+ /**
17
+ * A project with one API needs no service in its links: `#/op/FindPetsByStatus`, not
18
+ * `#/op/swagger-petstore-openapi-3-0/FindPetsByStatus`. The app names that service once it knows the
19
+ * registry; a longer link (an older one, or one from a project with several APIs) still opens.
20
+ */
21
+ let defaultService: string | undefined
22
+
23
+ export function setDefaultService(name: string | undefined) {
24
+ defaultService = name
25
+ if (typeof location !== 'undefined') route.value = parseHash(location.hash)
26
+ }
27
+
28
+ const keyOut = (key: string) =>
29
+ defaultService && key.startsWith(`${defaultService}/`)
30
+ ? key.slice(defaultService.length + 1)
31
+ : key
32
+ const keyIn = (key: string) =>
33
+ defaultService && !key.includes('/') ? `${defaultService}/${key}` : key
34
+ /** Slashes read fine in a fragment, so only what would be ambiguous is escaped. */
35
+ const soft = (s: string) => encodeURIComponent(s).replace(/%2F/gi, '/')
36
+
37
+ export function parseHash(hash: string): Route {
38
+ const [path = '', query = ''] = hash.split('?')
39
+ const q = new URLSearchParams(query)
40
+ const play = q.get('play') ?? undefined
41
+ const t = q.get('tab')
42
+ const tab = t === 'docs' || t === 'try' ? t : undefined
43
+ const op = /^#\/op\/(.+)$/.exec(path)
44
+ if (op) return { kind: 'op', key: keyIn(decodeURIComponent(op[1] as string)), play, tab }
45
+ const seq = /^#\/seq\/(.+)$/.exec(path)
46
+ if (seq) return { kind: 'seq', id: decodeURIComponent(seq[1] as string), play, tab }
47
+ if (path === '#/browse') return { kind: 'browse' }
48
+ return { kind: 'home' }
49
+ }
50
+
51
+ export function hashFor(r: Route): string {
52
+ if (r.kind === 'home') return '#/'
53
+ if (r.kind === 'browse') return '#/browse'
54
+ const base = r.kind === 'op' ? `#/op/${soft(keyOut(r.key))}` : `#/seq/${encodeURIComponent(r.id)}`
55
+ const q = new URLSearchParams()
56
+ if (r.play !== undefined) q.set('play', r.play)
57
+ if (r.tab) q.set('tab', r.tab)
58
+ const s = q.toString()
59
+ return s ? `${base}?${s}` : base
60
+ }
61
+
62
+ /** A phone's home is its splash screen, so closing a panel lands on the list rather than back on it. */
63
+ export const isPhone = () =>
64
+ typeof matchMedia === 'function' && matchMedia('(max-width: 820px)').matches
65
+ export const pageOf = (_r: Route): Route => (isPhone() ? { kind: 'browse' } : { kind: 'home' })
66
+
67
+ export const route = signal<Route>(parseHash(typeof location === 'undefined' ? '' : location.hash))
68
+ export const paletteOpen = signal(false)
69
+ /** The catalog's service pill ('' = overview) and filter text. */
70
+ export const catalogService = signal('')
71
+ export const catalogFilter = signal('')
72
+
73
+ export function go(r: Route) {
74
+ location.hash = hashFor(r)
75
+ route.value = r
76
+ }
77
+
78
+ export interface Entry {
79
+ route: Route
80
+ title: string
81
+ /** group label shown muted, e.g. "pet" */
82
+ group: string
83
+ service?: string
84
+ tag?: string
85
+ /** operations */
86
+ method?: string
87
+ path?: string
88
+ /** the spec requires credentials */
89
+ secured?: boolean
90
+ /** sequences */
91
+ tags?: string[]
92
+ /** the flow group heading */
93
+ flowGroup?: string
94
+ description?: string
95
+ /** extra text matched by search, never shown */
96
+ hay: string
97
+ kind: 'seq' | 'op'
98
+ }
99
+
100
+ export function search(entries: Entry[], query: string, limit = 40): Entry[] {
101
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
102
+ if (terms.length === 0) return entries.slice(0, limit)
103
+ const scored: [number, Entry][] = []
104
+ for (const e of entries) {
105
+ const title = e.title.toLowerCase()
106
+ const hay = `${title} ${e.group.toLowerCase()} ${e.hay.toLowerCase()}`
107
+ if (!terms.every((t) => hay.includes(t))) continue
108
+ // prefer title matches and sequences
109
+ let score = 0
110
+ for (const t of terms) score += title.startsWith(t) ? 3 : title.includes(t) ? 2 : 1
111
+ if (e.kind === 'seq') score += 0.5
112
+ scored.push([score, e])
113
+ }
114
+ return scored
115
+ .sort((a, b) => b[0] - a[0])
116
+ .slice(0, limit)
117
+ .map(([, e]) => e)
118
+ }
@@ -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
+ }