@clidey/dory 1.0.3 → 1.0.6

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 (66) hide show
  1. package/bin/dist/dory.js +36 -2
  2. package/bin/dist/dory.js.map +1 -1
  3. package/index.html +0 -1
  4. package/package.json +27 -31
  5. package/scripts/check-trigger-children.mjs +60 -0
  6. package/scripts/trigger-children-lib.mjs +149 -0
  7. package/scripts/trigger-children.json +47 -0
  8. package/src/components/hooks.tsx +2 -1
  9. package/src/components/store.ts +5 -4
  10. package/src/config/mdx.js +8 -3
  11. package/src/config/mdx.ts +8 -3
  12. package/src/embed/widget-iframe.ts +10 -3
  13. package/src/entry-server.tsx +11 -7
  14. package/src/main.tsx +5 -1
  15. package/src/mdx/accordion.tsx +11 -8
  16. package/src/mdx/api.tsx +2 -352
  17. package/src/mdx/async-api.tsx +124 -295
  18. package/src/mdx/callout.tsx +22 -24
  19. package/src/mdx/card.tsx +20 -22
  20. package/src/mdx/chip.tsx +14 -0
  21. package/src/mdx/code.tsx +133 -110
  22. package/src/mdx/expandable.tsx +8 -8
  23. package/src/mdx/fence-meta.test.ts +39 -0
  24. package/src/mdx/fence-meta.ts +61 -0
  25. package/src/mdx/fence.tsx +155 -44
  26. package/src/mdx/icon-grid.tsx +29 -0
  27. package/src/mdx/icon.tsx +2 -2
  28. package/src/mdx/latex.tsx +14 -7
  29. package/src/mdx/list.tsx +3 -3
  30. package/src/mdx/mdx.tsx +7 -1
  31. package/src/mdx/mermaid.tsx +81 -11
  32. package/src/mdx/open-api.tsx +396 -239
  33. package/src/mdx/source.tsx +148 -40
  34. package/src/mdx/specimen.tsx +69 -0
  35. package/src/mdx/step.tsx +22 -30
  36. package/src/mdx/table.tsx +4 -9
  37. package/src/mdx/tabs.tsx +39 -0
  38. package/src/mdx/tag.tsx +13 -21
  39. package/src/mdx/text.tsx +17 -9
  40. package/src/mdx/type-scale.tsx +28 -0
  41. package/src/mdx/websocket-playground.tsx +221 -704
  42. package/src/plugins/code-meta.ts +23 -0
  43. package/src/plugins/frontmatter-dev-server.ts +6 -2
  44. package/src/plugins/html-files-middleware.ts +9 -2
  45. package/src/plugins/html-metadata-injector.ts +4 -3
  46. package/src/plugins/llm-txt-dev-server.ts +5 -2
  47. package/src/plugins/llm-txt-generator.ts +2 -1
  48. package/src/plugins/prerender.ts +7 -4
  49. package/src/plugins/relative-assets.test.ts +26 -0
  50. package/src/plugins/relative-assets.ts +80 -0
  51. package/src/plugins/robots-generator.ts +6 -4
  52. package/src/plugins/sanitize.test.ts +5 -0
  53. package/src/plugins/sanitize.ts +2 -0
  54. package/src/plugins/shared.ts +17 -0
  55. package/src/plugins/sitemap-generator.ts +2 -1
  56. package/src/theme.scss +282 -3
  57. package/src/types/config.ts +3 -0
  58. package/src/ui/header.tsx +4 -3
  59. package/src/ui/layout.tsx +22 -4
  60. package/src/ui/page-meta.tsx +37 -0
  61. package/src/ui/prev-next-link.tsx +4 -3
  62. package/src/ui/table-of-content.tsx +11 -3
  63. package/src/utils/base-path.test.ts +46 -0
  64. package/src/utils/base-path.ts +41 -0
  65. package/vite.config.ssr.ts +3 -0
  66. package/vite.config.ts +3 -0
@@ -1,12 +1,7 @@
1
- import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
2
- import { CheckIcon } from '@heroicons/react/24/outline'
3
1
  import classNames from 'classnames'
4
- import { PencilIcon, TrashIcon, XIcon, PlusIcon, MessageCircleIcon } from 'lucide-react'
2
+ import { ChevronsUpDown, X } from 'lucide-react'
5
3
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
6
- import Dropdown from '../components/dropdown'
7
4
  import { Tag } from './tag'
8
- import { PlayButton } from './api-playground'
9
- import { Badge, Button, Card, EmptyState, Sheet, SheetContent, SheetHeader, SheetTitle, Input } from '@clidey/ux'
10
5
 
