@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
@@ -0,0 +1,680 @@
1
+ import { render, type ComponentChildren } from 'preact';
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+ import { evaluate } from '@mdx-js/mdx';
4
+ import * as jsxRuntime from 'preact/jsx-runtime';
5
+ import { preprocessMdxSource } from '../mdx/preprocess';
6
+ import { getCommonMdxConfig } from '../config/mdx-common';
7
+ import { h1, h2, h3, h4, h5, h6, p, Row, Col, Anchor, Image } from '../mdx/text';
8
+ import { Code, CodeGroup } from '../mdx/code';
9
+ import { Pre } from '../mdx/fence';
10
+ import { Latex } from '../mdx/latex';
11
+ import { Accordion, AccordionGroup } from '../mdx/accordion';
12
+ import { Icon } from '../mdx/icon';
13
+ import { Card, CardGroup } from '../mdx/card';
14
+ import { Callout, Note, Warning, Info, Tip, Check } from '../mdx/callout';
15
+ import { Expandable } from '../mdx/expandable';
16
+ import { Steps, Step } from '../mdx/step';
17
+ import { Table, Th, Td } from '../mdx/table';
18
+ import { UnorderedList, OrderedList, ListItem } from '../mdx/list';
19
+ import { Tag } from '../mdx/tag';
20
+ import { Chip, ChipGroup } from '../mdx/chip';
21
+ import { Tabs, Tab } from '../mdx/tabs';
22
+ import { TypeScale } from '../mdx/type-scale';
23
+ import { API } from '../mdx/api';
24
+ import { Properties, Property, ResponseField } from '../mdx/open-api';
25
+ import { Specimen, SpecimenProps, Prop } from '../mdx/specimen';
26
+ import { IconGrid } from '../mdx/icon-grid';
27
+
28
+ import { CatalogIcon, Button, Sheet, SheetContent } from '@clidey/ux';
29
+ import { Breadcrumb } from '../ui/breadcrumb';
30
+ import { ChatAssistant, useChatAssistant, type ChatTransport, type Message } from '../components/chat-assistant';
31
+ import { createSearchIndex } from '../components/search-index';
32
+ import { stripMdxToText } from '../utils/search-content';
33
+ import { PageActionsView } from '../ui/page-actions';
34
+ import { PageFooterView } from '../ui/page-footer';
35
+ import { DocsPageFrame } from '../ui/docs-page-frame';
36
+ import { DocsHeaderView } from '../ui/docs-header-view';
37
+ import { DocsNavigationView } from '../ui/docs-navigation-view';
38
+ import { PrevNextLinksView } from '../ui/prev-next-view';
39
+ import { PageFeedbackView } from '../ui/page-feedback';
40
+ import { PageMeta } from '../ui/page-meta';
41
+ import { TableOfContentsView } from '../ui/table-of-content-view';
42
+ import '@clidey/ux/theme-fonts.css';
43
+ import { applyEmbeddedConfig, parseEmbeddedConfig, type EmbeddedConfig } from './config';
44
+ import '@clidey/ux/styles.css';
45
+ import '@clidey/ux/brand.css';
46
+ import '../app.css';
47
+ import '../theme.scss';
48
+
49
+ type DocFile = { path: string; content: string };
50
+ type DocManifestEntry = { path: string; title?: string };
51
+ type ChatTurn = { role: 'user' | 'assistant'; content: string };
52
+ type ChatCitation = { path: string; title: string; excerpt?: string };
53
+ type Labels = {
54
+ documentation: string;
55
+ docsBadge: string;
56
+ version: string;
57
+ sections: string;
58
+ onThisPage: string;
59
+ searchPages: string;
60
+ findPage: string;
61
+ choosePage: string;
62
+ rendering: string;
63
+ waiting: string;
64
+ unsupported: string;
65
+ noPages: string;
66
+ invalidPage: string;
67
+ tooLarge: string;
68
+ renderFailed: string;
69
+ askDory: string;
70
+ chatGrounded: string;
71
+ chatIntro: string;
72
+ chatQuestion: string;
73
+ chatFollowUp: string;
74
+ chatSend: string;
75
+ chatLoading: string;
76
+ chatError: string;
77
+ chatNew: string;
78
+ chatSources: string;
79
+ chatSearch: string;
80
+ chatClose: string;
81
+ };
82
+ type LoadMessage = { __dory_docs: true; type: 'load'; files?: DocFile[]; manifest?: DocManifestEntry[]; path: string; theme?: 'light' | 'dark'; locale?: string; labels?: Partial<Labels>; config?: EmbeddedConfig; chatEnabled?: boolean };
83
+ type FileMessage = { __dory_docs: true; type: 'file'; path: string; requestId: string; content: string };
84
+ type ChatResultMessage = { __dory_docs: true; type: 'chat-result'; requestId: string; answer?: string; citations?: ChatCitation[]; followUps?: string[]; error?: string };
85
+ type MdxModule = { default: (props: { components: typeof components }) => ComponentChildren };
86
+
87
+ const root = (() => {
88
+ const element = document.getElementById('dory-root');
89
+ if (!element) throw new Error('Dory runtime root is missing');
90
+ return element;
91
+ })();
92
+ const filePath = /^docs\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+\.mdx$/;
93
+ let files = new Map<string, string>();
94
+ let manifest = new Map<string, string>();
95
+ const pendingReads = new Map<string, { path: string; resolve: (content: string) => void; reject: (error: Error) => void; timeout: number }>();
96
+ const pendingChats = new Map<string, { resolve: (result: ChatResultMessage) => void; reject: (error: Error) => void; timeout: number }>();
97
+ let nextRequestId = 0;
98
+ let version = 0;
99
+ let activePath = '';
100
+ let activeDocsRoot = 'docs/';
101
+ let site: EmbeddedConfig = {};
102
+ let activeLocale = 'en_US';
103
+ let activeTheme: 'light' | 'dark' = 'dark';
104
+ let chatEnabled = false;
105
+ let chatMessages: Message[] = [];
106
+ let chatScope = '';
107
+ let embeddedSearchIndex = createSearchIndex();
108
+ let searchIndexPromise: Promise<void> | null = null;
109
+ let searchIndexEpoch = 0;
110
+ const defaultLabels: Labels = {
111
+ documentation: 'Documentation',
112
+ docsBadge: 'Docs',
113
+ version: 'Version',
114
+ sections: 'Sections',
115
+ onThisPage: 'On this page',
116
+ searchPages: 'Search documentation',
117
+ findPage: 'Find a page',
118
+ choosePage: 'Choose documentation page',
119
+ rendering: 'Rendering documentation…',
120
+ waiting: 'Waiting for documentation…',
121
+ unsupported: 'This interactive component is unavailable in the embedded viewer.',
122
+ noPages: 'No valid documentation pages were provided.',
123
+ invalidPage: 'A documentation page has an invalid path or exceeds 1 MB.',
124
+ tooLarge: 'Documentation exceeds the 10 MB limit.',
125
+ renderFailed: 'Could not render this page',
126
+ askDory: 'Ask Dory',
127
+ chatGrounded: 'Grounded in these docs',
128
+ chatIntro: 'Find answers in these docs. Open cited pages for full context.',
129
+ chatQuestion: 'Your documentation question',
130
+ chatFollowUp: 'Ask a follow-up',
131
+ chatSend: 'Send question',
132
+ chatLoading: 'Reading the docs and checking sources…',
133
+ chatError: 'The answer is unavailable right now.',
134
+ chatNew: 'New',
135
+ chatSources: 'Sources',
136
+ chatSearch: 'Search',
137
+ chatClose: 'Close Ask Dory',
138
+ };
139
+ let labels = defaultLabels;
140
+
141
+ const send = (type: 'ready' | 'navigate' | 'external' | 'error', details: Record<string, string> = {}) => {
142
+ window.parent.postMessage({ __dory_docs: true, type, ...details }, '*');
143
+ };
144
+
145
+ function readPage(path: string): Promise<string> {
146
+ const cached = files.get(path);
147
+ if (cached !== undefined) return Promise.resolve(cached);
148
+ if (!manifest.has(path)) return Promise.reject(new Error(labels.invalidPage));
149
+ const requestId = String(++nextRequestId);
150
+ return new Promise((resolve, reject) => {
151
+ const timeout = window.setTimeout(() => {
152
+ pendingReads.delete(requestId);
153
+ reject(new Error(labels.renderFailed));
154
+ }, 10_000);
155
+ pendingReads.set(requestId, { path, resolve, reject, timeout });
156
+ window.parent.postMessage({ __dory_docs: true, type: 'read', path, requestId }, '*');
157
+ });
158
+ }
159
+
160
+ function ensureEmbeddedSearchIndex(): Promise<void> {
161
+ if (searchIndexPromise) return searchIndexPromise;
162
+ const epoch = searchIndexEpoch;
163
+ const index = embeddedSearchIndex;
164
+ const pages = [...manifest.entries()].filter(([path]) => path.startsWith(activeDocsRoot)).slice(0, 1000);
165
+ searchIndexPromise = (async () => {
166
+ let bytes = 0;
167
+ for (const [path, title] of pages) {
168
+ if (epoch !== searchIndexEpoch) return;
169
+ try {
170
+ const content = await readPage(path);
171
+ bytes += content.length;
172
+ if (bytes > 10_000_000) return;
173
+ const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
174
+ await index.add(path, { title }, `${title}\n${stripMdxToText(body)}`);
175
+ } catch {
176
+ // One unavailable page must not prevent searching the remaining docs.
177
+ }
178
+ }
179
+ })();
180
+ return searchIndexPromise;
181
+ }
182
+
183
+ function askHost(message: string, history: ChatTurn[]): Promise<ChatResultMessage> {
184
+ const requestId = String(++nextRequestId);
185
+ return new Promise((resolve, reject) => {
186
+ const timeout = window.setTimeout(() => {
187
+ pendingChats.delete(requestId);
188
+ reject(new Error(labels.chatError));
189
+ }, 45_000);
190
+ pendingChats.set(requestId, { resolve, reject, timeout });
191
+ window.parent.postMessage({ __dory_docs: true, type: 'chat', requestId, message, history, path: activePath, locale: activeLocale }, '*');
192
+ });
193
+ }
194
+
195
+ const unsupported = ({ children }: { children?: ComponentChildren }) => <div className="my-4 rounded-lg border border-[var(--border)] bg-muted p-4 text-sm text-muted-foreground">{labels.unsupported}{children}</div>;
196
+ const components = {
197
+ h1, h2, h3, h4, h5, h6, p, a: Anchor, img: Image,
198
+ code: Code, pre: Pre, table: Table, th: Th, td: Td,
199
+ ul: UnorderedList, ol: OrderedList, li: ListItem,
200
+ Row, Col, CodeGroup, Accordion, AccordionGroup, Icon, Card, CardGroup,
201
+ Callout, Note, Warning, Info, Tip, Check, Expandable,
202
+ Steps, Step, Table, Th, Td, UnorderedList, OrderedList, ListItem,
203
+ Tag, Chip, ChipGroup, Tabs, Tab, TypeScale, Latex,
204
+ API, Properties, Property, ResponseField, Specimen, SpecimenProps, Prop, IconGrid,
205
+ hr: () => <hr className="my-6 border-0 border-t border-[var(--border)]" />,
206
+ br: () => <br />,
207
+ APIPlayground: unsupported, WebSocketPlayground: unsupported,
208
+ AsyncAPI: unsupported, Source: unsupported,
209
+ };
210
+
211
+ function frontmatterField(content: string, field: string): string | undefined {
212
+ const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---/);
213
+ return frontmatter?.[1].match(new RegExp(`^${field}:\\s*['\"]?(.+?)['\"]?\\s*$`, 'm'))?.[1];
214
+ }
215
+
216
+ function frontmatterMeta(content: string): Record<string, string> | undefined {
217
+ const frontmatter = content.match(/^---\s*\n([\s\S]*?)\n---/)?.[1];
218
+ const block = frontmatter?.match(/^meta:\s*\n((?:[ \t]+[^\n]*\n?)*)/m)?.[1];
219
+ if (!block) return undefined;
220
+ const entries = block.split('\n').flatMap(line => {
221
+ const match = line.match(/^\s{2,}([^:]+):\s*(.+?)\s*$/);
222
+ return match ? [[match[1].trim(), match[2].trim().replace(/^['"]|['"]$/g, '')] as const] : [];
223
+ });
224
+ return entries.length ? Object.fromEntries(entries) : undefined;
225
+ }
226
+
227
+ function titleOf(path: string, content: string): string {
228
+ const title = frontmatterField(content, 'title');
229
+ return title || path.split('/').pop()!.replace(/\.mdx$/, '').replace(/[-_]/g, ' ');
230
+ }
231
+
232
+ type NavPage = { path: string; title: string; badge?: string };
233
+ type NavGroup = { title: string; pages: NavPage[] };
234
+ type NavTab = { title: string; groups: NavGroup[] };
235
+
236
+ function navigationTabs(): NavTab[] {
237
+ const localized = site.i18n?.locales?.find(item => item.code.replace('_', '-').toLowerCase() === activeLocale.replace('_', '-').toLowerCase())?.navigation;
238
+ const configured = localized ?? site.navigation;
239
+ if (configured?.tabs?.length) {
240
+ const tabs = configured.tabs.map(tab => ({
241
+ title: tab.tab,
242
+ groups: tab.groups.map(group => ({
243
+ title: group.group,
244
+ pages: group.pages.flatMap(entry => {
245
+ const id = typeof entry === 'string' ? entry : entry.page;
246
+ const path = `${activeDocsRoot}${id}.mdx`;
247
+ const title = manifest.get(path);
248
+ return title === undefined ? [] : [{ path, title, badge: typeof entry === 'string' ? undefined : entry.badge }];
249
+ }),
250
+ })).filter(group => group.pages.length),
251
+ })).filter(tab => tab.groups.length);
252
+ if (tabs.length) return tabs;
253
+ }
254
+ return [{ title: labels.documentation, groups: [{ title: labels.documentation, pages: [...manifest.entries()]
255
+ .filter(([path]) => path.startsWith(activeDocsRoot))
256
+ .map(([path, title]) => ({ path, title }))
257
+ .sort((a, b) => a.path === `${activeDocsRoot}index.mdx` ? -1 : b.path === `${activeDocsRoot}index.mdx` ? 1 : a.title.localeCompare(b.title, document.documentElement.lang)) }] }];
258
+ }
259
+
260
+ function EmbeddedContent({ module, title }: { module: MdxModule; title: string }) {
261
+ const ref = useRef<HTMLDivElement>(null);
262
+ const Content = module.default;
263
+ useEffect(() => {
264
+ const heading = ref.current?.querySelector('h1');
265
+ if (heading) heading.hidden = heading.innerText === title;
266
+ }, [module, title]);
267
+ return <div className="h-full antialiased"><div className="flex min-h-full"><div ref={ref} className="flex w-full flex-col"><Content components={components} /></div></div></div>;
268
+ }
269
+
270
+ function ChatDialog({ initialQuery, onClose, onSearch }: { initialQuery: string; onClose: () => void; onSearch: () => void }) {
271
+ const transport: ChatTransport = {
272
+ status: async () => true,
273
+ async ask(message, history) {
274
+ const result = await askHost(message, history);
275
+ return {
276
+ answer: result.answer,
277
+ error: result.error,
278
+ followUps: result.followUps,
279
+ citations: result.citations?.map(citation => ({
280
+ id: citation.path,
281
+ title: citation.title,
282
+ url: citation.path,
283
+ excerpt: citation.excerpt ?? '',
284
+ })),
285
+ };
286
+ },
287
+ };
288
+ const chat = useChatAssistant({
289
+ transport,
290
+ initialMessages: chatMessages,
291
+ initialDraft: initialQuery,
292
+ onMessagesChange: next => { chatMessages = next; },
293
+ });
294
+ useEffect(() => {
295
+ const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
296
+ window.addEventListener('keydown', onKeyDown);
297
+ return () => window.removeEventListener('keydown', onKeyDown);
298
+ }, [onClose]);
299
+ const questions = site.chat?.suggestedQuestions?.length ? site.chat.suggestedQuestions : [
300
+ `How do I get started with ${site.name ?? labels.documentation}?`,
301
+ `What can I do with ${site.name ?? labels.documentation}?`,
302
+ 'Explain this page',
303
+ ];
304
+ const openCitation = (path: string) => {
305
+ const [pagePath, hash] = path.split('#', 2);
306
+ if (!manifest.has(pagePath)) return;
307
+ onClose();
308
+ void showPage(pagePath).then(() => { if (hash) document.getElementById(hash)?.scrollIntoView(); });
309
+ };
310
+
311
+ return <div className="fixed inset-0 z-[70] overflow-y-auto bg-black/60 px-4 py-6 backdrop-blur-sm sm:py-[10vh]" role="presentation" onClick={onClose}>
312
+ <div className="dory-search-panel mx-auto flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] shadow-2xl" role="dialog" aria-modal="true" aria-label={labels.askDory} onClick={event => event.stopPropagation()}>
313
+ <div className="dory-search-tabs flex shrink-0 items-center gap-2 border-b border-[var(--border)] px-5 py-4 sm:px-7">
314
+ <div className="min-w-0 flex-1"><h2 className="text-base font-semibold">{labels.askDory}</h2><p className="text-xs text-muted-foreground">{labels.chatGrounded}</p></div>
315
+ {chat.messages.length > 0 && <Button type="button" variant="ghost" size="sm" onClick={chat.reset}>+ {labels.chatNew}</Button>}
316
+ <Button type="button" variant="ghost" size="sm" onClick={onSearch}>{labels.chatSearch}</Button>
317
+ <Button type="button" variant="ghost" size="sm" aria-label={labels.chatClose} onClick={onClose}><CatalogIcon name="close" className="size-4" /></Button>
318
+ </div>
319
+ <ChatAssistant chat={chat} onNavigate={onClose} onSearch={onSearch}
320
+ siteName={site.name ?? labels.documentation} questions={questions} intro={labels.chatIntro}
321
+ onSourceClick={source => openCitation(source.url)} />
322
+ </div>
323
+ </div>;
324
+ }
325
+
326
+ function EmbeddedSearchDialog({ page, pages, query, setQuery, indexing, onClose, onAsk, onSelect }: {
327
+ page: string;
328
+ pages: NavPage[];
329
+ query: string;
330
+ setQuery: (value: string) => void;
331
+ indexing: boolean;
332
+ onClose: () => void;
333
+ onAsk: () => void;
334
+ onSelect: (path: string) => void;
335
+ }) {
336
+ const input = useRef<HTMLInputElement>(null);
337
+ const [selected, setSelected] = useState(0);
338
+ useEffect(() => { input.current?.focus(); }, []);
339
+ useEffect(() => { setSelected(0); }, [query]);
340
+ const normalized = query.trim().toLocaleLowerCase();
341
+ const suggestions = normalized ? [] : [
342
+ ...pages.filter(item => item.path === page),
343
+ ...Array.from(document.querySelectorAll<HTMLElement>('.dory-article h2[id]')).map(heading => ({
344
+ path: `${page}#${heading.id}`,
345
+ title: heading.textContent?.replace(/#$/, '').trim() ?? '',
346
+ })),
347
+ ...pages.slice(Math.max(0, pages.findIndex(item => item.path === page)) + 1, Math.max(0, pages.findIndex(item => item.path === page)) + 4),
348
+ ];
349
+ const titleMatches = normalized ? pages.filter(item => item.title.toLocaleLowerCase().includes(normalized)) : [];
350
+ const contentMatches = normalized ? embeddedSearchIndex.search(query, { limit: 8 }).map(item => ({ path: item.url, title: item.title })) : [];
351
+ const results = (normalized ? [...titleMatches, ...contentMatches] : suggestions)
352
+ .filter((item, index, list) => item.title && list.findIndex(candidate => candidate.path === item.path) === index)
353
+ .slice(0, 8);
354
+ const hasAsk = chatEnabled && normalized.length >= 3;
355
+ const optionCount = results.length + Number(hasAsk);
356
+ const choose = (index: number) => {
357
+ if (hasAsk && index === 0) onAsk();
358
+ else {
359
+ const result = results[index - Number(hasAsk)];
360
+ if (result) onSelect(result.path);
361
+ }
362
+ };
363
+ const highlight = (title: string) => {
364
+ const start = normalized ? title.toLocaleLowerCase().indexOf(normalized) : -1;
365
+ if (start < 0) return title;
366
+ return <>{title.slice(0, start)}<mark className="dory-search-highlight">{title.slice(start, start + query.trim().length)}</mark>{title.slice(start + query.trim().length)}</>;
367
+ };
368
+
369
+ return <div className="fixed inset-0 z-[65] overflow-y-auto bg-black/50 px-4 py-4 backdrop-blur-sm sm:py-[15vh]" role="presentation" onClick={onClose}>
370
+ <div className="dory-search-panel mx-auto w-full max-w-5xl overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] shadow-2xl" role="dialog" aria-modal="true" aria-label={labels.searchPages} onClick={event => event.stopPropagation()}>
371
+ <div className="relative flex h-16 items-center gap-3 px-5 sm:px-7">
372
+ <CatalogIcon name="search" className="h-5 w-5 shrink-0 text-muted-foreground" aria-hidden="true" />
373
+ <input ref={input} className="dory-search-input h-full min-w-0 flex-1 bg-transparent outline-none" value={query} onInput={event => setQuery(event.currentTarget.value)}
374
+ onKeyDown={event => {
375
+ if (event.key === 'Escape') { event.preventDefault(); onClose(); }
376
+ else if (event.key === 'ArrowDown') { event.preventDefault(); setSelected(current => optionCount ? (current + 1) % optionCount : 0); }
377
+ else if (event.key === 'ArrowUp') { event.preventDefault(); setSelected(current => optionCount ? (current + optionCount - 1) % optionCount : 0); }
378
+ else if (event.key === 'Enter') { event.preventDefault(); choose(selected); }
379
+ else if (event.key === 'Tab' && !event.shiftKey && chatEnabled) { event.preventDefault(); onAsk(); }
380
+ }}
381
+ placeholder={labels.searchPages} aria-label={labels.searchPages} aria-controls="dory-embedded-search-results" aria-activedescendant={optionCount ? `dory-search-result-${selected}` : undefined} />
382
+ <span className="dory-search-active" aria-current="page">Search</span>
383
+ {chatEnabled && <button type="button" className="dory-search-ask-button" onClick={onAsk}>{labels.askDory}</button>}
384
+ </div>
385
+ <div id="dory-embedded-search-results" className="max-h-[60vh] overflow-y-auto border-t border-[var(--border)]">
386
+ {hasAsk && <button id="dory-search-result-0" type="button" onMouseEnter={() => setSelected(0)} onClick={onAsk} className={`dory-search-result flex w-full items-center gap-4 px-8 py-3 text-left ${selected === 0 ? 'bg-[var(--accent)]' : ''}`}>
387
+ <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)]"><CatalogIcon name="ai" className="size-4" /></span>
388
+ <span><span className="block text-sm font-semibold">{query.trim()}</span><span className="block text-xs text-muted-foreground">{labels.askDory} · {labels.chatGrounded}</span></span>
389
+ </button>}
390
+ {hasAsk && results.length > 0 && <p className="px-8 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Pages</p>}
391
+ {results.map((result, index) => <button id={`dory-search-result-${index + Number(hasAsk)}`} key={result.path} type="button" onMouseEnter={() => setSelected(index + Number(hasAsk))} onClick={() => onSelect(result.path)} className={`dory-search-result flex w-full items-center gap-4 px-8 py-3 text-left ${selected === index + Number(hasAsk) ? 'bg-[var(--accent)]' : ''}`}>
392
+ <span className="dory-search-result-icon flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--border)]"><CatalogIcon name="file" className="size-4" /></span>
393
+ <span className="dory-search-title text-sm">{highlight(result.title)}</span>
394
+ </button>)}
395
+ {normalized && results.length === 0 && !indexing && <p className="p-6 text-center text-sm text-muted-foreground">Nothing found for ‘{query.trim()}’. Please try again.</p>}
396
+ {normalized && indexing && <p role="status" className="px-8 py-3 text-xs text-muted-foreground">Searching documentation…</p>}
397
+ </div>
398
+ <div className="dory-search-footer" aria-hidden="true"><span>↑ ↓ Navigate</span><span>↵ Open</span>{chatEnabled && <span>Tab {labels.askDory}</span>}<span>Esc Close</span></div>
399
+ </div>
400
+ </div>;
401
+ }
402
+
403
+ function Shell({ page, source, module }: { page: string; source: string; module: MdxModule }) {
404
+ const [search, setSearch] = useState('');
405
+ const [searchOpen, setSearchOpen] = useState(false);
406
+ const [indexing, setIndexing] = useState(false);
407
+ const [, setSearchRevision] = useState(0);
408
+ const [chatOpen, setChatOpen] = useState(false);
409
+ const [mobileOpen, setMobileOpen] = useState(false);
410
+ useEffect(() => {
411
+ const onKeyDown = (event: KeyboardEvent) => {
412
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
413
+ event.preventDefault();
414
+ setSearchOpen(true);
415
+ } else if (event.key === 'Escape') setSearchOpen(false);
416
+ };
417
+ window.addEventListener('keydown', onKeyDown);
418
+ return () => window.removeEventListener('keydown', onKeyDown);
419
+ }, []);
420
+ useEffect(() => {
421
+ if (!searchOpen) return;
422
+ let active = true;
423
+ setIndexing(true);
424
+ void ensureEmbeddedSearchIndex().finally(() => {
425
+ if (active) { setIndexing(false); setSearchRevision(current => current + 1); }
426
+ });
427
+ return () => { active = false; };
428
+ }, [searchOpen]);
429
+ const [copyMessage, setCopyMessage] = useState('');
430
+ useEffect(() => {
431
+ let clearMessage = 0;
432
+ const onMessage = (event: MessageEvent) => {
433
+ if (event.source !== window.parent || event.data?.__dory_docs !== true || event.data?.type !== 'copied' || event.data?.path !== page) return;
434
+ setCopyMessage(event.data.success === true ? 'Copied to clipboard' : 'Could not copy this page');
435
+ window.clearTimeout(clearMessage);
436
+ clearMessage = window.setTimeout(() => setCopyMessage(''), 3000);
437
+ };
438
+ window.addEventListener('message', onMessage);
439
+ return () => { window.removeEventListener('message', onMessage); window.clearTimeout(clearMessage); };
440
+ }, [page]);
441
+ const tabs = navigationTabs();
442
+ const allPages = tabs.flatMap(tab => tab.groups.flatMap(group => group.pages));
443
+ const siteName = site.name ?? labels.documentation;
444
+ const logo = activeTheme === 'dark' ? site.logo?.dark ?? site.logo?.light : site.logo?.light ?? site.logo?.dark;
445
+ const title = titleOf(page, source);
446
+ const lead = frontmatterField(source, 'lead');
447
+ const groupTitle = tabs.flatMap(tab => tab.groups).find(group => group.pages.some(item => item.path === page))?.title;
448
+ const headerActions = <>
449
+ <div className="dory-topbar-search relative hidden lg:block">
450
+ <div data-slot="search-input-wrapper" className="flex items-center gap-2 px-3">
451
+ <CatalogIcon name="search" className="h-4 w-4 shrink-0" aria-hidden="true" />
452
+ <input id="dory-topbar-search" data-slot="search-input" readOnly value={search} onClick={() => setSearchOpen(true)} placeholder={labels.searchPages} aria-label={labels.searchPages} className="min-w-0 flex-1 cursor-pointer bg-transparent text-sm outline-none" />
453
+ </div>
454
+ <kbd className="dory-kbd" aria-hidden="true">⌘K</kbd>
455
+ </div>
456
+ {chatEnabled && <Button type="button" variant="outline" size="sm" className="hidden h-9 gap-2 border-white/20 bg-white/5 text-white hover:bg-white/10 lg:flex" onClick={() => setChatOpen(true)}><CatalogIcon name="ai" className="size-4" />{labels.askDory}</Button>}
457
+ <span className="dory-mode"><button type="button" className="flex size-9 items-center justify-center" aria-label="Toggle theme" onClick={() => { activeTheme = activeTheme === 'dark' ? 'light' : 'dark'; applyEmbeddedConfig(site, activeTheme); void showPage(page); }}><CatalogIcon name={activeTheme === 'dark' ? 'sun' : 'moon'} size={16} /></button></span>
458
+ <button type="button" aria-label={labels.searchPages} onClick={() => setSearchOpen(true)} className="flex size-9 items-center justify-center text-white/70 lg:hidden"><CatalogIcon name="search" className="size-4" aria-hidden="true" /></button>
459
+ {chatEnabled && <button type="button" aria-label={labels.askDory} onClick={() => setChatOpen(true)} className="flex size-9 items-center justify-center text-white/70 lg:hidden"><CatalogIcon name="ai" className="size-4" /></button>}
460
+ <Button variant="outline" size="icon" className="relative flex size-6 border-white/15 bg-white/5 p-4 text-white hover:bg-white/10 lg:hidden" aria-label="Toggle navigation" onClick={() => setMobileOpen(true)}><CatalogIcon name="menu" className="w-8 stroke-white" /></Button>
461
+ </>;
462
+ const header = <DocsHeaderView name={siteName} logo={logo} homeHref={allPages[0]?.path ?? page}
463
+ tabs={tabs.map(tab => ({ title: tab.title, href: tab.groups[0]?.pages[0]?.path ?? page, active: tab.groups.some(group => group.pages.some(item => item.path === page)) }))}
464
+ renderLink={(href, className, children) => <button key={href} type="button" onClick={() => void showPage(href)} className={`${className} text-left`}>{children}</button>}
465
+ actions={headerActions} docsLabel={labels.docsBadge} sectionsLabel={labels.sections}
466
+ homeClassName="flex w-60 min-w-0 shrink-0 items-center gap-2.5 px-4 max-sm:w-auto max-sm:max-w-[65%]" embedded />;
467
+ const sidebar = <DocsNavigationView
468
+ groups={tabs.flatMap(tab => tab.groups).map(group => ({ title: group.title, pages: group.pages.map(item => ({ href: item.path, title: item.title, badge: item.badge })) }))}
469
+ activeHref={page} renderPageLink={(item, className, active, children) => <button type="button" aria-current={active ? 'page' : undefined} onClick={() => void showPage(item.href)} className={`${className} w-full text-left`}>{children}</button>}
470
+ versionLabel={site.version} versionCaption={labels.version} ariaLabel={labels.documentation} />;
471
+ const prompt = encodeURIComponent(`Please read the documentation for ${title} and answer my questions about it.`);
472
+ const pagerPages = tabs.flatMap(tab => tab.groups.flatMap(group => group.pages.map(item => ({ title: item.title, href: item.path, group: group.title }))));
473
+ const repo = site.repository;
474
+ const repoFile = repo ? `${repo.path ? `${repo.path.replace(/\/$/, '')}/` : ''}${repo.path ? page.replace(/^docs\//, '') : page}` : '';
475
+ const editUrl = repo ? `${repo.url}/edit/${repo.branch ?? 'main'}/${repoFile}` : null;
476
+ const issueUrl = repo ? `${repo.url}/issues/new?title=${encodeURIComponent(`Docs: ${title}`)}&body=${encodeURIComponent(`Page: ${page}`)}` : null;
477
+ const actions = <PageActionsView
478
+ onCopy={() => window.parent.postMessage({ __dory_docs: true, type: 'copy', path: page, requestId: String(++nextRequestId) }, '*')}
479
+ onOpenChatGPT={() => send('external', { url: `https://chat.openai.com/?q=${prompt}` })}
480
+ onOpenClaude={() => send('external', { url: `https://claude.ai/new?q=${prompt}` })}
481
+ />;
482
+ return <><DocsPageFrame
483
+ className="dory-embedded-shell"
484
+ header={header}
485
+ sidebar={sidebar}
486
+ breadcrumb={<Breadcrumb group={groupTitle} title={title} />}
487
+ actions={actions}
488
+ title={title}
489
+ lead={lead}
490
+ meta={<PageMeta meta={frontmatterMeta(source)} />}
491
+ content={<EmbeddedContent module={module} title={title} />}
492
+ afterContent={<>
493
+ <PageFeedbackView pathname={page} onAnswer={helpful => window.parent.postMessage({ __dory_docs: true, type: 'feedback', path: page, helpful }, '*')} />
494
+ <PrevNextLinksView pages={pagerPages} pathname={page} onNavigate={href => void showPage(href)} />
495
+ <PageFooterView name={siteName} copyright={site.copyright} />
496
+ </>}
497
+ toc={<TableOfContentsView pageKey={page} editUrl={editUrl} issueUrl={issueUrl} labels={{ onThisPage: labels.onThisPage }} />}
498
+ />
499
+ <Sheet open={mobileOpen} onOpenChange={setMobileOpen}><SheetContent side="left" className="w-full overflow-y-auto border-white/10 bg-[var(--sidebar)] p-4 pt-12 text-white sm:max-w-sm">{sidebar}</SheetContent></Sheet>
500
+ {searchOpen && <EmbeddedSearchDialog page={page} pages={allPages} query={search} setQuery={setSearch} indexing={indexing} onClose={() => setSearchOpen(false)} onAsk={() => { setSearchOpen(false); setChatOpen(true); }} onSelect={path => {
501
+ setSearchOpen(false);
502
+ const [nextPage, hash] = path.split('#', 2);
503
+ void showPage(nextPage).then(() => { if (hash) document.getElementById(hash)?.scrollIntoView(); });
504
+ }} />}
505
+ {chatEnabled && chatOpen && <ChatDialog initialQuery={search} onClose={() => setChatOpen(false)} onSearch={() => { setChatOpen(false); setSearchOpen(true); }} />}
506
+ {copyMessage && <div role="status" className="fixed bottom-5 right-5 z-50 rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-2 text-sm text-[var(--foreground)] shadow-lg">{copyMessage}</div>}
507
+ </>;
508
+ }
509
+
510
+ function Status({ message, error = false }: { message: string; error?: boolean }) {
511
+ return <div className="dory-shell flex min-h-screen flex-col bg-[var(--sidebar)]">
512
+ <header className="dory-topbar flex h-14 items-center px-4 text-white"><span className="font-heading text-lg font-bold">{site.name ?? labels.documentation}</span><span className="dory-docs-pill ml-2 rounded border border-white/20 px-1.5 py-0.5 text-[10px] uppercase tracking-widest">{labels.docsBadge}</span></header>
513
+ <main className="dory-main flex flex-1 items-start bg-background px-6 py-10 text-foreground sm:px-10 lg:px-12"><div role={error ? 'alert' : 'status'} className={error ? 'rounded-lg border border-destructive p-4 text-destructive' : 'text-muted-foreground'}>{message}</div></main>
514
+ </div>;
515
+ }
516
+
517
+ async function showPage(path: string): Promise<void> {
518
+ if (!manifest.has(path)) return;
519
+ activePath = path;
520
+ const current = ++version;
521
+ render(<Status message={labels.rendering} />, root);
522
+ try {
523
+ const source = await readPage(path);
524
+ if (current !== version) return;
525
+ // MDX is executable JavaScript. Evaluate it only inside this opaque-origin
526
+ // iframe, never in the WhoDB host. Imports are deliberately unsupported.
527
+ const module = await evaluate(preprocessMdxSource(source, path), {
528
+ ...jsxRuntime,
529
+ ...getCommonMdxConfig(false),
530
+ }) as MdxModule;
531
+ if (current !== version) return;
532
+ document.title = `${titleOf(path, source)} — ${site.title ?? site.name ?? labels.documentation}`;
533
+ render(<Shell page={path} source={source} module={module} />, root);
534
+ send('navigate', { path });
535
+ } catch (error) {
536
+ if (current !== version) return;
537
+ const message = error instanceof Error ? error.message : labels.renderFailed;
538
+ render(<Status message={message} error />, root);
539
+ send('error', { message });
540
+ }
541
+ }
542
+
543
+ window.addEventListener('message', event => {
544
+ if (event.source !== window.parent) return;
545
+ const data: unknown = event.data;
546
+ if (!data || typeof data !== 'object') return;
547
+ const file = data as Partial<FileMessage>;
548
+ if (file.__dory_docs === true && file.type === 'file') {
549
+ const pending = typeof file.requestId === 'string' ? pendingReads.get(file.requestId) : undefined;
550
+ if (!pending) return;
551
+ pendingReads.delete(file.requestId!);
552
+ window.clearTimeout(pending.timeout);
553
+ if (file.path !== pending.path || typeof file.content !== 'string' || file.content.length > 1_000_000) {
554
+ pending.reject(new Error(labels.invalidPage));
555
+ return;
556
+ }
557
+ files.set(pending.path, file.content);
558
+ pending.resolve(file.content);
559
+ return;
560
+ }
561
+ const chatResult = data as Partial<ChatResultMessage>;
562
+ if (chatResult.__dory_docs === true && chatResult.type === 'chat-result') {
563
+ const pending = typeof chatResult.requestId === 'string' ? pendingChats.get(chatResult.requestId) : undefined;
564
+ if (!pending) return;
565
+ pendingChats.delete(chatResult.requestId!);
566
+ window.clearTimeout(pending.timeout);
567
+ const answer = typeof chatResult.answer === 'string' && chatResult.answer.length <= 6_000 ? chatResult.answer : undefined;
568
+ const error = typeof chatResult.error === 'string' && chatResult.error.length <= 300 ? chatResult.error : undefined;
569
+ const citations = Array.isArray(chatResult.citations) ? chatResult.citations.slice(0, 5).flatMap(item => {
570
+ if (!item || typeof item.path !== 'string' || !/^docs\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+\.mdx(?:#[a-zA-Z0-9_-]+)?$/.test(item.path)) return [];
571
+ const pagePath = item.path.split('#', 1)[0];
572
+ if (!manifest.has(pagePath) || !pagePath.startsWith(activeDocsRoot)) return [];
573
+ const title = typeof item.title === 'string' && item.title.length <= 200 ? item.title : manifest.get(pagePath)!;
574
+ const excerpt = typeof item.excerpt === 'string' && item.excerpt.length <= 300 ? item.excerpt : undefined;
575
+ return [{ path: item.path, title, excerpt }];
576
+ }) : [];
577
+ const followUps = Array.isArray(chatResult.followUps) ? chatResult.followUps.slice(0, 4).filter((item): item is string => typeof item === 'string' && item.length >= 3 && item.length <= 120) : [];
578
+ if (!answer && !error) pending.reject(new Error(labels.chatError));
579
+ else pending.resolve({ __dory_docs: true, type: 'chat-result', requestId: chatResult.requestId!, answer, error, citations, followUps });
580
+ return;
581
+ }
582
+ const message = data as Partial<LoadMessage>;
583
+ if (message.__dory_docs !== true || message.type !== 'load' || !(Array.isArray(message.manifest) || Array.isArray(message.files))) return;
584
+ site = parseEmbeddedConfig(message.config);
585
+ const locale = typeof message.locale === 'string' && /^[a-z]{2}(?:[-_][a-zA-Z]{2})?$/.test(message.locale) ? message.locale : site.i18n?.defaultLocale ?? 'en_US';
586
+ activeLocale = locale;
587
+ const localeParts = locale.replace('_', '-').toLowerCase().split('-');
588
+ const localePrefix = locale === 'en_US' ? '' : localeParts[0] === 'en' || localeParts[0] === 'zh' ? localeParts.join('-') : localeParts[0];
589
+ activeDocsRoot = `docs/${localePrefix ? `${localePrefix}/` : ''}`;
590
+ document.documentElement.lang = locale.replace('_', '-');
591
+ document.documentElement.dir = /^(?:ar|fa|he|iw|ur)(?:[-_]|$)/.test(locale) ? 'rtl' : 'ltr';
592
+ const supplied = message.labels && typeof message.labels === 'object' ? message.labels : {};
593
+ labels = { ...defaultLabels };
594
+ for (const key of Object.keys(defaultLabels) as (keyof Labels)[]) {
595
+ const value = supplied[key];
596
+ if (typeof value === 'string' && value.length <= 200) labels[key] = value;
597
+ }
598
+ const entries = Array.isArray(message.manifest) ? message.manifest : message.files!;
599
+ if (entries.length === 0 || entries.length > 1000 || typeof message.path !== 'string') {
600
+ send('error', { message: labels.noPages });
601
+ return;
602
+ }
603
+ const nextManifest = new Map<string, string>();
604
+ const nextFiles = new Map<string, string>();
605
+ let total = 0;
606
+ for (const entry of entries) {
607
+ if (!entry || typeof entry.path !== 'string' || !filePath.test(entry.path)) {
608
+ send('error', { message: labels.invalidPage });
609
+ return;
610
+ }
611
+ if ('content' in entry) {
612
+ if (typeof entry.content !== 'string' || entry.content.length > 1_000_000) {
613
+ send('error', { message: labels.invalidPage });
614
+ return;
615
+ }
616
+ total += entry.content.length;
617
+ if (total > 10_000_000) {
618
+ send('error', { message: labels.tooLarge });
619
+ return;
620
+ }
621
+ nextFiles.set(entry.path, entry.content);
622
+ nextManifest.set(entry.path, titleOf(entry.path, entry.content));
623
+ } else {
624
+ const title = typeof entry.title === 'string' && entry.title.length <= 200 ? entry.title.trim() : '';
625
+ nextManifest.set(entry.path, title || entry.path.split('/').pop()!.replace(/\.mdx$/, '').replace(/[-_]/g, ' '));
626
+ }
627
+ }
628
+ for (const pending of pendingReads.values()) {
629
+ window.clearTimeout(pending.timeout);
630
+ pending.reject(new Error(labels.renderFailed));
631
+ }
632
+ pendingReads.clear();
633
+ for (const pending of pendingChats.values()) {
634
+ window.clearTimeout(pending.timeout);
635
+ pending.reject(new Error('The documentation changed. Please ask again.'));
636
+ }
637
+ pendingChats.clear();
638
+ files = nextFiles;
639
+ manifest = nextManifest;
640
+ searchIndexEpoch++;
641
+ embeddedSearchIndex = createSearchIndex();
642
+ searchIndexPromise = null;
643
+ const nextChatScope = `${site.name ?? ''}|${locale}|${[...manifest.entries()].map(([path, title]) => `${path}:${title}`).join('|')}`;
644
+ if (chatScope !== nextChatScope) chatMessages = [];
645
+ chatScope = nextChatScope;
646
+ chatEnabled = message.chatEnabled === true;
647
+ activeTheme = message.theme === 'light' || message.theme === 'dark' ? message.theme : site.theme?.mode === 'light' || site.theme?.mode === 'dark' ? site.theme.mode : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
648
+ applyEmbeddedConfig(site, activeTheme);
649
+ void showPage(manifest.has(message.path) ? message.path : [...manifest.keys()][0] ?? '');
650
+ });
651
+
652
+ document.addEventListener('click', event => {
653
+ const target = event.target;
654
+ if (!(target instanceof Element)) return;
655
+ const link = target.closest('a[href]');
656
+ if (!link) return;
657
+ const href = link.getAttribute('href');
658
+ if (!href || href.startsWith('#')) return;
659
+ event.preventDefault();
660
+ if (/^https?:\/\//i.test(href)) {
661
+ send('external', { url: href });
662
+ return;
663
+ }
664
+ // Resolve relative links against the current MDX file and keep navigation
665
+ // inside the supplied docs tree. Absolute / paths start at docs/.
666
+ try {
667
+ const base = `https://dory.invalid/${activePath.slice(activeDocsRoot.length)}`;
668
+ const resolved = new URL(href, base);
669
+ if (resolved.origin !== 'https://dory.invalid') return;
670
+ const route = decodeURIComponent(resolved.pathname).replace(/^\/+|\/+$/g, '');
671
+ const path = route ? `${activeDocsRoot}${route.endsWith('.mdx') ? route : `${route}.mdx`}` : `${activeDocsRoot}index.mdx`;
672
+ if (!filePath.test(path) || !manifest.has(path)) return;
673
+ void showPage(path).then(() => {
674
+ if (resolved.hash) document.getElementById(decodeURIComponent(resolved.hash.slice(1)))?.scrollIntoView();
675
+ });
676
+ } catch { /* Invalid URLs cannot navigate the documentation frame. */ }
677
+ });
678
+
679
+ render(<Status message={labels.waiting} />, root);
680
+ send('ready');