@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,229 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import { withBase } from '../utils/base-path';
3
+ import { captureDocsEvent } from '../utils/analytics';
4
+
5
+ import { CatalogIcon, Button, CopyButton, copyToClipboard } from '@clidey/ux';
6
+ import docsConfig from '../../docs/dory.json' with { type: 'json' };
7
+ import type { DoryConfig } from '../types/config';
8
+
9
+ export type Citation = { id: string; title: string; url: string; excerpt: string };
10
+ export type Message = { role: 'user' | 'assistant'; content: string; citations?: Citation[]; followUps?: string[] };
11
+ export type ChatTransport = {
12
+ status: () => Promise<boolean>;
13
+ ask: (message: string, history: Pick<Message, 'role' | 'content'>[], pathname: string, signal: AbortSignal) => Promise<{ answer?: string; citations?: Citation[]; followUps?: string[]; error?: string }>;
14
+ };
15
+
16
+ class ChatRequestError extends Error {
17
+ readonly status: number;
18
+ constructor(message: string, status: number) { super(message); this.status = status; }
19
+ }
20
+
21
+ const defaultTransport: ChatTransport = {
22
+ async status() {
23
+ const response = await fetch(withBase('/api/chat/status'), { credentials: 'same-origin' });
24
+ if (!response.headers.get('content-type')?.includes('application/json')) return false;
25
+ const status = await response.json();
26
+ return status?.enabled === true;
27
+ },
28
+ async ask(message, history, pathname, signal) {
29
+ const response = await fetch(withBase('/api/chat'), {
30
+ method: 'POST', credentials: 'same-origin', signal,
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ message, history, pathname }),
33
+ });
34
+ const result = await response.json() as { answer?: string; citations?: Citation[]; followUps?: string[]; error?: string };
35
+ if (!response.ok) throw new ChatRequestError(result.error || 'The answer is unavailable right now.', response.status);
36
+ return result;
37
+ },
38
+ };
39
+
40
+ const site = docsConfig as DoryConfig;
41
+ const suggestedQuestions = Array.isArray(site.chat?.suggestedQuestions)
42
+ ? site.chat.suggestedQuestions.filter(question => typeof question === 'string' && question.trim().length >= 3 && question.trim().length <= 600).slice(0, 4)
43
+ : [];
44
+ const starters = suggestedQuestions?.length ? suggestedQuestions : [
45
+ `How do I get started with ${site.name}?`,
46
+ `What can I do with ${site.name}?`,
47
+ 'Explain this page',
48
+ ];
49
+
50
+ function renderInline(text: string) {
51
+ return text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g).map((part, index) => {
52
+ if (part.startsWith('**') && part.endsWith('**')) return <strong key={index}>{part.slice(2, -2)}</strong>;
53
+ if (part.startsWith('`') && part.endsWith('`')) return <code key={index} className="rounded bg-[var(--accent)] px-1 py-0.5 font-mono text-[0.9em]">{part.slice(1, -1)}</code>;
54
+ return part;
55
+ });
56
+ }
57
+
58
+ function AnswerText({ text }: { text: string }) {
59
+ return <div className="space-y-3 text-sm leading-7 text-foreground sm:text-base">{text.split(/(```[^\n]*\n[\s\S]*?```)/g).map((part, index) => {
60
+ if (part.startsWith('```') && part.endsWith('```')) {
61
+ const language = part.slice(3, part.indexOf('\n')).trim();
62
+ const code = part.slice(part.indexOf('\n') + 1, -3).trimEnd();
63
+ return <div key={index} className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--accent)]">
64
+ <div className="flex items-center justify-between border-b border-[var(--border)] px-3 py-1 text-xs text-muted-foreground">
65
+ <span>{language || 'Code'}</span>
66
+ <CopyButton text={code} tooltipLabel="Copy code" copiedLabel="Code copied" onCopy={() => captureDocsEvent('docs_chat_code_copied', { path: window.location.pathname })} />
67
+ </div>
68
+ <pre className="overflow-x-auto p-4 text-sm leading-6"><code>{code}</code></pre>
69
+ </div>;
70
+ }
71
+ return part.split(/\n\s*\n/).filter(Boolean).map((paragraph, paragraphIndex) => <p key={`${index}-${paragraphIndex}`} className="whitespace-pre-wrap">{renderInline(paragraph.trim())}</p>);
72
+ })}</div>;
73
+ }
74
+
75
+ function CopyAnswerButton({ text }: { text: string }) {
76
+ const [copied, setCopied] = useState(false);
77
+ useEffect(() => {
78
+ if (!copied) return;
79
+ const timeout = window.setTimeout(() => setCopied(false), 2000);
80
+ return () => window.clearTimeout(timeout);
81
+ }, [copied]);
82
+
83
+ return <Button type="button" variant="ghost" size="sm" className="gap-2 text-xs text-muted-foreground" onClick={async () => {
84
+ if (await copyToClipboard(text)) {
85
+ setCopied(true);
86
+ captureDocsEvent('docs_chat_answer_copied', { path: window.location.pathname });
87
+ }
88
+ }}>
89
+ <CatalogIcon name={copied ? 'check' : 'copy'} className="h-4 w-4" />
90
+ {copied ? 'Copied' : 'Copy answer'}
91
+ </Button>;
92
+ }
93
+
94
+ export function useChatAssistant(options: { transport?: ChatTransport; initialMessages?: Message[]; initialDraft?: string; onMessagesChange?: (messages: Message[]) => void } = {}) {
95
+ const transport = options.transport ?? defaultTransport;
96
+ const [enabled, setEnabled] = useState(false);
97
+ const [messages, setMessages] = useState<Message[]>(options.initialMessages ?? []);
98
+ const [draft, setDraft] = useState(options.initialDraft ?? '');
99
+ const [busy, setBusy] = useState(false);
100
+ const [error, setError] = useState('');
101
+ const controller = useRef<AbortController | null>(null);
102
+
103
+ useEffect(() => {
104
+ transport.status()
105
+ .then(setEnabled)
106
+ .catch(() => setEnabled(false));
107
+ return () => controller.current?.abort();
108
+ }, []);
109
+ useEffect(() => { options.onMessagesChange?.(messages); }, [messages]);
110
+
111
+ function reset() {
112
+ controller.current?.abort();
113
+ controller.current = null;
114
+ setMessages([]);
115
+ setDraft('');
116
+ setError('');
117
+ setBusy(false);
118
+ }
119
+
120
+ async function ask(question = draft) {
121
+ const text = question.trim();
122
+ if (busy || text.length < 3 || text.length > 600) return;
123
+ const previous = messages.slice(-6).map(({ role, content }) => ({ role, content }));
124
+ setMessages(current => [...current, { role: 'user', content: text }]);
125
+ setDraft('');
126
+ setError('');
127
+ setBusy(true);
128
+ captureDocsEvent('docs_chat_question', { path: window.location.pathname, question_length: text.length });
129
+ let errorKind = 'network';
130
+ const request = new AbortController();
131
+ controller.current = request;
132
+ try {
133
+ const result = await transport.ask(text, previous, window.location.pathname, request.signal);
134
+ if (!result.answer) { errorKind = 'invalid_answer'; throw new Error(result.error || 'The answer is unavailable right now.'); }
135
+ if (!request.signal.aborted) {
136
+ setMessages(current => [...current, { role: 'assistant', content: result.answer!, citations: result.citations, followUps: result.followUps }]);
137
+ captureDocsEvent('docs_chat_answer', { path: window.location.pathname, citation_count: result.citations?.length ?? 0 });
138
+ }
139
+ } catch (cause) {
140
+ if (!request.signal.aborted) {
141
+ if (cause instanceof ChatRequestError) errorKind = cause.status === 429 ? 'rate_limited' : 'server_error';
142
+ setError(cause instanceof Error ? cause.message : 'The answer is unavailable right now.');
143
+ captureDocsEvent('docs_chat_error', { path: window.location.pathname, error_kind: errorKind });
144
+ }
145
+ } finally {
146
+ if (controller.current === request) {
147
+ controller.current = null;
148
+ setBusy(false);
149
+ }
150
+ }
151
+ }
152
+
153
+ return { enabled, messages, draft, setDraft, busy, error, ask, reset };
154
+ }
155
+
156
+ export function ChatAssistant({ chat, onNavigate, onSearch, siteName = site.name, questions = starters, intro, onSourceClick }: {
157
+ chat: ReturnType<typeof useChatAssistant>;
158
+ onNavigate: () => void;
159
+ onSearch: () => void;
160
+ siteName?: string;
161
+ questions?: string[];
162
+ intro?: string;
163
+ onSourceClick?: (source: Citation) => void;
164
+ }) {
165
+ const bottom = useRef<HTMLDivElement>(null);
166
+ const input = useRef<HTMLTextAreaElement>(null);
167
+
168
+ useEffect(() => { input.current?.focus(); }, [chat.messages.length === 0]);
169
+ useEffect(() => bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }), [chat.messages, chat.busy]);
170
+
171
+ const composer = (
172
+ <form className="dory-chat-composer border-t border-[var(--border)] p-4 sm:px-6" onSubmit={event => { event.preventDefault(); chat.ask(); }}>
173
+ <label htmlFor="dory-chat-question" className="sr-only">Your documentation question</label>
174
+ <div className="flex items-end gap-2 rounded-xl border border-[var(--border)] bg-[var(--background)] px-3 py-2 focus-within:border-[var(--brand-primary)]">
175
+ <textarea id="dory-chat-question" ref={input} value={chat.draft} onInput={event => chat.setDraft(event.currentTarget.value)} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); chat.ask(); } else if (event.key === 'Tab' && !event.shiftKey) { event.preventDefault(); onSearch(); } }} rows={1} maxLength={600} placeholder={chat.messages.length ? 'Ask a follow-up' : `Ask Dory anything about ${siteName}`} className="max-h-28 min-h-10 flex-1 resize-none bg-transparent px-2 py-2 text-sm outline-none" />
176
+ <button type="button" disabled={chat.busy || chat.draft.trim().length < 3} onClick={() => void chat.ask()} className="rounded-lg bg-[var(--brand-primary)] px-3 py-2 text-sm font-semibold text-white disabled:opacity-40" aria-label="Send question">↑</button>
177
+ </div>
178
+ {chat.messages.length > 0 && <p className="mt-2 text-center text-[11px] text-muted-foreground">Sources are linked when the docs support an answer. Check important details on the page.</p>}
179
+ </form>
180
+ );
181
+
182
+ return (
183
+ <div className={`dory-search-chat flex flex-col ${chat.messages.length === 0 ? 'h-[min(58vh,480px)]' : 'h-[min(78vh,640px)] sm:h-[min(72vh,620px)]'}`}>
184
+ {chat.messages.length === 0 && composer}
185
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-8" aria-live="polite">
186
+ {chat.messages.length === 0 && (
187
+ <div className="space-y-5">
188
+ <p className="text-sm leading-6 text-muted-foreground">{intro ?? 'Ask in plain language. Dory reads these docs and answers with links to the sections it used.'}</p>
189
+ <div className="grid gap-2">
190
+ {questions.map(prompt => <button key={prompt} type="button" onClick={() => chat.ask(prompt)} className="flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm hover:bg-[var(--accent)]"><CatalogIcon name="arrow-up-right" className="h-4 w-4 shrink-0 text-[var(--primary)]" /><span>{prompt}</span></button>)}
191
+ </div>
192
+ </div>
193
+ )}
194
+ <div className="space-y-6">
195
+ {chat.messages.map((message, index) => (
196
+ <div key={index} className="space-y-3">
197
+ {message.role === 'user' ? (
198
+ <h2 className="text-xl font-semibold leading-snug text-foreground">{message.content}</h2>
199
+ ) : (
200
+ <div className="dory-chat-answer space-y-4">
201
+ <AnswerText text={message.content} />
202
+ <CopyAnswerButton text={message.content} />
203
+ {message.citations && message.citations.length > 0 && (
204
+ <div className="space-y-2 pt-2">
205
+ <p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Sources</p>
206
+ <div className="flex flex-wrap gap-2">
207
+ {message.citations.map((source, sourceIndex) => <a key={source.id} href={onSourceClick ? '#' : withBase(source.url)} onClick={event => { if (onSourceClick) { event.preventDefault(); onSourceClick(source); } else { captureDocsEvent('docs_chat_source_clicked', { path: window.location.pathname, source: source.url }); onNavigate(); } }} className="min-w-36 rounded-lg border border-[var(--border)] px-3 py-2 hover:bg-[var(--accent)]">
208
+ <span className="block text-sm font-medium text-foreground"><span className="mr-2 text-[var(--brand-primary)]">{sourceIndex + 1}</span>{source.title}</span>
209
+ {source.excerpt && <span className="mt-1 block line-clamp-2 text-xs text-muted-foreground">{source.excerpt}</span>}
210
+ </a>)}
211
+ </div>
212
+ </div>
213
+ )}
214
+ {message.followUps && message.followUps.length > 0 && (
215
+ <div className="flex flex-wrap gap-2 pt-2">{message.followUps.map(prompt => <button key={prompt} type="button" onClick={() => chat.ask(prompt)} disabled={chat.busy} className="rounded-full border border-[var(--border)] px-3 py-1.5 text-xs hover:bg-[var(--accent)] disabled:opacity-50">→ &nbsp; {prompt}</button>)}</div>
216
+ )}
217
+ </div>
218
+ )}
219
+ </div>
220
+ ))}
221
+ {chat.busy && <p className="text-sm text-muted-foreground" role="status">Reading the docs and checking sources…</p>}
222
+ {chat.error && <p className="rounded-lg bg-red-500/10 p-3 text-sm text-red-700 dark:text-red-300" role="alert">{chat.error}</p>}
223
+ <div ref={bottom} />
224
+ </div>
225
+ </div>
226
+ {chat.messages.length > 0 && composer}
227
+ </div>
228
+ );
229
+ }
@@ -1,4 +1,4 @@
1
- import {
1
+ import { CatalogIcon,
2
2
  DropdownMenu,
3
3
  DropdownMenuTrigger,
4
4
  DropdownMenuContent,
@@ -6,7 +6,7 @@ import {
6
6
  DropdownMenuSeparator,
7
7
  Button
8
8
  } from '@clidey/ux'
9
- import { ChevronDownIcon } from '@heroicons/react/20/solid'
9
+
10
10
  import type { ComponentChildren } from 'preact'
11
11
  import type { ReactNode } from 'preact/compat'
12
12
 
@@ -30,7 +30,7 @@ export default function Dropdown({ buttonLabel, items, className = '' }: Dropdow
30
30
  <DropdownMenuTrigger>
31
31
  <Button className={className}>
32
32
  {buttonLabel}
33
- <ChevronDownIcon className="ml-2 h-4 w-4" />
33
+ <CatalogIcon name="chevron-down" className="ml-2 h-4 w-4" />
34
34
  </Button>
35
35
  </DropdownMenuTrigger>
36
36
  <DropdownMenuContent>
@@ -1,5 +1,5 @@
1
+ import { CatalogIcon } from '@clidey/ux'
1
2
  import { Component, type ComponentChildren } from 'preact';
2
- import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
3
3
 
4
4
  interface ErrorBoundaryProps {
5
5
  children: ComponentChildren;
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
33
33
  return (
34
34
  <div className="flex flex-col items-center justify-center min-h-[50vh] p-8">
35
35
  <div className="w-16 h-16 mb-4 flex items-center justify-center bg-red-100 dark:bg-red-900/30 rounded-full">
36
- <ExclamationTriangleIcon className="w-8 h-8 text-red-500" />
36
+ <CatalogIcon name="warning" className="w-8 h-8 text-red-500" />
37
37
  </div>
38
38
  <h2 className="text-2xl font-semibold text-red-700 dark:text-red-300 mb-2">
39
39
  Something went wrong
@@ -2,6 +2,9 @@ import { useEffect, useState } from 'preact/hooks';
2
2
  import { useLocation } from 'wouter-preact';
3
3
  import { withBase } from '../utils/base-path';
4
4
 
5
+ const initialEmbedParams = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('embedded') === 'true'
6
+ ? new URLSearchParams(window.location.search) : null;
7
+
5
8
  /**
6
9
  * Strips trailing slash from a pathname (except root "/").
7
10
  */
@@ -53,5 +56,21 @@ export const useSearchParams = () => {
53
56
  */
54
57
  export const useIsEmbedded = () => {
55
58
  const searchParams = useSearchParams();
56
- return searchParams.get('embedded') === 'true';
59
+ const [pathname] = useLocation();
60
+
61
+ useEffect(() => {
62
+ if (!initialEmbedParams) return;
63
+ const params = new URLSearchParams(window.location.search);
64
+ let changed = false;
65
+ for (const key of ['embedded', 'parentOrigin', 'theme']) {
66
+ const value = initialEmbedParams.get(key);
67
+ if (value !== null && params.get(key) !== value) {
68
+ params.set(key, value);
69
+ changed = true;
70
+ }
71
+ }
72
+ if (changed) window.history.replaceState(window.history.state, '', `${window.location.pathname}?${params}${window.location.hash}`);
73
+ }, [pathname]);
74
+
75
+ return searchParams.get('embedded') === 'true' || initialEmbedParams !== null;
57
76
  };
@@ -1,58 +1,89 @@
1
- import classNames from 'classnames';
2
- import { useMemo } from 'preact/hooks';
1
+ import { CatalogIcon } from '@clidey/ux'
2
+ import type { ComponentChildren } from 'preact';
3
+
3
4
  import { Link } from 'wouter-preact';
5
+ import docsConfig from '../../docs/dory.json' with { type: 'json' };
6
+ import type { DoryConfig } from '../types/config';
7
+ import { DocsNavigationView, type NavigationPage } from '../ui/docs-navigation-view';
4
8
  import { usePathname } from './hooks';
5
- import { ALL_NAVIGATION } from './store';
9
+ import { ALL_PAGES, completeFrontMatter, getNavigationForPath } from './store';
10
+ import { contentScope, scopedPath } from '../utils/content-scope';
11
+ import { withBase } from '../utils/base-path';
12
+
13
+ const config = docsConfig as DoryConfig;
6
14
 
7
- export function Navigation() {
8
- const pathname = usePathname();
15
+ type Frontmatter = Record<string, any>;
9
16
 
10
- const effectivePath = pathname === '/'
11
- ? ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href ?? '/'
12
- : pathname;
17
+ /** Sidebar label for a page. API pages show a method tag and their path or message name. */
18
+ function pageLabel(href: string, title: string, frontmatter: Frontmatter[]): { method?: string; title: string } {
19
+ const fm = frontmatter.find((entry) => entry.path === href);
20
+ if (typeof fm?.openapi === 'string') {
21
+ const [verb, path] = fm.openapi.split(' ');
22
+ const method = verb.toUpperCase() === 'DELETE' ? 'DEL' : verb.toUpperCase();
23
+ return { method, title: path ?? title };
24
+ }
25
+ if (typeof fm?.asyncapi === 'string') {
26
+ const [operation] = fm.asyncapi.split(' ');
27
+ return { method: operation.toLowerCase() === 'publish' ? 'PUB' : 'SUB', title };
28
+ }
29
+ return { title };
30
+ }
13
31
 
14
- const currentTab = useMemo(() => {
15
- return ALL_NAVIGATION.find(tab =>
16
- tab.groups.some(group =>
17
- group.pages.some(page => page.href === effectivePath)
18
- )
19
- );
20
- }, [effectivePath]);
32
+ /** Static-site adapter for Dory's shared documentation navigation. */
33
+ export function Navigation({ frontmatter = completeFrontMatter }: { frontmatter?: Frontmatter[] }) {
34
+ const pathname = usePathname();
35
+ const scope = contentScope(config, pathname);
36
+ const navigation = getNavigationForPath(pathname);
37
+ const switchTo = (locale: string, version: string) => {
38
+ const candidate = scopedPath(config, pathname, locale, version);
39
+ if (ALL_PAGES[candidate]) return candidate;
40
+ return getNavigationForPath(candidate)[0]?.groups[0]?.pages[0]?.href ?? '/';
41
+ };
42
+ const effectivePath = pathname === '/'
43
+ ? navigation[0]?.groups[0]?.pages[0]?.href ?? '/'
44
+ : pathname;
45
+ const groups = navigation.flatMap(tab => tab.groups).map(group => ({
46
+ title: group.title,
47
+ pages: group.pages.map(page => {
48
+ const translatedTitle = frontmatter.find(entry => entry.path === page.href)?.title ?? page.title;
49
+ return { ...page, ...pageLabel(page.href, translatedTitle, frontmatter) };
50
+ }),
51
+ }));
52
+ const currentVersion = config.versions?.find(version => version.id === scope.version);
53
+ const versionLabel = currentVersion?.label ?? config.version;
54
+ const versionSelect = config.versions && config.versions.length > 0 ? <>
55
+ <CatalogIcon name="up-down" aria-hidden="true" className="h-3.5 w-3.5 text-white/50" />
56
+ {/* Transparent native select over the card: keyboard and screen-reader friendly. */}
57
+ <select
58
+ aria-label="Version"
59
+ value={scope.version}
60
+ onChange={event => {
61
+ const id = (event.target as HTMLSelectElement).value;
62
+ const version = config.versions?.find(entry => entry.id === id || entry.url === id);
63
+ const target = version?.url ?? withBase(switchTo(scope.locale, id));
64
+ window.location.href = target;
65
+ }}
66
+ className="absolute inset-0 cursor-pointer opacity-0"
67
+ >
68
+ <option value="">{config.version}</option>
69
+ {config.versions.map(version => <option key={version.id ?? version.url} value={version.id ?? version.url}>{version.label}</option>)}
70
+ </select>
71
+ </> : undefined;
72
+ const languageSelect = config.i18n && config.i18n.locales.length > 1 ?
73
+ <label className="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white">
74
+ <span className="dory-mono-label">Language</span>
75
+ <select
76
+ aria-label="Language"
77
+ value={scope.locale}
78
+ onChange={event => { window.location.href = withBase(switchTo((event.target as HTMLSelectElement).value, scope.version)); }}
79
+ className="min-w-0 max-w-32 bg-transparent text-right text-sm text-white"
80
+ >
81
+ {config.i18n.locales.map(locale => <option key={locale.code} value={locale.code} className="text-black">{locale.label}</option>)}
82
+ </select>
83
+ </label> : undefined;
84
+ const renderPageLink = (page: NavigationPage, className: string, active: boolean, children: ComponentChildren) =>
85
+ <Link href={page.href} aria-current={active ? 'page' : undefined} className={className}>{children}</Link>;
21
86
 
22
- return (
23
- <nav className="dory-navigation text-base lg:text-sm">
24
- <ul role="list" className="space-y-9">
25
- {currentTab && (
26
- <li key={currentTab.title}>
27
- <h2 className="font-display font-medium text-xs">{currentTab.title}</h2>
28
- {currentTab.groups.map(group => (
29
- <div key={group.title}>
30
- <h3 className="mt-4 text-sm font-semibold">{group.title}</h3>
31
- <ul role="list" className="mt-2 space-y-2 border-l-2 border-border lg:mt-4 lg:space-y-4 lg:border-border">
32
- {group.pages.map((page) => {
33
- const isActive = effectivePath === page.href;
34
- return (
35
- <li key={page.href} className="relative">
36
- <Link
37
- href={page.href}
38
- className={classNames(
39
- 'block w-full pl-3.5 before:pointer-events-none before:absolute before:top-1/2 before:-left-1 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full',
40
- {
41
- 'font-semibold text-brand-foreground': isActive,
42
- 'text-muted-foreground before:hidden before:bg-slate-300 hover:text-muted-foreground hover:before:block dark:text-muted-foreground dark:before:bg-slate-700 dark:hover:text-slate-300': !isActive
43
- },
44
- )}>
45
- {page.title}
46
- </Link>
47
- </li>
48
- );
49
- })}
50
- </ul>
51
- </div>
52
- ))}
53
- </li>
54
- )}
55
- </ul>
56
- </nav>
57
- );
87
+ return <DocsNavigationView groups={groups} activeHref={effectivePath} renderPageLink={renderPageLink}
88
+ versionLabel={versionLabel} versionSelect={versionSelect} languageSelect={languageSelect} />;
58
89
  }
@@ -1,11 +1,10 @@
1
- import { toast } from '@clidey/ux';
2
- import { InfoIcon } from 'lucide-react';
1
+ import { CatalogIcon, toast } from '@clidey/ux';
3
2
 
4
3
  export function useNotification() {
5
4
  return {
6
5
  showNotification: (message: string, description?: string) => {
7
6
  toast(message, {
8
- icon: <InfoIcon className="h-4 w-4" />,
7
+ icon: <CatalogIcon name="info" className="h-4 w-4" />,
9
8
  description: description,
10
9
  });
11
10
  }
@@ -1,26 +1,32 @@
1
1
  import { useEffect, useMemo } from 'preact/hooks';
2
2
  import { Route, Router, useLocation } from 'wouter-preact';
3
3
  import { usePathname } from './hooks';
4
- import { ALL_NAVIGATION, ALL_PAGES, pathFromFilename } from './store';
4
+ import { ALL_PAGES, getNavigationForPath, pathFromFilename } from './store';
5
+ import docsConfig from '../../docs/dory.json' with { type: 'json' };
6
+ import type { DoryConfig } from '../types/config';
7
+ import { contentScope } from '../utils/content-scope';
8
+ import { NotFoundPage } from '@clidey/ux';
5
9
 
6
10
 
7
11
  export default function Routes() {
8
12
  const pathname = usePathname();
9
13
  const [, navigate] = useLocation();
14
+ const scope = contentScope(docsConfig as DoryConfig, pathname);
15
+ const isScopeRoot = pathname === '/' || (Boolean(scope.prefix) && pathname === scope.prefix);
10
16
 
11
17
  // Redirect root to the first page so the URL reflects the actual content
12
18
  useEffect(() => {
13
- if (pathname === '/') {
14
- const firstPage = ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href;
19
+ if (isScopeRoot) {
20
+ const firstPage = getNavigationForPath(pathname)[0]?.groups[0]?.pages[0]?.href;
15
21
  if (firstPage) navigate(firstPage, { replace: true });
16
22
  }
17
23
  }, [pathname]);
18
24
 
19
25
  const route = useMemo(() => {
20
26
  let targetPath = pathname;
21
- if (pathname === '/') {
27
+ if (isScopeRoot) {
22
28
  const ssrRoute = typeof window !== 'undefined' ? window.__DORY_ROUTE__ : undefined;
23
- targetPath = ssrRoute || ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href || pathname;
29
+ targetPath = (pathname === '/' ? ssrRoute : undefined) || getNavigationForPath(pathname)[0]?.groups[0]?.pages[0]?.href || pathname;
24
30
  }
25
31
 
26
32
  const entry = Object.entries(ALL_PAGES).find(([path]) => path === targetPath);
@@ -28,7 +34,7 @@ export default function Routes() {
28
34
 
29
35
  const [path, mod] = entry;
30
36
  return {
31
- path: pathname === '/' ? '/' : pathFromFilename(path),
37
+ path: isScopeRoot ? pathname : pathFromFilename(path),
32
38
  component: (mod as any).default,
33
39
  };
34
40
  }, [pathname]);
@@ -36,19 +42,11 @@ export default function Routes() {
36
42
  // 404 — no MDX module for this path (even if it appears in navigation)
37
43
  if (!route && pathname !== '/') {
38
44
  return (
39
- <div className="flex flex-col items-center justify-center min-h-[50vh] p-8">
40
- <h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
41
- <h2 className="text-2xl font-semibold mb-4">Page Not Found</h2>
42
- <p className="text-gray-600 dark:text-muted-foreground mb-6 text-center max-w-md">
43
- The page <code className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm">{pathname}</code> doesn't exist.
44
- </p>
45
- <button
46
- onClick={() => navigate(ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href ?? '/')}
47
- className="px-4 py-2 text-sm font-medium rounded-md bg-[var(--brand-primary)] text-white hover:opacity-90 transition-opacity"
48
- >
49
- Go to Home
50
- </button>
51
- </div>
45
+ <NotFoundPage
46
+ description={`The page ${pathname} does not exist.`}
47
+ onHome={() => navigate(getNavigationForPath(pathname)[0]?.groups[0]?.pages[0]?.href ?? '/')}
48
+ homeLabel="Go to home"
49
+ />
52
50
  );
53
51
  }
54
52
 
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createSearchIndex } from './search-index';
3
+
4
+ describe('createSearchIndex', () => {
5
+ it('keeps runtime locale indexes separate', async () => {
6
+ const english = createSearchIndex();
7
+ const french = createSearchIndex();
8
+ await english.add('docs/index.mdx', { title: 'Inventory' }, 'publish the app');
9
+ await french.add('docs/fr/index.mdx', { title: 'Inventaire' }, 'publier l’application');
10
+
11
+ expect(english.search('publish').map(item => item.url)).toEqual(['docs/index.mdx']);
12
+ expect(french.search('publish')).toEqual([]);
13
+ expect(french.search('publier').map(item => item.url)).toEqual(['docs/fr/index.mdx']);
14
+ });
15
+ });
@@ -1,54 +1,55 @@
1
- let searchIndex: any = null;
1
+ /** A separate index lets the embedded viewer search only its authorized docs. */
2
+ export function createSearchIndex() {
3
+ let searchIndex: any = null;
2
4
 
3
- async function getSearchIndex() {
4
- if (!searchIndex) {
5
- const { Document } = await import('flexsearch');
6
- searchIndex = new Document({
7
- tokenize: 'full',
8
- document: {
9
- id: 'url',
10
- index: 'content',
11
- store: ['title', 'pageTitle'],
12
- },
13
- context: {
14
- resolution: 9,
15
- depth: 2,
16
- bidirectional: true
17
- }
18
- });
5
+ async function getSearchIndex() {
6
+ if (!searchIndex) {
7
+ const { Document } = await import('flexsearch');
8
+ searchIndex = new Document({
9
+ tokenize: 'full',
10
+ document: {
11
+ id: 'url',
12
+ index: 'content',
13
+ store: ['title', 'pageTitle'],
14
+ },
15
+ context: {
16
+ resolution: 9,
17
+ depth: 2,
18
+ bidirectional: true
19
+ }
20
+ });
21
+ }
22
+ return searchIndex;
19
23
  }
20
- return searchIndex;
21
- }
22
24
 
23
- export async function addToSearchIndex(filename: string, data: any, content: string) {
24
- try {
25
- const index = await getSearchIndex();
26
- index.add({
27
- url: filename,
28
- title: data.title || '',
29
- content: content,
30
- pageTitle: data.title
31
- });
32
- } catch (error) {
33
- console.warn(`Failed to index ${filename}:`, error);
34
- }
25
+ return {
26
+ async add(filename: string, data: { title?: string }, content: string) {
27
+ try {
28
+ const index = await getSearchIndex();
29
+ index.add({
30
+ url: filename,
31
+ title: data.title || '',
32
+ content,
33
+ pageTitle: data.title,
34
+ });
35
+ } catch (error) {
36
+ console.warn(`Failed to index ${filename}:`, error);
37
+ }
38
+ },
39
+ search(query: string, options = {}): Array<{ url: string; title: string; pageTitle: string }> {
40
+ if (!searchIndex) return [];
41
+ const result = searchIndex.search(query, { ...options, enrich: true }) as any[];
42
+ if (result.length === 0) return [];
43
+ return result[0].result.map((item: any) => ({
44
+ url: item.id as string,
45
+ title: item.doc?.title || '',
46
+ pageTitle: item.doc?.pageTitle || '',
47
+ }));
48
+ },
49
+ };
35
50
  }
36
51
 
37
- export function search(query: string, options = {}) {
38
- if (!searchIndex) return [];
52
+ const defaultIndex = createSearchIndex();
39
53
 
40
- const result = searchIndex.search(query, {
41
- ...options,
42
- enrich: true,
43
- }) as any[];
44
-
45
- if (result.length === 0) {
46
- return [];
47
- }
48
-
49
- return result[0].result.map((item: any) => ({
50
- url: item.id,
51
- title: item.doc?.title || '',
52
- pageTitle: item.doc?.pageTitle || '',
53
- }));
54
- }
54
+ export const addToSearchIndex = defaultIndex.add;
55
+ export const search = defaultIndex.search;