@flamingo-stack/openframe-frontend-core 0.0.285 → 0.0.286

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 (211) hide show
  1. package/dist/chunk-3MCHAFHB.js +89 -0
  2. package/dist/chunk-3MCHAFHB.js.map +1 -0
  3. package/dist/{chunk-IMNGGLKL.js → chunk-3ZXUQQL4.js} +2 -2
  4. package/dist/{chunk-44ZFLMYC.cjs → chunk-4WACBTZU.cjs} +30 -30
  5. package/dist/{chunk-44ZFLMYC.cjs.map → chunk-4WACBTZU.cjs.map} +1 -1
  6. package/dist/{chunk-23UOJUDF.js → chunk-5BTZOVDQ.js} +4 -4
  7. package/dist/chunk-5KD3S25X.cjs +312 -0
  8. package/dist/chunk-5KD3S25X.cjs.map +1 -0
  9. package/dist/chunk-6N26CURS.cjs +2417 -0
  10. package/dist/chunk-6N26CURS.cjs.map +1 -0
  11. package/dist/{chunk-XX7P4IL2.js → chunk-7NM7DEUK.js} +4 -143
  12. package/dist/chunk-7NM7DEUK.js.map +1 -0
  13. package/dist/chunk-BBZ7AX5H.cjs +290 -0
  14. package/dist/chunk-BBZ7AX5H.cjs.map +1 -0
  15. package/dist/chunk-C6SCWXDP.js +290 -0
  16. package/dist/chunk-C6SCWXDP.js.map +1 -0
  17. package/dist/{chunk-GVSTRN36.cjs → chunk-D3LEFMOA.cjs} +3 -3
  18. package/dist/{chunk-GVSTRN36.cjs.map → chunk-D3LEFMOA.cjs.map} +1 -1
  19. package/dist/{chunk-P45Q4QCU.cjs → chunk-D5YY5U6J.cjs} +19 -15
  20. package/dist/chunk-D5YY5U6J.cjs.map +1 -0
  21. package/dist/chunk-DDAT4RKX.js +2417 -0
  22. package/dist/chunk-DDAT4RKX.js.map +1 -0
  23. package/dist/{chunk-PVXCVPJD.cjs → chunk-EI4WALN2.cjs} +67 -1626
  24. package/dist/chunk-EI4WALN2.cjs.map +1 -0
  25. package/dist/{chunk-ZIODAAFY.cjs → chunk-EL5GCMPU.cjs} +20 -159
  26. package/dist/chunk-EL5GCMPU.cjs.map +1 -0
  27. package/dist/{chunk-VQW7CEGS.js → chunk-FSBDVT6R.js} +1831 -3672
  28. package/dist/chunk-FSBDVT6R.js.map +1 -0
  29. package/dist/{chunk-DPKUSBQH.cjs → chunk-GDF2R2ER.cjs} +1073 -2914
  30. package/dist/chunk-GDF2R2ER.cjs.map +1 -0
  31. package/dist/chunk-HLQW7MWJ.cjs +1594 -0
  32. package/dist/chunk-HLQW7MWJ.cjs.map +1 -0
  33. package/dist/chunk-I6ZPGKZ2.cjs +151 -0
  34. package/dist/chunk-I6ZPGKZ2.cjs.map +1 -0
  35. package/dist/{chunk-J66U3LPZ.cjs → chunk-KBKZYJRI.cjs} +33 -332
  36. package/dist/chunk-KBKZYJRI.cjs.map +1 -0
  37. package/dist/{chunk-USPATRSC.js → chunk-N6ZM5PYZ.js} +45 -344
  38. package/dist/chunk-N6ZM5PYZ.js.map +1 -0
  39. package/dist/{chunk-RGALV4UX.js → chunk-POKKCWKF.js} +3 -82
  40. package/dist/chunk-POKKCWKF.js.map +1 -0
  41. package/dist/{chunk-XDI5JSWB.cjs → chunk-QCKN37OP.cjs} +45 -41
  42. package/dist/chunk-QCKN37OP.cjs.map +1 -0
  43. package/dist/{chunk-KNCDUWY2.js → chunk-QF2X6PTD.js} +10 -6
  44. package/dist/{chunk-KNCDUWY2.js.map → chunk-QF2X6PTD.js.map} +1 -1
  45. package/dist/{chunk-Z3V2SGQZ.js → chunk-R2KT5GDD.js} +324 -1883
  46. package/dist/chunk-R2KT5GDD.js.map +1 -0
  47. package/dist/{chunk-RJUGNL2A.cjs → chunk-RQ6RTBKF.cjs} +16 -12
  48. package/dist/chunk-RQ6RTBKF.cjs.map +1 -0
  49. package/dist/chunk-TFSYSWPS.cjs +89 -0
  50. package/dist/chunk-TFSYSWPS.cjs.map +1 -0
  51. package/dist/chunk-TYZEMPPH.js +312 -0
  52. package/dist/chunk-TYZEMPPH.js.map +1 -0
  53. package/dist/{chunk-O5N57CRV.js → chunk-VCQ3CTYK.js} +12 -8
  54. package/dist/{chunk-O5N57CRV.js.map → chunk-VCQ3CTYK.js.map} +1 -1
  55. package/dist/chunk-VRSXJ5QJ.js +1599 -0
  56. package/dist/chunk-VRSXJ5QJ.js.map +1 -0
  57. package/dist/{chunk-53CFL7PH.js → chunk-XQL4WDML.js} +11 -7
  58. package/dist/{chunk-53CFL7PH.js.map → chunk-XQL4WDML.js.map} +1 -1
  59. package/dist/chunk-Y4JNA4W6.cjs +1599 -0
  60. package/dist/chunk-Y4JNA4W6.cjs.map +1 -0
  61. package/dist/{chunk-ZSUNQJRO.cjs → chunk-YETA25JW.cjs} +2 -81
  62. package/dist/chunk-YETA25JW.cjs.map +1 -0
  63. package/dist/chunk-YVB3VDIQ.js +1594 -0
  64. package/dist/chunk-YVB3VDIQ.js.map +1 -0
  65. package/dist/chunk-ZLN6SM2U.js +151 -0
  66. package/dist/chunk-ZLN6SM2U.js.map +1 -0
  67. package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
  68. package/dist/components/chat/index.cjs +12 -6
  69. package/dist/components/chat/index.cjs.map +1 -1
  70. package/dist/components/chat/index.js +45 -39
  71. package/dist/components/contact/index.cjs +11 -7
  72. package/dist/components/contact/index.cjs.map +1 -1
  73. package/dist/components/contact/index.js +10 -6
  74. package/dist/components/docs/doc-navigation-context.d.ts +39 -0
  75. package/dist/components/docs/doc-navigation-context.d.ts.map +1 -0
  76. package/dist/components/docs/doc-viewer.d.ts +66 -0
  77. package/dist/components/docs/doc-viewer.d.ts.map +1 -0
  78. package/dist/components/docs/index.cjs +34 -0
  79. package/dist/components/docs/index.cjs.map +1 -0
  80. package/dist/components/docs/index.d.ts +8 -0
  81. package/dist/components/docs/index.d.ts.map +1 -0
  82. package/dist/components/docs/index.js +34 -0
  83. package/dist/components/docs/index.js.map +1 -0
  84. package/dist/components/docs/use-document-tree.d.ts +41 -0
  85. package/dist/components/docs/use-document-tree.d.ts.map +1 -0
  86. package/dist/components/docs/use-scroll-spy.d.ts +16 -0
  87. package/dist/components/docs/use-scroll-spy.d.ts.map +1 -0
  88. package/dist/components/embeds/embed-iframe.d.ts +29 -0
  89. package/dist/components/embeds/embed-iframe.d.ts.map +1 -0
  90. package/dist/components/embeds/figma-embed.d.ts +27 -0
  91. package/dist/components/embeds/figma-embed.d.ts.map +1 -0
  92. package/dist/components/embeds/google-sheets-viewer.d.ts +8 -0
  93. package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -0
  94. package/dist/components/embeds/index.cjs +37 -0
  95. package/dist/components/embeds/index.cjs.map +1 -0
  96. package/dist/components/embeds/index.d.ts +9 -0
  97. package/dist/components/embeds/index.d.ts.map +1 -0
  98. package/dist/components/embeds/index.js +37 -0
  99. package/dist/components/embeds/index.js.map +1 -0
  100. package/dist/components/embeds/pdf-viewer.d.ts +10 -0
  101. package/dist/components/embeds/pdf-viewer.d.ts.map +1 -0
  102. package/dist/components/faq/index.cjs +12 -8
  103. package/dist/components/faq/index.cjs.map +1 -1
  104. package/dist/components/faq/index.js +11 -7
  105. package/dist/components/features/index.cjs +10 -6
  106. package/dist/components/features/index.cjs.map +1 -1
  107. package/dist/components/features/index.js +9 -5
  108. package/dist/components/index.cjs +247 -1120
  109. package/dist/components/index.cjs.map +1 -1
  110. package/dist/components/index.d.ts +2 -0
  111. package/dist/components/index.d.ts.map +1 -1
  112. package/dist/components/index.js +645 -1518
  113. package/dist/components/index.js.map +1 -1
  114. package/dist/components/navigation/index.cjs +15 -6
  115. package/dist/components/navigation/index.cjs.map +1 -1
  116. package/dist/components/navigation/index.d.ts +2 -0
  117. package/dist/components/navigation/index.d.ts.map +1 -1
  118. package/dist/components/navigation/index.js +15 -6
  119. package/dist/components/navigation/multi-level-navigation.d.ts +28 -0
  120. package/dist/components/navigation/multi-level-navigation.d.ts.map +1 -0
  121. package/dist/components/onboarding-guides/index.cjs +34 -29
  122. package/dist/components/onboarding-guides/index.cjs.map +1 -1
  123. package/dist/components/onboarding-guides/index.js +13 -8
  124. package/dist/components/onboarding-guides/index.js.map +1 -1
  125. package/dist/components/related-content/index.cjs +12 -8
  126. package/dist/components/related-content/index.cjs.map +1 -1
  127. package/dist/components/related-content/index.js +11 -7
  128. package/dist/components/tickets/index.cjs +75 -70
  129. package/dist/components/tickets/index.cjs.map +1 -1
  130. package/dist/components/tickets/index.js +16 -11
  131. package/dist/components/tickets/index.js.map +1 -1
  132. package/dist/components/ui/index.cjs +12 -6
  133. package/dist/components/ui/index.cjs.map +1 -1
  134. package/dist/components/ui/index.js +65 -59
  135. package/dist/embed-shims/index.cjs +3 -3
  136. package/dist/embed-shims/index.cjs.map +1 -1
  137. package/dist/embed-shims/index.js +8 -8
  138. package/dist/hooks/index.cjs +6 -3
  139. package/dist/hooks/index.cjs.map +1 -1
  140. package/dist/hooks/index.js +7 -4
  141. package/dist/index.cjs +62 -6
  142. package/dist/index.cjs.map +1 -1
  143. package/dist/index.js +257 -201
  144. package/dist/types/doc-source.cjs +4 -0
  145. package/dist/types/doc-source.cjs.map +1 -0
  146. package/dist/types/doc-source.d.ts +86 -0
  147. package/dist/types/doc-source.d.ts.map +1 -0
  148. package/dist/types/doc-source.js +3 -0
  149. package/dist/types/doc-source.js.map +1 -0
  150. package/dist/types/index.d.ts +1 -0
  151. package/dist/types/index.d.ts.map +1 -1
  152. package/dist/utils/doc-path-utils.d.ts +24 -0
  153. package/dist/utils/doc-path-utils.d.ts.map +1 -0
  154. package/dist/utils/doc-tree-nav.d.ts +35 -0
  155. package/dist/utils/doc-tree-nav.d.ts.map +1 -0
  156. package/dist/utils/embed-url-converters.d.ts +16 -0
  157. package/dist/utils/embed-url-converters.d.ts.map +1 -0
  158. package/dist/utils/format.d.ts +9 -0
  159. package/dist/utils/format.d.ts.map +1 -1
  160. package/dist/utils/index.cjs +297 -0
  161. package/dist/utils/index.cjs.map +1 -1
  162. package/dist/utils/index.d.ts +7 -1
  163. package/dist/utils/index.d.ts.map +1 -1
  164. package/dist/utils/index.js +276 -1
  165. package/dist/utils/index.js.map +1 -1
  166. package/dist/utils/markdown-section-extractor.d.ts +23 -0
  167. package/dist/utils/markdown-section-extractor.d.ts.map +1 -0
  168. package/dist/utils/markdown-to-plain.d.ts +32 -0
  169. package/dist/utils/markdown-to-plain.d.ts.map +1 -0
  170. package/dist/utils/tree-builder.d.ts +30 -0
  171. package/dist/utils/tree-builder.d.ts.map +1 -0
  172. package/package.json +19 -1
  173. package/src/components/chat/chat-attachment-bar.tsx +6 -11
  174. package/src/components/docs/doc-navigation-context.tsx +123 -0
  175. package/src/components/docs/doc-viewer.tsx +345 -0
  176. package/src/components/docs/index.ts +12 -0
  177. package/src/components/docs/use-document-tree.ts +427 -0
  178. package/src/components/docs/use-scroll-spy.ts +93 -0
  179. package/src/components/embeds/embed-iframe.tsx +103 -0
  180. package/src/components/embeds/figma-embed.tsx +147 -0
  181. package/src/components/embeds/google-sheets-viewer.tsx +54 -0
  182. package/src/components/embeds/index.ts +11 -0
  183. package/src/components/embeds/pdf-viewer.tsx +64 -0
  184. package/src/components/index.ts +2 -0
  185. package/src/components/navigation/index.ts +5 -0
  186. package/src/components/navigation/multi-level-navigation.tsx +358 -0
  187. package/src/types/doc-source.ts +88 -0
  188. package/src/types/index.ts +4 -0
  189. package/src/utils/doc-path-utils.ts +47 -0
  190. package/src/utils/doc-tree-nav.ts +111 -0
  191. package/src/utils/embed-url-converters.ts +77 -0
  192. package/src/utils/format.ts +16 -0
  193. package/src/utils/index.ts +11 -0
  194. package/src/utils/markdown-section-extractor.ts +106 -0
  195. package/src/utils/markdown-to-plain.ts +69 -0
  196. package/src/utils/tree-builder.ts +114 -0
  197. package/dist/chunk-DPKUSBQH.cjs.map +0 -1
  198. package/dist/chunk-J66U3LPZ.cjs.map +0 -1
  199. package/dist/chunk-P45Q4QCU.cjs.map +0 -1
  200. package/dist/chunk-PVXCVPJD.cjs.map +0 -1
  201. package/dist/chunk-RGALV4UX.js.map +0 -1
  202. package/dist/chunk-RJUGNL2A.cjs.map +0 -1
  203. package/dist/chunk-USPATRSC.js.map +0 -1
  204. package/dist/chunk-VQW7CEGS.js.map +0 -1
  205. package/dist/chunk-XDI5JSWB.cjs.map +0 -1
  206. package/dist/chunk-XX7P4IL2.js.map +0 -1
  207. package/dist/chunk-Z3V2SGQZ.js.map +0 -1
  208. package/dist/chunk-ZIODAAFY.cjs.map +0 -1
  209. package/dist/chunk-ZSUNQJRO.cjs.map +0 -1
  210. /package/dist/{chunk-IMNGGLKL.js.map → chunk-3ZXUQQL4.js.map} +0 -0
  211. /package/dist/{chunk-23UOJUDF.js.map → chunk-5BTZOVDQ.js.map} +0 -0
