@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,167 @@
1
+ import { CatalogIcon } from '@clidey/ux'
2
+ import { useEffect, useState } from 'preact/hooks'
3
+ import classNames from 'classnames'
4
+
5
+ // A heading counts as current once it scrolls below the sticky top bar.
6
+ const ACTIVE_OFFSET = 120
7
+
8
+ type Heading = {
9
+ id: string
10
+ title: string
11
+ level: 2 | 3
12
+ }
13
+
14
+ export type TableOfContentsLabels = {
15
+ onThisPage?: string
16
+ editThisPage?: string
17
+ reportIssue?: string
18
+ backToTop?: string
19
+ }
20
+
21
+ export type TableOfContentsViewProps = {
22
+ pageKey: string
23
+ editUrl?: string | null
24
+ issueUrl?: string | null
25
+ labels?: TableOfContentsLabels
26
+ }
27
+
28
+ function slugify(text: string): string {
29
+ return text
30
+ .toLowerCase()
31
+ .trim()
32
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
33
+ .replace(/\s+/g, '-')
34
+ }
35
+
36
+ function headingText(heading: Element): string {
37
+ return Array.from(heading.childNodes)
38
+ .filter(node => !(node instanceof Element && node.classList.contains('dory-anchor')))
39
+ .map(node => node.textContent ?? '')
40
+ .join('')
41
+ .trim()
42
+ }
43
+
44
+ function collectHeadings(article: Element): Heading[] {
45
+ const headings: Heading[] = []
46
+ article.querySelectorAll('h2, h3').forEach(heading => {
47
+ if (heading.closest('.dory-specimen') || heading.hasAttribute('data-toc-ignore')) return
48
+ const title = headingText(heading)
49
+ const id = heading.id || slugify(title)
50
+ if (!id) return
51
+ if (!heading.id) heading.id = id
52
+ headings.push({ id, title, level: heading.tagName === 'H2' ? 2 : 3 })
53
+ })
54
+ return headings
55
+ }
56
+
57
+ /** The shared Dory heading rail used by the site and the app documentation viewer. */
58
+ export function TableOfContentsView({ pageKey, editUrl, issueUrl, labels }: TableOfContentsViewProps) {
59
+ const [headings, setHeadings] = useState<Heading[]>([])
60
+ const [currentId, setCurrentId] = useState('')
61
+ const [progress, setProgress] = useState(0)
62
+
63
+ useEffect(() => {
64
+ const article = document.querySelector('.dory-article')
65
+ if (!article) return
66
+ const update = () => setHeadings(collectHeadings(article))
67
+ update()
68
+ // Mermaid and other components may add their headings after mount.
69
+ const observer = new MutationObserver(update)
70
+ observer.observe(article, { childList: true, subtree: true })
71
+ return () => observer.disconnect()
72
+ }, [pageKey])
73
+
74
+ useEffect(() => {
75
+ function onScroll() {
76
+ const scrollable = document.documentElement.scrollHeight - window.innerHeight
77
+ const ratio = scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 1
78
+ setProgress(Math.round(ratio * 100))
79
+
80
+ let current = headings[0]?.id ?? ''
81
+ if (ratio >= 1 && headings.length > 0) {
82
+ current = headings[headings.length - 1].id
83
+ } else {
84
+ for (const heading of headings) {
85
+ const el = document.getElementById(heading.id)
86
+ if (el && el.getBoundingClientRect().top <= ACTIVE_OFFSET) current = heading.id
87
+ else if (el) break
88
+ }
89
+ }
90
+ setCurrentId(current)
91
+ }
92
+
93
+ onScroll()
94
+ window.addEventListener('scroll', onScroll, { passive: true })
95
+ window.addEventListener('resize', onScroll)
96
+ return () => {
97
+ window.removeEventListener('scroll', onScroll)
98
+ window.removeEventListener('resize', onScroll)
99
+ }
100
+ }, [headings])
101
+
102
+ let sectionNumber = 0
103
+
104
+ return (
105
+ <aside className="dory-toc hidden w-52 shrink-0 xl:block" aria-labelledby="on-this-page-title">
106
+ {headings.length > 0 && (
107
+ <div className="sticky top-24 max-h-[calc(100vh-7rem)] overflow-y-auto pb-6">
108
+ <div className="flex items-center justify-between">
109
+ <h2 id="on-this-page-title" className="dory-toc-label">{labels?.onThisPage ?? 'On this page'}</h2>
110
+ <span className="dory-toc-label">{progress}%</span>
111
+ </div>
112
+ <div className="dory-toc-track mt-2.5" aria-hidden="true">
113
+ <div className="dory-toc-fill" style={{ width: `${progress}%` }} />
114
+ </div>
115
+
116
+ <ol role="list" className="dory-toc-list mt-5">
117
+ {headings.map(heading => {
118
+ if (heading.level === 2) sectionNumber += 1
119
+ return (
120
+ <li key={heading.id}>
121
+ <a
122
+ href={`#${heading.id}`}
123
+ aria-current={heading.id === currentId ? 'location' : undefined}
124
+ className={classNames('dory-toc-link', {
125
+ active: heading.id === currentId,
126
+ 'dory-toc-sub': heading.level === 3,
127
+ })}
128
+ >
129
+ {heading.level === 2 && (
130
+ <span className="dory-toc-number">{String(sectionNumber).padStart(2, '0')}</span>
131
+ )}
132
+ <span>{heading.title}</span>
133
+ </a>
134
+ </li>
135
+ )
136
+ })}
137
+ </ol>
138
+
139
+ <div className="dory-toc-actions mt-6 space-y-2.5 border-t border-[var(--border)] pt-5">
140
+ {editUrl && (
141
+ <a href={editUrl} target="_blank" rel="noopener noreferrer">
142
+ <CatalogIcon name="edit" aria-hidden="true" className="h-3.5 w-3.5" />
143
+ {labels?.editThisPage ?? 'Edit this page'}
144
+ </a>
145
+ )}
146
+ {issueUrl && (
147
+ <a href={issueUrl} target="_blank" rel="noopener noreferrer">
148
+ <CatalogIcon name="alert-circle" aria-hidden="true" className="h-3.5 w-3.5" />
149
+ {labels?.reportIssue ?? 'Report an issue'}
150
+ </a>
151
+ )}
152
+ <a
153
+ href="#"
154
+ onClick={event => {
155
+ event.preventDefault()
156
+ window.scrollTo({ top: 0, behavior: 'smooth' })
157
+ }}
158
+ >
159
+ <CatalogIcon name="chevron-up" aria-hidden="true" className="h-3.5 w-3.5" />
160
+ {labels?.backToTop ?? 'Back to top'}
161
+ </a>
162
+ </div>
163
+ </div>
164
+ )}
165
+ </aside>
166
+ )
167
+ }
@@ -1,192 +1,34 @@
1
- import { useCallback, useEffect, useState } from 'react'
2
- import classNames from 'classnames'
3
-
4
- /**
5
- * Slugifies heading text matching rehype-slug (github-slugger) behavior:
6
- * lowercase, strip punctuation, spaces to dashes. Used only as a fallback
7
- * for headings that did not get an id at build time, so client-assigned
8
- * anchors agree with SSR-assigned ones.
9
- */
10
- function slugify(text: string): string {
11
- return text
12
- .toLowerCase()
13
- .trim()
14
- .replace(/[^\p{L}\p{N}\s_-]/gu, '')
15
- .replace(/\s+/g, '-')
16
- }
17
-
18
- export type Section = {
19
- id: string
20
- title: string
21
- children: Array<Subsection>
1
+ import { useEffect, useState } from 'preact/hooks'
2
+ import docsConfig from '../../docs/dory.json' with { type: 'json' }
3
+ import type { DoryConfig } from '../types/config'
4
+ import { usePathname } from '../components/hooks'
5
+ import { TableOfContentsView } from './table-of-content-view'
6
+
7
+ const config = docsConfig as DoryConfig
8
+
9
+ function editUrl(pathname: string) {
10
+ const repo = config.repository
11
+ if (!repo) return null
12
+ const dir = repo.path ? `${repo.path.replace(/\/$/, '')}/` : ''
13
+ return `${repo.url}/edit/${repo.branch ?? 'main'}/${dir}${pathname.replace(/^\//, '')}.mdx`
22
14
  }
23
15
 
24
- export type Subsection = {
25
- id: string
26
- title: string
16
+ function issueUrl() {
17
+ const repo = config.repository
18
+ if (!repo) return null
19
+ const title = encodeURIComponent(`Docs: ${document.title}`)
20
+ const body = encodeURIComponent(`Page: ${window.location.origin}${window.location.pathname}`)
21
+ return `${repo.url}/issues/new?title=${title}&body=${body}`
27
22
  }
28
23
 
24
+ /** Site TOC with repository actions; the embedded viewer uses the shared view directly. */
29
25
  export function TableOfContents() {
30
- const [currentSection, setCurrentSection] = useState<string>('')
31
- const [sections, setSections] = useState<Section[]>([])
32
- const [isContentLoaded, setIsContentLoaded] = useState(false)
33
-
34
- const collectHeadings = useCallback(() => {
35
- const article = document.querySelector('article')
36
- if (!article) return
37
-
38
- // Wait for content to be loaded
39
- const observer = new MutationObserver(() => {
40
- const headings = article.querySelectorAll('h2:not([style*="display: none"]), h3:not([style*="display: none"])')
41
- const sections: Section[] = []
42
- let currentSection: Section | null = null
43
-
44
- // Heading text without the trailing "#" anchor link
45
- const headingText = (heading: Element) =>
46
- Array.from(heading.childNodes)
47
- .filter((node) => !(node instanceof Element && node.classList.contains('dory-anchor')))
48
- .map((node) => node.textContent ?? '')
49
- .join('')
50
- .trim()
51
-
52
- headings.forEach((heading) => {
53
- // Check if heading is a valid Element and is visible
54
- if (!(heading instanceof Element)) return
55
- const style = window.getComputedStyle(heading)
56
- if (style.display === 'none' || style.visibility === 'hidden') return
57
-
58
- // Prefer the build-time id from rehype-slug; only assign when missing
59
- const id = heading.id || slugify(headingText(heading))
60
- if (!heading.id && id) {
61
- heading.id = id
62
- }
63
-
64
- if (heading.tagName === 'H2') {
65
- currentSection = {
66
- id,
67
- title: headingText(heading),
68
- children: []
69
- }
70
- sections.push(currentSection)
71
- } else if (heading.tagName === 'H3' && currentSection) {
72
- currentSection.children.push({
73
- id,
74
- title: headingText(heading)
75
- })
76
- }
77
- })
78
-
79
- setSections(sections)
80
- setIsContentLoaded(true)
81
- })
82
-
83
- observer.observe(article, {
84
- childList: true,
85
- subtree: true,
86
- attributes: true,
87
- attributeFilter: ['style', 'class']
88
- })
89
-
90
- return () => observer.disconnect()
91
- }, [])
26
+ const pathname = usePathname()
27
+ const [links, setLinks] = useState<{ edit: string | null; issue: string | null }>({ edit: null, issue: null })
92
28
 
93
29
  useEffect(() => {
94
- const cleanup = collectHeadings()
95
- return () => cleanup?.()
96
- }, [collectHeadings])
97
-
98
- const getHeadings = useCallback(() => {
99
- return sections
100
- .flatMap((section) => [section.id, ...section.children.map((child) => child.id)])
101
- .map((id) => {
102
- const el = document.getElementById(id)
103
- if (!el) return null
104
- const top = window.scrollY + el.getBoundingClientRect().top
105
- return { id, top }
106
- })
107
- .filter((x): x is { id: string; top: number } => x !== null)
108
- }, [sections])
109
-
110
- useEffect(() => {
111
- if (!isContentLoaded || sections.length === 0) return
112
- const headings = getHeadings()
113
-
114
- function onScroll() {
115
- const top = window.scrollY
116
- let current = headings[0].id
117
- for (const heading of headings) {
118
- if (top >= heading.top - 10) {
119
- current = heading.id
120
- } else {
121
- break
122
- }
123
- }
124
- setCurrentSection(current)
125
- }
126
-
127
- window.addEventListener('scroll', onScroll, { passive: true })
128
- onScroll()
129
- return () => {
130
- window.removeEventListener('scroll', onScroll)
131
- }
132
- }, [getHeadings, sections, isContentLoaded])
133
-
134
- function isActive(section: Section | Subsection) {
135
- return section.id === currentSection
136
- }
137
-
138
- if (!isContentLoaded) {
139
- return null
140
- }
30
+ setLinks({ edit: editUrl(pathname), issue: issueUrl() })
31
+ }, [pathname])
141
32
 
142
- return (
143
- <div className="hidden xl:sticky xl:top-[4.75rem] xl:-mr-6 xl:block xl:h-[calc(100vh-4.75rem)] xl:flex-none xl:overflow-y-auto xl:py-16 xl:pr-6 pl-8">
144
- <nav aria-labelledby="on-this-page-title" className="w-56">
145
- {sections.length > 0 && (
146
- <>
147
- <h2
148
- id="on-this-page-title"
149
- className="font-display text-sm font-medium text-foreground dark:text-white"
150
- >
151
- On this page
152
- </h2>
153
- <ol role="list" className="mt-4 space-y-3 text-sm">
154
- {sections.map((section) => (
155
- <li key={section.id}>
156
- <h3>
157
- <a
158
- href={`#${section.id}`}
159
- className={classNames({
160
- "text-brand-foreground": isActive(section),
161
- "font-normal text-muted-foreground hover:text-foreground/80 dark:hover:text-slate-300": !isActive(section),
162
- })}>
163
- {section.title}
164
- </a>
165
- </h3>
166
- {section.children.length > 0 && (
167
- <ol role="list" className="mt-2 space-y-3 pl-5 text-muted-foreground dark:text-muted-foreground">
168
- {section.children.map((subSection) => (
169
- <li key={subSection.id}>
170
- <a
171
- href={`#${subSection.id}`}
172
- className={
173
- isActive(subSection)
174
- ? 'text-brand-foreground'
175
- : 'hover:text-muted-foreground dark:hover:text-slate-300'
176
- }
177
- >
178
- {subSection.title}
179
- </a>
180
- </li>
181
- ))}
182
- </ol>
183
- )}
184
- </li>
185
- ))}
186
- </ol>
187
- </>
188
- )}
189
- </nav>
190
- </div>
191
- )
33
+ return <TableOfContentsView pageKey={pathname} editUrl={links.edit} issueUrl={links.issue} />
192
34
  }
