@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
package/src/ui/layout.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ComponentChildren } from 'preact';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
3
3
  import { Navigation } from '../components/navigation';
4
- import { ALL_NAVIGATION, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, preloadFrontmatter, ALL_ASYNCAPI, isFrontmatterReady } from '../components/store';
4
+ import { ALL_NAVIGATION, ALL_PAGES, ALL_OPENAPI, completeFrontMatter, loadMDXFrontMatterForPath, loadAllMDXFrontMatter, preloadFrontmatter, ALL_ASYNCAPI, isFrontmatterReady, getNavigationForPath } from '../components/store';
5
5
  import docsConfig from '../../docs/dory.json' with { type: 'json' };
6
6
  import type { DoryConfig } from '../types/config';
7
7
  import { usePathname } from '../components/hooks';
@@ -9,33 +9,42 @@ import { OpenAPI } from '../mdx/open-api';
9
9
  import { AsyncAPI } from '../mdx/async-api';
10
10
  import { Header } from './header';
11
11
  import { PrevNextLinks } from './prev-next-link';
12
- import { TableOfContents } from './table-of-content';
13
12
  import { PageMeta } from './page-meta';
13
+ import { Breadcrumb } from './breadcrumb';
14
+ import { PageActions } from './page-actions';
15
+ import { PageFeedback } from './page-feedback';
16
+ import { PageFooter } from './page-footer';
17
+ import { TableOfContents } from './table-of-content';
18
+ import { DocsPageFrame } from './docs-page-frame';
14
19
  import { Loading } from '../components/loading';
15
- import Dropdown from '../components/dropdown';
16
- import { useNotification } from '../components/notification';
17
- import { SparkleIcon } from 'lucide-react';
18
20
  import { useIsEmbedded } from '../components/hooks';
19
21
  import { ErrorBoundary } from '../components/error-boundary';
20
- import { cn } from '@clidey/ux';
21
- import { withBase } from '../utils/base-path';
22
+ import { contentScope } from '../utils/content-scope';
22
23
 
23
24
  const config = docsConfig as DoryConfig;
24
25
 
26
+ function embedParentOrigin(): string | null {
27
+ try {
28
+ const value = new URLSearchParams(window.location.search).get('parentOrigin');
29
+ if (!value) return null;
30
+ const origin = new URL(value);
31
+ if (origin.origin !== value || !['http:', 'https:'].includes(origin.protocol)) return null;
32
+ return origin.origin === window.location.origin || config.embed?.security?.allowedOrigins?.includes(origin.origin)
33
+ ? origin.origin : null;
34
+ } catch { return null; }
35
+ }
36
+
25
37
  interface LayoutProps {
26
38
  children: ComponentChildren;
27
39
  }
28
40
 
29
- const prompt = (pathname: string) => `Please read and analyze the content from this documentation page: ${window.location.origin}${withBase(pathname)}.mdx\n\n` +
30
- `Then, help me understand and answer any questions I have about it. ` +
31
- `Please provide clear, detailed explanations and examples where relevant.`;
32
-
33
41
  /**
34
- * Main documentation layout: header, sidebar navigation, article content,
35
- * table of contents, and prev/next links.
42
+ * Main documentation layout: header, sidebar navigation, breadcrumb and page
43
+ * actions, article content, feedback, prev/next cards, footer, and the
44
+ * "On this page" rail.
36
45
  *
37
46
  * SYNC NOTE: src/entry-server.tsx renders a static SSR copy of this layout's
38
- * shell (the #container / sidebar nav / main / article wrappers in render()).
47
+ * shell (the .dory-shell / aside / main / article wrappers in render()).
39
48
  * If you change that markup structure here, update entry-server.tsx to match
40
49
  * or hydration will mismatch.
41
50
  */
