@clidey/dory 1.0.8 → 1.10.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 (210) hide show
  1. package/README.md +59 -164
  2. package/bin/chat-server.ts +270 -0
  3. package/bin/dist/analytics-server.d.ts +11 -0
  4. package/bin/dist/analytics-server.d.ts.map +1 -0
  5. package/bin/dist/analytics-server.js +121 -0
  6. package/bin/dist/analytics-server.js.map +1 -0
  7. package/bin/dist/automation.d.ts +35 -0
  8. package/bin/dist/automation.d.ts.map +1 -0
  9. package/bin/dist/automation.js +259 -0
  10. package/bin/dist/automation.js.map +1 -0
  11. package/bin/dist/chat-server.d.ts +8 -0
  12. package/bin/dist/chat-server.d.ts.map +1 -0
  13. package/bin/dist/chat-server.js +322 -0
  14. package/bin/dist/chat-server.js.map +1 -0
  15. package/bin/dist/check.d.ts +16 -0
  16. package/bin/dist/check.d.ts.map +1 -0
  17. package/bin/dist/check.js +247 -0
  18. package/bin/dist/check.js.map +1 -0
  19. package/bin/dist/dependency-overlay.d.ts +7 -0
  20. package/bin/dist/dependency-overlay.d.ts.map +1 -0
  21. package/bin/dist/dependency-overlay.js +137 -0
  22. package/bin/dist/dependency-overlay.js.map +1 -0
  23. package/bin/dist/dory.js +254 -402
  24. package/bin/dist/dory.js.map +1 -1
  25. package/bin/dist/i18n-init.d.ts +6 -0
  26. package/bin/dist/i18n-init.d.ts.map +1 -0
  27. package/bin/dist/i18n-init.js +56 -0
  28. package/bin/dist/i18n-init.js.map +1 -0
  29. package/bin/dist/mcp-http.d.ts +8 -0
  30. package/bin/dist/mcp-http.d.ts.map +1 -0
  31. package/bin/dist/mcp-http.js +72 -0
  32. package/bin/dist/mcp-http.js.map +1 -0
  33. package/bin/dist/mcp.d.ts +2 -0
  34. package/bin/dist/mcp.d.ts.map +1 -0
  35. package/bin/dist/mcp.js +81 -0
  36. package/bin/dist/mcp.js.map +1 -0
  37. package/bin/dist/preview-entry.d.ts +2 -0
  38. package/bin/dist/preview-entry.d.ts.map +1 -0
  39. package/bin/dist/preview-entry.js +6 -0
  40. package/bin/dist/preview-entry.js.map +1 -0
  41. package/bin/dist/preview-server.d.ts +5 -0
  42. package/bin/dist/preview-server.d.ts.map +1 -0
  43. package/bin/dist/preview-server.js +146 -0
  44. package/bin/dist/preview-server.js.map +1 -0
  45. package/bin/dist/preview-static.d.ts +7 -0
  46. package/bin/dist/preview-static.d.ts.map +1 -0
  47. package/bin/dist/preview-static.js +70 -0
  48. package/bin/dist/preview-static.js.map +1 -0
  49. package/bin/dist/project-config.d.ts +22 -0
  50. package/bin/dist/project-config.d.ts.map +1 -0
  51. package/bin/dist/project-config.js +44 -0
  52. package/bin/dist/project-config.js.map +1 -0
  53. package/dist/index.html +57 -0
  54. package/dist/runtime/dory-runtime.css +2 -0
  55. package/dist/runtime/dory-runtime.html +14 -0
  56. package/dist/runtime/dory-runtime.js +5490 -0
  57. package/docs/api-playground-demo.mdx +119 -0
  58. package/docs/api-reference/asyncapi.json +324 -0
  59. package/docs/api-reference/openapi.json +366 -0
  60. package/docs/api-reference/operations/create.mdx +5 -0
  61. package/docs/api-reference/operations/delete.mdx +5 -0
  62. package/docs/api-reference/operations/get.mdx +5 -0
  63. package/docs/api-reference/operations/update.mdx +5 -0
  64. package/docs/api-reference/websocket-operations/receive-chat-message.mdx +5 -0
  65. package/docs/api-reference/websocket-operations/receive-notifications.mdx +5 -0
  66. package/docs/api-reference/websocket-operations/receive-presence-update.mdx +5 -0
  67. package/docs/api-reference/websocket-operations/send-chat-message.mdx +5 -0
  68. package/docs/api-reference/websocket-operations/send-presence-update.mdx +5 -0
  69. package/docs/assets/dory.jpeg +0 -0
  70. package/docs/components/accordion.mdx +145 -0
  71. package/docs/components/callouts.mdx +134 -0
  72. package/docs/components/cards.mdx +153 -0
  73. package/docs/components/code-blocks.mdx +281 -0
  74. package/docs/components/expandable.mdx +189 -0
  75. package/docs/components/icons.mdx +71 -0
  76. package/docs/components/images.mdx +82 -0
  77. package/docs/components/latex.mdx +148 -0
  78. package/docs/components/layout.mdx +178 -0
  79. package/docs/components/lists.mdx +134 -0
  80. package/docs/components/mermaid.mdx +149 -0
  81. package/docs/components/overview.mdx +81 -0
  82. package/docs/components/source.mdx +130 -0
  83. package/docs/components/steps.mdx +122 -0
  84. package/docs/components/tables.mdx +114 -0
  85. package/docs/components/tags.mdx +179 -0
  86. package/docs/components/typography.mdx +185 -0
  87. package/docs/configuration/analytics.mdx +45 -0
  88. package/docs/configuration/chat.mdx +59 -0
  89. package/docs/configuration/dory-json.mdx +278 -0
  90. package/docs/configuration/embed.mdx +73 -0
  91. package/docs/configuration/fonts.mdx +86 -0
  92. package/docs/configuration/internationalization.mdx +61 -0
  93. package/docs/configuration/navigation.mdx +217 -0
  94. package/docs/configuration/theming.mdx +166 -0
  95. package/docs/contributing.mdx +47 -0
  96. package/docs/dory.json +136 -0
  97. package/docs/embed-example.html +176 -0
  98. package/docs/favicon.svg +18 -0
  99. package/docs/features.mdx +440 -0
  100. package/docs/getting-started/automation.mdx +76 -0
  101. package/docs/getting-started/configuration.mdx +95 -0
  102. package/docs/getting-started/custom-components.mdx +60 -0
  103. package/docs/getting-started/installation.mdx +88 -0
  104. package/docs/getting-started/quick-start.mdx +53 -0
  105. package/docs/introduction.mdx +50 -0
  106. package/docs/logo/logo.svg +18 -0
  107. package/docs/playgrounds/api-playground.mdx +122 -0
  108. package/docs/playgrounds/websocket-playground.mdx +98 -0
  109. package/docs/social-card.png +0 -0
  110. package/docs/social-card.svg +17 -0
  111. package/docs/websocket-playground-demo.mdx +135 -0
  112. package/package.json +23 -17
  113. package/scripts/verify-component-examples.mjs +126 -0
  114. package/scripts/verify-runtime-parity.mjs +147 -0
  115. package/src/app.css +22 -1
  116. package/src/assets/fonts/HankenGrotesk.ttf +0 -0
  117. package/src/assets/fonts/OFL.txt +94 -0
  118. package/src/components/chat-assistant.tsx +229 -0
  119. package/src/components/dropdown.tsx +3 -3
  120. package/src/components/error-boundary.tsx +2 -2
  121. package/src/components/hooks.tsx +20 -1
  122. package/src/components/navigation.tsx +82 -51
  123. package/src/components/notification.tsx +2 -3
  124. package/src/components/routes.tsx +17 -19
  125. package/src/components/search-index.test.ts +15 -0
  126. package/src/components/search-index.ts +49 -48
  127. package/src/components/search.tsx +208 -104
  128. package/src/components/store.ts +27 -9
  129. package/src/config/mdx-common.ts +11 -0
  130. package/src/config/mdx.ts +10 -11
  131. package/src/embed/loader.ts +0 -3
  132. package/src/embed/types.ts +0 -2
  133. package/src/embed/widget-iframe.ts +19 -21
  134. package/src/embedded-runtime/config.ts +138 -0
  135. package/src/embedded-runtime/index.tsx +680 -0
  136. package/src/embedded-runtime/runtime.html +14 -0
  137. package/src/entry-server.tsx +46 -124
  138. package/src/main.tsx +17 -5
  139. package/src/mdx/accordion.tsx +3 -3
  140. package/src/mdx/api-playground.tsx +18 -16
  141. package/src/mdx/async-api.tsx +2 -2
  142. package/src/mdx/card.tsx +3 -2
  143. package/src/mdx/code.tsx +11 -9
  144. package/src/mdx/fence.tsx +6 -5
  145. package/src/mdx/icon-grid.tsx +4 -1
  146. package/src/mdx/icon-names.test.ts +15 -0
  147. package/src/mdx/icon-names.ts +60 -0
  148. package/src/mdx/icon-source-variants.ts +102 -0
  149. package/src/mdx/icon.tsx +12 -73
  150. package/src/mdx/latex.test.ts +38 -0
  151. package/src/mdx/latex.tsx +42 -64
  152. package/src/mdx/mdx.tsx +1 -1
  153. package/src/mdx/mermaid.tsx +12 -6
  154. package/src/mdx/open-api.tsx +130 -74
  155. package/src/mdx/preprocess.test.ts +16 -0
  156. package/src/mdx/preprocess.ts +61 -0
  157. package/src/mdx/processor.test.ts +135 -37
  158. package/src/mdx/processor.ts +54 -92
  159. package/src/mdx/source.tsx +5 -4
  160. package/src/mdx/specimen.tsx +57 -43
  161. package/src/mdx/tabs.tsx +32 -21
  162. package/src/mdx/text.tsx +11 -11
  163. package/src/mdx/try-it-panel.tsx +284 -0
  164. package/src/mdx/type-scale.tsx +7 -7
  165. package/src/mdx/websocket-playground.tsx +21 -14
  166. package/src/plugins/analytics-injector.ts +8 -0
  167. package/src/plugins/chat-dev-server.ts +32 -0
  168. package/src/plugins/docs-assets.test.ts +25 -0
  169. package/src/plugins/docs-assets.ts +6 -2
  170. package/src/plugins/frontmatter-dev-server.ts +2 -2
  171. package/src/plugins/frontmatter-generator.ts +1 -1
  172. package/src/plugins/html-files-middleware.test.ts +21 -1
  173. package/src/plugins/html-files-middleware.ts +26 -6
  174. package/src/plugins/html-metadata-injector.ts +24 -4
  175. package/src/plugins/llm-txt-dev-server.ts +2 -2
  176. package/src/plugins/llm-txt-generator.ts +21 -4
  177. package/src/plugins/prerender.ts +47 -20
  178. package/src/plugins/robots-generator.ts +1 -1
  179. package/src/plugins/sanitize.ts +6 -5
  180. package/src/plugins/shared.ts +6 -18
  181. package/src/plugins/sitemap-generator.ts +1 -1
  182. package/src/plugins/social-preview.test.ts +71 -0
  183. package/src/plugins/social-preview.ts +87 -0
  184. package/src/plugins/unified-mdx-plugin.ts +1 -1
  185. package/src/theme.scss +630 -207
  186. package/src/types/config.ts +53 -1
  187. package/src/ui/breadcrumb.tsx +17 -0
  188. package/src/ui/docs-header-view.tsx +32 -0
  189. package/src/ui/docs-navigation-view.tsx +69 -0
  190. package/src/ui/docs-page-frame.tsx +50 -0
  191. package/src/ui/dory-mark.tsx +9 -0
  192. package/src/ui/header.tsx +33 -90
  193. package/src/ui/layout.tsx +99 -153
  194. package/src/ui/mobile-navigation.tsx +10 -8
  195. package/src/ui/page-actions.tsx +67 -0
  196. package/src/ui/page-feedback.tsx +50 -0
  197. package/src/ui/page-footer.tsx +23 -0
  198. package/src/ui/prev-next-link.tsx +15 -69
  199. package/src/ui/prev-next-view.tsx +27 -0
  200. package/src/ui/table-of-content-view.tsx +167 -0
  201. package/src/ui/table-of-content.tsx +25 -183
  202. package/src/utils/analytics.test.ts +36 -0
  203. package/src/utils/analytics.ts +35 -0
  204. package/src/utils/colors.ts +7 -0
  205. package/src/utils/content-scope.test.ts +26 -0
  206. package/src/utils/content-scope.ts +39 -0
  207. package/src/utils/search-content.ts +12 -0
  208. package/src/utils/theme.ts +1 -7
  209. package/vite.config.ssr.ts +4 -4
  210. package/vite.config.ts +22 -16
