@clidey/dory 1.0.8 → 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
@@ -6,44 +6,83 @@ import {
6
6
  } from '@algolia/autocomplete-core'
7
7
  import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'
8
8
  import {
9
- forwardRef,
10
9
  Fragment,
10
+ forwardRef,
11
11
  Suspense,
12
12
  useCallback,
13
13
  useEffect,
14
14
  useId,
15
15
  useRef,
16
16
  useState,
17
- type JSX,
18
17
  } from 'react'
18
+ import type { TargetedEvent, TargetedKeyboardEvent, TargetedMouseEvent } from 'preact'
19
19
  import Highlighter from 'react-highlight-words'
20
- import { cn, Spinner, SearchInput as UxSearchInput } from '@clidey/ux'
20
+ import { CatalogIcon, cn, Spinner, SearchInput as UxSearchInput } from '@clidey/ux'
21
+
21
22
  import classNames from 'classnames'
22
23
  import { useLocation } from 'wouter-preact'
23
24
  import { useMobileNavigation } from '../ui/mobile-navigation'
24
25
  import { useIsEmbedded, usePathname, useSearchParams } from './hooks'
25
26
  import { search } from './search-index'
26
- import { ALL_NAVIGATION } from './store'
27
+ import { ensureSearchContent, getNavigationForPath } from './store'
28
+ import { captureDocsEvent } from '../utils/analytics'
29
+ import { ChatAssistant, useChatAssistant } from './chat-assistant'
27
30
 
28
31
  type EmptyObject = Record<string, never>
29
- type Result = any;
32
+ type Result = { url: string; title: string; pageTitle?: string; kind?: 'ask' }
33
+ const ASK_RESULT_URL = '__dory_ask__'
30
34
 
31
35
  type Autocomplete = AutocompleteApi<
32
36
  Result,
33
- JSX.TargetedEvent,
34
- JSX.TargetedMouseEvent<HTMLElement>,
35
- JSX.TargetedKeyboardEvent<HTMLElement>
37
+ TargetedEvent,
38
+ TargetedMouseEvent<HTMLElement>,
39
+ TargetedKeyboardEvent<HTMLElement>
36
40
  >
37
41
 
