@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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +60 -3
- package/src/app.tsx +363 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +46 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +171 -0
- package/src/virtual.d.ts +104 -0
package/src/fielddoc.ts
ADDED
|
@@ -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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const esc = (s: string) =>
|
|
2
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|