@clidey/dory 1.0.7 → 1.9.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 (198) hide show
  1. package/README.md +12 -1
  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/dory.js +252 -402
  20. package/bin/dist/dory.js.map +1 -1
  21. package/bin/dist/i18n-init.d.ts +6 -0
  22. package/bin/dist/i18n-init.d.ts.map +1 -0
  23. package/bin/dist/i18n-init.js +56 -0
  24. package/bin/dist/i18n-init.js.map +1 -0
  25. package/bin/dist/mcp-http.d.ts +8 -0
  26. package/bin/dist/mcp-http.d.ts.map +1 -0
  27. package/bin/dist/mcp-http.js +72 -0
  28. package/bin/dist/mcp-http.js.map +1 -0
  29. package/bin/dist/mcp.d.ts +2 -0
  30. package/bin/dist/mcp.d.ts.map +1 -0
  31. package/bin/dist/mcp.js +81 -0
  32. package/bin/dist/mcp.js.map +1 -0
  33. package/bin/dist/preview-entry.d.ts +2 -0
  34. package/bin/dist/preview-entry.d.ts.map +1 -0
  35. package/bin/dist/preview-entry.js +6 -0
  36. package/bin/dist/preview-entry.js.map +1 -0
  37. package/bin/dist/preview-server.d.ts +5 -0
  38. package/bin/dist/preview-server.d.ts.map +1 -0
  39. package/bin/dist/preview-server.js +159 -0
  40. package/bin/dist/preview-server.js.map +1 -0
  41. package/bin/dist/project-config.d.ts +22 -0
  42. package/bin/dist/project-config.d.ts.map +1 -0
  43. package/bin/dist/project-config.js +44 -0
  44. package/bin/dist/project-config.js.map +1 -0
  45. package/dist/index.html +57 -0
  46. package/dist/runtime/dory-runtime.css +2 -0
  47. package/dist/runtime/dory-runtime.html +14 -0
  48. package/dist/runtime/dory-runtime.js +5490 -0
  49. package/docs/api-playground-demo.mdx +119 -0
  50. package/docs/api-reference/asyncapi.json +324 -0
  51. package/docs/api-reference/openapi.json +366 -0
  52. package/docs/api-reference/operations/create.mdx +5 -0
  53. package/docs/api-reference/operations/delete.mdx +5 -0
  54. package/docs/api-reference/operations/get.mdx +5 -0
  55. package/docs/api-reference/operations/update.mdx +5 -0
  56. package/docs/api-reference/websocket-operations/receive-chat-message.mdx +5 -0
  57. package/docs/api-reference/websocket-operations/receive-notifications.mdx +5 -0
  58. package/docs/api-reference/websocket-operations/receive-presence-update.mdx +5 -0
  59. package/docs/api-reference/websocket-operations/send-chat-message.mdx +5 -0
  60. package/docs/api-reference/websocket-operations/send-presence-update.mdx +5 -0
  61. package/docs/assets/dory.jpeg +0 -0
  62. package/docs/components/accordion.mdx +145 -0
  63. package/docs/components/callouts.mdx +134 -0
  64. package/docs/components/cards.mdx +153 -0
  65. package/docs/components/code-blocks.mdx +281 -0
  66. package/docs/components/expandable.mdx +189 -0
  67. package/docs/components/icons.mdx +71 -0
  68. package/docs/components/images.mdx +82 -0
  69. package/docs/components/latex.mdx +148 -0
  70. package/docs/components/layout.mdx +175 -0
  71. package/docs/components/lists.mdx +134 -0
  72. package/docs/components/mermaid.mdx +149 -0
  73. package/docs/components/overview.mdx +81 -0
  74. package/docs/components/source.mdx +130 -0
  75. package/docs/components/steps.mdx +122 -0
  76. package/docs/components/tables.mdx +114 -0
  77. package/docs/components/tags.mdx +179 -0
  78. package/docs/components/typography.mdx +185 -0
  79. package/docs/configuration/analytics.mdx +45 -0
  80. package/docs/configuration/chat.mdx +59 -0
  81. package/docs/configuration/dory-json.mdx +278 -0
  82. package/docs/configuration/embed.mdx +73 -0
  83. package/docs/configuration/fonts.mdx +86 -0
  84. package/docs/configuration/internationalization.mdx +61 -0
  85. package/docs/configuration/navigation.mdx +217 -0
  86. package/docs/configuration/theming.mdx +166 -0
  87. package/docs/dory.json +129 -0
  88. package/docs/embed-example.html +176 -0
  89. package/docs/favicon.svg +18 -0
  90. package/docs/features.mdx +440 -0
  91. package/docs/getting-started/automation.mdx +76 -0
  92. package/docs/getting-started/configuration.mdx +95 -0
  93. package/docs/getting-started/installation.mdx +88 -0
  94. package/docs/getting-started/quick-start.mdx +53 -0
  95. package/docs/getting-started/whodb-ee-apps.mdx +122 -0
  96. package/docs/introduction.mdx +50 -0
  97. package/docs/logo/logo.svg +18 -0
  98. package/docs/playgrounds/api-playground.mdx +122 -0
  99. package/docs/playgrounds/websocket-playground.mdx +98 -0
  100. package/docs/social-card.png +0 -0
  101. package/docs/social-card.svg +17 -0
  102. package/docs/websocket-playground-demo.mdx +135 -0
  103. package/package.json +23 -17
  104. package/scripts/verify-runtime-parity.mjs +146 -0
  105. package/src/app.css +19 -1
  106. package/src/assets/fonts/HankenGrotesk.ttf +0 -0
  107. package/src/assets/fonts/OFL.txt +94 -0
  108. package/src/components/chat-assistant.tsx +229 -0
  109. package/src/components/dropdown.tsx +3 -3
  110. package/src/components/error-boundary.tsx +2 -2
  111. package/src/components/hooks.tsx +20 -1
  112. package/src/components/navigation.tsx +82 -51
  113. package/src/components/notification.tsx +2 -3
  114. package/src/components/routes.tsx +17 -19
  115. package/src/components/search-index.test.ts +15 -0
  116. package/src/components/search-index.ts +49 -48
  117. package/src/components/search.tsx +208 -104
  118. package/src/components/store.ts +27 -9
  119. package/src/config/mdx-common.ts +11 -0
  120. package/src/config/mdx.ts +10 -11
  121. package/src/embed/loader.ts +0 -3
  122. package/src/embed/types.ts +0 -2
  123. package/src/embed/widget-iframe.ts +19 -21
  124. package/src/embedded-runtime/config.ts +138 -0
  125. package/src/embedded-runtime/index.tsx +680 -0
  126. package/src/embedded-runtime/runtime.html +14 -0
  127. package/src/entry-server.tsx +46 -124
  128. package/src/main.tsx +16 -4
  129. package/src/mdx/accordion.tsx +3 -3
  130. package/src/mdx/api-playground.tsx +18 -16
  131. package/src/mdx/async-api.tsx +2 -2
  132. package/src/mdx/card.tsx +3 -2
  133. package/src/mdx/code.tsx +11 -9
  134. package/src/mdx/fence.tsx +6 -5
  135. package/src/mdx/icon-grid.tsx +4 -1
  136. package/src/mdx/icon-names.test.ts +15 -0
  137. package/src/mdx/icon-names.ts +60 -0
  138. package/src/mdx/icon-source-variants.ts +102 -0
  139. package/src/mdx/icon.tsx +12 -73
  140. package/src/mdx/latex.tsx +3 -2
  141. package/src/mdx/mdx.tsx +1 -1
  142. package/src/mdx/mermaid.tsx +12 -6
  143. package/src/mdx/open-api.tsx +130 -74
  144. package/src/mdx/preprocess.test.ts +16 -0
  145. package/src/mdx/preprocess.ts +61 -0
  146. package/src/mdx/processor.ts +4 -96
  147. package/src/mdx/source.tsx +5 -4
  148. package/src/mdx/specimen.tsx +38 -6
  149. package/src/mdx/tabs.tsx +29 -21
  150. package/src/mdx/text.tsx +9 -9
  151. package/src/mdx/try-it-panel.tsx +284 -0
  152. package/src/mdx/type-scale.tsx +4 -4
  153. package/src/mdx/websocket-playground.tsx +21 -14
  154. package/src/plugins/analytics-injector.ts +8 -0
  155. package/src/plugins/chat-dev-server.ts +32 -0
  156. package/src/plugins/docs-assets.test.ts +25 -0
  157. package/src/plugins/docs-assets.ts +6 -2
  158. package/src/plugins/frontmatter-dev-server.ts +2 -2
  159. package/src/plugins/frontmatter-generator.ts +1 -1
  160. package/src/plugins/html-files-middleware.test.ts +21 -1
  161. package/src/plugins/html-files-middleware.ts +26 -6
  162. package/src/plugins/html-metadata-injector.ts +24 -4
  163. package/src/plugins/llm-txt-dev-server.ts +2 -2
  164. package/src/plugins/llm-txt-generator.ts +21 -4
  165. package/src/plugins/prerender.ts +47 -20
  166. package/src/plugins/robots-generator.ts +1 -1
  167. package/src/plugins/sanitize.ts +1 -1
  168. package/src/plugins/shared.ts +6 -18
  169. package/src/plugins/sitemap-generator.ts +1 -1
  170. package/src/plugins/social-preview.test.ts +71 -0
  171. package/src/plugins/social-preview.ts +87 -0
  172. package/src/plugins/unified-mdx-plugin.ts +1 -1
  173. package/src/theme.scss +630 -207
  174. package/src/types/config.ts +53 -1
  175. package/src/ui/breadcrumb.tsx +17 -0
  176. package/src/ui/docs-header-view.tsx +32 -0
  177. package/src/ui/docs-navigation-view.tsx +69 -0
  178. package/src/ui/docs-page-frame.tsx +50 -0
  179. package/src/ui/dory-mark.tsx +9 -0
  180. package/src/ui/header.tsx +33 -90
  181. package/src/ui/layout.tsx +99 -153
  182. package/src/ui/mobile-navigation.tsx +10 -8
  183. package/src/ui/page-actions.tsx +67 -0
  184. package/src/ui/page-feedback.tsx +50 -0
  185. package/src/ui/page-footer.tsx +23 -0
  186. package/src/ui/prev-next-link.tsx +15 -69
  187. package/src/ui/prev-next-view.tsx +27 -0
  188. package/src/ui/table-of-content-view.tsx +167 -0
  189. package/src/ui/table-of-content.tsx +25 -183
  190. package/src/utils/analytics.test.ts +36 -0
  191. package/src/utils/analytics.ts +35 -0
  192. package/src/utils/colors.ts +7 -0
  193. package/src/utils/content-scope.test.ts +26 -0
  194. package/src/utils/content-scope.ts +39 -0
  195. package/src/utils/search-content.ts +12 -0
  196. package/src/utils/theme.ts +1 -7
  197. package/vite.config.ssr.ts +3 -3
  198. package/vite.config.ts +21 -15