38
- function useAutocomplete({ onNavigate }: { onNavigate: () => void }) {
42
+ const SUGGESTED_PAGES = 3
43
+
44
+ /**
45
+ * Results shown before the reader types: the current page, its sections, and
46
+ * the next few pages in navigation order.
47
+ */
48
+ function suggestions(pathname: string): Result[] {
49
+ const pages = getNavigationForPath(pathname).flatMap(tab => tab.groups.flatMap(group => group.pages))
50
+ const index = Math.max(0, pages.findIndex(page => page.href === pathname))
51
+ const current = pages[index]
52
+ if (!current) return []
53
+ const sections = Array.from(document.querySelectorAll('.dory-article h2[id]'))
54
+ .filter(heading => !heading.closest('.dory-specimen'))
55
+ .map(heading => ({
56
+ url: `${current.href}#${heading.id}`,
57
+ title: Array.from(heading.childNodes)
58
+ .filter(node => !(node instanceof Element && node.classList.contains('dory-anchor')))
59
+ .map(node => node.textContent ?? '')
60
+ .join('')
61
+ .trim(),
62
+ }))
63
+ const next = pages.slice(index + 1, index + 1 + SUGGESTED_PAGES)
64
+ return [current, ...sections, ...next].map(item => ({ url: 'href' in item ? item.href : item.url, title: item.title }))
65
+ }
66
+
67
+ function useAutocomplete({ onNavigate, onAsk, chatEnabled }: { onNavigate: () => void; onAsk: (query: string) => void; chatEnabled: boolean }) {
39
68
  const id = useId()
40
69
  const [, nav] = useLocation()
70
+ // getSources is created once, so read the current page through a ref.
71
+ const pathnameRef = useRef('')
72
+ pathnameRef.current = usePathname()
73
+ const queryRef = useRef('')
74
+ const onAskRef = useRef(onAsk)
75
+ onAskRef.current = onAsk
76
+ const chatEnabledRef = useRef(chatEnabled)
77
+ chatEnabledRef.current = chatEnabled
41
78
  const [autocompleteState, setAutocompleteState] = useState<
42
79
  AutocompleteState<Result> | EmptyObject
43
80
  >({})
44
81
 
45
82
  function navigate({ itemUrl }: { itemUrl?: string }) {
46
83
  if (itemUrl) {
84
+ const query = queryRef.current.trim()
85
+ if (query) captureDocsEvent('docs_search_result_clicked', { query, result: itemUrl, path: pathnameRef.current })
47
86
  nav(itemUrl);
48
87
  }
49
88
 
@@ -53,18 +92,20 @@ function useAutocomplete({ onNavigate }: { onNavigate: () => void }) {
53
92
  const [autocomplete] = useState<Autocomplete>(() =>
54
93
  createAutocomplete<
55
94
  Result,
56
- JSX.TargetedEvent,
57
- JSX.TargetedMouseEvent<HTMLElement>,
58
- JSX.TargetedKeyboardEvent<HTMLElement>
95
+ TargetedEvent,
96
+ TargetedMouseEvent<HTMLElement>,
97
+ TargetedKeyboardEvent<HTMLElement>
59
98
  >({
60
99
  id,
61
- placeholder: 'Find something...',
100
+ placeholder: 'Search documentation',
62
101
  defaultActiveItemId: 0,
63
102
  onStateChange({ state }) {
103
+ queryRef.current = state.query
64
104
  setAutocompleteState(state)
65
105
  },
66
- shouldPanelOpen({ state }) {
67
- return state.query !== ''
106
+ openOnFocus: true,
107
+ shouldPanelOpen() {
108
+ return true
68
109
  },
69
110
  navigator: {
70
111
  navigate,
@@ -73,13 +114,21 @@ function useAutocomplete({ onNavigate }: { onNavigate: () => void }) {
73
114
  return [
74
115
  {
75
116
  sourceId: 'documentation',
76
- getItems() {
77
- return search(query, { limit: 5 })
117
+ async getItems() {
118
+ if (!query) return suggestions(pathnameRef.current)
119
+ await ensureSearchContent()
120
+ const pages = search(query, { limit: 8 })
121
+ return chatEnabledRef.current
122
+ ? [{ url: ASK_RESULT_URL, title: query, kind: 'ask' as const }, ...pages]
123
+ : pages
78
124
  },
79
125
  getItemUrl({ item }) {
80
- return item.url
126
+ return item.kind === 'ask' ? undefined : item.url
127
+ },
128
+ onSelect({ item }) {
129
+ if (item.kind === 'ask') onAskRef.current(item.title)
130
+ else navigate({ itemUrl: item.url })
81
131
  },
82
- onSelect: navigate,
83
132
  },
84
133
  ]
85
134
  },
@@ -89,11 +138,10 @@ function useAutocomplete({ onNavigate }: { onNavigate: () => void }) {
89
138
  return { autocomplete, autocompleteState }
90
139
  }
91
140
 
92
-
93
141
  function HighlightQuery({ text, query }: { text: string; query: string }) {
94
142
  return (
95
143
  <Highlighter
96
- highlightClassName="underline bg-transparent text-brand-foreground"
144
+ highlightClassName="dory-search-highlight"
97
145
  searchWords={[query]}
98
146
  autoEscape={true}
99
147
  textToHighlight={text}
@@ -114,64 +162,46 @@ function SearchResult({
114
162
  }) {
115
163
  const id = useId()
116
164
 
117
- const currentPage = result.url.split('#')[0];
118
-
119
- // Get the group containing the current page
120
- const group = ALL_NAVIGATION.find(group =>
121
- group.groups.some(group =>
122
- group.pages.some(page => page.href === currentPage)
165
+ if (result.kind === 'ask') {
166
+ return (
167
+ <li className="dory-search-result dory-search-ask-result flex cursor-default items-center gap-4 px-5 py-3" {...autocomplete.getItemProps({ item: result, source: collection.source })}>
168
+ <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)]" aria-hidden="true"><CatalogIcon name="ai" className="h-4 w-4" /></span>
169
+ <span className="min-w-0 flex-1">
170
+ <span className="block truncate text-sm font-semibold text-foreground">{result.title}</span>
171
+ <span className="block text-xs text-muted-foreground">Ask Dory · answers cite these docs</span>
172
+ </span>
173
+ <span className="hidden gap-1 text-xs text-muted-foreground sm:flex" aria-hidden="true"><kbd className="dory-search-key">Tab</kbd><kbd className="dory-search-key">↵</kbd></span>
174
+ </li>
123
175
  )
124
- );
125
-
126
- const page = group?.groups.find(group => group.pages.find(page => page.href === currentPage));
176
+ }
127
177
 
128
- const sectionTitle = group?.title;
129
- const pageTitle = page?.title;
130
-
131
- const hierarchy: any[] = [sectionTitle, pageTitle].filter(
132
- (x): x is string => typeof x === 'string',
133
- );
178
+ const [currentPage, hash] = result.url.split('#')
179
+ const group = getNavigationForPath(currentPage).flatMap(tab => tab.groups)
180
+ .find(group => group.pages.some(page => page.href === currentPage))
181
+ const pageTitle = group?.pages.find(page => page.href === currentPage)?.title
182
+ // Sections show "Group · Page" above their title; pages show only their group.
183
+ const eyebrow = [group?.title, hash ? pageTitle : undefined].filter(Boolean).join(' · ')
134
184
 
135
185
  return (
136
186
  <li
137
- className={classNames(
138
- 'group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50',
139
- )}
187
+ className="dory-search-result group flex cursor-default items-center gap-4 px-8 py-3"
140
188
  aria-labelledby={`${id}-hierarchy ${id}-title`}
141
189
  {...autocomplete.getItemProps({
142
190
  item: result,
143
191
  source: collection.source,
144
192
  })}
145
193
  >
146
- <div
147
- id={`${id}-title`}
148
- aria-hidden="true"
149
- className="text-sm font-medium text-brand-foreground group-aria-selected:text-brand-foreground"
150
- >
151
- <HighlightQuery text={result.title} query={query} />
152
- </div>
153
- {hierarchy.length > 0 && (
154
- <div
155
- id={`${id}-hierarchy`}
156
- aria-hidden="true"
157
- className="mt-1 truncate text-2xs whitespace-nowrap text-muted-foreground"
158
- >
159
- {hierarchy.map((item, itemIndex, items) => (
160
- <Fragment key={itemIndex}>
161
- <HighlightQuery text={item} query={query} />
162
- <span
163
- className={
164
- itemIndex === items.length - 1
165
- ? 'sr-only'
166
- : 'mx-2 text-zinc-300 dark:text-zinc-700'
167
- }
168
- >
169
- /
170
- </span>
171
- </Fragment>
172
- ))}
194
+ <span className="dory-search-result-icon flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--border)]" aria-hidden="true"><CatalogIcon name="file" className="h-4 w-4" /></span>
195
+ <div className="min-w-0 flex-1">
196
+ <div id={`${id}-title`} aria-hidden="true" className="dory-search-title">
197
+ <HighlightQuery text={result.title} query={query} />
173
198
  </div>
174
- )}
199
+ {eyebrow && (
200
+ <div id={`${id}-hierarchy`} aria-hidden="true" className="dory-search-eyebrow truncate">
201
+ <HighlightQuery text={eyebrow} query={query} />
202
+ </div>
203
+ )}
204
+ </div>
175
205
  </li>
176
206
  )
177
207
  }
@@ -207,15 +237,17 @@ function SearchResults({
207
237
  }
208
238
 
209
239
  return (
210
- <ul {...autocomplete.getListProps()}>
211
- {collection.items.map((result) => (
212
- <SearchResult
213
- key={result.url}
214
- result={result}
215
- autocomplete={autocomplete}
216
- collection={collection}
217
- query={query}
218
- />
240
+ <ul className="py-3" {...autocomplete.getListProps()}>
241
+ {collection.items.map((result, index) => (
242
+ <Fragment key={result.url}>
243
+ {index === 1 && collection.items[0]?.kind === 'ask' && <li className="px-8 pb-1 pt-4 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground" role="presentation">Pages</li>}
244
+ <SearchResult
245
+ result={result}
246
+ autocomplete={autocomplete}
247
+ collection={collection}
248
+ query={query}
249
+ />
250
+ </Fragment>
219
251
  ))}
220
252
  </ul>
221
253
  )
@@ -227,24 +259,26 @@ const SearchInput = forwardRef<
227
259
  autocomplete: Autocomplete
228
260
  autocompleteState: AutocompleteState<Result> | EmptyObject
229
261
  onClose: () => void
262
+ onAsk: () => void
263
+ chatEnabled: boolean
230
264
  }
231
- >(function SearchInput({ autocomplete, autocompleteState, onClose }, inputRef) {
265
+ >(function SearchInput({ autocomplete, autocompleteState, onClose, onAsk, chatEnabled }, inputRef) {
232
266
  const inputProps = autocomplete.getInputProps({ inputElement: null })
233
267
 
234
268
  return (
235
- <div className="group relative flex">
236
- <UxSearchInput
269
+ <div className="relative flex h-16 items-center gap-2 px-4 sm:gap-3 sm:px-7">
270
+ <CatalogIcon name="search" aria-hidden="true" className="h-5 w-5 shrink-0 text-muted-foreground" />
271
+ <input
237
272
  ref={inputRef}
238
273
  data-autofocus
239
- className={cn({
240
- 'pr-11': autocompleteState.status === 'stalled',
241
- 'pr-4': autocompleteState.status !== 'stalled',
242
- })}
274
+ className="dory-search-input h-full min-w-0 flex-1 bg-transparent outline-none"
243
275
  {...inputProps}
244
276
  onKeyDown={(event) => {
245
- if (
277
+ if (event.key === 'Tab' && !event.shiftKey && chatEnabled) {
278
+ event.preventDefault()
279
+ onAsk()
280
+ } else if (
246
281
  event.key === 'Escape' &&
247
- !autocompleteState.isOpen &&
248
282
  autocompleteState.query === ''
249
283
  ) {
250
284
  // In Safari, closing the dialog with the escape key can sometimes cause the scroll position to jump to the
@@ -259,11 +293,11 @@ const SearchInput = forwardRef<
259
293
  }
260
294
  }}
261
295
  />
262
- {autocompleteState.status === 'stalled' && (
263
- <div className="absolute inset-y-0 right-3 flex items-center">
264
- <Spinner className="h-5 w-5 animate-spin text-brand-foreground" />
265
- </div>
266
- )}
296
+ {autocompleteState.status === 'stalled'
297
+ ? <Spinner className="h-5 w-5 animate-spin text-brand-foreground" />
298
+ : null}
299
+ <span className="dory-search-active" aria-current="page">Search</span>
300
+ {chatEnabled && <button type="button" className="dory-search-ask-button" onClick={onAsk}>Ask Dory</button>}
267
301
  </div>
268
302
  )
269
303
  })
@@ -282,19 +316,63 @@ function SearchDialog({
282
316
  const formRef = useRef<React.ElementRef<'form'>>(null)
283
317
  const panelRef = useRef<React.ElementRef<'div'>>(null)
284
318
  const inputRef = useRef<React.ElementRef<typeof SearchInput>>(null)
319
+ const pathname = usePathname()
320
+ const searchParams = useSearchParams()
321
+ const chat = useChatAssistant()
322
+ const [mode, setMode] = useState<'search' | 'chat'>('search')
285
323
  const { autocomplete, autocompleteState } = useAutocomplete({
286
324
  onNavigate() {
287
325
  onNavigate()
288
326
  setOpen(false)
289
327
  },
328
+ onAsk: askSearchQuery,
329
+ chatEnabled: chat.enabled,
290
330
  })
291
- const pathname = usePathname()
292
- const searchParams = useSearchParams()
331
+
332
+ function askSearchQuery(query: string) {
333
+ if (!chat.enabled) return
334
+ captureDocsEvent('docs_chat_opened', { path: window.location.pathname })
335
+ setMode('chat')
336
+ if (query.trim().length >= 3) void chat.ask(query)
337
+ else chat.setDraft(query)
338
+ }
339
+
340
+ function showChat() {
341
+ if (!chat.enabled) return
342
+ const query = autocompleteState.query?.trim()
343
+ if (query) chat.setDraft(query)
344
+ captureDocsEvent('docs_chat_opened', { path: window.location.pathname })
345
+ setMode('chat')
346
+ }
347
+
348
+ function showSearch() {
349
+ if (chat.draft.trim() && !autocompleteState.query?.trim()) autocomplete.setQuery(chat.draft)
350
+ setMode('search')
351
+ }
352
+
353
+ useEffect(() => {
354
+ if (chat.enabled && autocompleteState.query) void autocomplete.refresh()
355
+ }, [chat.enabled])
356
+
357
+ useEffect(() => {
358
+ if (!open || !autocompleteState.isOpen || !autocompleteState.collections) return
359
+ const query = autocompleteState.query?.trim()
360
+ if (!query) return
361
+ const resultCount = autocompleteState.collections.reduce((count, collection) => count + collection.items.filter(item => item.kind !== 'ask').length, 0)
362
+ const timeout = window.setTimeout(() => {
363
+ captureDocsEvent('docs_search', { query, result_count: resultCount, path: pathname })
364
+ }, 600)
365
+ return () => window.clearTimeout(timeout)
366
+ }, [open, autocompleteState.query, autocompleteState.collections, autocompleteState.isOpen, pathname])
293
367
 
294
368
  useEffect(() => {
295
369
  setOpen(false)
296
370
  }, [pathname, searchParams, setOpen])
297
371
 
372
+ useEffect(() => {
373
+ if (!open) setMode('search')
374
+ }, [open])
375
+
298
376
  useEffect(() => {
299
377
  if (open) {
300
378
  return
@@ -331,9 +409,9 @@ function SearchDialog({
331
409
  <div className="fixed inset-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-20 md:py-32 lg:px-8 lg:py-[15vh] max-w-[calc(100vw-16px)]">
332
410
  <DialogPanel
333
411
  transition
334
- className="max-sm:mt-[25vh] mx-auto transform-gpu overflow-hidden rounded-lg bg-zinc-50 shadow-xl ring-1 ring-zinc-900/7.5 data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:max-w-xl dark:bg-zinc-900 dark:ring-zinc-800"
412
+ className="dory-search-panel max-sm:mt-[4vh] mx-auto transform-gpu overflow-hidden data-closed:scale-95 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:max-w-5xl"
335
413
  >
336
- <div {...autocomplete.getRootProps({})}>
414
+ {mode === 'search' ? <div {...autocomplete.getRootProps({})}>
337
415
  <form
338
416
  ref={formRef}
339
417
  {...autocomplete.getFormProps({
@@ -345,13 +423,15 @@ function SearchDialog({
345
423
  autocomplete={autocomplete}
346
424
  autocompleteState={autocompleteState}
347
425
  onClose={() => setOpen(false)}
426
+ onAsk={showChat}
427
+ chatEnabled={chat.enabled}
348
428
  />
349
429
  <div
350
430
  ref={panelRef}
351
- className="border-t border-border bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
431
+ className="max-h-[60vh] overflow-y-auto border-t border-[var(--border)] empty:hidden"
352
432
  {...autocomplete.getPanelProps({})}
353
433
  >
354
- {autocompleteState.isOpen && (
434
+ {autocompleteState.isOpen && autocompleteState.collections[0] && (
355
435
  <SearchResults
356
436
  autocomplete={autocomplete}
357
437
  query={autocompleteState.query}
@@ -360,7 +440,21 @@ function SearchDialog({
360
440
  )}
361
441
  </div>
362
442
  </form>
363
- </div>
443
+ <div className="dory-search-footer" aria-hidden="true">
444
+ <span>↑ ↓ Navigate</span><span>↵ Open</span>{chat.enabled && <span>Tab Ask Dory</span>}<span>Esc Close</span>
445
+ </div>
446
+ </div> : <>
447
+ <div className="dory-search-tabs flex items-center gap-2 border-b border-[var(--border)] px-5 py-4 sm:px-7">
448
+ <div className="min-w-0 flex-1">
449
+ <h2 className="text-base font-semibold text-foreground">Ask Dory</h2>
450
+ <p className="text-xs text-muted-foreground">Grounded in these docs</p>
451
+ </div>
452
+ {chat.messages.length > 0 && <button type="button" onClick={chat.reset} className="rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-[var(--accent)]">+ New</button>}
453
+ <button type="button" onClick={showSearch} className="rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-[var(--accent)]">Search</button>
454
+ <span className="rounded-lg bg-[var(--brand-primary)] px-3 py-2 text-sm font-semibold text-white">Ask Dory</span>
455
+ </div>
456
+ <ChatAssistant chat={chat} onNavigate={() => { onNavigate(); setOpen(false) }} onSearch={showSearch} />
457
+ </>}
364
458
  </DialogPanel>
365
459
  </div>
366
460
  </Dialog>
@@ -383,8 +477,9 @@ function useSearchProps() {
383
477
  open,
384
478
  setOpen: useCallback(
385
479
  (open: boolean) => {
480
+ const trigger = inputRef.current?.closest('[data-slot="search-input-wrapper"]') ?? inputRef.current
386
481
  const { width = 0, height = 0 } =
387
- inputRef.current?.getBoundingClientRect?.() ?? {}
482
+ trigger?.getBoundingClientRect?.() ?? {}
388
483
  if (!open || (width !== 0 && height !== 0)) {
389
484
  setOpen(open)
390
485
  }
@@ -407,8 +502,8 @@ export function Search() {
407
502
  onClick={isEmbedded ? buttonProps.onClick : undefined}
408
503
  >
409
504
  <UxSearchInput
410
- aria-label="Find something..."
411
- placeholder="Find something..."
505
+ aria-label="Search documentation"
506
+ placeholder="Search documentation"
412
507
  {...buttonProps}
413
508
  />
414
509
  <Suspense fallback={null}>
@@ -426,16 +521,25 @@ export function MobileSearch() {
426
521
  return (
427
522
  <div
428
523
  className={cn({
429
- "contents": !isEmbedded,
524
+ "inline-flex": !isEmbedded,
430
525
  "[&_input]:hidden [&_svg]:mr-0 cursor-pointer": isEmbedded,
431
526
  }, "lg:hidden")}
432
- onClick={isEmbedded ? buttonProps.onClick : undefined}
527
+ role="button"
528
+ tabIndex={0}
529
+ aria-label="Search documentation"
530
+ onClick={buttonProps.onClick}
531
+ onKeyDown={event => {
532
+ if (event.key === 'Enter' || event.key === ' ') {
533
+ event.preventDefault()
534
+ buttonProps.onClick()
535
+ }
536
+ }}
433
537
  >
434
538
  <UxSearchInput
435
539
  className="text-sm"
436
- aria-label="Find something..."
540
+ aria-label="Search documentation"
437
541
  {...buttonProps}
438
- placeholder="Find something..."
542
+ placeholder="Search documentation"
439
543
  />
440
544
  <Suspense fallback={null}>
441
545
  <SearchDialog
@@ -4,6 +4,7 @@ import docsConfig from '../../docs/dory.json' with { type: 'json' };
4
4
  import type { DoryConfig } from '../types/config';
5
5
  import { addToSearchIndex } from './search-index';
6
6
  import { withBase } from '../utils/base-path';
7
+ import { contentScope } from '../utils/content-scope';
7
8
 
8
9
  const config = docsConfig as DoryConfig;
9
10
 
@@ -37,30 +38,39 @@ export interface Navigation {
37
38
  pages: Array<{
38
39
  title: string;
39
40
  href: string;
41
+ badge?: string;
40
42
  }>;
41
43
  }>;
42
44
  }
43
45
 
44
- export const ALL_NAVIGATION: Navigation[] = config.navigation.tabs.map(tab => ({
46
+ export function getNavigationForPath(pathname: string): Navigation[] {
47
+ const scope = contentScope(config, pathname);
48
+ return scope.navigation.tabs.map(tab => ({
45
49
  title: tab.tab,
46
50
  groups: tab.groups.map(group => ({
47
51
  title: group.group,
48
- pages: group.pages.map(page => {
49
- const path = `/${page}`;
52
+ pages: group.pages.map(entry => {
53
+ const page = typeof entry === 'string' ? entry : entry.page;
54
+ const path = `${scope.prefix}/${page}`;
50
55
  return {
51
56
  title: page.charAt(0).toUpperCase() + page.slice(1).replace(/-/g, ' '),
52
- href: path
57
+ href: path,
58
+ badge: typeof entry === 'string' ? undefined : entry.badge,
53
59
  };
54
- })
60
+ }).filter(page => !scope.prefix || Boolean(ALL_PAGES[page.href]))
55
61
  }))
56
- }));
62
+ })).filter(tab => tab.groups.some(group => group.pages.length > 0));
63
+ }
64
+
65
+ export const ALL_NAVIGATION: Navigation[] = getNavigationForPath('/');
57
66
 
58
67
  export function pathFromFilename(filename: string): string {
59
- return filename
68
+ const path = filename
60
69
  .replace('../../docs', '')
61
70
  .replace(/\/?index\.mdx$/, '/')
62
71
  .replace(/\.mdx$/, '')
63
72
  .toLowerCase();
73
+ return path.length > 1 ? path.replace(/\/$/, '') : path;
64
74
  }
65
75
 
66
76
 
@@ -77,7 +87,7 @@ if (typeof window !== 'undefined' && window.__DORY_FRONTMATTER__) {
77
87
  }
78
88
  delete window.__DORY_FRONTMATTER__;
79
89
  // Build search index in the background after the page has fully rendered
80
- setTimeout(() => addPreloadedContentToSearch(), 2000);
90
+ setTimeout(() => void ensureSearchContent(), 2000);
81
91
  }
82
92
 
83
93
  // Preload frontmatter from JSON file (fallback when no inline data)
@@ -98,7 +108,7 @@ export async function preloadFrontmatter() {
98
108
 
99
109
  // Build search index in the background after the page has fully rendered.
100
110
  // Delay avoids competing with the route chunk for network connections.
101
- setTimeout(() => addPreloadedContentToSearch(), 2000);
111
+ setTimeout(() => void ensureSearchContent(), 2000);
102
112
  }
103
113
  }
104
114
  } catch (error) {
@@ -106,6 +116,14 @@ export async function preloadFrontmatter() {
106
116
  }
107
117
  }
108
118
 
119
+ let searchContentPromise: Promise<void> | undefined;
120
+
121
+ // Start immediately when search is used; otherwise load in the background.
122
+ export function ensureSearchContent(): Promise<void> {
123
+ searchContentPromise ??= addPreloadedContentToSearch();
124
+ return searchContentPromise;
125
+ }
126
+
109
127
  // Add preloaded content to search index (runs in background, never blocks rendering).
110
128
  // Fetches pre-built search-content.json generated at build time — no ?raw glob needed.
111
129
  async function addPreloadedContentToSearch() {
@@ -0,0 +1,11 @@
1
+ import remarkGfm from 'remark-gfm';
2
+ import rehypeSlug from 'rehype-slug';
3
+ import { remarkSafeVars } from '../plugins/sanitize.ts';
4
+ import { rehypeCodeMeta } from '../plugins/code-meta.ts';
5
+
6
+ /** MDX plugins that work in both the static builder and the sandboxed runtime. */
7
+ export const getCommonMdxConfig = (development = false) => ({
8
+ remarkPlugins: [remarkGfm, remarkSafeVars],
9
+ rehypePlugins: [rehypeSlug, rehypeCodeMeta],
10
+ development,
11
+ });
package/src/config/mdx.ts CHANGED
@@ -1,8 +1,5 @@
1
- import remarkGfm from 'remark-gfm';
2
- import rehypeSlug from 'rehype-slug';
3
- import { remarkSafeVars } from '../plugins/sanitize';
4
- import { rehypeCodeMeta } from '../plugins/code-meta';
5
- import { remarkRelativeAssets } from '../plugins/relative-assets';
1
+ import { getCommonMdxConfig } from './mdx-common.ts';
2
+ import { remarkRelativeAssets } from '../plugins/relative-assets.ts';
6
3
 
7
4
  /**
8
5
  * Shared MDX configuration used by the main build (vite.config.ts),
@@ -13,9 +10,11 @@ import { remarkRelativeAssets } from '../plugins/relative-assets';
13
10
  * remark-relative-assets turns relative image paths into root paths so they
14
11
  * work on pages served with a trailing slash.
15
12
  */
16
- export const getMdxConfig = (isDevelopment: boolean = false) => ({
17
- providerImportSource: '@mdx-js/preact',
18
- remarkPlugins: [remarkGfm, remarkSafeVars, remarkRelativeAssets],
19
- rehypePlugins: [rehypeSlug, rehypeCodeMeta],
20
- development: isDevelopment,
21
- });
13
+ export const getMdxConfig = (isDevelopment: boolean = false) => {
14
+ const common = getCommonMdxConfig(isDevelopment);
15
+ return {
16
+ providerImportSource: '@mdx-js/preact',
17
+ ...common,
18
+ remarkPlugins: [...common.remarkPlugins, remarkRelativeAssets],
19
+ };
20
+ };
@@ -171,9 +171,6 @@ if (typeof window !== 'undefined' && window.DoryDocs) {
171
171
  on(event: string, handler: Function) {
172
172
  const eventKey = `on${event.charAt(0).toUpperCase()}${event.slice(1)}` as keyof DoryEmbedConfig;
173
173
  (config as any)[eventKey] = handler;
174
- if (widgetInstance) {
175
- widgetInstance.on(event, handler);
176
- }
177
174
  return this;
178
175
  },
179
176
  };
@@ -18,8 +18,6 @@ export interface DoryEmbedConfig {
18
18
  onClose?: () => void;
19
19
  onNavigate?: (path: string) => void;
20
20
  onReady?: () => void;
21
- authToken?: string;
22
- getAuthToken?: () => Promise<string>;
23
21
  }
24
22
 
25
23
  export interface EmbeddedAppConfig {