@@ -0,0 +1,284 @@
1
+ import { CatalogIcon, Sheet, SheetContent, SheetTitle } from '@clidey/ux'
2
+ import classNames from 'classnames'
3
+
4
+ import { useEffect, useState } from 'preact/hooks'
5
+ import type { Server } from './api-playground'
6
+ import { Fence } from './fence'
7
+ import { Tag } from './tag'
8
+
9
+ /** An editable request value: a body property, a path/query parameter, or an auth credential. */
10
+ export interface TryItField {
11
+ name: string
12
+ /** Short type shown beside the name, e.g. `string`, `integer`, `enum`. */
13
+ type: string
14
+ value: string
15
+ /** Where a parameter goes. Body fields leave this unset. */
16
+ in?: 'path' | 'query' | 'header'
17
+ }
18
+
19
+ interface TryItPanelProps {
20
+ open: boolean
21
+ onOpenChange: (open: boolean) => void
22
+ method: string
23
+ path: string
24
+ servers: Server[]
25
+ parameters: TryItField[]
26
+ body: TryItField[]
27
+ /** Auth headers, e.g. `{ name: 'Authorization', value: 'Bearer ' }`. */
28
+ auth: TryItField[]
29
+ }
30
+
31
+ interface ResponseData {
32
+ status: number
33
+ statusText: string
34
+ duration: number
35
+ body: string
36
+ }
37
+
38
+ /** Turns a form string back into JSON for its declared type; unparseable values stay strings. */
39
+ function bodyValue(field: TryItField): unknown {
40
+ if (field.type === 'integer' || field.type === 'number') return Number(field.value)
41
+ if (field.type === 'boolean') return field.value === 'true'
42
+ if (field.type === 'object' || field.type.startsWith('array')) {
43
+ try {
44
+ return JSON.parse(field.value)
45
+ } catch {
46
+ return field.value
47
+ }
48
+ }
49
+ return field.value
50
+ }
51
+
52
+ function FieldInput({ field, onChange }: { field: TryItField; onChange: (value: string) => void }) {
53
+ return (
54
+ <label className="block">
55
+ <span className="flex items-baseline gap-2">
56
+ <span className="font-mono text-sm font-semibold text-foreground">{field.name}</span>
57
+ <span className="font-mono text-xs text-[var(--primary)]">{field.type}</span>
58
+ </span>
59
+ <input
60
+ type="text"
61
+ value={field.value}
62
+ onInput={(event) => onChange((event.target as HTMLInputElement).value)}
63
+ className="dory-tryit-input mt-1.5"
64
+ />
65
+ </label>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Right-hand "Try it" panel for a generated API page: server picker, a form
71
+ * built from the operation's body, parameters and auth, and the live response.
72
+ */
73
+ export function TryItPanel({ open, onOpenChange, method, path, servers, parameters, body, auth }: TryItPanelProps) {
74
+ const [serverUrl, setServerUrl] = useState(servers[0]?.url ?? '')
75
+ const [params, setParams] = useState(parameters)
76
+ const [bodyFields, setBodyFields] = useState(body)
77
+ const [authFields, setAuthFields] = useState(auth)
78
+ const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>([])
79
+ const tabs = [
80
+ bodyFields.length > 0 && 'Body',
81
+ params.length > 0 && 'Params',
82
+ 'Headers',
83
+ authFields.length > 0 && 'Auth',
84
+ ].filter((tab): tab is string => Boolean(tab))
85
+ const [tab, setTab] = useState(tabs[0])
86
+ const [loading, setLoading] = useState(false)
87
+ const [response, setResponse] = useState<ResponseData | null>(null)
88
+ const [error, setError] = useState<string | null>(null)
89
+
90
+ // A different operation (client-side navigation) starts from its own defaults.
91
+ useEffect(() => {
92
+ setParams(parameters)
93
+ setBodyFields(body)
94
+ setAuthFields(auth)
95
+ setResponse(null)
96
+ setError(null)
97
+ }, [method, path])
98
+
99
+ const update = (setter: typeof setParams, index: number, value: string) =>
100
+ setter((fields) => fields.map((field, i) => (i === index ? { ...field, value } : field)))
101
+
102
+ async function send() {
103
+ setLoading(true)
104
+ setError(null)
105
+ setResponse(null)
106
+
107
+ let url = `${serverUrl.replace(/\/$/, '')}${path}`
108
+ const query = new URLSearchParams()
109
+ const requestHeaders: Record<string, string> = {}
110
+ for (const param of params) {
111
+ if (param.in === 'path') url = url.replace(`{${param.name}}`, encodeURIComponent(param.value))
112
+ else if (param.in === 'header' && param.value) requestHeaders[param.name] = param.value
113
+ else if (param.value) query.set(param.name, param.value)
114
+ }
115
+ const queryString = query.toString()
116
+ if (queryString) url += `?${queryString}`
117
+ for (const field of authFields) if (field.value.trim()) requestHeaders[field.name] = field.value
118
+ for (const header of headers) if (header.key.trim()) requestHeaders[header.key] = header.value
119
+
120
+ const init: RequestInit = { method: method.toUpperCase(), headers: requestHeaders }
121
+ const filled = bodyFields.filter((field) => field.value !== '')
122
+ if (filled.length > 0) {
123
+ requestHeaders['Content-Type'] = 'application/json'
124
+ init.body = JSON.stringify(Object.fromEntries(filled.map((field) => [field.name, bodyValue(field)])))
125
+ }
126
+
127
+ const started = performance.now()
128
+ try {
129
+ const res = await fetch(url, init)
130
+ const text = await res.text()
131
+ let pretty = text
132
+ try {
133
+ pretty = JSON.stringify(JSON.parse(text), null, 2)
134
+ } catch {
135
+ // Not JSON; show the raw text.
136
+ }
137
+ setResponse({ status: res.status, statusText: res.statusText, duration: Math.round(performance.now() - started), body: pretty })
138
+ } catch (err) {
139
+ setError(err instanceof Error ? err.message : 'Request failed')
140
+ } finally {
141
+ setLoading(false)
142
+ }
143
+ }
144
+
145
+ return (
146
+ <Sheet open={open} onOpenChange={onOpenChange}>
147
+ <SheetContent
148
+ side="right"
149
+ className="dory-tryit w-full gap-0 bg-background sm:max-w-[34rem]"
150
+ footer={
151
+ <div className="flex gap-3">
152
+ <button type="button" className="dory-tryit-cancel" onClick={() => onOpenChange(false)}>Cancel</button>
153
+ <button type="button" className="dory-tryit-send" onClick={send} disabled={loading}>
154
+ <CatalogIcon name="play" aria-hidden="true" className="h-3 w-3 fill-current" />
155
+ {loading ? 'Sending…' : 'Send request'}
156
+ </button>
157
+ </div>
158
+ }
159
+ >
160
+ <div className="-mx-4 -mt-4 flex items-center gap-3 border-b border-[var(--border)] px-5 py-4 pr-12">
161
+ <Tag>{method}</Tag>
162
+ <SheetTitle className="truncate font-mono text-base font-medium">{path}</SheetTitle>
163
+ </div>
164
+
165
+ <div className="space-y-6 px-1 pt-5">
166
+ {servers.length > 0 && (
167
+ <label className="block">
168
+ <span className="dory-tryit-label">Server</span>
169
+ <span className="relative mt-2 block">
170
+ <select
171
+ value={serverUrl}
172
+ onChange={(event) => setServerUrl((event.target as HTMLSelectElement).value)}
173
+ className="dory-tryit-input appearance-none pr-9 font-sans"
174
+ >
175
+ {servers.map((server) => (
176
+ <option key={server.url} value={server.url}>
177
+ {server.description ? `${server.description} · ${server.url}` : server.url}
178
+ </option>
179
+ ))}
180
+ </select>
181
+ <CatalogIcon name="up-down" 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" />
182
+ </span>
183
+ </label>
184
+ )}
185
+
186
+ <div>
187
+ <div role="tablist" className="flex gap-5 border-b border-[var(--border)]">
188
+ {tabs.map((name) => (
189
+ <button
190
+ key={name}
191
+ type="button"
192
+ role="tab"
193
+ aria-selected={tab === name}
194
+ onClick={() => setTab(name)}
195
+ className={classNames('-mb-px border-b-2 pb-2 text-sm font-medium transition', {
196
+ 'border-[var(--primary)] text-foreground': tab === name,
197
+ 'border-transparent text-muted-foreground hover:text-foreground': tab !== name,
198
+ })}
199
+ >
200
+ {name}
201
+ </button>
202
+ ))}
203
+ </div>
204
+
205
+ <div className="space-y-4 pt-4">
206
+ {tab === 'Body' && bodyFields.map((field, index) => (
207
+ <FieldInput key={field.name} field={field} onChange={(value) => update(setBodyFields, index, value)} />
208
+ ))}
209
+ {tab === 'Params' && params.map((field, index) => (
210
+ <FieldInput key={`${field.in}-${field.name}`} field={field} onChange={(value) => update(setParams, index, value)} />
211
+ ))}
212
+ {tab === 'Auth' && authFields.map((field, index) => (
213
+ <FieldInput key={field.name} field={field} onChange={(value) => update(setAuthFields, index, value)} />
214
+ ))}
215
+ {tab === 'Headers' && (
216
+ <>
217
+ {headers.map((header, index) => (
218
+ <div key={index} className="flex gap-2">
219
+ <input
220
+ type="text"
221
+ placeholder="Header"
222
+ value={header.key}
223
+ onInput={(event) => setHeaders((list) => list.map((h, i) => (i === index ? { ...h, key: (event.target as HTMLInputElement).value } : h)))}
224
+ className="dory-tryit-input w-2/5"
225
+ />
226
+ <input
227
+ type="text"
228
+ placeholder="Value"
229
+ value={header.value}
230
+ onInput={(event) => setHeaders((list) => list.map((h, i) => (i === index ? { ...h, value: (event.target as HTMLInputElement).value } : h)))}
231
+ className="dory-tryit-input flex-1"
232
+ />
233
+ <button
234
+ type="button"
235
+ aria-label="Remove header"
236
+ onClick={() => setHeaders((list) => list.filter((_, i) => i !== index))}
237
+ className="rounded-lg px-2 text-muted-foreground hover:text-foreground"
238
+ >
239
+ <CatalogIcon name="trash" className="h-4 w-4" />
240
+ </button>
241
+ </div>
242
+ ))}
243
+ <button
244
+ type="button"
245
+ onClick={() => setHeaders((list) => [...list, { key: '', value: '' }])}
246
+ className="flex items-center gap-1.5 text-sm font-medium text-[var(--primary)]"
247
+ >
248
+ <CatalogIcon name="plus" className="h-3.5 w-3.5" />
249
+ Add header
250
+ </button>
251
+ </>
252
+ )}
253
+ </div>
254
+ </div>
255
+
256
+ <div className="pb-2">
257
+ <div className="flex items-center justify-between">
258
+ <span className="dory-tryit-label">Response</span>
259
+ {response && (
260
+ <span className="flex items-center gap-2 font-mono text-xs">
261
+ <span className={classNames('font-semibold', response.status < 300 ? 'text-green-600' : response.status < 400 ? 'text-[var(--highlight)]' : 'text-[var(--destructive)]')}>
262
+ {response.status} {response.statusText}
263
+ </span>
264
+ <span className="text-muted-foreground">{response.duration}ms</span>
265
+ </span>
266
+ )}
267
+ </div>
268
+ <div className="dory-tryit-response mt-2">
269
+ {error ? (
270
+ <p className="p-4 font-mono text-sm text-[var(--destructive)]">{error}</p>
271
+ ) : response ? (
272
+ <div className="overflow-x-auto p-4 font-mono text-[12.5px] leading-[1.7]">
273
+ <Fence language="json">{response.body}</Fence>
274
+ </div>
275
+ ) : (
276
+ <p className="flex min-h-36 items-center justify-center text-sm text-muted-foreground">Send the request to see the response</p>
277
+ )}
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </SheetContent>
282
+ </Sheet>
283
+ )
284
+ }
@@ -1,18 +1,18 @@
1
1
  // Mirrors the heading scale in theme.scss (.dory-h1 to .dory-h4). Keep both in sync.
2
2
  const scale = [
3
- { level: 'H1', size: 26, tracking: -0.025, weight: 700 },
4
- { level: 'H2', size: 21, tracking: -0.02, weight: 700 },
5
- { level: 'H3', size: 17, tracking: -0.01, weight: 700 },
6
- { level: 'H4', size: 15, tracking: -0.005, weight: 600 },
3
+ { level: 'H1', size: 48, tracking: -0.035, weight: 700 },
4
+ { level: 'H2', size: 34, tracking: -0.03, weight: 700 },
5
+ { level: 'H3', size: 22, tracking: -0.02, weight: 700 },
6
+ { level: 'H4', size: 18, tracking: -0.01, weight: 600 },
7
7
  ]
8
8
 
9
9
  /** Shows each heading level with its size and letter-spacing, without emitting real headings. */
10
10
  export function TypeScale() {
11
11
  return (
12
- <div className="not-prose my-2">
12
+ <div className="dory-type-scale not-prose my-2 min-w-0">
13
13
  {scale.map(({ level, size, tracking, weight }) => (
14
- <div key={level} className="flex items-baseline gap-6 border-b border-[var(--border)] py-4 last:border-b-0">
15
- <span className="w-36 shrink-0 font-mono text-[11px] tracking-[0.12em] text-muted-foreground">
14
+ <div key={level} className="flex min-w-0 flex-col items-start gap-2 border-b border-[var(--border)] py-4 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-6">
15
+ <span className="shrink-0 font-mono text-[11px] tracking-[0.12em] text-muted-foreground sm:w-36">
16
16
  {level} · {size} / {tracking}
17
17
  </span>
18
18
  <span
@@ -1,5 +1,6 @@
1
+ import { CatalogIcon } from '@clidey/ux'
1
2
  import classNames from 'classnames'
2
- import { ChevronsUpDown, X } from 'lucide-react'
3
+
3
4
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
4
5
  import { Tag } from './tag'
5
6
 
@@ -45,7 +46,7 @@ function MonoLabel({ children }: { children: string }) {
45
46
  function RemoveButton({ onClick, label }: { onClick: () => void; label: string }) {
46
47
  return (
47
48
  <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
+ <CatalogIcon name="close" className="h-3.5 w-3.5" />
49
50
  </button>
50
51
  )
51
52
  }
@@ -119,16 +120,18 @@ function Select({ value, onChange, children, label }: { value: string; onChange:
119
120
  >
120
121
  {children}
121
122
  </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
+ <CatalogIcon name="up-down" 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
124
  </div>
124
125
  )
125
126
  }
126
127
 
127
128
  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
- )
129
+ const field = (key: keyof AuthState, placeholder: string, password = false) => {
130
+ const onInput = (e: Event) => setAuth({ ...auth, [key]: (e.target as HTMLInputElement).value })
131
+ return password
132
+ ? <input className={inputClass} type="password" value={auth[key] as string} placeholder={placeholder} aria-label={placeholder} onInput={onInput} />
133
+ : <input className={inputClass} value={auth[key] as string} placeholder={placeholder} aria-label={placeholder} onInput={onInput} />
134
+ }
132
135
  return (
133
136
  <div className="flex flex-col gap-3">
134
137
  <Select label="Authentication type" value={auth.type} onChange={(type) => setAuth({ ...auth, type: type as AuthState['type'] })}>
@@ -137,9 +140,9 @@ function AuthTab({ auth, setAuth }: { auth: AuthState; setAuth: (auth: AuthState
137
140
  <option value="apiKey">API key</option>
138
141
  <option value="basic">Basic auth</option>
139
142
  </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>}
143
+ {auth.type === 'bearer' && <div className="flex">{field('token', 'Bearer token', true)}</div>}
144
+ {auth.type === 'apiKey' && <div className="flex">{field('apiKey', 'API key', true)}</div>}
145
+ {auth.type === 'basic' && <div className="flex gap-2">{field('username', 'Username')}{field('password', 'Password', true)}</div>}
143
146
  </div>
144
147
  )
145
148
  }
@@ -148,7 +151,7 @@ const statusDot: Record<ConnectionState, string> = {
148
151
  connected: 'bg-green-600',
149
152
  connecting: 'bg-[var(--highlight)]',
150
153
  error: 'bg-[var(--destructive)]',
151
- disconnected: 'bg-muted-foreground',
154
+ disconnected: 'bg-[var(--muted-foreground)]',
152
155
  }
153
156
 
154
157
  const statusText: Record<ConnectionState, string> = {
@@ -254,7 +257,8 @@ export function WebSocketPanel({
254
257
  </button>
255
258
  </div>
256
259
 
257
- <div className="flex flex-col gap-4 px-4 pt-4 pb-5">
260
+ <div className="grid lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
261
+ <div className="flex flex-col gap-4 border-b border-[var(--border)] px-4 pt-4 pb-5 lg:border-b-0 lg:border-r">
258
262
  {servers.length > 0 && (
259
263
  <div className="flex flex-col gap-2">
260
264
  <MonoLabel>Server</MonoLabel>
@@ -286,11 +290,12 @@ export function WebSocketPanel({
286
290
  </div>
287
291
  </div>
288
292
 
289
- <div className="flex items-center justify-between border-t border-[var(--border)] px-4 py-2.5">
293
+ <div className="flex flex-col">
294
+ <div className="flex items-center justify-between px-4 py-2.5">
290
295
  <MonoLabel>Messages</MonoLabel>
291
296
  <button type="button" onClick={() => setMessages([])} className="text-sm text-muted-foreground hover:text-foreground">Clear</button>
292
297
  </div>
293
- <div className="max-h-72 min-h-56 overflow-y-auto border-t border-[var(--border)] bg-background px-4 py-3">
298
+ <div className="max-h-96 min-h-64 flex-1 overflow-y-auto border-t border-[var(--border)] bg-background px-4 py-3">
294
299
  {messages.length === 0 ? (
295
300
  <p className="flex h-48 items-center justify-center text-sm text-muted-foreground">Connect to start receiving messages</p>
296
301
  ) : (
@@ -327,6 +332,8 @@ export function WebSocketPanel({
327
332
  Send
328
333
  </button>
329
334
  </div>
335
+ </div>
336
+ </div>
330
337
  </div>
331
338
  )
332
339
  }
@@ -7,9 +7,14 @@ import { resolve } from 'path';
7
7
  * PostHog is only injected if `analytics.posthog.token` is configured.
8
8
  */
9
9
  export function analyticsInjector(): Plugin {
10
+ let isProductionBuild = false;
10
11
  return {
11
12
  name: 'analytics-injector',
13
+ configResolved(config) {
14
+ isProductionBuild = config.command === 'build';
15
+ },
12
16
  transformIndexHtml(html) {
17
+ if (!isProductionBuild) return html;
13
18
  const doryJsonPath = resolve(process.cwd(), 'docs', 'dory.json');
14
19
  if (!existsSync(doryJsonPath)) return html;
15
20
 
@@ -28,6 +33,9 @@ export function analyticsInjector(): Plugin {
28
33
  posthog.init(${token}, {
29
34
  api_host: ${apiHost},
30
35
  person_profiles: 'identified_only',
36
+ autocapture: false,
37
+ capture_pageview: 'history_change',
38
+ capture_pageleave: 'if_capture_pageview',
31
39
  })
32
40
  </script>`;
33
41
 
@@ -0,0 +1,32 @@
1
+ import { loadEnv, type Plugin } from 'vite';
2
+ import { resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
4
+ import { handleChatRequest } from '../../bin/chat-server.ts';
5
+
6
+ /** Development-only same-origin chat endpoint. Production uses `dory preview`. */
7
+ export function chatDevServer(): Plugin {
8
+ return {
9
+ name: 'dory-chat-dev-server',
10
+ configResolved(config) {
11
+ if (config.command !== 'serve') return;
12
+ // Vite parses .env for its own client env, but does not populate process.env.
13
+ // The chat handler reads these values only on the dev server.
14
+ const env = loadEnv(config.mode, config.root, '');
15
+ for (const name of [
16
+ 'DORY_CHAT_API_KEY', 'OPENAI_API_KEY', 'DORY_CHAT_MODEL', 'DORY_CHAT_BASE_URL',
17
+ 'DORY_CHAT_PER_MINUTE', 'DORY_CHAT_PER_IP_DAY', 'DORY_CHAT_GLOBAL_DAY',
18
+ 'DORY_CHAT_MAX_CONCURRENT', 'DORY_CHAT_MAX_OUTPUT_TOKENS',
19
+ ]) {
20
+ if (!process.env[name] && env[name]) process.env[name] = env[name];
21
+ }
22
+ },
23
+ configureServer(server) {
24
+ server.middlewares.use((req, res, next) => {
25
+ const root = resolve(process.cwd(), 'docs');
26
+ handleChatRequest(req, res, { docsDir: existsSync(root) ? root : process.cwd(), localDevelopment: true })
27
+ .then(handled => { if (!handled) next(); })
28
+ .catch(next);
29
+ });
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { docsAssetsPlugin } from './docs-assets';
6
+
7
+ describe('docs assets', () => {
8
+ it('copies regular assets and excludes symlinks to files outside docs', () => {
9
+ const root = mkdtempSync(join(tmpdir(), 'dory-assets-'));
10
+ try {
11
+ mkdirSync(join(root, 'docs'));
12
+ writeFileSync(join(root, 'secret'), 'private');
13
+ writeFileSync(join(root, 'docs', 'safe.svg'), '<svg />');
14
+ symlinkSync(join(root, 'secret'), join(root, 'docs', 'leak.svg'));
15
+ vi.spyOn(process, 'cwd').mockReturnValue(root);
16
+ const closeBundle = docsAssetsPlugin().closeBundle as () => void;
17
+ closeBundle();
18
+ expect(existsSync(join(root, 'dist', 'safe.svg'))).toBe(true);
19
+ expect(existsSync(join(root, 'dist', 'leak.svg'))).toBe(false);
20
+ } finally {
21
+ vi.restoreAllMocks();
22
+ rmSync(root, { recursive: true, force: true });
23
+ }
24
+ });
25
+ });
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import { cpSync, existsSync, readdirSync, statSync } from 'fs';
2
+ import { cpSync, existsSync, lstatSync, readdirSync } from 'fs';
3
3
  import { resolve, extname } from 'path';
4
4
 
5
5
  /**
@@ -54,7 +54,11 @@ export function docsAssetsPlugin(): Plugin {
54
54
  const itemRelativePath = relativePath ? `${relativePath}/${item}` : item;
55
55
 
56
56
  try {
57
- const stats = statSync(srcPath);
57
+ const stats = lstatSync(srcPath);
58
+
59
+ // Do not copy symlinks: they can point outside docs and leak files
60
+ // from the build machine through the generated static site.
61
+ if (stats.isSymbolicLink()) continue;
58
62
 
59
63
  if (stats.isDirectory()) {
60
64
  // Recursively copy directories
@@ -1,7 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import path from 'path';
3
- import { generateFrontmatterJson, generateSearchContentJson } from './frontmatter-generator';
4
- import { getBasePath, readDoryConfig } from './shared';
3
+ import { generateFrontmatterJson, generateSearchContentJson } from './frontmatter-generator.ts';
4
+ import { getBasePath, readDoryConfig } from './shared.ts';
5
5
 
6
6
  /**
7
7
  * Dev server plugin that serves frontmatter.json and search-content.json
@@ -7,7 +7,7 @@ import {
7
7
  pathFromFilename,
8
8
  findMdxFilesMap,
9
9
  stripMdxToText,
10
- } from './shared';
10
+ } from './shared.ts';
11
11
 
12
12
  interface FrontmatterGeneratorOptions {
13
13
  docsDir?: string;
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { resolve } from 'path';
3
- import { isPathInside } from './html-files-middleware';
3
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs';
4
+ import { tmpdir } from 'os';
5
+ import { join } from 'path';
6
+ import { isPathInside, isRealPathInside } from './html-files-middleware';
4
7
 
5
8
  describe('isPathInside', () => {
6
9
  const root = resolve('/srv/app/dist');
@@ -19,3 +22,20 @@ describe('isPathInside', () => {
19
22
  expect(isPathInside(root, resolve('/srv/app/dist-other/file'))).toBe(false);
20
23
  });
21
24
  });
25
+
26
+ describe('isRealPathInside', () => {
27
+ it('rejects a symlink that escapes the served directory', () => {
28
+ const parent = mkdtempSync(join(tmpdir(), 'dory-assets-'));
29
+ try {
30
+ const docs = join(parent, 'docs');
31
+ mkdirSync(docs);
32
+ writeFileSync(join(parent, 'secret'), 'private');
33
+ writeFileSync(join(docs, 'safe.svg'), '<svg />');
34
+ symlinkSync(join(parent, 'secret'), join(docs, 'leak.svg'));
35
+ expect(isRealPathInside(docs, join(docs, 'safe.svg'))).toBe(true);
36
+ expect(isRealPathInside(docs, join(docs, 'leak.svg'))).toBe(false);
37
+ } finally {
38
+ rmSync(parent, { recursive: true, force: true });
39
+ }
40
+ });
41
+ });
@@ -1,7 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readFileSync, existsSync, statSync } from 'fs';
2
+ import { readFileSync, existsSync, realpathSync, statSync } from 'fs';
3
3
  import { resolve, extname, sep } from 'path';
4
- import { getBasePath, readDoryConfig } from './shared';
4
+ import { getBasePath, readDoryConfig } from './shared.ts';
5
5
 
6
6
  /**
7
7
  * Returns true if `target` is the `root` path itself or is contained within it.
@@ -11,6 +11,15 @@ export function isPathInside(root: string, target: string): boolean {
11
11
  return target === root || target.startsWith(root + sep);
12
12
  }
13
13
 
14
+ /** Resolve symlinks before serving a file so a path inside docs cannot point outside it. */
15
+ export function isRealPathInside(root: string, target: string): boolean {
16
+ try {
17
+ return isPathInside(realpathSync(root), realpathSync(target));
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
14
23
  /**
15
24
  * Vite plugin to serve HTML files and assets directly from the docs directory
16
25
  * and static files from the dist directory without routing them through the SPA
@@ -46,6 +55,7 @@ export function htmlFilesMiddleware(): Plugin {
46
55
  '.woff2': 'font/woff2',
47
56
  '.ttf': 'font/ttf',
48
57
  '.eot': 'application/vnd.ms-fontobject',
58
+ '.mdx': 'text/markdown; charset=utf-8',
49
59
  };
50
60
 
51
61
  return {
@@ -80,7 +90,12 @@ export function htmlFilesMiddleware(): Plugin {
80
90
 
81
91
  if (existsSync(distPath)) {
82
92
  try {
83
- const content = readFileSync(distPath);
93
+ if (!isRealPathInside(distRoot, distPath)) {
94
+ res.statusCode = 403;
95
+ res.end('Forbidden');
96
+ return;
97
+ }
98
+ const content = readFileSync(realpathSync(distPath));
84
99
  const ext = extname(url).toLowerCase();
85
100
  const contentType = mimeTypes[ext] || 'application/octet-stream';
86
101
 
@@ -108,15 +123,20 @@ export function htmlFilesMiddleware(): Plugin {
108
123
 
109
124
  if (existsSync(docsPath)) {
110
125
  try {
126
+ if (!isRealPathInside(docsRoot, docsPath)) {
127
+ res.statusCode = 403;
128
+ res.end('Forbidden');
129
+ return;
130
+ }
111
131
  const stats = statSync(docsPath);
112
132
 
113
133
  // If it's a file, serve it
114
134
  if (stats.isFile()) {
115
135
  const ext = extname(url).toLowerCase();
116
136
 
117
- // Only serve HTML files and assets, not MDX files
118
- if (url.endsWith('.html') || assetExtensions.has(ext)) {
119
- const content = readFileSync(docsPath);
137
+ // HTML, assets, and MDX sources (the build copies these too, for Open MDX / Copy page)
138
+ if (url.endsWith('.html') || ext === '.mdx' || assetExtensions.has(ext)) {
139
+ const content = readFileSync(realpathSync(docsPath));
120
140
  const contentType = mimeTypes[ext] || 'application/octet-stream';
121
141
 
122
142
  res.setHeader('Content-Type', contentType);