@@ -43,8 +52,22 @@ export default function Layout({ children }: LayoutProps) {
43
52
  const [loading, setLoading] = useState(!isFrontmatterReady());
44
53
  const rawPathname = usePathname();
45
54
  const pathname = rawPathname === '/' ? ALL_NAVIGATION[0]?.groups[0]?.pages[0]?.href ?? '/' : rawPathname;
46
- const { showNotification } = useNotification();
47
55
  const isEmbedded = useIsEmbedded();
56
+ const parentOrigin = useRef<string | null>(typeof window === 'undefined' ? null : embedParentOrigin());
57
+ const embeddedReady = useRef(false);
58
+ const lastEmbeddedPath = useRef<string | null>(null);
59
+
60
+ useEffect(() => {
61
+ if (!isEmbedded || loading || !parentOrigin.current || window.parent === window) return;
62
+ if (!embeddedReady.current) {
63
+ window.parent.postMessage({ source: 'dory', type: 'dory:ready' }, parentOrigin.current);
64
+ embeddedReady.current = true;
65
+ }
66
+ if (lastEmbeddedPath.current !== pathname) {
67
+ window.parent.postMessage({ source: 'dory', type: 'dory:navigate', path: pathname }, parentOrigin.current);
68
+ lastEmbeddedPath.current = pathname;
69
+ }
70
+ }, [isEmbedded, loading, pathname]);
48
71
 
49
72
  // Client-side navigation keeps the previous scroll position, so start each new
50
73
  // page at the top. The initial load keeps the browser's own position, and a
@@ -60,49 +83,6 @@ export default function Layout({ children }: LayoutProps) {
60
83
  }
61
84
  }, [pathname]);
62
85
 