@@ -0,0 +1,427 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react'
4
+ import type { DocNode, DocContent } from '../../types/doc-source'
5
+ import {
6
+ stripFolderIndexFromPath,
7
+ findDocNodeByPath,
8
+ getDocAncestorNodeIds,
9
+ DEFAULT_FOLDER_INDEX_FILE,
10
+ } from '../../utils/doc-tree-nav'
11
+ import { useDocNavigation } from './doc-navigation-context'
12
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
13
+
14
+ function scrollToContent() {
15
+ const article = document.querySelector('article') as HTMLElement | null
16
+ if (article) {
17
+ scrollElementIntoView(article, { headerOffset: 80 })
18
+ } else {
19
+ window.scrollTo({ top: 0, behavior: 'smooth' })
20
+ }
21
+ }
22
+
23
+ export interface UseDocumentTreeConfig {
24
+ /** API endpoint for fetching the document tree structure */
25
+ structureEndpoint: string
26
+ /** API endpoint for fetching document content */
27
+ contentEndpoint: string
28
+ /** Base route path for URL navigation (e.g., '/knowledge-base', '/data-room') */
29
+ baseRoute: string
30
+ /** Folder-index filename (defaults to 'README.md'). */
31
+ folderIndexFile?: string
32
+ }
33
+
34
+ /**
35
+ * Generic hook for document tree navigation and content fetching.
36
+ * Drives DocViewer across all doc-source consumers.
37
+ *
38
+ * Client-only: structure + content fetches run in parallel on first mount.
39
+ * No SSR pre-population — the previous SSR path required a Supabase admin
40
+ * client (service role key) and silently fell back to client fetches on
41
+ * envs where the key wasn't set; the parallel client fetches keep behavior
42
+ * uniform across local + prod (latency ~= max(structure, content), not sum).
43
+ */
44
+ export function useDocumentTree(
45
+ config: UseDocumentTreeConfig,
46
+ initialPath?: string,
47
+ ) {
48
+ const { structureEndpoint, contentEndpoint, baseRoute } = config
49
+ const folderIndexFile = config.folderIndexFile ?? DEFAULT_FOLDER_INDEX_FILE
50
+
51
+ const cleanInitialPath = stripFolderIndexFromPath(
52
+ initialPath?.replace(/\/$/, '') || '',
53
+ folderIndexFile,
54
+ )
55
+
56
+ const [structure, setStructure] = useState<DocNode[]>([])
57
+ const [selectedPath, setSelectedPath] = useState<string>(cleanInitialPath)
58
+ const [content, setContent] = useState<DocContent | null>(null)
59
+ const [isLoadingStructure, setIsLoadingStructure] = useState(true)
60
+ const [isLoadingContent, setIsLoadingContent] = useState(false)
61
+ const [error, setError] = useState<string | null>(null)
62
+ const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
63
+ const [isInitialized, setIsInitialized] = useState(false)
64
+ const lastFetchedPath = useRef<string | null>(null)
65
+
66
+ const normalizedBaseRoute = baseRoute.replace(/\/$/, '')
67
+
68
+ const selectedPathRef = useRef(selectedPath)
69
+ selectedPathRef.current = selectedPath
70
+
71
+ const docNavigation = useDocNavigation()
72
+
73
+ useEffect(() => {
74
+ const handlePopState = () => {
75
+ const pathname = window.location.pathname
76
+ let pathFromUrl = ''
77
+
78
+ if (pathname === normalizedBaseRoute || pathname === `${normalizedBaseRoute}/`) {
79
+ pathFromUrl = ''
80
+ } else if (pathname.startsWith(`${normalizedBaseRoute}/`)) {
81
+ pathFromUrl = pathname.substring(`${normalizedBaseRoute}/`.length)
82
+ }
83
+
84
+ pathFromUrl = stripFolderIndexFromPath(pathFromUrl, folderIndexFile)
85
+
86
+ if (pathFromUrl !== selectedPathRef.current) {
87
+ setSelectedPath(pathFromUrl)
88
+ if (pathFromUrl) {
89
+ const parentPath = pathFromUrl.includes('/')
90
+ ? pathFromUrl.substring(0, pathFromUrl.lastIndexOf('/'))
91
+ : pathFromUrl
92
+ setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)))
93
+ }
94
+ setTimeout(() => {
95
+ scrollToContent()
96
+ }, 150)
97
+ }
98
+ }
99
+
100
+ window.addEventListener('popstate', handlePopState)
101
+ return () => window.removeEventListener('popstate', handlePopState)
102
+ }, [normalizedBaseRoute, folderIndexFile])
103
+
104
+ useEffect(() => {
105
+ if (!isInitialized) {
106
+ // Kick off the speculative content fetch IN PARALLEL with the structure
107
+ // fetch — the two endpoints are independent and most landing pages have
108
+ // a root README (the default folder-index). If the structure ends up
109
+ // pointing at a different path (e.g. knowledge-base falls back to the
110
+ // first-folder README because there's no root README), the content
111
+ // useEffect issues the correct fetch after structure arrives — the
112
+ // speculative result silently no-ops (the content state update gets
113
+ // overwritten by the targeted fetch).
114
+ const speculativeContentPath = cleanInitialPath || folderIndexFile
115
+ lastFetchedPath.current = speculativeContentPath
116
+ fetchContent(speculativeContentPath)
117
+ fetchStructure()
118
+ setIsInitialized(true)
119
+ }
120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
121
+ }, [isInitialized])
122
+
123
+ useEffect(() => {
124
+ if (selectedPath === null || selectedPath === undefined) return
125
+ if (!structure || structure.length === 0) return
126
+
127
+ let pathToFetch: string | null = null
128
+
129
+ if (selectedPath === '') {
130
+ pathToFetch = folderIndexFile
131
+ } else {
132
+ const node = findDocNodeByPath(selectedPath, structure)
133
+
134
+ if (node && node.type === 'folder' && !node.hasReadme) {
135
+ return
136
+ }
137
+
138
+ pathToFetch = selectedPath
139
+ if (node && node.type === 'folder' && node.hasReadme) {
140
+ pathToFetch = `${selectedPath}/${folderIndexFile}`
141
+ }
142
+ }
143
+
144
+ if (pathToFetch === lastFetchedPath.current) {
145
+ return
146
+ }
147
+
148
+ if (pathToFetch) {
149
+ lastFetchedPath.current = pathToFetch
150
+ fetchContent(pathToFetch)
151
+ }
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, [selectedPath, structure, folderIndexFile])
154
+
155
+ const fetchStructure = async () => {
156
+ try {
157
+ setIsLoadingStructure(true)
158
+ setError(null)
159
+
160
+ const response = await fetch(structureEndpoint)
161
+
162
+ if (!response.ok) {
163
+ throw new Error('Failed to load documentation structure')
164
+ }
165
+
166
+ const result = await response.json()
167
+
168
+ if (result.success && result.data) {
169
+ setStructure(result.data)
170
+
171
+ if (cleanInitialPath) {
172
+ const pathForExpansion = cleanInitialPath.includes('.')
173
+ ? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))
174
+ : cleanInitialPath
175
+ if (pathForExpansion) {
176
+ setExpandedNodes(new Set(getDocAncestorNodeIds(pathForExpansion)))
177
+ }
178
+ } else if (result.data.length > 0) {
179
+ const hasRootReadme = result.data.some(
180
+ (node: DocNode) => node.type === 'file' && node.path === folderIndexFile
181
+ )
182
+
183
+ if (!hasRootReadme) {
184
+ const firstNode = result.data[0]
185
+ if (firstNode.type === 'folder') {
186
+ setExpandedNodes(new Set([firstNode.id]))
187
+ if (firstNode.hasReadme) {
188
+ setSelectedPath(firstNode.path)
189
+ window.history.replaceState({}, '', `${normalizedBaseRoute}/${firstNode.path}`)
190
+ }
191
+ }
192
+ }
193
+ }
194
+ } else {
195
+ setError('Failed to load documentation structure')
196
+ }
197
+ } catch (err) {
198
+ console.error('Error fetching documentation structure:', err)
199
+ setError(err instanceof Error ? err.message : 'Failed to load documentation')
200
+ } finally {
201
+ setIsLoadingStructure(false)
202
+ }
203
+ }
204
+
205
+ const fetchContent = async (path: string) => {
206
+ try {
207
+ setIsLoadingContent(true)
208
+ // Don't clear `error` here — if a previous fetch set an error and this
209
+ // is a stale/speculative call that gets superseded, the guard below
210
+ // returns early without writing to state. Clearing error here would
211
+ // briefly flicker the user-visible error message.
212
+
213
+ const response = await fetch(`${contentEndpoint}?path=${encodeURIComponent(path)}`)
214
+
215
+ // Request-id guard: between awaits, `lastFetchedPath.current` may have
216
+ // been bumped by a newer fetch (the structure-arrives auto-select issues
217
+ // a more-targeted fetch while the speculative one is in flight). Bail
218
+ // BEFORE writing to state — otherwise the late 404 of the speculative
219
+ // fetch overwrites the targeted fetch's good content with null.
220
+ if (path !== lastFetchedPath.current) return
221
+
222
+ if (!response.ok) {
223
+ if (response.status === 404) {
224
+ const result = await response.json().catch(() => ({}))
225
+ if (path !== lastFetchedPath.current) return
226
+ // Landing-page silent fallback: when the user lands on the source's
227
+ // root URL and there's no root `README.md` (knowledge-base case),
228
+ // the speculative fetch 404s — surface an empty state instead of
229
+ // an error banner. The structure-arrives auto-select will fire
230
+ // a targeted fetch for the first-folder README on the next render.
231
+ if (path === folderIndexFile && selectedPath === '') {
232
+ setError(null)
233
+ setContent(null)
234
+ return
235
+ }
236
+ setError(result.error || 'Documentation file not found')
237
+ setContent(null)
238
+ return
239
+ }
240
+ throw new Error('Failed to load documentation content')
241
+ }
242
+
243
+ const result = await response.json()
244
+ if (path !== lastFetchedPath.current) return
245
+ setError(null)
246
+
247
+ if (result.success && result.data) {
248
+ if (result.redirect && result.correctPath !== undefined) {
249
+ const correctedPath = result.correctPath
250
+ setSelectedPath(correctedPath)
251
+ window.history.replaceState({}, '', `${normalizedBaseRoute}/${correctedPath}`)
252
+ setContent(result.data)
253
+ } else {
254
+ setContent(result.data)
255
+ }
256
+ } else {
257
+ setError(result.error || 'Failed to load content')
258
+ setContent(null)
259
+ }
260
+ } catch (err) {
261
+ if (path !== lastFetchedPath.current) return
262
+ console.error('Error fetching documentation content:', err)
263
+ setError(err instanceof Error ? err.message : 'Failed to load content')
264
+ setContent(null)
265
+ } finally {
266
+ // Only clear loading state if THIS fetch is still the active one — a
267
+ // superseded speculative shouldn't flip the spinner off while the
268
+ // targeted fetch is still in flight.
269
+ if (path === lastFetchedPath.current) setIsLoadingContent(false)
270
+ }
271
+ }
272
+
273
+ const toggleNode = useCallback((nodeId: string) => {
274
+ setExpandedNodes(prev => {
275
+ const next = new Set(prev)
276
+ if (next.has(nodeId)) {
277
+ next.delete(nodeId)
278
+ } else {
279
+ next.add(nodeId)
280
+ }
281
+ return next
282
+ })
283
+ }, [])
284
+
285
+ // Structural minimum the body uses — `id`, `path`, `type`, `hasReadme`.
286
+ // Widening from `DocNode` lets the navigation components (which carry the
287
+ // narrower `NavigationNode` row shape) pass their own node back without the
288
+ // cross-type `as` cast. Both DocNode and NavigationNode satisfy this.
289
+ const selectNode = useCallback((node: Pick<DocNode, 'id' | 'path' | 'type' | 'hasReadme'>) => {
290
+ if (node.type === 'folder') {
291
+ setExpandedNodes(prev => {
292
+ if (prev.has(node.id)) {
293
+ const ancestorIds = getDocAncestorNodeIds(node.path)
294
+ ancestorIds.pop()
295
+ return new Set(ancestorIds)
296
+ } else {
297
+ return new Set(getDocAncestorNodeIds(node.path))
298
+ }
299
+ })
300
+
301
+ if (node.hasReadme) {
302
+ lastFetchedPath.current = null
303
+ setSelectedPath(node.path)
304
+ window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`)
305
+ setTimeout(() => {
306
+ scrollToContent()
307
+ }, 150)
308
+ } else {
309
+ setSelectedPath(node.path)
310
+ }
311
+ } else {
312
+ const lastSlash = node.path.lastIndexOf('/')
313
+ if (lastSlash > 0) {
314
+ const parentPath = node.path.substring(0, lastSlash)
315
+ setExpandedNodes(new Set(getDocAncestorNodeIds(parentPath)))
316
+ }
317
+
318
+ lastFetchedPath.current = null
319
+ setSelectedPath(node.path)
320
+ window.history.pushState({}, '', `${normalizedBaseRoute}/${node.path}`)
321
+ setTimeout(() => {
322
+ scrollToContent()
323
+ }, 150)
324
+ }
325
+ }, [normalizedBaseRoute])
326
+
327
+ const navigateToDoc = useCallback((path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => {
328
+ const hashIndex = path.indexOf('#')
329
+ const anchor = hashIndex !== -1 ? path.substring(hashIndex) : ''
330
+ const cleanPath = path.replace(/\/$/, '').split('#')[0]
331
+
332
+ const scrollAfterNav = () => {
333
+ if (anchor) {
334
+ setTimeout(() => {
335
+ const el = document.getElementById(anchor.substring(1))
336
+ if (el) {
337
+ scrollElementIntoView(el, { headerOffset: 80 })
338
+ } else {
339
+ scrollToContent()
340
+ }
341
+ }, 300)
342
+ } else {
343
+ setTimeout(() => {
344
+ scrollToContent()
345
+ }, 150)
346
+ }
347
+ }
348
+
349
+ if (options?.expandFolder) {
350
+ lastFetchedPath.current = null
351
+ setSelectedPath(cleanPath)
352
+ window.history.pushState({}, '', `${normalizedBaseRoute}/${cleanPath}${anchor}`)
353
+
354
+ const pathParts = cleanPath.split('/')
355
+ const nodeIdsToExpand: string[] = []
356
+ let currentPath = ''
357
+
358
+ for (const part of pathParts) {
359
+ currentPath = currentPath ? `${currentPath}-${part}` : part
360
+ nodeIdsToExpand.push(currentPath.toLowerCase())
361
+ }
362
+
363
+ setExpandedNodes(new Set(nodeIdsToExpand))
364
+
365
+ if (options?.fromInternalLink) {
366
+ scrollAfterNav()
367
+ }
368
+
369
+ setError(null)
370
+ return
371
+ }
372
+
373
+ const pathForSelection = stripFolderIndexFromPath(cleanPath, folderIndexFile)
374
+ const urlPath = pathForSelection
375
+
376
+ lastFetchedPath.current = null
377
+ setSelectedPath(pathForSelection)
378
+ window.history.pushState({}, '', `${normalizedBaseRoute}/${urlPath}${anchor}`)
379
+
380
+ if (options?.fromInternalLink) {
381
+ scrollAfterNav()
382
+ }
383
+
384
+ const pathParts = cleanPath.split('/')
385
+ const parentIds: string[] = []
386
+ let currentPath = ''
387
+ const partsToProcess = cleanPath.includes('.') ? pathParts.slice(0, -1) : pathParts
388
+
389
+ for (const part of partsToProcess) {
390
+ currentPath = currentPath ? `${currentPath}-${part}` : part
391
+ parentIds.push(currentPath.toLowerCase())
392
+ }
393
+
394
+ if (parentIds.length > 0) {
395
+ setExpandedNodes(new Set(parentIds))
396
+ }
397
+ }, [structure, normalizedBaseRoute, folderIndexFile])
398
+
399
+ useEffect(() => {
400
+ return docNavigation.register({
401
+ baseRoute: normalizedBaseRoute,
402
+ findNodeByPath: (path) => {
403
+ const clean = stripFolderIndexFromPath(path.replace(/\/$/, '').split('#')[0], folderIndexFile)
404
+ return findDocNodeByPath(clean, structure) ?? null
405
+ },
406
+ selectNode,
407
+ })
408
+ }, [docNavigation, normalizedBaseRoute, structure, selectNode, folderIndexFile])
409
+
410
+ return {
411
+ structure,
412
+ selectedPath,
413
+ setSelectedPath,
414
+ content,
415
+ isLoadingStructure,
416
+ isLoadingContent,
417
+ error,
418
+ expandedNodes,
419
+ toggleNode,
420
+ selectNode,
421
+ navigateToDoc,
422
+ refetch: fetchStructure,
423
+ setExpandedNodes,
424
+ }
425
+ }
426
+
427
+ export type { DocNode, DocContent }
@@ -0,0 +1,93 @@
1
+ "use client"
2
+
3
+ import { useState, useRef, useCallback, useEffect, useMemo } from "react"
4
+ import { scrollElementIntoView } from "../../utils/scroll-into-view"
5
+
6
+ // Single source for the sticky-chrome height. Used for BOTH the scroll target
7
+ // offset (where a clicked section lands) AND the active-section detection
8
+ // threshold (where the scroll listener flips highlight). They must match —
9
+ // previously 100 vs 150 caused a 50px window where the indicator jumped to
10
+ // the next section even though that section's top was still below the
11
+ // clicked one's resting offset.
12
+ const SCROLL_OFFSET = 100
13
+
14
+ interface ScrollSpySection {
15
+ id: string
16
+ title?: string
17
+ level?: number
18
+ }
19
+
20
+ interface UseScrollSpyReturn {
21
+ activeSection: string
22
+ handleSectionClick: (sectionId: string) => void
23
+ }
24
+
25
+ /**
26
+ * Shared scroll spy hook for tracking active section based on scroll position.
27
+ * Used by DocViewer for sticky section navigation.
28
+ */
29
+ export function useScrollSpy(sections: ScrollSpySection[] | undefined): UseScrollSpyReturn {
30
+ const [activeSection, setActiveSection] = useState("")
31
+ const isScrollingFromClick = useRef(false)
32
+
33
+ const sectionIds = useMemo(
34
+ () => sections?.map(s => s.id).join(',') ?? '',
35
+ [sections]
36
+ )
37
+ const stableSections = useRef(sections)
38
+ if (sectionIds !== (stableSections.current?.map(s => s.id).join(',') ?? '')) {
39
+ stableSections.current = sections
40
+ }
41
+
42
+ const handleSectionClick = useCallback((sectionId: string) => {
43
+ const targetElement = document.getElementById(sectionId)
44
+ if (!targetElement) return
45
+
46
+ isScrollingFromClick.current = true
47
+ setActiveSection(sectionId)
48
+
49
+ scrollElementIntoView(targetElement, { headerOffset: SCROLL_OFFSET })
50
+
51
+ setTimeout(() => {
52
+ isScrollingFromClick.current = false
53
+ }, 800)
54
+ }, [])
55
+
56
+ useEffect(() => {
57
+ const currentSections = stableSections.current
58
+ if (!currentSections || currentSections.length === 0) return
59
+
60
+ const handleScroll = () => {
61
+ if (isScrollingFromClick.current) return
62
+
63
+ const scrollPosition = window.scrollY + SCROLL_OFFSET
64
+ let currentSection = currentSections[0]?.id || ""
65
+
66
+ for (let i = currentSections.length - 1; i >= 0; i--) {
67
+ const element = document.getElementById(currentSections[i].id)
68
+ if (element && scrollPosition >= element.offsetTop) {
69
+ currentSection = currentSections[i].id
70
+ break
71
+ }
72
+ }
73
+
74
+ setActiveSection((prev) => (prev !== currentSection ? currentSection : prev))
75
+ }
76
+
77
+ let scrollTimer: ReturnType<typeof setTimeout>
78
+ const throttledScroll = () => {
79
+ clearTimeout(scrollTimer)
80
+ scrollTimer = setTimeout(handleScroll, 100)
81
+ }
82
+
83
+ window.addEventListener("scroll", throttledScroll)
84
+ handleScroll()
85
+
86
+ return () => {
87
+ window.removeEventListener("scroll", throttledScroll)
88
+ clearTimeout(scrollTimer)
89
+ }
90
+ }, [sectionIds])
91
+
92
+ return { activeSection, handleSectionClick }
93
+ }
@@ -0,0 +1,103 @@
1
+ "use client"
2
+
3
+ import React, { useState, useCallback, useRef, useEffect } from 'react'
4
+
5
+ /** Loading skeleton for iframe embeds — matches project skeleton pattern */
6
+ function EmbedLoadingSkeleton({ height }: { height?: string }) {
7
+ return (
8
+ <div
9
+ className="w-full rounded-lg border border-ods-border overflow-hidden bg-ods-skeleton animate-pulse"
10
+ style={{ height: height || 'calc(100vh - 250px)' }}
11
+ >
12
+ <div className="flex flex-col items-center justify-center h-full gap-4">
13
+ <div className="w-12 h-12 rounded-lg bg-ods-card" />
14
+ <div className="h-4 w-48 rounded bg-ods-card" />
15
+ <div className="h-3 w-32 rounded bg-ods-card" />
16
+ </div>
17
+ </div>
18
+ )
19
+ }
20
+
21
+ export interface EmbedIframeProps {
22
+ /** The URL to embed */
23
+ src: string
24
+ /** Accessible title for the iframe */
25
+ title: string
26
+ /** Additional class names for the outer container */
27
+ className?: string
28
+ /** Container height (CSS value). Defaults to `calc(100vh - 250px)` */
29
+ height?: string
30
+ /** iframe `allow` attribute */
31
+ allow?: string
32
+ /** iframe `referrerPolicy` attribute */
33
+ referrerPolicy?: React.IframeHTMLAttributes<HTMLIFrameElement>['referrerPolicy']
34
+ /** iframe `loading` attribute */
35
+ loading?: 'eager' | 'lazy'
36
+ /** iframe `allowFullScreen` attribute */
37
+ allowFullScreen?: boolean
38
+ }
39
+
40
+ /**
41
+ * Base iframe wrapper with loading skeleton and proper memory cleanup.
42
+ *
43
+ * Prevents memory leaks by:
44
+ * - Using `key={src}` to force full unmount/remount when src changes
45
+ * - Setting iframe src to about:blank on unmount to release the embedded document
46
+ * - Resetting loaded state when src changes
47
+ */
48
+ export function EmbedIframe({
49
+ src,
50
+ title,
51
+ className,
52
+ height,
53
+ allow,
54
+ referrerPolicy,
55
+ loading,
56
+ allowFullScreen,
57
+ }: EmbedIframeProps) {
58
+ const [isLoaded, setIsLoaded] = useState(false)
59
+ const iframeRef = useRef<HTMLIFrameElement>(null)
60
+ const handleLoad = useCallback(() => setIsLoaded(true), [])
61
+
62
+ useEffect(() => {
63
+ setIsLoaded(false)
64
+ }, [src])
65
+
66
+ useEffect(() => {
67
+ const iframe = iframeRef.current
68
+ return () => {
69
+ if (iframe) {
70
+ try {
71
+ iframe.src = 'about:blank'
72
+ } catch {
73
+ // Cross-origin iframes may throw — safe to ignore
74
+ }
75
+ }
76
+ }
77
+ }, [src])
78
+
79
+ const resolvedHeight = height || 'calc(100vh - 250px)'
80
+
81
+ return (
82
+ <>
83
+ {!isLoaded && <EmbedLoadingSkeleton height={resolvedHeight} />}
84
+ <div
85
+ className={`w-full rounded-lg border border-ods-border overflow-hidden ${!isLoaded ? 'h-0 overflow-hidden' : ''} ${className || ''}`}
86
+ style={isLoaded ? { height: resolvedHeight } : undefined}
87
+ >
88
+ <iframe
89
+ key={src}
90
+ ref={iframeRef}
91
+ src={src}
92
+ className="w-full h-full border-0"
93
+ title={title}
94
+ onLoad={handleLoad}
95
+ allow={allow}
96
+ referrerPolicy={referrerPolicy}
97
+ loading={loading}
98
+ allowFullScreen={allow?.includes('fullscreen') ? undefined : allowFullScreen}
99
+ />
100
+ </div>
101
+ </>
102
+ )
103
+ }