@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,100 @@
1
+ import type { SequenceInfo, Variant } from '../engine'
2
+ import type { PanelTab } from '../nav'
3
+ import { OperationDocs } from './OperationDocs'
4
+ import { Panel } from './Panel'
5
+ import { type OpInfo, RequestPanel } from './RequestPanel'
6
+ import { SequenceDocs } from './SequenceDocs'
7
+ import { SequencePanel } from './SequencePanel'
8
+
9
+ type Props = {
10
+ onClose: () => void
11
+ /** dims the page and traps focus; the standalone site docks the panel beside its list instead */
12
+ modal?: boolean
13
+ tab?: PanelTab
14
+ /** switch tab, optionally opening a named example */
15
+ onNavigate: (to: { tab: PanelTab; example?: string }) => void
16
+ onPrev?: () => void
17
+ onNext?: () => void
18
+ } & (
19
+ | { kind: 'op'; service: string; op: OpInfo; variants: Variant[]; example: string }
20
+ | { kind: 'seq'; info: SequenceInfo }
21
+ )
22
+
23
+ /** Lazy entry for everything the side panel needs, so the catalog carries no editor code. */
24
+ export function Host(p: Props) {
25
+ const tab = p.tab ?? 'try'
26
+ const tabs = (
27
+ <div class="rq-tabs panel-tabs" role="tablist">
28
+ {(
29
+ [
30
+ ['try', p.kind === 'seq' ? 'Run' : 'Try'],
31
+ ['docs', 'Docs'],
32
+ ] as const
33
+ ).map(([id, label]) => (
34
+ <button
35
+ type="button"
36
+ role="tab"
37
+ aria-selected={tab === id}
38
+ class={`rq-tab ${tab === id ? 'on' : ''}`}
39
+ onClick={() => p.onNavigate({ tab: id })}
40
+ >
41
+ {label}
42
+ </button>
43
+ ))}
44
+ </div>
45
+ )
46
+
47
+ if (p.kind === 'seq')
48
+ return (
49
+ <Panel
50
+ title={p.info.seq.meta.title}
51
+ subtitle="Sequence"
52
+ flush
53
+ modal={p.modal}
54
+ onPrev={p.onPrev}
55
+ onNext={p.onNext}
56
+ onClose={p.onClose}
57
+ >
58
+ <div class="panel-split">
59
+ {tabs}
60
+ <div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
61
+ {tab === 'docs' ? <SequenceDocs info={p.info} /> : <SequencePanel info={p.info} />}
62
+ </div>
63
+ </div>
64
+ </Panel>
65
+ )
66
+ return (
67
+ <Panel
68
+ title={p.op.summary?.replace(/\.$/, '') || p.op.path}
69
+ subtitle={`${p.op.method} ${p.op.path}`}
70
+ flush
71
+ modal={p.modal}
72
+ onPrev={p.onPrev}
73
+ onNext={p.onNext}
74
+ onClose={p.onClose}
75
+ >
76
+ <div class="panel-split">
77
+ {tabs}
78
+ <div class={`panel-main ${tab === 'docs' ? 'scroll' : ''}`}>
79
+ {tab === 'docs' ? (
80
+ <OperationDocs
81
+ key={`${p.service}/${p.op.id}`}
82
+ service={p.service}
83
+ op={p.op}
84
+ variants={p.variants}
85
+ onTry={(example) => p.onNavigate({ tab: 'try', example })}
86
+ />
87
+ ) : (
88
+ <RequestPanel
89
+ key={`${p.service}/${p.op.id}`}
90
+ service={p.service}
91
+ op={p.op}
92
+ variants={p.variants}
93
+ initial={p.example}
94
+ />
95
+ )}
96
+ </div>
97
+ </div>
98
+ </Panel>
99
+ )
100
+ }
@@ -0,0 +1,181 @@
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import type { Variant } from '../engine'
3
+ import { markdownToHtml } from '../markdown'
4
+ import { registry } from '../runtime'
5
+ import type { Defs, JsonSchema } from '../schema'
6
+ import { unwrap } from '../schema'
7
+ import { statusClass, statusName } from '../status'
8
+ import { FieldTable } from './FieldTable'
9
+ import { useOperationNotes } from './fieldnotes'
10
+
11
+ export interface DocOp {
12
+ id: string
13
+ method: string
14
+ path: string
15
+ summary?: string
16
+ tags: string[]
17
+ statuses: number[]
18
+ }
19
+
20
+ interface LongDocs {
21
+ description?: string
22
+ examples: { name: string; title: string; source: string }[]
23
+ }
24
+
25
+ const SECTIONS = [
26
+ ['path', 'Path'],
27
+ ['query', 'Query'],
28
+ ['headers', 'Headers'],
29
+ ] as const
30
+
31
+ /** Everything the page shows below its header, fetched together so the page is laid out once. */
32
+ interface Loaded {
33
+ long?: LongDocs
34
+ req?: JsonSchema
35
+ defs?: Defs
36
+ /** a saved request's own markdown (`<name>.req.md` beside its `.req.ts`), by file */
37
+ saved: Record<string, string>
38
+ }
39
+
40
+ const soft = <T,>(p: Promise<T> | undefined): Promise<T | undefined> =>
41
+ (p ?? Promise.resolve(undefined)).catch(() => undefined)
42
+
43
+ function loadAll(key: string, service: string, files: string[]): Promise<Loaded> {
44
+ return Promise.all([
45
+ soft(registry.operationDocs[key]?.().then((m: { default: LongDocs }) => m.default)),
46
+ soft(registry.schemas[key]?.().then((m: { default: JsonSchema }) => m.default)),
47
+ soft(registry.definitions[service]?.().then((m: { default: Defs }) => m.default)),
48
+ Promise.all(files.map((f) => soft(registry.docs.find((d) => d.requestFile === f)?.load()))),
49
+ ]).then(([long, req, defs, notes]) => ({
50
+ long,
51
+ req,
52
+ defs,
53
+ saved: Object.fromEntries(files.flatMap((f, i) => (notes[i] ? [[f, notes[i] as string]] : []))),
54
+ }))
55
+ }
56
+
57
+ /** A saved request's own markdown (`<name>.req.md` beside its `.req.ts`), when it has one. */
58
+ function SavedRequestNote({ variant, html }: { variant: Variant; html?: string }) {
59
+ if (!html) return null
60
+ return (
61
+ <section class="saved-note-doc">
62
+ <h2>
63
+ Saved request <code>{variant.name}</code>
64
+ </h2>
65
+ <div class="doc-custom" dangerouslySetInnerHTML={{ __html: html }} />
66
+ </section>
67
+ )
68
+ }
69
+
70
+ export function OperationDocs({
71
+ service,
72
+ op,
73
+ variants = [],
74
+ onTry,
75
+ }: {
76
+ service: string
77
+ op: DocOp
78
+ variants?: Variant[]
79
+ /** open the Try tab, optionally on a named example */
80
+ onTry: (example?: string) => void
81
+ }) {
82
+ const key = `${service}/${op.id}`
83
+ const [loaded, setLoaded] = useState<Loaded>()
84
+ const files = variants.map((v) => v.file)
85
+ const fileKey = files.join('\n')
86
+
87
+ useEffect(() => {
88
+ let live = true
89
+ setLoaded(undefined)
90
+ loadAll(key, service, fileKey ? fileKey.split('\n') : []).then((l) => live && setLoaded(l))
91
+ return () => {
92
+ live = false
93
+ }
94
+ }, [key, service, fileKey])
95
+
96
+ const page = useOperationNotes(key)
97
+ // the header is there at once; everything below it appears together once it has all arrived
98
+ const ready = !!loaded && page.loaded
99
+ const { long, req, defs } = loaded ?? { saved: {} }
100
+ const root = req ? unwrap(req, defs) : undefined
101
+ const section = (name: string) => root?.properties?.[name] as JsonSchema | undefined
102
+ const body = section('data')
103
+
104
+ return (
105
+ <article class="doc" data-op={key}>
106
+ <div class="doc-intro">
107
+ <span class={`method ${op.method}`}>{op.method}</span> <code>{op.path}</code>
108
+ <span class="spacer" />
109
+ <button type="button" class="btn primary" onClick={() => onTry()}>
110
+ Try it
111
+ </button>
112
+ </div>
113
+
114
+ {ready && (
115
+ <>
116
+ {/* the spec's own text first; the project's page follows it, or stands in for it with `replace: true` */}
117
+ {!(page.found && page.replace) && long?.description && (
118
+ <div
119
+ class="doc-custom"
120
+ dangerouslySetInnerHTML={{ __html: markdownToHtml(long.description) }}
121
+ />
122
+ )}
123
+ {page.intro && (
124
+ <div
125
+ class={`doc-custom ${page.replace ? '' : 'doc-project'}`}
126
+ dangerouslySetInnerHTML={{ __html: page.intro }}
127
+ />
128
+ )}
129
+
130
+ {variants.map((v) => (
131
+ <SavedRequestNote key={v.file} variant={v} html={loaded?.saved[v.file]} />
132
+ ))}
133
+
134
+ {SECTIONS.map(([k, title]) =>
135
+ section(k) ? (
136
+ <section>
137
+ <h2>{title} parameters</h2>
138
+ <FieldTable schema={section(k)} defs={defs} section={k} notes={page.notes} />
139
+ </section>
140
+ ) : null,
141
+ )}
142
+
143
+ {body && (
144
+ <section>
145
+ <h2>Request body</h2>
146
+ <FieldTable schema={body} defs={defs} section="data" notes={page.notes} />
147
+ </section>
148
+ )}
149
+
150
+ {op.statuses.length > 0 && (
151
+ <section>
152
+ <h2>Responses</h2>
153
+ <ul class="statuses">
154
+ {op.statuses.map((s) => (
155
+ <li>
156
+ <span class={`state-badge ${statusClass(s)}`}>{s}</span> {statusName(s)}
157
+ </li>
158
+ ))}
159
+ </ul>
160
+ </section>
161
+ )}
162
+
163
+ {(long?.examples.length ?? 0) > 1 && (
164
+ <section>
165
+ <h2>Examples</h2>
166
+ <ul class="examples">
167
+ {long?.examples.map((e) => (
168
+ <li>
169
+ <button type="button" class="link" onClick={() => onTry(e.name)}>
170
+ ▶ {e.title}
171
+ </button>
172
+ </li>
173
+ ))}
174
+ </ul>
175
+ </section>
176
+ )}
177
+ </>
178
+ )}
179
+ </article>
180
+ )
181
+ }
@@ -0,0 +1,193 @@
1
+ import type { ComponentChildren } from 'preact'
2
+ import { useEffect, useRef } from 'preact/hooks'
3
+ import { env, environments } from '../store'
4
+
5
+ interface Props {
6
+ title: string
7
+ subtitle?: string
8
+ /** the body fills the panel and manages its own scrolling */
9
+ flush?: boolean
10
+ /** dims the page and traps focus (embeds); the standalone site leaves its list usable beside the panel */
11
+ modal?: boolean
12
+ onPrev?: () => void
13
+ onNext?: () => void
14
+ onClose: () => void
15
+ children: ComponentChildren
16
+ }
17
+
18
+ const FOCUSABLE =
19
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
20
+
21
+ /** The three-quarter sheet from the right where requests and sequences run. */
22
+ export function Panel({
23
+ title,
24
+ subtitle,
25
+ flush,
26
+ modal = true,
27
+ onPrev,
28
+ onNext,
29
+ onClose,
30
+ children,
31
+ }: Props) {
32
+ const aside = useRef<HTMLElement>(null)
33
+
34
+ useEffect(() => {
35
+ const root = aside.current?.getRootNode() as Document | ShadowRoot
36
+ const before = root?.activeElement as HTMLElement | null
37
+ if (modal) aside.current?.focus()
38
+ return () => before?.focus?.()
39
+ }, [modal])
40
+
41
+ // The keyboard hides the lower half of a phone's screen and iOS does not reliably scroll a field in
42
+ // a fixed panel above it. While a text field is focused its scroller gets room under the content,
43
+ // and the field is placed near the top of the panel, which the keyboard never covers.
44
+ useEffect(() => {
45
+ const el = aside.current
46
+ if (!el || typeof matchMedia === 'undefined') return
47
+ const phone = matchMedia('(max-width: 820px)')
48
+ const FIELD = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), textarea'
49
+ let padded: { box: HTMLElement; was: string } | undefined
50
+ let timers: number[] = []
51
+ const scroller = (from: HTMLElement) => {
52
+ for (let n = from.parentElement; n && n !== el; n = n.parentElement) {
53
+ const y = getComputedStyle(n).overflowY
54
+ if (y === 'auto' || y === 'scroll') return n
55
+ }
56
+ return null
57
+ }
58
+ const place = (field: HTMLElement) => {
59
+ const box = scroller(field)
60
+ if (!box) return
61
+ // measured against what is visible: iOS pans its visual viewport under the keyboard
62
+ const vv = typeof visualViewport === 'undefined' ? undefined : visualViewport
63
+ const top = vv?.offsetTop ?? 0
64
+ const height = vv?.height ?? innerHeight
65
+ const at = field.getBoundingClientRect().top
66
+ const want = top + height * 0.2
67
+ if (at > top + height * 0.4 || at < top) box.scrollTop += at - want
68
+ }
69
+ const focusin = (e: Event) => {
70
+ const field = e.target as HTMLElement
71
+ if (!phone.matches || !field.matches?.(FIELD)) return
72
+ const box = scroller(field)
73
+ if (box && padded?.box !== box) {
74
+ if (padded) padded.box.style.paddingBottom = padded.was
75
+ padded = { box, was: box.style.paddingBottom }
76
+ box.style.paddingBottom = '70vh'
77
+ }
78
+ // the keyboard animates in; place the field as it opens and again once it has settled
79
+ for (const t of timers) clearTimeout(t)
80
+ timers = [0, 150, 400, 800].map((ms) => window.setTimeout(() => place(field), ms))
81
+ }
82
+ const focusout = () => {
83
+ timers.push(
84
+ window.setTimeout(() => {
85
+ const at = (el.getRootNode() as Document | ShadowRoot).activeElement
86
+ if (at?.matches?.(FIELD) || !padded) return
87
+ padded.box.style.paddingBottom = padded.was
88
+ padded = undefined
89
+ }, 200),
90
+ )
91
+ }
92
+ el.addEventListener('focusin', focusin)
93
+ el.addEventListener('focusout', focusout)
94
+ return () => {
95
+ el.removeEventListener('focusin', focusin)
96
+ el.removeEventListener('focusout', focusout)
97
+ for (const t of timers) clearTimeout(t)
98
+ if (padded) padded.box.style.paddingBottom = padded.was
99
+ }
100
+ }, [])
101
+
102
+ useEffect(() => {
103
+ const key = (e: KeyboardEvent) => {
104
+ if (e.key === 'Escape') onClose()
105
+ if (e.key !== 'Tab' || !modal || !aside.current) return
106
+ const items = [...aside.current.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
107
+ (el) => el.offsetParent !== null,
108
+ )
109
+ const first = items[0]
110
+ const last = items[items.length - 1]
111
+ const root = aside.current.getRootNode() as Document | ShadowRoot
112
+ const at = root.activeElement
113
+ if (!first || !last) return
114
+ if (e.shiftKey && (at === first || at === aside.current)) {
115
+ e.preventDefault()
116
+ last.focus()
117
+ } else if (!e.shiftKey && at === last) {
118
+ e.preventDefault()
119
+ first.focus()
120
+ }
121
+ }
122
+ addEventListener('keydown', key)
123
+ return () => removeEventListener('keydown', key)
124
+ }, [onClose, modal])
125
+
126
+ // a dialog when it dims the page (embeds), a complementary region when docked beside the list
127
+ const attrs = {
128
+ class: `panel ${modal ? '' : 'docked'}`,
129
+ part: 'panel',
130
+ 'aria-label': title,
131
+ 'aria-modal': modal ? true : undefined,
132
+ role: (modal ? 'dialog' : 'complementary') as 'dialog',
133
+ tabIndex: -1,
134
+ }
135
+
136
+ return (
137
+ <>
138
+ {modal && (
139
+ <button type="button" class="panel-backdrop" aria-label="Close" onClick={onClose} />
140
+ )}
141
+ <section {...attrs} ref={aside}>
142
+ <header class="panel-head" part="header">
143
+ <div class="panel-title">
144
+ <h2>{title}</h2>
145
+ {subtitle && <div class="muted small">{subtitle}</div>}
146
+ </div>
147
+ <span class="spacer" />
148
+ {modal && environments.value.length > 1 && (
149
+ <label class="row">
150
+ <span class="muted">Environment</span>
151
+ <select
152
+ value={env.value}
153
+ onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
154
+ >
155
+ {environments.value.map((e) => (
156
+ <option value={e}>{e}</option>
157
+ ))}
158
+ </select>
159
+ </label>
160
+ )}
161
+ {(onPrev || onNext) && (
162
+ <span class="stepper">
163
+ <button
164
+ type="button"
165
+ class="icon"
166
+ title="Previous"
167
+ aria-label="Previous"
168
+ disabled={!onPrev}
169
+ onClick={onPrev}
170
+ >
171
+ ↑
172
+ </button>
173
+ <button
174
+ type="button"
175
+ class="icon"
176
+ title="Next"
177
+ aria-label="Next"
178
+ disabled={!onNext}
179
+ onClick={onNext}
180
+ >
181
+ ↓
182
+ </button>
183
+ </span>
184
+ )}
185
+ <button type="button" class="icon" aria-label="Close panel" onClick={onClose}>
186
+ ×
187
+ </button>
188
+ </header>
189
+ <div class={`panel-body ${flush ? 'flush' : ''}`}>{children}</div>
190
+ </section>
191
+ </>
192
+ )
193
+ }