@@ -1,9 +1,9 @@
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. */
@@ -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);
@@ -1,5 +1,6 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readDoryConfig, escapeHtml, applyTitleTemplate, getBasePath, getSiteUrl } from './shared';
2
+ import { readDoryConfig, escapeHtml, applyTitleTemplate, getBasePath, getSiteUrl } from './shared.ts';
3
+ import { socialPreviewPath } from './social-preview.ts';
3
4
 
4
5
  /**
5
6
  * Vite plugin that injects site metadata from dory.json into index.html.
@@ -25,7 +26,8 @@ export function htmlMetadataInjector(): Plugin {
25
26
  const twitterHandle: string = config.twitter || '';
26
27
 
27
28
  // Resolve image to absolute URL
28
- const rawImage: string = config.image || '';
29
+ const generateImages = seo.generateSocialImages !== false;
30
+ const rawImage: string = generateImages ? socialPreviewPath('/') : seo.ogImage || config.image || '';
29
31
  const siteImage = rawImage && siteUrl && !rawImage.startsWith('http')
30
32
  ? `${siteUrl}${rawImage.startsWith('/') ? '' : '/'}${rawImage}`
31
33
  : rawImage;
@@ -48,6 +50,24 @@ export function htmlMetadataInjector(): Plugin {
48
50
  html = html.replace('</head>', () => ` ${tag}\n </head>`);
49
51
  };
50
52
 
53
+ // A single site URL is enough for clients to find the MDX index.
54
+ injectHeadTag(`<link rel="alternate" type="text/plain" title="LLM documentation index" href="${escapeHtml(basePath)}/llms.txt" />`);
55
+
56
+ const siteSchema = JSON.stringify({
57
+ '@context': 'https://schema.org',
58
+ '@graph': [
59
+ {
60
+ '@type': 'WebSite', name: siteName, description: defaultDescription,
61
+ ...(siteUrl ? { '@id': `${siteUrl}/#website`, url: `${siteUrl}/` } : {}),
62
+ },
63
+ {
64
+ '@type': 'WebPage', name: defaultTitle, description: defaultDescription,
65
+ ...(siteUrl ? { url: `${siteUrl}/`, isPartOf: { '@id': `${siteUrl}/#website` } } : {}),
66
+ },
67
+ ],
68
+ }).replace(/</g, '\\u003c');
69
+ injectHeadTag(`<script type="application/ld+json" data-dory-site-schema>${siteSchema}</script>`);
70
+
51
71
  // Title
52
72
  setTag(/<title>.*?<\/title>/, `<title>${escapeHtml(defaultTitle)}</title>`);
53
73
 
@@ -128,10 +148,10 @@ export function htmlMetadataInjector(): Plugin {
128
148
  }
129
149
 
130
150
  // Twitter tags
131
- if (seo.twitterCard) {
151
+ if (generateImages || seo.twitterCard) {
132
152
  setTag(
133
153
  /<meta name="twitter:card" content=".*?" \/>/,
134
- `<meta name="twitter:card" content="${escapeHtml(seo.twitterCard)}" />`
154
+ `<meta name="twitter:card" content="${generateImages ? 'summary_large_image' : escapeHtml(seo.twitterCard)}" />`
135
155
  );
136
156
  }
137
157
  setTag(
@@ -1,7 +1,7 @@
1
1
  import type { Plugin } from 'vite';
2
2
  import path from 'path';
3
- import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator';
4
- import { getBasePath, readDoryConfig } from './shared';
3
+ import { generateLlmsIndex, generateLlmContent } from './llm-txt-generator.ts';
4
+ import { getBasePath, readDoryConfig } from './shared.ts';
5
5
 
6
6
  /**
7
7
  * Dev server plugin that serves llms.txt and llms-full.txt on demand,
@@ -8,7 +8,7 @@ import {
8
8
  findMdxFilesMap,
9
9
  pathFromFilename,
10
10
  getSiteUrl,
11
- } from './shared';
11
+ } from './shared.ts';
12
12
 
13
13
  interface LlmTxtGeneratorOptions {
14
14
  docsDir?: string;
@@ -44,28 +44,34 @@ export function generateLlmsIndex(docsDir: string): string {
44
44
  const siteName: string = config.name || 'Documentation';
45
45
  const baseUrl: string = getSiteUrl(config);
46
46
  const mdxFilesMap = findMdxFilesMap(docsDir, docsDir);
47
+ const listed = new Set<string>();
47
48
 
48
49
  let output = `# ${siteName}\n`;
49
50
  if (config.description) {
50
51
  output += `\n> ${config.description}\n`;
51
52
  }
52
53
 
54
+ output += `\nThis site publishes its MDX source. Fetch the .mdx links below for individual pages, or ${baseUrl || ''}/llms-full.txt for one combined text file. Start from this URL to discover documentation without guessing repository paths.\n`;
55
+ output += `\nIf this site is served by Dory's Node preview server, a read-only MCP endpoint is available at ${baseUrl || ''}/mcp. MCP clients require a one-time connection to that endpoint; the page URL alone is sufficient for clients that can browse these links.\n`;
56
+
53
57
  const tabs: any[] = config.navigation?.tabs || [];
54
58
  for (const tab of tabs) {
55
59
  const links: string[] = [];
56
60
  const walkPages = (pages: any[]) => {
57
- for (const page of pages) {
61
+ for (const entry of pages) {
62
+ const page = typeof entry === 'object' && entry && typeof entry.page === 'string' ? entry.page : entry;
58
63
  if (typeof page === 'string') {
59
64
  const fileKey = resolveFileKey(page, mdxFilesMap);
60
65
  if (!fileKey) continue;
66
+ listed.add(fileKey);
61
67
  const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
62
68
  const { data } = parseFrontmatter(raw);
63
69
  const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
64
70
  const url = `${baseUrl}${routePath}.mdx`;
65
71
  const title = data.title || page;
66
72
  links.push(data.description ? `- [${title}](${url}): ${data.description}` : `- [${title}](${url})`);
67
- } else if (typeof page === 'object' && page.pages) {
68
- walkPages(page.pages);
73
+ } else if (typeof entry === 'object' && entry.pages) {
74
+ walkPages(entry.pages);
69
75
  }
70
76
  }
71
77
  };
@@ -77,6 +83,17 @@ export function generateLlmsIndex(docsDir: string): string {
77
83
  }
78
84
  }
79
85
 
86
+ const additional = Object.keys(mdxFilesMap).filter(key => !listed.has(key)).sort();
87
+ if (additional.length > 0) {
88
+ output += '\n## Other pages and translations\n\n';
89
+ for (const fileKey of additional) {
90
+ const raw = fs.readFileSync(mdxFilesMap[fileKey], 'utf-8');
91
+ const { data } = parseFrontmatter(raw);
92
+ const routePath = pathFromFilename(mdxFilesMap[fileKey], docsDir);
93
+ output += `- [${data.title || fileKey}](${baseUrl}${routePath}.mdx)${data.description ? `: ${data.description}` : ''}\n`;
94
+ }
95
+ }
96
+
80
97
  return output.trim() + '\n';
81
98
  }
82
99
 
@@ -11,7 +11,10 @@ import {
11
11
  applyTitleTemplate,
12
12
  getBasePath,
13
13
  getSiteUrl,
14
- } from './shared';
14
+ } from './shared.ts';
15
+ import { contentScope, scopedPath } from '../utils/content-scope.ts';
16
+ import type { DoryConfig } from '../types/config.ts';
17
+ import { renderSocialPreview } from './social-preview.ts';
15
18
 
16
19
  /**
17
20
  * Generates per-route HTML files with page-specific meta tags and JSON-LD
@@ -42,6 +45,7 @@ export function prerender(): Plugin {
42
45
  const baseHtml = readFileSync(indexHtmlPath, 'utf-8');
43
46
 
44
47
  const siteConfig = readDoryConfig() || {};
48
+ const typedConfig = siteConfig as DoryConfig;
45
49
  const seo = siteConfig.seo || {};
46
50
  const baseUrl: string = getSiteUrl(siteConfig);
47
51
  const siteName: string = siteConfig.name || 'Documentation';
@@ -57,7 +61,6 @@ export function prerender(): Plugin {
57
61
  const ogImage = rawOgImage && baseUrl && !rawOgImage.startsWith('http')
58
62
  ? `${baseUrl}${rawOgImage.startsWith('/') ? '' : '/'}${rawOgImage}`
59
63
  : rawOgImage;
60
- const hasImage = Boolean(ogImage || siteConfig.image);
61
64
 
62
65
  // Map route paths to MDX source files for description fallbacks
63
66
  const docsDir = resolve(process.cwd(), 'docs');
@@ -69,6 +72,9 @@ export function prerender(): Plugin {
69
72
 
70
73
  const missingDescriptions: string[] = [];
71
74
  let count = 0;
75
+ if (seo.generateSocialImages !== false) {
76
+ renderSocialPreview({ route: '/', title: siteConfig.title || siteName, description: siteConfig.description || '', config: typedConfig, docsDir, outDir });
77
+ }
72
78
 
73
79
  for (const page of frontmatter) {
74
80
  const routePath = page.path;
@@ -80,8 +86,21 @@ export function prerender(): Plugin {
80
86
  missingDescriptions.push(routePath);
81
87
  }
82
88
  const fullUrl = baseUrl ? `${baseUrl}${routePath}` : '';
89
+ const scope = contentScope(typedConfig, routePath);
90
+ const customImage = typeof page.image === 'string' ? page.image : typeof page.ogImage === 'string' ? page.ogImage : '';
91
+ let image = customImage;
92
+ if (!image && seo.generateSocialImages !== false) {
93
+ image = renderSocialPreview({ route: routePath, title: page.title || siteName, description, config: typedConfig, docsDir, outDir });
94
+ }
95
+ image ||= ogImage || siteConfig.image || '';
96
+ if (image && !/^https?:\/\//i.test(image)) image = `${baseUrl || getBasePath(siteConfig)}/${image.replace(/^\.?\//, '')}`;
83
97
 
84
- let html = baseHtml;
98
+ // Route-specific structured data replaces the homepage graph.
99
+ let html = baseHtml.replace(/<script type="application\/ld\+json" data-dory-site-schema>[\s\S]*?<\/script>/, '');
100
+ html = html.replace(/<html\b([^>]*)>/, (_match, attrs: string) => {
101
+ const withoutLang = attrs.replace(/\s+lang="[^"]*"/, '');
102
+ return `<html${withoutLang} lang="${escapeHtml(scope.locale)}">`;
103
+ });
85
104
  // Function replacements so `$&` etc. in values cannot corrupt output
86
105
  const setTag = (re: RegExp, replacement: string) => {
87
106
  html = html.replace(re, () => replacement);
@@ -105,27 +124,30 @@ export function prerender(): Plugin {
105
124
  /<meta property="og:description" content=".*?" \/>/,
106
125
  `<meta property="og:description" content="${escapeHtml(description)}" />`
107
126
  );
127
+ if (typedConfig.i18n) {
128
+ setTag(
129
+ /<meta property="og:locale" content=".*?" \/>/,
130
+ `<meta property="og:locale" content="${escapeHtml(scope.locale.replace(/-/g, '_'))}" />`
131
+ );
132
+ }
108
133
  setTag(
109
134
  /<meta property="og:type" content=".*?" \/>/,
110
135
  '<meta property="og:type" content="article" />'
111
136
  );
112
- if (ogImage) {
137
+ if (image) {
113
138
  setTag(
114
139
  /<meta property="og:image" content=".*?" \/>/,
115
- `<meta property="og:image" content="${escapeHtml(ogImage)}" />`
140
+ `<meta property="og:image" content="${escapeHtml(image)}" />`
116
141
  );
117
142
  setTag(
118
143
  /<meta name="twitter:image" content=".*?" \/>/,
119
- `<meta name="twitter:image" content="${escapeHtml(ogImage)}" />`
120
- );
121
- }
122
- if (!hasImage) {
123
- // No shareable image — a large-image card would render broken
124
- setTag(
125
- /<meta name="twitter:card" content=".*?" \/>/,
126
- '<meta name="twitter:card" content="summary" />'
144
+ `<meta name="twitter:image" content="${escapeHtml(image)}" />`
127
145
  );
128
146
  }
147
+ setTag(
148
+ /<meta name="twitter:card" content=".*?" \/>/,
149
+ `<meta name="twitter:card" content="${image ? escapeHtml(seo.twitterCard || 'summary_large_image') : 'summary'}" />`
150
+ );
129
151
 
130
152
  // Twitter tags
131
153
  setTag(
@@ -150,6 +172,14 @@ export function prerender(): Plugin {
150
172
  );
151
173
  }
152
174
 
175
+ if (baseUrl && typedConfig.i18n) {
176
+ for (const locale of typedConfig.i18n.locales) {
177
+ const alternate = scopedPath(typedConfig, routePath, locale.code, scope.version);
178
+ if (!fileByRoute[alternate]) continue;
179
+ injectHeadTag(`<link rel="alternate" hreflang="${escapeHtml(locale.code)}" href="${escapeHtml(`${baseUrl}${alternate}`)}" />`);
180
+ }
181
+ }
182
+
153
183
  // Per-page robots directive from frontmatter
154
184
  if (page.robots) {
155
185
  injectHeadTag(`<meta name="robots" content="${escapeHtml(String(page.robots))}" />`);
@@ -253,14 +283,11 @@ function write404(
253
283
  let html = baseHtml;
254
284
  html = html.replace(/<title>.*?<\/title>/, () => `<title>Page not found — ${escapeHtml(siteName)}</title>`);
255
285
  html = html.replace('</head>', () => ' <meta name="robots" content="noindex" />\n </head>');
286
+ // Leave #app empty so the client renders the same UX NotFoundPage used for unknown routes.
287
+ // A separate noscript fallback keeps the static 404 useful without JavaScript.
256
288
  html = html.replace(
257
- '<div id="app"></div>',
258
- () =>
259
- '<div id="app"><main style="max-width:36rem;margin:6rem auto;padding:0 1.5rem;font-family:sans-serif;text-align:center">' +
260
- '<h1>Page not found</h1>' +
261
- '<p>The page you are looking for does not exist.</p>' +
262
- `<p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p>` +
263
- '</main></div>'
289
+ '<noscript>You need to enable JavaScript to run this app.</noscript>',
290
+ () => `<noscript><main><h1>Page not found</h1><p>The page you are looking for does not exist.</p><p><a href="${escapeHtml(firstHref)}">Go to ${escapeHtml(firstTitle)}</a></p></main></noscript>`
264
291
  );
265
292
  writeFileSync(resolve(outDir, '404.html'), html, 'utf-8');
266
293
  }
@@ -1,5 +1,5 @@
1
1
  import type { Plugin } from 'vite';
2
- import { readDoryConfig, getBasePath, getSiteUrl } from './shared';
2
+ import { readDoryConfig, getBasePath, getSiteUrl } from './shared.ts';
3
3
 
4
4
  /**
5
5
  * Generates robots.txt at build time. Emits `Disallow: /` when seo.noindex
@@ -26,7 +26,7 @@ const KNOWN_COMPONENTS = [
26
26
  'Steps', 'Step', 'table', 'th', 'td', 'ul', 'ol', 'li',
27
27
  'UnorderedList', 'OrderedList', 'ListItem',
28
28
  'APIPlayground', 'WebSocketPlayground', 'AsyncAPI', 'Source', 'Tag',
29
- 'Specimen', 'SpecimenProps', 'Chip', 'ChipGroup', 'Tabs', 'Tab', 'IconGrid', 'TypeScale',
29
+ 'Specimen', 'SpecimenProps', 'Prop', 'Chip', 'ChipGroup', 'Tabs', 'Tab', 'IconGrid', 'TypeScale',
30
30
  // Common HTML elements that should be preserved
31
31
  'div', 'span', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 's',
32
32
  'blockquote', 'nav', 'section', 'article', 'header', 'footer', 'main',