11
6
  export interface WebSocketServer {
12
7
  url: string;
@@ -38,789 +33,311 @@ interface WebSocketMessage {
38
33
  binary?: boolean
39
34
  }
40
35
 
41
- export function DisconnectButton(props: React.ComponentPropsWithoutRef<'svg'>) {
42
- return (
43
- <svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
44
- <path
45
- fill="currentColor"
46
- d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
47
- />
48
- </svg>
49
- )
50
- }
36
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
51
37
 
52
- function LoadingSpinner(props: React.ComponentPropsWithoutRef<'svg'>) {
53
- return (
54
- <svg viewBox="0 0 20 20" aria-hidden="true" className="animate-spin" {...props}>
55
- <path
56
- fill="currentColor"
57
- d="M10 2a8 8 0 0 1 8 8c0 .3-.02.59-.06.88l-1.99-.22A6 6 0 1 0 4 10c0-.34.03-.67.09-1L2.1 8.78A8 8 0 0 1 10 2Z"
58
- />
59
- </svg>
60
- )
61
- }
38
+ const inputClass = 'min-w-0 flex-1 rounded-md border border-[var(--border)] bg-muted px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[var(--primary)] focus:outline-none'
39
+ const labelClass = 'font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground'
62
40
 
63
- interface WebSocketConfig {
64
- headers: Record<string, string>
65
- subprotocols: string[]
41
+ function MonoLabel({ children }: { children: string }) {
42
+ return <p className={labelClass}>{children}</p>
66
43
  }
67
44
 
68
- interface HeadersTabProps {
69
- wsConfig: WebSocketConfig
70
- setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
45
+ function RemoveButton({ onClick, label }: { onClick: () => void; label: string }) {
46
+ return (
47
+ <button type="button" onClick={onClick} aria-label={label} className="rounded-md p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground">
48
+ <X className="h-3.5 w-3.5" />
49
+ </button>
50
+ )
71
51
  }
72
52
 
73
- interface HeaderItem {
74
- key: string
75
- value: string
76
- isEditing: boolean
77
- isNew?: boolean
53
+ function AddLink({ onClick, children }: { onClick: () => void; children: string }) {
54
+ return (
55
+ <button type="button" onClick={onClick} className="mt-1 w-fit text-sm font-medium text-[var(--primary)] hover:underline">
56
+ + {children}
57
+ </button>
58
+ )
78
59
  }
79
60
 
80
- export const HeadersTab = ({ wsConfig, setWsConfig }: HeadersTabProps) => {
81
- const [headersList, setHeadersList] = useState<HeaderItem[]>([])
82
- const hasInitialized = useRef(false)
61
+ interface KeyValue { key: string; value: string }
83
62
 
84
- useEffect(() => {
85
- if (!hasInitialized.current) {
86
- const initialHeaders = Object.entries(wsConfig.headers ?? {}).map(([key, value]) => ({
87
- key,
88
- value,
89
- isEditing: false
90
- }))
91
- setHeadersList(initialHeaders)
92
- hasInitialized.current = true
93
- }
94
- }, [wsConfig.headers])
95
-
96
- useEffect(() => {
97
- const newHeaders: Record<string, string> = {}
98
- headersList.forEach(h => {
99
- if (h.key.trim()) newHeaders[h.key] = h.value
100
- })
101
- setWsConfig(prev => ({ ...prev, headers: newHeaders }))
102
- }, [headersList, setWsConfig])
103
-
104
- const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
105
- setHeadersList(list =>
106
- list.map((h, i) => (i === index ? { ...h, [field]: value } : h))
107
- )
108
- }
109
-
110
- const saveHeader = (index: number) => {
111
- setHeadersList(list =>
112
- list.map((h, i) => (i === index ? { ...h, isEditing: false, isNew: false } : h))
113
- )
114
- }
115
-
116
- const cancelEdit = (index: number) => {
117
- setHeadersList(list =>
118
- list.reduce<HeaderItem[]>((acc, h, i) => {
119
- if (i === index) {
120
- if (h.isNew) return acc // remove new row if cancelled
121
- return [...acc, { ...h, isEditing: false }]
122
- }
123
- return [...acc, h]
124
- }, [])
125
- )
126
- }
127
-
128
- const editHeader = (index: number) => {
129
- setHeadersList(list =>
130
- list.map((h, i) => (i === index ? { ...h, isEditing: true } : h))
131
- )
132
- }
133
-
134
- const removeHeader = (index: number) => {
135
- setHeadersList(list => list.filter((_, i) => i !== index))
136
- }
63
+ /** Key/value rows for request headers. Changes are pushed into the connection config. */
64
+ export function HeadersTab({ headers, onChange }: { headers: Record<string, string>; onChange: (headers: Record<string, string>) => void }) {
65
+ const [rows, setRows] = useState<KeyValue[]>(() => Object.entries(headers).map(([key, value]) => ({ key, value })))
137
66
 
138
- const addEmptyHeaderRow = () => {
139
- setHeadersList(list => [
140
- ...list,
141
- { key: '', value: '', isEditing: true, isNew: true }
142
- ])
67
+ const update = (next: KeyValue[]) => {
68
+ setRows(next)
69
+ const out: Record<string, string> = {}
70
+ for (const row of next) if (row.key.trim()) out[row.key.trim()] = row.value
71
+ onChange(out)
143
72
  }
144
73
 
145
74
  return (
146
- <div className="flex flex-col gap-2 px-2 py-4">
147
- <div className="flex items-center gap-2 px-2">
148
- <span className="text-sm font-medium text-muted-foreground w-32">Key</span>
149
- <span className="text-sm font-medium text-muted-foreground flex-1">Value</span>
150
- </div>
151
- {headersList.map((hdr, idx) => (
152
- <div key={idx} className="flex items-center gap-2">
153
- {hdr.isEditing ? (
154
- <>
155
- <Input
156
- type="text"
157
- value={hdr.key}
158
- onChange={e => updateHeader(idx, 'key', (e.target as HTMLInputElement).value)}
159
- placeholder="Header name"
160
- className="w-32 text-sm"
161
- />
162
- <Input
163
- type="text"
164
- value={hdr.value}
165
- onChange={e => updateHeader(idx, 'value', (e.target as HTMLInputElement).value)}
166
- placeholder="Header value"
167
- className="flex-1 text-sm"
168
- />
169
- <Button onClick={() => saveHeader(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
170
- <CheckIcon className="w-4 h-4" />
171
- </Button>
172
- <Button onClick={() => cancelEdit(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
173
- <XIcon className="w-4 h-4" />
174
- </Button>
175
- </>
176
- ) : (
177
- <>
178
- <span className="text-sm text-white w-32 truncate">{hdr.key}</span>
179
- <span className="text-sm text-white flex-1 truncate">{hdr.value}</span>
180
- <Button onClick={() => editHeader(idx)} variant="ghost" size="icon" className="text-white h-8 w-8">
181
- <PencilIcon className="w-4 h-4" />
182
- </Button>
183
- <Button onClick={() => removeHeader(idx)} variant="ghost" size="icon" className="text-red-400 h-8 w-8">
184
- <TrashIcon className="w-4 h-4" />
185
- </Button>
186
- </>
187
- )}
75
+ <div className="flex flex-col gap-2">
76
+ {rows.map((row, index) => (
77
+ <div key={index} className="flex items-center gap-2">
78
+ <input className={inputClass} value={row.key} placeholder="Header" aria-label="Header name"
79
+ onInput={(e) => update(rows.map((r, i) => i === index ? { ...r, key: (e.target as HTMLInputElement).value } : r))} />
80
+ <input className={inputClass} value={row.value} placeholder="Value" aria-label="Header value"
81
+ onInput={(e) => update(rows.map((r, i) => i === index ? { ...r, value: (e.target as HTMLInputElement).value } : r))} />
82
+ <RemoveButton label="Remove header" onClick={() => update(rows.filter((_, i) => i !== index))} />
188
83
  </div>
189
84
  ))}
190
- <Button onClick={addEmptyHeaderRow} variant="ghost" size="sm" className="mt-2 w-fit">
191
- <PlusIcon className="w-4 h-4" /> Add Header
192
- </Button>
85
+ <AddLink onClick={() => update([...rows, { key: '', value: '' }])}>Add header</AddLink>
193
86
  </div>
194
87
  )
195
88
  }
196
89
 
197
- interface SubprotocolItem {
198
- value: string
199
- isEditing: boolean
200
- isNew?: boolean
201
- }
90
+ /** Editable list of subprotocols offered during the handshake. */
91
+ export function SubprotocolsTab({ subprotocols, onChange }: { subprotocols: string[]; onChange: (subprotocols: string[]) => void }) {
92
+ const [rows, setRows] = useState<string[]>(subprotocols)
93
+ const update = (next: string[]) => { setRows(next); onChange(next.map((s) => s.trim()).filter(Boolean)) }
202
94
 
203
- interface SubprotocolsTabProps {
204
- subprotocols: string[]
205
- setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
95
+ return (
96
+ <div className="flex flex-col gap-2">
97
+ {rows.map((value, index) => (
98
+ <div key={index} className="flex items-center gap-2">
99
+ <input className={inputClass} value={value} placeholder="Subprotocol" aria-label="Subprotocol"
100
+ onInput={(e) => update(rows.map((r, i) => i === index ? (e.target as HTMLInputElement).value : r))} />
101
+ <RemoveButton label="Remove subprotocol" onClick={() => update(rows.filter((_, i) => i !== index))} />
102
+ </div>
103
+ ))}
104
+ <AddLink onClick={() => update([...rows, ''])}>Add subprotocol</AddLink>
105
+ </div>
106
+ )
206
107
  }
207
108
 
208
- export const SubprotocolsTab = ({ subprotocols, setWsConfig }: SubprotocolsTabProps) => {
209
- const [subprotocolsList, setSubprotocolsList] = useState<SubprotocolItem[]>([])
210
- const hasInitialized = useRef(false)
211
-
212
- useEffect(() => {
213
- if (!hasInitialized.current) {
214
- const initial = subprotocols.map(value => ({
215
- value,
216
- isEditing: false
217
- }))
218
- setSubprotocolsList(initial)
219
- hasInitialized.current = true
220
- }
221
- }, [subprotocols])
109
+ interface AuthState { type: 'none' | 'bearer' | 'apiKey' | 'basic'; token: string; apiKey: string; username: string; password: string }
222
110
 
223
- useEffect(() => {
224
- const newSubprotocols = subprotocolsList
225
- .filter(s => s.value.trim())
226
- .map(s => s.value)
227
- setWsConfig(prev => ({ ...prev, subprotocols: newSubprotocols }))
228
- }, [subprotocolsList, setWsConfig])
229
-
230
- const updateSubprotocol = (idx: number, val: string) =>
231
- setSubprotocolsList(list => list.map((s, i) =>
232
- i === idx ? { ...s, value: val } : s
233
- ))
234
-
235
- const saveSubprotocol = (idx: number) =>
236
- setSubprotocolsList(list => list.map((s, i) =>
237
- i === idx ? { ...s, isEditing: false, isNew: false } : s
238
- ))
239
-
240
- const cancelEdit = (idx: number) =>
241
- setSubprotocolsList(list =>
242
- list.reduce<SubprotocolItem[]>((acc, s, i) => {
243
- if (i === idx) {
244
- if (s.isNew) return acc
245
- return [...acc, { ...s, isEditing: false }]
246
- }
247
- return [...acc, s]
248
- }, [])
249
- )
250
-
251
- const editSubprotocol = (idx: number) =>
252
- setSubprotocolsList(list =>
253
- list.map((s, i) => (i === idx ? { ...s, isEditing: true } : s))
254
- )
255
-
256
- const removeSubprotocol = (idx: number) =>
257
- setSubprotocolsList(list => list.filter((_, i) => i !== idx))
258
-
259
- const addSubprotocolRow = () =>
260
- setSubprotocolsList(list => [
261
- ...list, { value: '', isEditing: true, isNew: true }
262
- ])
111
+ function Select({ value, onChange, children, label }: { value: string; onChange: (value: string) => void; children: preact.ComponentChildren; label: string }) {
112
+ return (
113
+ <div className="relative">
114
+ <select
115
+ aria-label={label}
116
+ value={value}
117
+ onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
118
+ className="w-full appearance-none rounded-md border border-[var(--border)] bg-card py-2 pl-3 pr-9 text-sm text-foreground focus:border-[var(--primary)] focus:outline-none"
119
+ >
120
+ {children}
121
+ </select>
122
+ <ChevronsUpDown aria-hidden="true" className="pointer-events-none absolute right-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
123
+ </div>
124
+ )
125
+ }
263
126
 
127
+ function AuthTab({ auth, setAuth }: { auth: AuthState; setAuth: (auth: AuthState) => void }) {
128
+ const field = (key: keyof AuthState, placeholder: string, type = 'text') => (
129
+ <input className={inputClass} type={type} value={auth[key] as string} placeholder={placeholder} aria-label={placeholder}
130
+ onInput={(e) => setAuth({ ...auth, [key]: (e.target as HTMLInputElement).value })} />
131
+ )
264
132
  return (
265
- <div className="flex flex-col gap-2 px-2 py-4">
266
- <div className="flex items-center gap-2">
267
- <span className="text-sm font-medium text-muted-foreground flex-1">Subprotocol</span>
268
- </div>
269
- {subprotocolsList.map((s, i) => (
270
- <div key={i} className="flex items-center gap-2">
271
- {s.isEditing ? (
272
- <>
273
- <Input
274
- value={s.value}
275
- onChange={e => updateSubprotocol(i, (e.target as HTMLInputElement).value)}
276
- placeholder="Subprotocol name"
277
- className="flex-1 text-sm"
278
- />
279
- <Button onClick={() => saveSubprotocol(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
280
- <CheckIcon className="w-4 h-4" />
281
- </Button>
282
- <Button onClick={() => cancelEdit(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
283
- <XIcon className="w-4 h-4" />
284
- </Button>
285
- </>
286
- ) : (
287
- <>
288
- <span className="flex-1 truncate text-sm">{s.value}</span>
289
- <Button onClick={() => editSubprotocol(i)} variant="ghost" size="icon" className="text-white h-8 w-8">
290
- <PencilIcon className="w-4 h-4" />
291
- </Button>
292
- <Button onClick={() => removeSubprotocol(i)} variant="ghost" size="icon" className="text-red-400 h-8 w-8">
293
- <TrashIcon className="w-4 h-4" />
294
- </Button>
295
- </>
296
- )}
297
- </div>
298
- ))}
299
- <Button onClick={addSubprotocolRow} variant="ghost" size="sm" className="text-sky-400 hover:text-sky-300 mt-2 self-start">
300
- + Add Subprotocol
301
- </Button>
133
+ <div className="flex flex-col gap-3">
134
+ <Select label="Authentication type" value={auth.type} onChange={(type) => setAuth({ ...auth, type: type as AuthState['type'] })}>
135
+ <option value="none">No authentication</option>
136
+ <option value="bearer">Bearer token</option>
137
+ <option value="apiKey">API key</option>
138
+ <option value="basic">Basic auth</option>
139
+ </Select>
140
+ {auth.type === 'bearer' && <div className="flex">{field('token', 'Bearer token', 'password')}</div>}
141
+ {auth.type === 'apiKey' && <div className="flex">{field('apiKey', 'API key', 'password')}</div>}
142
+ {auth.type === 'basic' && <div className="flex gap-2">{field('username', 'Username')}{field('password', 'Password', 'password')}</div>}
302
143
  </div>
303
144
  )
304
145
  }
305
146
 
306
- type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
147
+ const statusDot: Record<ConnectionState, string> = {
148
+ connected: 'bg-green-600',
149
+ connecting: 'bg-[var(--highlight)]',
150
+ error: 'bg-[var(--destructive)]',
151
+ disconnected: 'bg-muted-foreground',
152
+ }
153
+
154
+ const statusText: Record<ConnectionState, string> = {
155
+ connected: 'Connected',
156
+ connecting: 'Connecting…',
157
+ error: 'Error',
158
+ disconnected: 'Disconnected',
159
+ }
307
160
 
308
- // Internal playground component that contains the actual WebSocket UI
309
- function WebSocketPlaygroundInternal({
161
+ /**
162
+ * Inline WebSocket client: connection bar, server picker, Headers/Subprotocols/Auth
163
+ * tabs, message log and composer. Browsers cannot send custom headers on a
164
+ * WebSocket handshake, so headers and auth are kept for documentation and for
165
+ * servers that read them from the first message.
166
+ */
167
+ export function WebSocketPanel({
310
168
  url,
311
169
  title,
312
- description,
313
170
  subprotocols: defaultSubprotocols = [],
314
171
  headers: defaultHeaders = {},
315
172
  servers = [],
316
- authType = 'none'
173
+ authType = 'none',
317
174
  }: WebSocketPlaygroundProps) {
318
- const [selectedTab, setSelectedTab] = useState(0)
175
+ const [tab, setTab] = useState<'headers' | 'subprotocols' | 'auth'>('headers')
319
176
  const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
320
177
  const [messages, setMessages] = useState<WebSocketMessage[]>([])
321
178
  const [messageInput, setMessageInput] = useState('')
322
179
  const [error, setError] = useState<string | null>(null)
323
- const [selectedServer, setSelectedServer] = useState<WebSocketServer | null>(servers?.[0] || null)
180
+ const [serverIndex, setServerIndex] = useState(0)
324
181
  const wsRef = useRef<WebSocket | null>(null)
325
182
  const messageIdRef = useRef(0)
326
-
327
- // WebSocket configuration state
328
183
  const [wsConfig, setWsConfig] = useState<WebSocketConfig>({
329
- url: (selectedServer?.url || '') + url,
184
+ url: (servers[0]?.url ?? '') + url,
330
185
  headers: { ...defaultHeaders },
331
- subprotocols: [...defaultSubprotocols]
186
+ subprotocols: [...defaultSubprotocols],
332
187
  })
188
+ const [auth, setAuth] = useState<AuthState>({ type: authType, token: '', apiKey: '', username: '', password: '' })
333
189
 
334
- // Auth state
335
- const [auth, setAuth] = useState({
336
- type: authType,
337
- token: '',
338
- apiKey: '',
339
- username: '',
340
- password: ''
341
- })
342
-
343
- // Update URL when server changes
344
190
  useEffect(() => {
345
- if (selectedServer) {
346
- setWsConfig(prev => ({ ...prev, url: selectedServer.url + url }))
347
- }
348
- }, [selectedServer, url])
191
+ const server = servers[serverIndex]
192
+ if (server) setWsConfig((prev) => ({ ...prev, url: server.url + url }))
193
+ }, [serverIndex, url])
194
+
195
+ useEffect(() => () => wsRef.current?.close(), [])
349
196
 
350
197
  const addMessage = useCallback((type: WebSocketMessage['type'], data: string, binary = false) => {
351
- const message: WebSocketMessage = {
352
- id: (++messageIdRef.current).toString(),
353
- type,
354
- data,
355
- timestamp: Date.now(),
356
- binary
357
- }
358
- setMessages(prev => [...prev, message])
198
+ setMessages((prev) => [...prev, { id: String(++messageIdRef.current), type, data, timestamp: Date.now(), binary }])
359
199
  }, [])
360
200
 
361
- const connectWebSocket = useCallback(() => {
362
- if (wsRef.current) {
363
- wsRef.current.close()
364
- }
365
-
201
+ const connect = useCallback(() => {
202
+ wsRef.current?.close()
366
203
  setConnectionState('connecting')
367
204
  setError(null)
368
-
369
205
  try {
370
- // Prepare headers based on auth type
371
- const headers = { ...wsConfig.headers }
372
-
373
- switch (auth.type) {
374
- case 'bearer':
375
- if (auth.token) {
376
- headers['Authorization'] = `Bearer ${auth.token}`
377
- }
378
- break
379
- case 'apiKey':
380
- if (auth.apiKey) {
381
- headers['X-API-Key'] = auth.apiKey
382
- }
383
- break
384
- case 'basic':
385
- if (auth.username && auth.password) {
386
- headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`
387
- }
388
- break
389
- }
390
-
391
206
  const ws = new WebSocket(wsConfig.url, wsConfig.subprotocols)
392
-
393
- ws.onopen = () => {
394
- setConnectionState('connected')
395
- addMessage('system', 'Connected to WebSocket server')
396
- }
397
-
207
+ ws.onopen = () => { setConnectionState('connected'); addMessage('system', `Connected to ${wsConfig.url}`) }
398
208
  ws.onmessage = (event) => {
399
209
  const isBinary = event.data instanceof ArrayBuffer || event.data instanceof Blob
400
- const data = isBinary ?
401
- `[Binary data: ${event.data instanceof Blob ? event.data.size : event.data.byteLength} bytes]` :
402
- event.data
403
- addMessage('received', data, isBinary)
404
- }
405
-
406
- ws.onerror = () => {
407
- setConnectionState('error')
408
- setError('WebSocket connection error')
409
- addMessage('error', 'Connection error occurred')
210
+ const size = event.data instanceof Blob ? event.data.size : event.data instanceof ArrayBuffer ? event.data.byteLength : 0
211
+ addMessage('received', isBinary ? `[Binary data: ${size} bytes]` : String(event.data), isBinary)
410
212
  }
411
-
412
- ws.onclose = (event) => {
413
- setConnectionState('disconnected')
414
- const reason = event.reason || 'Connection closed'
415
- addMessage('system', `Disconnected: ${reason} (Code: ${event.code})`)
416
- }
417
-
213
+ ws.onerror = () => { setConnectionState('error'); setError('WebSocket connection error'); addMessage('error', 'Connection error') }
214
+ ws.onclose = (event) => { setConnectionState('disconnected'); addMessage('system', `Disconnected: ${event.reason || 'connection closed'} (code ${event.code})`) }
418
215
  wsRef.current = ws
419
216
  } catch (err) {
420
217
  setConnectionState('error')
421
218
  setError(err instanceof Error ? err.message : 'Failed to connect')
422
219
  }
423
- }, [wsConfig, auth, addMessage])
220
+ }, [wsConfig, addMessage])
424
221
 
425
- const disconnectWebSocket = useCallback(() => {
426
- if (wsRef.current) {
427
- wsRef.current.close()
428
- wsRef.current = null
429
- }
430
- }, [])
222
+ const disconnect = useCallback(() => { wsRef.current?.close(); wsRef.current = null }, [])
431
223
 
432
- const sendMessage = useCallback(() => {
433
- if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && messageInput.trim()) {
434
- try {
435
- wsRef.current.send(messageInput)
436
- addMessage('sent', messageInput)
437
- setMessageInput('')
438
- } catch (err) {
439
- addMessage('error', `Failed to send message: ${err}`)
440
- }
224
+ const send = useCallback(() => {
225
+ if (wsRef.current?.readyState === WebSocket.OPEN && messageInput.trim()) {
226
+ wsRef.current.send(messageInput)
227
+ addMessage('sent', messageInput)
228
+ setMessageInput('')
441
229
  }
442
230
  }, [messageInput, addMessage])
443
231
 
444
- const clearMessages = useCallback(() => {
445
- setMessages([])
446
- }, [])
447
-
448
- const tabs = [
449
- { name: 'Headers', key: 'headers' },
450
- { name: 'Subprotocols', key: 'subprotocols' },
451
- { name: 'Auth', key: 'auth' }
452
- ]
453
-
454
- const getConnectionStatusColor = () => {
455
- switch (connectionState) {
456
- case 'connected':
457
- return 'text-green-400'
458
- case 'connecting':
459
- return 'text-yellow-400'
460
- case 'error':
461
- return 'text-red-400'
462
- default:
463
- return 'text-muted-foreground'
464
- }
465
- }
466
-
467
- const getConnectionStatusText = () => {
468
- switch (connectionState) {
469
- case 'connected':
470
- return 'Connected'
471
- case 'connecting':
472
- return 'Connecting...'
473
- case 'error':
474
- return 'Error'
475
- default:
476
- return 'Disconnected'
477
- }
478
- }
232
+ const connected = connectionState === 'connected'
233
+ const tabs: Array<[typeof tab, string]> = [['headers', 'Headers'], ['subprotocols', 'Subprotocols'], ['auth', 'Auth']]
479
234
 
480
235
  return (
481
- <div className="my-6 overflow-hidden rounded-2xl">
482
- {/* Header */}
483
- <div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 px-4 pt-2 pb-4 justify-between">
484
- <div className="flex items-center gap-3 pt-3">
485
- <Tag variant="small">WS</Tag>
486
- <span className="font-mono text-sm">{title || url}</span>
487
- <div className="flex items-center gap-2">
488
- <div className={`w-2 h-2 rounded-full ${connectionState === 'connected' ? 'bg-green-400' : connectionState === 'connecting' ? 'bg-yellow-400' : connectionState === 'error' ? 'bg-red-400' : 'bg-zinc-400'}`} />
489
- <span className={`text-xs ${getConnectionStatusColor()}`}>
490
- {getConnectionStatusText()}
491
- </span>
492
- </div>
493
- </div>
494
- <div className="flex gap-2 ml-auto pt-3">
495
- <Button
496
- onClick={connectWebSocket}
497
- disabled={connectionState === 'connecting' || connectionState === 'connected'}
498
- className={classNames(
499
- 'flex items-center gap-2',
500
- connectionState === 'connecting' || connectionState === 'connected'
501
- ? 'bg-zinc-600 text-muted-foreground cursor-not-allowed'
502
- : 'bg-green-600 text-white hover:bg-green-700'
503
- )}
504
- >
505
- {connectionState === 'connecting' ? (
506
- <LoadingSpinner className="h-4 w-4" />
507
- ) : (
508
- <PlayButton className="h-4 w-4" />
509
- )}
510
- {connectionState === 'connecting' ? 'Connecting...' : 'Connect'}
511
- </Button>
512
- <Button
513
- onClick={disconnectWebSocket}
514
- disabled={connectionState === 'disconnected'}
515
- variant="destructive"
516
- className={classNames(
517
- 'flex items-center gap-2',
518
- connectionState === 'disconnected' && 'bg-zinc-600 text-muted-foreground cursor-not-allowed'
519
- )}
520
- >
521
- <DisconnectButton className="h-4 w-4" />
522
- Disconnect
523
- </Button>
524
- </div>
236
+ <div className="dory-ws not-prose my-5 overflow-hidden rounded-lg border border-[var(--border)] bg-card">
237
+ <div className="flex items-center gap-3 border-b border-[var(--border)] py-2 pl-3 pr-2">
238
+ <Tag color="sky">WS</Tag>
239
+ <span className="font-mono text-sm font-semibold text-foreground">{title || url}</span>
240
+ <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
241
+ <span aria-hidden="true" className={classNames('size-1.5 rounded-full', statusDot[connectionState])} />
242
+ {statusText[connectionState]}
243
+ </span>
244
+ <button
245
+ type="button"
246
+ onClick={connected ? disconnect : connect}
247
+ disabled={connectionState === 'connecting'}
248
+ className={classNames('ml-auto rounded-md px-4 py-1.5 text-sm font-semibold transition disabled:opacity-60', {
249
+ 'bg-[var(--primary)] text-white hover:opacity-90': !connected,
250
+ 'border border-[var(--border)] bg-muted text-foreground hover:bg-card': connected,
251
+ })}
252
+ >
253
+ {connected ? 'Disconnect' : 'Connect'}
254
+ </button>
525
255
  </div>
526
256
 
527
- {description && (
528
- <p className="text-sm px-4 py-3">{description}</p>
529
- )}
530
-
531
- <div className="flex flex-col lg:flex-row gap-4">
532
- {/* Configuration */}
533
- <div className="flex-1 min-w-0">
534
- <div className="flex flex-col gap-2 p-4">
535
- <label className="text-sm font-medium">Server</label>
536
- <Dropdown
537
- buttonLabel={selectedServer?.description || selectedServer?.url || 'Select Server'}
538
- items={servers.map(server => ({
539
- label: server.description || server.url,
540
- onClick: () => setSelectedServer(server)
541
- }))}
542
- className="w-full"
543
- />
257
+ <div className="flex flex-col gap-4 px-4 pt-4 pb-5">
258
+ {servers.length > 0 && (
259
+ <div className="flex flex-col gap-2">
260
+ <MonoLabel>Server</MonoLabel>
261
+ <Select label="Server" value={String(serverIndex)} onChange={(v) => setServerIndex(Number(v))}>
262
+ {servers.map((server, index) => (
263
+ <option key={server.url} value={index}>{server.description ? `${server.description} · ${server.url}` : server.url}</option>
264
+ ))}
265
+ </Select>
544
266
  </div>
267
+ )}
545
268
 
546
- <div>
547
- <TabGroup selectedIndex={selectedTab} onChange={setSelectedTab}>
548
- <TabList className="flex">
549
- {tabs.map((tab) => (
550
- <Tab
551
- key={tab.key}
552
- className={({ selected }) =>
553
- classNames(
554
- 'px-4 py-3 text-sm font-medium transition border-b-2',
555
- selected
556
- ? 'border-brand-foreground text-brand-foreground'
557
- : 'border-transparent text-muted-foreground hover:text-zinc-300'
558
- )
559
- }
560
- >
561
- {tab.name}
562
- </Tab>
563
- ))}
564
- </TabList>
565
-
566
- <TabPanels className="min-h-[250px]">
567
- <TabPanel className="p-4">
568
- <HeadersTab
569
- wsConfig={wsConfig}
570
- setWsConfig={setWsConfig}
571
- />
572
- </TabPanel>
573
-
574
- <TabPanel className="p-4">
575
- <SubprotocolsTab
576
- subprotocols={wsConfig.subprotocols}
577
- setWsConfig={setWsConfig}
578
- />
579
- </TabPanel>
580
-
581
- {/* Auth Tab */}
582
- <TabPanel className="p-4 space-y-4">
583
- <div className="flex flex-col space-y-2">
584
- <label className="text-sm font-medium">Authentication Type</label>
585
- <Dropdown
586
- buttonLabel={auth.type === 'none' ? 'Select Auth Type' : auth.type.charAt(0).toUpperCase() + auth.type.slice(1)}
587
- items={[
588
- { label: 'None', onClick: () => setAuth(prev => ({ ...prev, type: 'none' })) },
589
- { label: 'Bearer Token', onClick: () => setAuth(prev => ({ ...prev, type: 'bearer' })) },
590
- { label: 'API Key', onClick: () => setAuth(prev => ({ ...prev, type: 'apiKey' })) },
591
- { label: 'Basic Auth', onClick: () => setAuth(prev => ({ ...prev, type: 'basic' })) }
592
- ]}
593
- className="w-full"
594
- />
595
- </div>
596
-
597
- {auth.type === 'bearer' && (
598
- <div className="space-y-2">
599
- <label className="text-sm font-medium">Bearer Token</label>
600
- <Input
601
- type="password"
602
- value={auth.token}
603
- onChange={(e) => setAuth(prev => ({ ...prev, token: (e.target as HTMLInputElement).value }))}
604
- placeholder="Enter bearer token..."
605
- className="w-full text-sm"
606
- />
607
- </div>
608
- )}
609
-
610
- {auth.type === 'apiKey' && (
611
- <div className="space-y-2">
612
- <label className="text-sm font-medium">API Key</label>
613
- <Input
614
- type="password"
615
- value={auth.apiKey}
616
- onChange={(e) => setAuth(prev => ({ ...prev, apiKey: (e.target as HTMLInputElement).value }))}
617
- placeholder="Enter API key..."
618
- className="w-full text-sm"
619
- />
620
- </div>
621
- )}
622
-
623
- {auth.type === 'basic' && (
624
- <div className="space-y-4">
625
- <div className="space-y-2">
626
- <label className="text-sm font-medium">Username</label>
627
- <Input
628
- type="text"
629
- value={auth.username}
630
- onChange={(e) => setAuth(prev => ({ ...prev, username: (e.target as HTMLInputElement).value }))}
631
- placeholder="Enter username..."
632
- className="w-full text-sm"
633
- />
634
- </div>
635
- <div className="space-y-2">
636
- <label className="text-sm font-medium">Password</label>
637
- <Input
638
- type="password"
639
- value={auth.password}
640
- onChange={(e) => setAuth(prev => ({ ...prev, password: (e.target as HTMLInputElement).value }))}
641
- placeholder="Enter password..."
642
- className="w-full text-sm"
643
- />
644
- </div>
645
- </div>
646
- )}
647
- </TabPanel>
648
- </TabPanels>
649
- </TabGroup>
269
+ <div>
270
+ <div role="tablist" className="flex gap-5 border-b border-[var(--border)]">
271
+ {tabs.map(([key, label]) => (
272
+ <button key={key} type="button" role="tab" aria-selected={tab === key} onClick={() => setTab(key)}
273
+ className={classNames('-mb-px border-b-2 pb-2.5 pt-1 text-sm font-medium transition', {
274
+ 'border-[var(--primary)] text-foreground': tab === key,
275
+ 'border-transparent text-muted-foreground hover:text-foreground': tab !== key,
276
+ })}>
277
+ {label}
278
+ </button>
279
+ ))}
650
280
  </div>
651
- </div>
652
-
653
- {/* Messages */}
654
- <div className="flex-1 min-w-0">
655
- <div>
656
- <div className="flex items-center justify-between px-4 py-3">
657
- <h3 className="text-sm font-semibold">Messages</h3>
658
- <Button
659
- onClick={clearMessages}
660
- variant="ghost"
661
- size="sm"
662
- className="text-xs text-muted-foreground hover:text-zinc-300"
663
- >
664
- Clear
665
- </Button>
666
- </div>
667
-
668
- <div className="flex flex-col h-64 overflow-y-auto p-4 space-y-2">
669
- {messages.length === 0 ? (
670
- <EmptyState icon={<MessageCircleIcon className="h-5 w-5" />} title="No messages yet" description="Connect to start receiving messages" />
671
- ) : (
672
- messages.map((msg) => (
673
- <div
674
- key={msg.id}
675
- className={classNames(
676
- 'flex flex-col gap-1 p-3 rounded-lg text-sm',
677
- msg.type === 'sent' && 'bg-sky-500/10 border border-sky-500/20 ml-8',
678
- msg.type === 'received' && 'bg-green-500/10 border border-green-500/20 mr-8',
679
- msg.type === 'error' && 'bg-red-500/10 border border-red-500/20',
680
- msg.type === 'system' && 'bg-zinc-500/10 border border-zinc-500/20'
681
- )}
682
- >
683
- <div className="flex items-center justify-between">
684
- <span className={classNames(
685
- 'text-xs font-medium',
686
- msg.type === 'sent' && 'text-sky-400',
687
- msg.type === 'received' && 'text-green-400',
688
- msg.type === 'error' && 'text-red-400',
689
- msg.type === 'system' && 'text-muted-foreground'
690
- )}>
691
- {msg.type === 'sent' ? 'Sent' :
692
- msg.type === 'received' ? 'Received' :
693
- msg.type === 'error' ? 'Error' : 'System'}
694
- {msg.binary && ' (Binary)'}
695
- </span>
696
- <span className="text-xs text-muted-foreground">
697
- {new Date(msg.timestamp).toLocaleTimeString()}
698
- </span>
699
- </div>
700
- <div className="text-white break-words font-mono text-xs">
701
- {msg.data}
702
- </div>
703
- </div>
704
- ))
705
- )}
706
- </div>
707
-
708
- {/* Send Message */}
709
- <div className="p-4">
710
- <div className="flex gap-2">
711
- <Input
712
- type="text"
713
- value={messageInput}
714
- onChange={(e) => setMessageInput((e.target as HTMLInputElement).value)}
715
- onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
716
- placeholder="Enter message to send..."
717
- disabled={connectionState !== 'connected'}
718
- className="flex-1 text-sm"
719
- />
720
- <Button
721
- onClick={sendMessage}
722
- disabled={connectionState !== 'connected' || !messageInput.trim()}
723
- className={classNames({
724
- 'bg-sky-600 text-white hover:bg-sky-700': connectionState === 'connected' && messageInput.trim()
725
- })}
726
- >
727
- Send
728
- </Button>
729
- </div>
730
- </div>
281
+ <div className="pt-4">
282
+ {tab === 'headers' && <HeadersTab headers={wsConfig.headers} onChange={(headers) => setWsConfig((prev) => ({ ...prev, headers }))} />}
283
+ {tab === 'subprotocols' && <SubprotocolsTab subprotocols={wsConfig.subprotocols} onChange={(subprotocols) => setWsConfig((prev) => ({ ...prev, subprotocols }))} />}
284
+ {tab === 'auth' && <AuthTab auth={auth} setAuth={setAuth} />}
731
285
  </div>
732
286
  </div>
733
287
  </div>
734
288
 
735
- {error && (
736
- <div className="p-4">
737
- <div className="flex items-center gap-2">
738
- <span className="inline-flex items-center rounded-full bg-rose-500/10 px-2 py-1 text-xs font-medium text-rose-400 ring-1 ring-rose-500/20">
739
- Error
740
- </span>
741
- </div>
742
- <pre className="text-sm text-rose-400 bg-rose-500/5 p-3 rounded-lg border border-rose-500/20 mt-2">
743
- {error}
744
- </pre>
745
- </div>
746
- )}
289
+ <div className="flex items-center justify-between border-t border-[var(--border)] px-4 py-2.5">
290
+ <MonoLabel>Messages</MonoLabel>
291
+ <button type="button" onClick={() => setMessages([])} className="text-sm text-muted-foreground hover:text-foreground">Clear</button>
292
+ </div>
293
+ <div className="max-h-72 min-h-56 overflow-y-auto border-t border-[var(--border)] bg-background px-4 py-3">
294
+ {messages.length === 0 ? (
295
+ <p className="flex h-48 items-center justify-center text-sm text-muted-foreground">Connect to start receiving messages</p>
296
+ ) : (
297
+ <ul className="flex flex-col gap-1.5">
298
+ {messages.map((message) => (
299
+ <li key={message.id} className="flex items-baseline gap-3 font-mono text-xs">
300
+ <span className={classNames('w-10 shrink-0 font-semibold uppercase tracking-[0.08em]', {
301
+ 'text-[var(--brand-readable,#b65401)]': message.type === 'sent',
302
+ 'text-[var(--primary)]': message.type === 'received',
303
+ 'text-[var(--destructive)]': message.type === 'error',
304
+ 'text-muted-foreground': message.type === 'system',
305
+ })}>
306
+ {message.type === 'sent' ? 'send' : message.type === 'received' ? 'recv' : message.type === 'error' ? 'err' : 'sys'}
307
+ </span>
308
+ <span className="min-w-0 flex-1 break-words text-foreground">{message.data}</span>
309
+ <span className="shrink-0 text-muted-foreground">{new Date(message.timestamp).toLocaleTimeString()}</span>
310
+ </li>
311
+ ))}
312
+ </ul>
313
+ )}
314
+ {error && <p className="mt-2 font-mono text-xs text-[var(--destructive)]">{error}</p>}
315
+ </div>
316
+ <div className="flex items-center gap-2 border-t border-[var(--border)] bg-muted px-4 py-3">
317
+ <input
318
+ className={classNames(inputClass, 'bg-card')}
319
+ value={messageInput}
320
+ onInput={(e) => setMessageInput((e.target as HTMLInputElement).value)}
321
+ onKeyDown={(e) => { if (e.key === 'Enter') send() }}
322
+ placeholder={'{"type":"query.start","sql":"select * from orders limit 3"}'}
323
+ aria-label="Message to send"
324
+ />
325
+ <button type="button" onClick={send} disabled={!connected || !messageInput.trim()}
326
+ className="rounded-md border border-[var(--border)] bg-card px-4 py-2 text-sm font-semibold text-foreground transition disabled:text-muted-foreground disabled:opacity-70">
327
+ Send
328
+ </button>
329
+ </div>
747
330
  </div>
748
331
  )
749
332
  }
750
333
 
751
- // Main exported component with Sheet wrapper
334
+ /** MDX component: optional description, then the inline WebSocket panel. */
752
335
  export function WebSocketPlayground(props: WebSocketPlaygroundProps) {
753
336
  if (typeof window === 'undefined') return null
754
-
755
- const [showPlayground, setShowPlayground] = useState(false)
756
-
757
- const handleShowPlayground = () => {
758
- setShowPlayground(true)
759
- }
760
-
761
337
  return (
762
- <div className="max-w-none w-full my-6">
763
- <Sheet open={showPlayground} onOpenChange={setShowPlayground} modal={true}>
764
- <SheetContent side="bottom" className="h-[90vh] w-full overflow-y-auto p-8">
765
- <SheetHeader className="p-0">
766
- <SheetTitle>WebSocket Playground</SheetTitle>
767
- </SheetHeader>
768
- <WebSocketPlaygroundInternal {...props} />
769
- </SheetContent>
770
- </Sheet>
771
-
772
- <Card className="overflow-hidden py-0 border-1 border-black/10">
773
- <div className="flex items-center justify-between px-4 py-3">
774
- <div className="flex items-center gap-3">
775
- <Tag variant="small">WS</Tag>
776
- <span className="font-mono text-sm">{props.title || props.url}</span>
777
- </div>
778
- <Button
779
- onClick={handleShowPlayground}
780
- className="flex items-center gap-2"
781
- >
782
- Try
783
- <PlayButton className="h-4 w-4" />
784
- </Button>
785
- </div>
786
-
787
- {props.description && (
788
- <p className="text-sm px-4 py-3">{props.description}</p>
789
- )}
790
-
791
- <div className="px-4 py-3">
792
- <div className="flex flex-col gap-2">
793
- <div className="flex items-center gap-2">
794
- <span className="text-xs font-medium text-muted-foreground">Endpoint:</span>
795
- <Badge>
796
- {props.servers?.[0]?.url || 'ws://localhost'}{props.url}
797
- </Badge>
798
- </div>
799
-
800
- {props.subprotocols && props.subprotocols.length > 0 && (
801
- <div className="flex items-center gap-2">
802
- <span className="text-xs font-medium text-muted-foreground">Subprotocols:</span>
803
- <div className="flex gap-1">
804
- {props.subprotocols.map((protocol, idx) => (
805
- <Badge key={idx}>
806
- {protocol}
807
- </Badge>
808
- ))}
809
- </div>
810
- </div>
811
- )}
812
-
813
- {props.authType && props.authType !== 'none' && (
814
- <div className="flex items-center gap-2">
815
- <span className="text-xs font-medium text-muted-foreground">Authentication:</span>
816
- <Badge>
817
- {props.authType}
818
- </Badge>
819
- </div>
820
- )}
821
- </div>
822
- </div>
823
- </Card>
338
+ <div className="my-5">
339
+ {props.description && <p className="mb-3 text-sm text-muted-foreground">{props.description}</p>}
340
+ <WebSocketPanel {...props} />
824
341
  </div>
825
342
  )
826
- }
343
+ }