@@ -0,0 +1,36 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ describe('documentation analytics delivery', () => {
4
+ beforeEach(() => vi.resetModules())
5
+ afterEach(() => vi.unstubAllGlobals())
6
+
7
+ it('sends a redacted search query to PostHog without contacting a Dory server', async () => {
8
+ const capture = vi.fn()
9
+ const fetch = vi.fn()
10
+ vi.stubGlobal('window', { posthog: { capture } })
11
+ vi.stubGlobal('fetch', fetch)
12
+ const { captureDocsEvent } = await import('./analytics')
13
+
14
+ captureDocsEvent('docs_search', { query: 'api key=short123 email me@example.com', result_count: 0, path: '/intro' })
15
+ expect(capture).toHaveBeenCalledOnce()
16
+ expect(capture).toHaveBeenCalledWith('docs_search', expect.objectContaining({
17
+ query: expect.not.stringContaining('short123'),
18
+ result_count: 0,
19
+ path: '/intro',
20
+ }))
21
+ expect(capture.mock.calls[0][1].query).not.toContain('me@example.com')
22
+ expect(fetch).not.toHaveBeenCalled()
23
+ })
24
+
25
+ it('does not require a Dory analytics server', async () => {
26
+ const capture = vi.fn()
27
+ const fetch = vi.fn()
28
+ vi.stubGlobal('window', { posthog: { capture } })
29
+ vi.stubGlobal('fetch', fetch)
30
+ const { captureDocsEvent } = await import('./analytics')
31
+
32
+ captureDocsEvent('docs_code_copied', { path: '/intro' })
33
+ expect(capture).toHaveBeenCalledOnce()
34
+ expect(fetch).not.toHaveBeenCalled()
35
+ })
36
+ })
@@ -0,0 +1,35 @@
1
+ declare global {
2
+ interface Window {
3
+ posthog?: { capture: (event: string, properties?: Record<string, unknown>) => void }
4
+ }
5
+ }
6
+
7
+ type DocsEvent =
8
+ | 'docs_search'
9
+ | 'docs_search_result_clicked'
10
+ | 'docs_page_feedback'
11
+ | 'docs_code_copied'
12
+ | 'docs_page_engaged'
13
+ | 'docs_chat_opened'
14
+ | 'docs_chat_question'
15
+ | 'docs_chat_answer'
16
+ | 'docs_chat_answer_copied'
17
+ | 'docs_chat_code_copied'
18
+ | 'docs_chat_error'
19
+ | 'docs_chat_source_clicked'
20
+
21
+ /** Search text may contain credentials or personal data typed by mistake. */
22
+ function sanitizeQuery(value: string): string {
23
+ return value.trim().slice(0, 160)
24
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
25
+ .replace(/\b(?:Bearer\s+\S+|sk-[A-Za-z0-9_-]{12,}|(?:api[\s_-]?key|token|password|secret)\s*[:=]\s*\S+)/gi, '[redacted]')
26
+ }
27
+
28
+ /** Sends a small event to the configured PostHog project. */
29
+ export function captureDocsEvent(event: DocsEvent, properties: Record<string, unknown>): void {
30
+ if (typeof window === 'undefined') return
31
+ const safeProperties = { ...properties }
32
+ if (typeof safeProperties.query === 'string') safeProperties.query = sanitizeQuery(safeProperties.query)
33
+ try { window.posthog?.capture(event, safeProperties) }
34
+ catch { /* Analytics must not interrupt reading. */ }
35
+ }
@@ -16,6 +16,13 @@ export function loadColors() {
16
16
  if (!colors) return;
17
17
 
18
18
  const root = document.documentElement;
19
+ const light = colors.light || colors.primary;
20
+ const dark = colors.dark || colors.primary;
21
+ if (light || dark) {
22
+ root.setAttribute('data-dory-colors', 'true');
23
+ root.style.setProperty('--brand-light', light || dark);
24
+ root.style.setProperty('--brand-dark', dark || light);
25
+ }
19
26
 
20
27
  // Brand accent colors
21
28
  if (colors.primary) {
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { contentScope, scopedPath } from './content-scope';
3
+ import type { DoryConfig } from '../types/config';
4
+
5
+ const navigation = { tabs: [{ tab: 'Guides', groups: [{ group: 'Start', pages: ['intro'] }] }] };
6
+ const config = {
7
+ name: 'Example', favicon: 'favicon.svg', logo: { light: 'logo.svg', dark: 'logo.svg' },
8
+ navigation,
9
+ i18n: { defaultLocale: 'en', locales: [{ code: 'en', label: 'English' }, { code: 'fr', label: 'Français' }] },
10
+ versions: [{ id: 'v1', label: 'Version 1' }],
11
+ } satisfies DoryConfig;
12
+
13
+ describe('content scope', () => {
14
+ it('preserves current URLs for the default language and version', () => {
15
+ expect(contentScope(config, '/intro')).toMatchObject({ locale: 'en', version: '', prefix: '', page: '/intro' });
16
+ });
17
+
18
+ it('resolves a translated, versioned page', () => {
19
+ expect(contentScope(config, '/fr/v1/intro')).toMatchObject({ locale: 'fr', version: 'v1', prefix: '/fr/v1', page: '/intro' });
20
+ });
21
+
22
+ it('keeps the page when switching language and version', () => {
23
+ expect(scopedPath(config, '/fr/v1/intro', 'en', '')).toBe('/intro');
24
+ expect(scopedPath(config, '/intro', 'fr', 'v1')).toBe('/fr/v1/intro');
25
+ });
26
+ });
@@ -0,0 +1,39 @@
1
+ import type { DoryConfig, DoryNavigation } from '../types/config.ts';
2
+
3
+ export interface ContentScope {
4
+ locale: string;
5
+ version: string;
6
+ prefix: string;
7
+ page: string;
8
+ navigation: DoryNavigation;
9
+ }
10
+
11
+ /** Content lives at the route it publishes: docs/fr/intro.mdx -> /fr/intro. */
12
+ export function contentScope(config: DoryConfig, pathname: string): ContentScope {
13
+ const parts = pathname.split('/').filter(Boolean);
14
+ const locales = config.i18n?.locales ?? [];
15
+ const defaultLocale = config.i18n?.defaultLocale ?? locales[0]?.code ?? 'en';
16
+ const localeEntry = locales.find(entry => entry.code === parts[0] && entry.code !== defaultLocale);
17
+ if (localeEntry) parts.shift();
18
+
19
+ const versionEntry = config.versions?.find(entry => entry.id && entry.id === parts[0]);
20
+ if (versionEntry) parts.shift();
21
+
22
+ const locale = localeEntry?.code ?? defaultLocale;
23
+ const version = versionEntry?.id ?? '';
24
+ const prefix = [localeEntry?.code, version].filter(Boolean).map(segment => `/${segment}`).join('');
25
+ return {
26
+ locale,
27
+ version,
28
+ prefix,
29
+ page: `/${parts.join('/')}`,
30
+ navigation: (version ? localeEntry?.versions?.[version] : undefined) ?? localeEntry?.navigation ?? versionEntry?.navigation ?? config.navigation,
31
+ };
32
+ }
33
+
34
+ export function scopedPath(config: DoryConfig, pathname: string, locale: string, version: string): string {
35
+ const current = contentScope(config, pathname);
36
+ const defaultLocale = config.i18n?.defaultLocale ?? config.i18n?.locales[0]?.code ?? 'en';
37
+ const prefix = [locale !== defaultLocale ? locale : '', version].filter(Boolean).map(segment => `/${segment}`).join('');
38
+ return `${prefix}${current.page}`;
39
+ }
@@ -0,0 +1,12 @@
1
+ /** Extracts the searchable prose from MDX in both builds and the runtime. */
2
+ export function stripMdxToText(content: string): string {
3
+ return content
4
+ .replace(/^import\s+.*$/gm, '')
5
+ .replace(/^export\s+.*$/gm, '')
6
+ .replace(/```[\s\S]*?```/g, '')
7
+ .replace(/<[^>]+>/g, '')
8
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
9
+ .replace(/[#*_~`>|]/g, '')
10
+ .replace(/\n{3,}/g, '\n\n')
11
+ .trim();
12
+ }
@@ -5,7 +5,7 @@ const config = docsConfig as DoryConfig;
5
5
 
6
6
  export function loadTheme() {
7
7
  const root = document.documentElement;
8
- const { theme, colors } = config;
8
+ const { theme } = config;
9
9
 
10
10
  // Apply forced dark/light mode
11
11
  if (theme?.mode === 'dark') {
@@ -20,10 +20,4 @@ export function loadTheme() {
20
20
  if (theme?.radius) {
21
21
  root.style.setProperty('--radius', theme.radius);
22
22
  }
23
-
24
- // Mark as themed if custom background colors are provided
25
- const hasCustomColors = colors?.background || colors?.surface || colors?.foreground;
26
- if (hasCustomColors) {
27
- root.setAttribute('data-dory-themed', 'true');
28
- }
29
23
  }
@@ -1,9 +1,9 @@
1
1
  import { defineConfig } from 'vite';
2
2
  import preact from '@preact/preset-vite';
3
3
  import mdx from '@mdx-js/rollup';
4
- import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin';
5
- import { getMdxConfig } from './src/config/mdx';
6
- import { getBasePath, readDoryConfig } from './src/plugins/shared';
4
+ import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin.ts';
5
+ import { getMdxConfig } from './src/config/mdx.ts';
6
+ import { getBasePath, readDoryConfig } from './src/plugins/shared.ts';
7
7
 
8
8
  // Minimal Vite config for the SSR build.
9
9
  // Only includes plugins needed for MDX compilation — no sitemap, prerender,
@@ -17,7 +17,7 @@ export default defineConfig({
17
17
  mdx(getMdxConfig(false)),
18
18
  ],
19
19
  resolve: {
20
- dedupe: ['preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
20
+ dedupe: ['recharts', 'preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
21
21
  alias: {
22
22
  react: 'preact/compat',
23
23
  'react-dom': 'preact/compat',
package/vite.config.ts CHANGED
@@ -2,20 +2,21 @@ import { defineConfig } from 'vite';
2
2
  import preact from '@preact/preset-vite';
3
3
  import mdx from '@mdx-js/rollup';
4
4
  import tailwindcss from '@tailwindcss/vite'
5
- import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin';
6
- import { llmTxtGenerator } from './src/plugins/llm-txt-generator';
7
- import { llmTxtDevServer } from './src/plugins/llm-txt-dev-server';
8
- import { frontmatterGenerator } from './src/plugins/frontmatter-generator';
9
- import { frontmatterDevServer } from './src/plugins/frontmatter-dev-server';
10
- import { docsAssetsPlugin } from './src/plugins/docs-assets';
11
- import { htmlMetadataInjector } from './src/plugins/html-metadata-injector';
12
- import { htmlFilesMiddleware } from './src/plugins/html-files-middleware';
13
- import { analyticsInjector } from './src/plugins/analytics-injector';
14
- import { sitemapGenerator } from './src/plugins/sitemap-generator';
15
- import { robotsGenerator } from './src/plugins/robots-generator';
16
- import { prerender } from './src/plugins/prerender';
17
- import { getMdxConfig } from './src/config/mdx';
18
- import { getBasePath, readDoryConfig } from './src/plugins/shared';
5
+ import { unifiedMdxPreprocessor } from './src/plugins/unified-mdx-plugin.ts';
6
+ import { llmTxtGenerator } from './src/plugins/llm-txt-generator.ts';
7
+ import { llmTxtDevServer } from './src/plugins/llm-txt-dev-server.ts';
8
+ import { frontmatterGenerator } from './src/plugins/frontmatter-generator.ts';
9
+ import { frontmatterDevServer } from './src/plugins/frontmatter-dev-server.ts';
10
+ import { docsAssetsPlugin } from './src/plugins/docs-assets.ts';
11
+ import { htmlMetadataInjector } from './src/plugins/html-metadata-injector.ts';
12
+ import { htmlFilesMiddleware } from './src/plugins/html-files-middleware.ts';
13
+ import { analyticsInjector } from './src/plugins/analytics-injector.ts';
14
+ import { sitemapGenerator } from './src/plugins/sitemap-generator.ts';
15
+ import { robotsGenerator } from './src/plugins/robots-generator.ts';
16
+ import { prerender } from './src/plugins/prerender.ts';
17
+ import { chatDevServer } from './src/plugins/chat-dev-server.ts';
18
+ import { getMdxConfig } from './src/config/mdx.ts';
19
+ import { getBasePath, readDoryConfig } from './src/plugins/shared.ts';
19
20
 
20
21
  export default defineConfig(({ command }) => ({
21
22
  // Serve and build under `basePath` from dory.json (asset URLs, import.meta.env.BASE_URL).
@@ -23,6 +24,7 @@ export default defineConfig(({ command }) => ({
23
24
  plugins: [
24
25
  // SINGLE SOURCE OF TRUTH: All MDX preprocessing happens in src/mdx/processor.ts
25
26
  htmlFilesMiddleware(), // Must be first to intercept HTML file requests
27
+ chatDevServer(),
26
28
  unifiedMdxPreprocessor(),
27
29
  preact(),
28
30
  mdx(getMdxConfig(command === 'serve')),
@@ -39,12 +41,17 @@ export default defineConfig(({ command }) => ({
39
41
  prerender(),
40
42
  ],
41
43
  resolve: {
42
- dedupe: ['preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
44
+ dedupe: ['recharts', 'preact', 'preact/compat', 'preact/hooks', 'preact/jsx-runtime', 'preact/compat/jsx-runtime'],
43
45
  alias: {
44
46
  react: 'preact/compat',
45
47
  'react-dom': 'preact/compat',
46
48
  }
47
49
  },
50
+ // Mermaid is loaded on demand; prebundle it at startup so opening a diagram
51
+ // does not trigger a late dependency-cache rebuild and stale module URL.
52
+ optimizeDeps: {
53
+ include: ['mermaid'],
54
+ },
48
55
  publicDir: false, // Don't use a public directory since we need to import from docs
49
56
  build: {
50
57
  rollupOptions: {
@@ -54,7 +61,6 @@ export default defineConfig(({ command }) => ({
54
61
  if (!id.includes('node_modules')) return;
55
62
  if (/node_modules\/preact($|\/)/.test(id)) return 'vendor-preact';
56
63
  if (id.includes('node_modules/@clidey/ux') || id.includes('node_modules/@headlessui/react')) return 'vendor-ui';
57
- if (id.includes('node_modules/lucide-react')) return 'vendor-icons';
58
64
  if (id.includes('node_modules/katex')) return 'katex';
59
65
  if (id.includes('node_modules/flexsearch')) return 'flexsearch';
60
66
  }