63
- const handleOpenMDX = useCallback(() => {
64
- window.open(`${window.location.origin}${withBase(pathname)}.mdx`, '_blank');
65
- }, [pathname]);
66
-
67
- const handleCopyMDX = useCallback(async () => {
68
- try {
69
- const response = await fetch(`${withBase(pathname)}.mdx`);
70
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
71
- await navigator.clipboard.writeText(await response.text());
72
- showNotification('Copied to clipboard');
73
- } catch (error) {
74
- console.warn(`Failed to fetch ${pathname}.mdx:`, error);
75
- showNotification('Could not copy MDX', 'The raw MDX file is not available.');
76
- }
77
- }, [pathname, showNotification]);
78
- const handleOpenChatGPT = useCallback(() => {
79
- const url = `https://chat.openai.com/?q=${encodeURIComponent(prompt(pathname))}`;
80
- window.open(url, '_blank');
81
- }, [pathname]);
82
- const handleOpenAnthropic = useCallback(() => {
83
- const url = `https://claude.ai/new?q=${encodeURIComponent(prompt(pathname))}`;
84
- window.open(url, '_blank');
85
- }, [pathname]);
86
-
87
- const dropdownItems = useMemo(() => [
88
- {
89
- label: 'Open MDX',
90
- onClick: handleOpenMDX,
91
- },
92
- {
93
- label: 'Copy MDX',
94
- onClick: handleCopyMDX
95
- },
96
- {
97
- label: 'Open in ChatGPT',
98
- onClick: handleOpenChatGPT
99
- },
100
- {
101
- label: 'Open in Anthropic',
102
- onClick: handleOpenAnthropic
103
- }
104
- ], [handleOpenMDX, handleCopyMDX, handleOpenChatGPT, handleOpenAnthropic]);
105
-
106
86
  useEffect(() => {
107
87
  // Try to preload frontmatter first (optimized approach).
108
88
  // If inline data was available from SSR, preloadFrontmatter() resolves immediately.
@@ -124,14 +104,12 @@ export default function Layout({ children }: LayoutProps) {
124
104
 
125
105
 
126
106
  const { title: group, group: title, page } = useMemo(() => {
127
- const group = ALL_NAVIGATION.flatMap((tab) =>
128
- tab.groups.find((group) =>
129
- group.pages.some((page) => page.href === pathname)
130
- )
131
- );
107
+ const group = getNavigationForPath(pathname)
108
+ .flatMap((tab) => tab.groups)
109
+ .find((group) => group.pages.some((page) => page.href === pathname));
132
110
  const page = completeFrontMatter.find((page) => page.path === pathname);
133
111
 
134
- return { group: group[0]?.title, title: page?.title, page };
112
+ return { group: group?.title, title: page?.title, page };
135
113
  }, [pathname, loading]);
136
114
 
137
115
  // Update document title and meta tags when page changes
@@ -194,7 +172,7 @@ export default function Layout({ children }: LayoutProps) {
194
172
  }, [page]);
195
173
 
196
174
  const isLandingPage = useMemo(() => {
197
- const firstTab = ALL_NAVIGATION[0];
175
+ const firstTab = getNavigationForPath(pathname)[0];
198
176
  if (!firstTab) return false;
199
177
  return (
200
178
  firstTab.groups.length === 1 &&
@@ -211,8 +189,9 @@ export default function Layout({ children }: LayoutProps) {
211
189
  if (tab == null || tab.openapi == null) {
212
190
  return null;
213
191
  }
214
- const firstDirectory = `/${tab.path.split('/')[1]}`;
215
- return ALL_OPENAPI[`${firstDirectory}/openapi.json`].default;
192
+ const scope = contentScope(config, tab.path);
193
+ const firstDirectory = `/${scope.page.split('/')[1]}`;
194
+ return (ALL_OPENAPI[`${scope.prefix}${firstDirectory}/openapi.json`] ?? ALL_OPENAPI[`${firstDirectory}/openapi.json`])?.default ?? null;
216
195
  }, [tab, loading]);
217
196
 
218
197
  const { method, path } = useMemo(() => {
@@ -226,8 +205,9 @@ export default function Layout({ children }: LayoutProps) {
226
205
  if (tab == null || tab.asyncapi == null) {
227
206
  return null;
228
207
  }
229
- const firstDirectory = `/${tab.path.split('/')[1]}`;
230
- return ALL_ASYNCAPI[`${firstDirectory}/asyncapi.json`].default;
208
+ const scope = contentScope(config, tab.path);
209
+ const firstDirectory = `/${scope.page.split('/')[1]}`;
210
+ return (ALL_ASYNCAPI[`${scope.prefix}${firstDirectory}/asyncapi.json`] ?? ALL_ASYNCAPI[`${firstDirectory}/asyncapi.json`])?.default ?? null;
231
211
  }, [tab, loading]);
232
212
 
233
213
  const { operation, channel } = useMemo(() => {
@@ -239,95 +219,61 @@ export default function Layout({ children }: LayoutProps) {
239
219
 
240
220
  if (loading) return <div className="h-[25vh] flex grow"><Loading /></div>;
241
221
 
242
- if (isLandingPage) {
222
+ const errorFallback = (error: Error) => (
223
+ <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
224
+ <p className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
225
+ This page failed to render
226
+ </p>
227
+ <pre className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-3 max-w-md overflow-auto">
228
+ {error.message}
229
+ </pre>
230
+ </div>
231
+ );
232
+
233
+ const content = openAPIJSON && tab && "openapi" in tab && method && path
234
+ ? <OpenAPI openAPIJson={JSON.parse(openAPIJSON)} method={method} path={path} />
235
+ : asyncAPIJSON && tab && "asyncapi" in tab && operation && channel
236
+ ? <AsyncAPI asyncAPIJson={JSON.parse(asyncAPIJSON)} operation={operation} channel={channel} />
237
+ : children;
238
+
239
+ const scope = contentScope(config, pathname);
240
+ const isScopeRoot = Boolean(scope.prefix) && pathname === scope.prefix;
241
+ const isNotFound = !ALL_PAGES[pathname] && !isScopeRoot;
242
+ const showSidebar = !isEmbedded && !isLandingPage;
243
+ // OpenAPI pages lay out their own two columns, so they skip the TOC and take its width.
244
+ const isOpenAPIPage = openAPIJSON != null;
245
+
246
+ if (isNotFound) {
243
247
  return (
244
- <div className="flex w-full flex-col">
245
- <Header />
246
- <main className="w-full">
247
- <ErrorBoundary fallback={(error) => (
248
- <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
249
- <p className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
250
- This page failed to render
251
- </p>
252
- <pre className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-3 max-w-md overflow-auto">
253
- {error.message}
254
- </pre>
255
- </div>
256
- )}>
257
- {children}
258
- </ErrorBoundary>
259
- </main>
260
- </div>
248
+ <main className="dory-main flex min-h-dvh w-full items-center justify-center bg-background px-6 py-12 text-foreground">
249
+ <div className="w-full">
250
+ <ErrorBoundary fallback={errorFallback}>{children}</ErrorBoundary>
251
+ </div>
252
+ </main>
261
253
  );
262
254
  }
263
255
 
264
256
  return (
265
- <div className="flex w-full flex-col">
257
+ isLandingPage ? <div className="dory-shell flex min-h-screen w-full flex-col bg-[var(--sidebar)]">
266
258
  <Header />
267
- <div id="container" className={cn("relative flex w-full", {
268
- "max-w-8xl sm:px-2 lg:px-8": !isEmbedded,
269
- "px-0": isEmbedded,
270
- })}>
271
- <div className={cn("hidden lg:relative lg:block lg:flex-none", {
272
- "lg:hidden": isEmbedded,
273
- })}>
274
- <div className="sticky top-[4.75rem] -ml-0.5 h-[calc(100vh-4.75rem)] w-64 overflow-x-hidden overflow-y-auto py-16 pr-8 pl-0.5 xl:w-72 xl:pr-16">
275
- <Navigation />
276
- </div>
277
- </div>
278
- <main className={cn("flex grow max-w-full", {
279
- "px-4 py-16 sm:px-6 lg:px-8": !isEmbedded,
280
- "p-8": isEmbedded,
281
- })}>
282
- <div className="min-w-0 w-full">
283
- <article className="h-full flex flex-1 flex-col">
284
- <div className="flex justify-between items-start">
285
- <header className="mb-8">
286
- {title && (
287
- <p className="dory-subtitle text-sm font-medium tracking-widest uppercase mb-2">
288
- {title}
289
- </p>
290
- )}
291
- {group && (
292
- <h1 className="dory-page-title text-4xl tracking-tight">
293
- {group}
294
- </h1>
295
- )}
296
- {page?.lead && <p className="dory-lead">{page.lead}</p>}
297
- </header>
298
- {
299
- openAPIJSON == null && asyncAPIJSON == null &&
300
- <Dropdown className="gap-0" buttonLabel={<SparkleIcon className="w-4 h-4" />} items={dropdownItems} />
301
- }
302
- </div>
303
- <PageMeta meta={page?.meta} />
304
- <div className="flex-1 min-h-[calc(100vh-2rem)]">
305
- <ErrorBoundary fallback={(error) => (
306
- <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
307
- <p className="text-lg font-semibold text-red-700 dark:text-red-300 mb-2">
308
- This page failed to render
309
- </p>
310
- <pre className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-3 max-w-md overflow-auto">
311
- {error.message}
312
- </pre>
313
- </div>
314
- )}>
315
- {openAPIJSON && tab && "openapi" in tab && method && path ?
316
- <OpenAPI openAPIJson={JSON.parse(openAPIJSON)} method={method} path={path} />
317
- : asyncAPIJSON && tab && "asyncapi" in tab && operation && channel ?
318
- <AsyncAPI asyncAPIJson={JSON.parse(asyncAPIJSON)} operation={operation} channel={channel} />
319
- : children}
320
- </ErrorBoundary>
321
- </div>
322
- <PrevNextLinks />
323
- </article>
324
- </div>
325
- {
326
- openAPIJSON == null && asyncAPIJSON == null && !isEmbedded &&
327
- <TableOfContents />
328
- }
259
+ <div className="flex flex-1">
260
+ <main className="dory-main min-w-0 flex-1 bg-background px-6 py-10 text-foreground sm:px-10">
261
+ <ErrorBoundary fallback={errorFallback}>{children}</ErrorBoundary>
329
262
  </main>
330
263
  </div>
331
- </div>
264
+ </div> : <DocsPageFrame
265
+ header={<Header />}
266
+ sidebar={showSidebar ? <Navigation /> : undefined}
267
+ breadcrumb={<Breadcrumb group={title} title={group} />}
268
+ actions={<PageActions pathname={pathname} />}
269
+ title={group}
270
+ lead={page?.lead}
271
+ meta={<PageMeta meta={page?.meta} />}
272
+ content={<ErrorBoundary fallback={errorFallback}>{content}</ErrorBoundary>}
273
+ afterContent={<><PageFeedback pathname={pathname} /><PrevNextLinks /><PageFooter /></>}
274
+ toc={!isOpenAPIPage && <TableOfContents />}
275
+ embedded={isEmbedded}
276
+ wide={isOpenAPIPage}
277
+ />
332
278
  );
333
- }
279
+ }
@@ -1,11 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { Sheet, SheetContent, Button, ModeToggle } from '@clidey/ux'
4
- import { createContext, Suspense, useCallback, useContext, useRef, useState } from 'react'
3
+ import { CatalogIcon, Sheet, SheetContent, Button, ModeToggle } from '@clidey/ux'
4
+ import { createContext, Suspense, useCallback, useContext, useEffect, useRef, useState } from 'react'
5
5
 
6
- import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'
7
6
  import { Navigation } from '../components/navigation'
8
- import { useIsEmbedded } from '../components/hooks'
7
+ import { useIsEmbedded, usePathname } from '../components/hooks'
9
8
 
10
9
  const IsInsideMobileNavigationContext = createContext(false)
11
10
 
@@ -34,8 +33,11 @@ export function useMobileNavigation() {
34
33
  export function MobileNavigation() {
35
34
  const isInsideMobileNavigation = useIsInsideMobileNavigation();
36
35
  const { isOpen, open, close } = useMobileNavigation();
37
- const ToggleIcon = isOpen ? XMarkIcon : Bars3Icon;
38
36
  const isEmbedded = useIsEmbedded();
37
+ const pathname = usePathname();
38
+
39
+ // Picking a page navigates client-side, so close the menu once the route changes.
40
+ useEffect(() => close(), [pathname, close]);
39
41
 
40
42
  return (
41
43
  <IsInsideMobileNavigationContext.Provider value={true}>
@@ -47,15 +49,15 @@ export function MobileNavigation() {
47
49
  <Button
48
50
  variant="outline"
49
51
  size="icon"
50
- className="relative flex size-6 lg:hidden p-4"
52
+ className="relative flex size-6 border-white/15 bg-white/5 text-white hover:bg-white/10 lg:hidden p-4"
51
53
  aria-label="Toggle navigation"
52
54
  onClick={open}
53
55
  >
54
- <ToggleIcon className="w-8 stroke-zinc-900 dark:stroke-white" />
56
+ <CatalogIcon name={isOpen ? 'close' : 'menu'} className="w-8 stroke-white" />
55
57
  </Button>
56
58
  {!isInsideMobileNavigation && (
57
59
  <Suspense fallback={null}>
58
- <SheetContent side="left" className="w-full sm:max-w-sm p-8 overflow-y-auto flex flex-col justify-between">
60
+ <SheetContent side="left" className="w-full sm:max-w-sm overflow-y-auto border-white/10 bg-[var(--sidebar)] p-4 pt-12 text-white">
59
61
  <Navigation />
60
62
  {
61
63
  isEmbedded &&
@@ -0,0 +1,67 @@
1
+ import { useCallback } from 'preact/hooks'
2
+
3
+ import { CatalogIcon, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@clidey/ux'
4
+ import { useNotification } from '../components/notification'
5
+ import { withBase } from '../utils/base-path'
6
+
7
+ const prompt = (pathname: string) => `Please read and analyze the content from this documentation page: ${window.location.origin}${withBase(pathname)}.mdx\n\n` +
8
+ `Then, help me understand and answer any questions I have about it. ` +
9
+ `Please provide clear, detailed explanations and examples where relevant.`
10
+
11
+ /** The page action controls shared by built sites and sandboxed docs. */
12
+ export function PageActionsView({ onCopy, onOpenMDX, onOpenChatGPT, onOpenClaude }: {
13
+ onCopy: () => void;
14
+ onOpenMDX?: () => void;
15
+ onOpenChatGPT: () => void;
16
+ onOpenClaude: () => void;
17
+ }) {
18
+ const items = [
19
+ ...(onOpenMDX ? [{ label: 'Open MDX', action: onOpenMDX }] : []),
20
+ { label: 'Open in ChatGPT', action: onOpenChatGPT },
21
+ { label: 'Open in Claude', action: onOpenClaude },
22
+ ]
23
+ return (
24
+ <div className="dory-page-actions">
25
+ <button type="button" onClick={onCopy}>
26
+ <CatalogIcon name="copy" aria-hidden="true" className="h-3.5 w-3.5" />
27
+ Copy page
28
+ </button>
29
+ <DropdownMenu>
30
+ <DropdownMenuTrigger className="dory-page-actions-ai">
31
+ <CatalogIcon name="ai" aria-hidden="true" className="h-3.5 w-3.5 text-[var(--primary)]" />
32
+ AI actions
33
+ <CatalogIcon name="chevron-down" aria-hidden="true" className="h-3.5 w-3.5" />
34
+ </DropdownMenuTrigger>
35
+ <DropdownMenuContent align="end">
36
+ {items.map((item) => (
37
+ <DropdownMenuItem key={item.label} className="cursor-pointer" onClick={item.action}>
38
+ {item.label}
39
+ </DropdownMenuItem>
40
+ ))}
41
+ </DropdownMenuContent>
42
+ </DropdownMenu>
43
+ </div>
44
+ )
45
+ }
46
+
47
+ /** "Copy page" and "AI actions" connected to a built Dory site's raw MDX URLs. */
48
+ export function PageActions({ pathname }: { pathname: string }) {
49
+ const { showNotification } = useNotification()
50
+ const handleCopyMDX = useCallback(async () => {
51
+ try {
52
+ const response = await fetch(`${withBase(pathname)}.mdx`)
53
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
54
+ await navigator.clipboard.writeText(await response.text())
55
+ showNotification('Copied to clipboard')
56
+ } catch (error) {
57
+ console.warn(`Failed to fetch ${pathname}.mdx:`, error)
58
+ showNotification('Could not copy MDX', 'The raw MDX file is not available.')
59
+ }
60
+ }, [pathname, showNotification])
61
+ return <PageActionsView
62
+ onCopy={handleCopyMDX}
63
+ onOpenMDX={() => window.open(`${window.location.origin}${withBase(pathname)}.mdx`, '_blank')}
64
+ onOpenChatGPT={() => window.open(`https://chat.openai.com/?q=${encodeURIComponent(prompt(pathname))}`, '_blank')}
65
+ onOpenClaude={() => window.open(`https://claude.ai/new?q=${encodeURIComponent(prompt(pathname))}`, '_blank')}
66
+ />
67
+ }
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import { captureDocsEvent } from '../utils/analytics'
3
+
4
+ /**
5
+ * "Was this page helpful?" prompt and a small page engagement signal.
6
+ */
7
+ export function PageFeedbackView({ pathname, onAnswer }: { pathname: string; onAnswer: (helpful: boolean) => void }) {
8
+ const [answered, setAnswered] = useState(false)
9
+
10
+ useEffect(() => setAnswered(false), [pathname])
11
+
12
+ const answer = (helpful: boolean) => {
13
+ onAnswer(helpful)
14
+ setAnswered(true)
15
+ }
16
+
17
+ return (
18
+ <div className="dory-feedback mt-16 flex items-center justify-between border-y border-[var(--border)] py-4">
19
+ {answered ? (
20
+ <p className="text-sm text-muted-foreground" role="status">Thanks for the feedback.</p>
21
+ ) : (
22
+ <>
23
+ <p className="text-sm font-medium text-foreground">Was this page helpful?</p>
24
+ <div className="flex gap-2">
25
+ <button type="button" onClick={() => answer(true)}>Yes</button>
26
+ <button type="button" onClick={() => answer(false)}>No</button>
27
+ </div>
28
+ </>
29
+ )}
30
+ </div>
31
+ )
32
+ }
33
+
34
+ export function PageFeedback({ pathname }: { pathname: string }) {
35
+
36
+ useEffect(() => {
37
+ let visibleSeconds = 0
38
+ const timer = window.setInterval(() => {
39
+ if (document.visibilityState !== 'visible') return
40
+ visibleSeconds += 1
41
+ if (visibleSeconds === 15) {
42
+ captureDocsEvent('docs_page_engaged', { path: pathname, active_seconds: 15 })
43
+ window.clearInterval(timer)
44
+ }
45
+ }, 1000)
46
+ return () => window.clearInterval(timer)
47
+ }, [pathname])
48
+
49
+ return <PageFeedbackView pathname={pathname} onAnswer={helpful => captureDocsEvent('docs_page_feedback', { path: pathname, helpful })} />
50
+ }
@@ -0,0 +1,23 @@
1
+ import docsConfig from '../../docs/dory.json' with { type: 'json' }
2
+ import type { DoryConfig } from '../types/config'
3
+ import { DORY_REPO_URL, DoryMark } from './dory-mark'
4
+
5
+ const config = docsConfig as DoryConfig
6
+
7
+ /** Article footer: "Powered by Dory" and the copyright line. */
8
+ export function PageFooterView({ copyright, name }: { copyright?: string; name: string }) {
9
+ return (
10
+ <footer className="dory-page-footer mt-12 flex items-center justify-between pb-4">
11
+ <a href={DORY_REPO_URL} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2">
12
+ <DoryMark className="h-3.5 w-3.5" />
13
+ Powered by Dory
14
+ </a>
15
+ <span>© {new Date().getFullYear()} {copyright ?? name}</span>
16
+ </footer>
17
+ )
18
+ }
19
+
20
+ /** Article footer configured for the current built Dory site. */
21
+ export function PageFooter() {
22
+ return <PageFooterView copyright={config.copyright} name={config.name} />
23
+ }
@@ -1,75 +1,21 @@
1
- import { ArrowRightIcon } from "@heroicons/react/24/outline";
2
- import { withBase } from '../utils/base-path';
3
- import classNames from "classnames";
4
- import { ALL_NAVIGATION } from "../components/store";
1
+ import { completeFrontMatter, getNavigationForPath, type Navigation } from "../components/store";
5
2
  import { usePathname } from "../components/hooks";
3
+ import { PrevNextLinksView, type PageEntry } from './prev-next-view';
6
4
 
7
- function PageLink({
8
- title,
9
- href,
10
- dir = 'next',
11
- ...props
12
- }: Omit<React.ComponentPropsWithoutRef<'div'>, 'dir' | 'title'> & {
13
- title: string
14
- href: string
15
- dir?: 'previous' | 'next'
16
- }) {
17
- return (
18
- <div {...props}>
19
- <p className="font-display text-xs font-medium text-muted-foreground">
20
- {dir === 'next' ? 'Next' : 'Previous'}
21
- </p>
22
- <div className="mt-1">
23
- <a href={withBase(href)} className={classNames('flex items-center gap-x-1 text-sm font-semibold hover:opacity-80', {
24
- 'flex-row-reverse': dir === 'previous',
25
- },
26
- )}>
27
- <p className={classNames("flex items-center gap-x-1 text-sm font-semibold hover:opacity-80", {
28
- "flex-row-reverse": dir === "previous"
29
- })}>
30
- {title}
31
- <ArrowRightIcon
32
- className={classNames(
33
- 'h-4 w-4 flex-none fill-current',
34
- {
35
- 'rotate-180': dir === 'previous',
36
- },
37
- )}
38
- />
39
- </p>
40
- </a>
41
- </div>
42
- </div>
43
- )
44
- }
45
- export function PrevNextLinks() {
5
+ /**
6
+ * Previous/next page cards in navigation order. Pass `navigation` when
7
+ * rendering on the server, where store titles are not yet loaded.
8
+ */
9
+ export function PrevNextLinks({ navigation }: { navigation?: Navigation[] }) {
46
10
  const pathname = usePathname();
47
- const allLinks = ALL_NAVIGATION.flatMap(tab =>
48
- tab.groups.flatMap(group =>
49
- group.pages.map(page => page.href)
11
+ const pages: PageEntry[] = (navigation ?? getNavigationForPath(pathname)).flatMap(tab =>
12
+ tab.groups.flatMap(group =>
13
+ group.pages.map(page => ({
14
+ title: completeFrontMatter.find(entry => entry.path === page.href)?.title ?? page.title,
15
+ href: page.href,
16
+ group: group.title,
17
+ }))
50
18
  )
51
19
  );
52
- const linkIndex = allLinks.findIndex(link => link === pathname);
53
- const previousPage = linkIndex > -1 ? allLinks[linkIndex - 1] : null;
54
- const nextPage = linkIndex > -1 ? allLinks[linkIndex + 1] : null;
55
-
56
- const previousPageTitle = previousPage ? ALL_NAVIGATION
57
- .flatMap(tab => tab.groups)
58
- .flatMap(group => group.pages)
59
- .find(page => page.href === previousPage)?.title : null;
60
- const nextPageTitle = nextPage ? ALL_NAVIGATION
61
- .flatMap(tab => tab.groups)
62
- .flatMap(group => group.pages)
63
- .find(page => page.href === nextPage)?.title : null;
64
-
65
- if (!nextPage && !previousPage) {
66
- return null;
67
- }
68
-
69
- return (
70
- <div className="mt-12 pt-6 flex border-t border-black/20 dark:border-white/20">
71
- {previousPage && <PageLink dir="previous" title={previousPageTitle ?? ''} href={previousPage} />}
72
- {nextPage && <PageLink className="ml-auto text-right" title={nextPageTitle ?? ''} href={nextPage} />}
73
- </div>
74
- )
20
+ return <PrevNextLinksView pages={pages} pathname={pathname} />;
75
21
  }
@@ -0,0 +1,27 @@
1
+ import classNames from 'classnames';
2
+ import { Link } from 'wouter-preact';
3
+
4
+ export type PageEntry = { title: string; href: string; group: string };
5
+
6
+ function PageCard({ page, dir, className, onNavigate }: { page: PageEntry; dir: 'previous' | 'next'; className?: string; onNavigate?: (href: string) => void }) {
7
+ const contents = <>
8
+ <span className="dory-pager-label">{dir === 'previous' ? `← ${page.group}` : `${page.group} →`}</span>
9
+ <span className="dory-pager-title">{page.title}</span>
10
+ </>;
11
+ const classes = classNames('dory-pager-card', className, { 'text-right': dir === 'next' });
12
+ return onNavigate
13
+ ? <a href="#" className={classes} onClick={event => { event.preventDefault(); onNavigate(page.href); }}>{contents}</a>
14
+ : <Link href={page.href} className={classes}>{contents}</Link>;
15
+ }
16
+
17
+ /** Navigation cards shared by the built site and the app-owned runtime. */
18
+ export function PrevNextLinksView({ pages, pathname, onNavigate }: { pages: PageEntry[]; pathname: string; onNavigate?: (href: string) => void }) {
19
+ const index = pages.findIndex(page => page.href === pathname);
20
+ const previousPage = index > 0 ? pages[index - 1] : null;
21
+ const nextPage = index > -1 ? pages[index + 1] : null;
22
+ if (!nextPage && !previousPage) return null;
23
+ return <div className="mt-6 grid gap-4 sm:grid-cols-2">
24
+ {previousPage && <PageCard dir="previous" page={previousPage} onNavigate={onNavigate} />}
25
+ {nextPage && <PageCard dir="next" page={nextPage} onNavigate={onNavigate} className={classNames({ 'sm:col-span-2': !previousPage })} />}
26
+ </div>;
27
+ }