@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.
- package/dist/chunk-3MCHAFHB.js +89 -0
- package/dist/chunk-3MCHAFHB.js.map +1 -0
- package/dist/{chunk-IMNGGLKL.js → chunk-3ZXUQQL4.js} +2 -2
- package/dist/{chunk-44ZFLMYC.cjs → chunk-4WACBTZU.cjs} +30 -30
- package/dist/{chunk-44ZFLMYC.cjs.map → chunk-4WACBTZU.cjs.map} +1 -1
- package/dist/{chunk-23UOJUDF.js → chunk-5BTZOVDQ.js} +4 -4
- package/dist/chunk-5KD3S25X.cjs +312 -0
- package/dist/chunk-5KD3S25X.cjs.map +1 -0
- package/dist/chunk-6N26CURS.cjs +2417 -0
- package/dist/chunk-6N26CURS.cjs.map +1 -0
- package/dist/{chunk-XX7P4IL2.js → chunk-7NM7DEUK.js} +4 -143
- package/dist/chunk-7NM7DEUK.js.map +1 -0
- package/dist/chunk-BBZ7AX5H.cjs +290 -0
- package/dist/chunk-BBZ7AX5H.cjs.map +1 -0
- package/dist/chunk-C6SCWXDP.js +290 -0
- package/dist/chunk-C6SCWXDP.js.map +1 -0
- package/dist/{chunk-GVSTRN36.cjs → chunk-D3LEFMOA.cjs} +3 -3
- package/dist/{chunk-GVSTRN36.cjs.map → chunk-D3LEFMOA.cjs.map} +1 -1
- package/dist/{chunk-P45Q4QCU.cjs → chunk-D5YY5U6J.cjs} +19 -15
- package/dist/chunk-D5YY5U6J.cjs.map +1 -0
- package/dist/chunk-DDAT4RKX.js +2417 -0
- package/dist/chunk-DDAT4RKX.js.map +1 -0
- package/dist/{chunk-PVXCVPJD.cjs → chunk-EI4WALN2.cjs} +67 -1626
- package/dist/chunk-EI4WALN2.cjs.map +1 -0
- package/dist/{chunk-ZIODAAFY.cjs → chunk-EL5GCMPU.cjs} +20 -159
- package/dist/chunk-EL5GCMPU.cjs.map +1 -0
- package/dist/{chunk-VQW7CEGS.js → chunk-FSBDVT6R.js} +1831 -3672
- package/dist/chunk-FSBDVT6R.js.map +1 -0
- package/dist/{chunk-DPKUSBQH.cjs → chunk-GDF2R2ER.cjs} +1073 -2914
- package/dist/chunk-GDF2R2ER.cjs.map +1 -0
- package/dist/chunk-HLQW7MWJ.cjs +1594 -0
- package/dist/chunk-HLQW7MWJ.cjs.map +1 -0
- package/dist/chunk-I6ZPGKZ2.cjs +151 -0
- package/dist/chunk-I6ZPGKZ2.cjs.map +1 -0
- package/dist/{chunk-J66U3LPZ.cjs → chunk-KBKZYJRI.cjs} +33 -332
- package/dist/chunk-KBKZYJRI.cjs.map +1 -0
- package/dist/{chunk-USPATRSC.js → chunk-N6ZM5PYZ.js} +45 -344
- package/dist/chunk-N6ZM5PYZ.js.map +1 -0
- package/dist/{chunk-RGALV4UX.js → chunk-POKKCWKF.js} +3 -82
- package/dist/chunk-POKKCWKF.js.map +1 -0
- package/dist/{chunk-XDI5JSWB.cjs → chunk-QCKN37OP.cjs} +45 -41
- package/dist/chunk-QCKN37OP.cjs.map +1 -0
- package/dist/{chunk-KNCDUWY2.js → chunk-QF2X6PTD.js} +10 -6
- package/dist/{chunk-KNCDUWY2.js.map → chunk-QF2X6PTD.js.map} +1 -1
- package/dist/{chunk-Z3V2SGQZ.js → chunk-R2KT5GDD.js} +324 -1883
- package/dist/chunk-R2KT5GDD.js.map +1 -0
- package/dist/{chunk-RJUGNL2A.cjs → chunk-RQ6RTBKF.cjs} +16 -12
- package/dist/chunk-RQ6RTBKF.cjs.map +1 -0
- package/dist/chunk-TFSYSWPS.cjs +89 -0
- package/dist/chunk-TFSYSWPS.cjs.map +1 -0
- package/dist/chunk-TYZEMPPH.js +312 -0
- package/dist/chunk-TYZEMPPH.js.map +1 -0
- package/dist/{chunk-O5N57CRV.js → chunk-VCQ3CTYK.js} +12 -8
- package/dist/{chunk-O5N57CRV.js.map → chunk-VCQ3CTYK.js.map} +1 -1
- package/dist/chunk-VRSXJ5QJ.js +1599 -0
- package/dist/chunk-VRSXJ5QJ.js.map +1 -0
- package/dist/{chunk-53CFL7PH.js → chunk-XQL4WDML.js} +11 -7
- package/dist/{chunk-53CFL7PH.js.map → chunk-XQL4WDML.js.map} +1 -1
- package/dist/chunk-Y4JNA4W6.cjs +1599 -0
- package/dist/chunk-Y4JNA4W6.cjs.map +1 -0
- package/dist/{chunk-ZSUNQJRO.cjs → chunk-YETA25JW.cjs} +2 -81
- package/dist/chunk-YETA25JW.cjs.map +1 -0
- package/dist/chunk-YVB3VDIQ.js +1594 -0
- package/dist/chunk-YVB3VDIQ.js.map +1 -0
- package/dist/chunk-ZLN6SM2U.js +151 -0
- package/dist/chunk-ZLN6SM2U.js.map +1 -0
- package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +12 -6
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +45 -39
- package/dist/components/contact/index.cjs +11 -7
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +10 -6
- package/dist/components/docs/doc-navigation-context.d.ts +39 -0
- package/dist/components/docs/doc-navigation-context.d.ts.map +1 -0
- package/dist/components/docs/doc-viewer.d.ts +66 -0
- package/dist/components/docs/doc-viewer.d.ts.map +1 -0
- package/dist/components/docs/index.cjs +34 -0
- package/dist/components/docs/index.cjs.map +1 -0
- package/dist/components/docs/index.d.ts +8 -0
- package/dist/components/docs/index.d.ts.map +1 -0
- package/dist/components/docs/index.js +34 -0
- package/dist/components/docs/index.js.map +1 -0
- package/dist/components/docs/use-document-tree.d.ts +41 -0
- package/dist/components/docs/use-document-tree.d.ts.map +1 -0
- package/dist/components/docs/use-scroll-spy.d.ts +16 -0
- package/dist/components/docs/use-scroll-spy.d.ts.map +1 -0
- package/dist/components/embeds/embed-iframe.d.ts +29 -0
- package/dist/components/embeds/embed-iframe.d.ts.map +1 -0
- package/dist/components/embeds/figma-embed.d.ts +27 -0
- package/dist/components/embeds/figma-embed.d.ts.map +1 -0
- package/dist/components/embeds/google-sheets-viewer.d.ts +8 -0
- package/dist/components/embeds/google-sheets-viewer.d.ts.map +1 -0
- package/dist/components/embeds/index.cjs +37 -0
- package/dist/components/embeds/index.cjs.map +1 -0
- package/dist/components/embeds/index.d.ts +9 -0
- package/dist/components/embeds/index.d.ts.map +1 -0
- package/dist/components/embeds/index.js +37 -0
- package/dist/components/embeds/index.js.map +1 -0
- package/dist/components/embeds/pdf-viewer.d.ts +10 -0
- package/dist/components/embeds/pdf-viewer.d.ts.map +1 -0
- package/dist/components/faq/index.cjs +12 -8
- package/dist/components/faq/index.cjs.map +1 -1
- package/dist/components/faq/index.js +11 -7
- package/dist/components/features/index.cjs +10 -6
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +9 -5
- package/dist/components/index.cjs +247 -1120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +645 -1518
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +15 -6
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +2 -0
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +15 -6
- package/dist/components/navigation/multi-level-navigation.d.ts +28 -0
- package/dist/components/navigation/multi-level-navigation.d.ts.map +1 -0
- package/dist/components/onboarding-guides/index.cjs +34 -29
- package/dist/components/onboarding-guides/index.cjs.map +1 -1
- package/dist/components/onboarding-guides/index.js +13 -8
- package/dist/components/onboarding-guides/index.js.map +1 -1
- package/dist/components/related-content/index.cjs +12 -8
- package/dist/components/related-content/index.cjs.map +1 -1
- package/dist/components/related-content/index.js +11 -7
- package/dist/components/tickets/index.cjs +75 -70
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +16 -11
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/ui/index.cjs +12 -6
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +65 -59
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +8 -8
- package/dist/hooks/index.cjs +6 -3
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +7 -4
- package/dist/index.cjs +62 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +257 -201
- package/dist/types/doc-source.cjs +4 -0
- package/dist/types/doc-source.cjs.map +1 -0
- package/dist/types/doc-source.d.ts +86 -0
- package/dist/types/doc-source.d.ts.map +1 -0
- package/dist/types/doc-source.js +3 -0
- package/dist/types/doc-source.js.map +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/doc-path-utils.d.ts +24 -0
- package/dist/utils/doc-path-utils.d.ts.map +1 -0
- package/dist/utils/doc-tree-nav.d.ts +35 -0
- package/dist/utils/doc-tree-nav.d.ts.map +1 -0
- package/dist/utils/embed-url-converters.d.ts +16 -0
- package/dist/utils/embed-url-converters.d.ts.map +1 -0
- package/dist/utils/format.d.ts +9 -0
- package/dist/utils/format.d.ts.map +1 -1
- package/dist/utils/index.cjs +297 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +7 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +276 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-section-extractor.d.ts +23 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -0
- package/dist/utils/markdown-to-plain.d.ts +32 -0
- package/dist/utils/markdown-to-plain.d.ts.map +1 -0
- package/dist/utils/tree-builder.d.ts +30 -0
- package/dist/utils/tree-builder.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/components/chat/chat-attachment-bar.tsx +6 -11
- package/src/components/docs/doc-navigation-context.tsx +123 -0
- package/src/components/docs/doc-viewer.tsx +345 -0
- package/src/components/docs/index.ts +12 -0
- package/src/components/docs/use-document-tree.ts +427 -0
- package/src/components/docs/use-scroll-spy.ts +93 -0
- package/src/components/embeds/embed-iframe.tsx +103 -0
- package/src/components/embeds/figma-embed.tsx +147 -0
- package/src/components/embeds/google-sheets-viewer.tsx +54 -0
- package/src/components/embeds/index.ts +11 -0
- package/src/components/embeds/pdf-viewer.tsx +64 -0
- package/src/components/index.ts +2 -0
- package/src/components/navigation/index.ts +5 -0
- package/src/components/navigation/multi-level-navigation.tsx +358 -0
- package/src/types/doc-source.ts +88 -0
- package/src/types/index.ts +4 -0
- package/src/utils/doc-path-utils.ts +47 -0
- package/src/utils/doc-tree-nav.ts +111 -0
- package/src/utils/embed-url-converters.ts +77 -0
- package/src/utils/format.ts +16 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/markdown-section-extractor.ts +106 -0
- package/src/utils/markdown-to-plain.ts +69 -0
- package/src/utils/tree-builder.ts +114 -0
- package/dist/chunk-DPKUSBQH.cjs.map +0 -1
- package/dist/chunk-J66U3LPZ.cjs.map +0 -1
- package/dist/chunk-P45Q4QCU.cjs.map +0 -1
- package/dist/chunk-PVXCVPJD.cjs.map +0 -1
- package/dist/chunk-RGALV4UX.js.map +0 -1
- package/dist/chunk-RJUGNL2A.cjs.map +0 -1
- package/dist/chunk-USPATRSC.js.map +0 -1
- package/dist/chunk-VQW7CEGS.js.map +0 -1
- package/dist/chunk-XDI5JSWB.cjs.map +0 -1
- package/dist/chunk-XX7P4IL2.js.map +0 -1
- package/dist/chunk-Z3V2SGQZ.js.map +0 -1
- package/dist/chunk-ZIODAAFY.cjs.map +0 -1
- package/dist/chunk-ZSUNQJRO.cjs.map +0 -1
- /package/dist/{chunk-IMNGGLKL.js.map → chunk-3ZXUQQL4.js.map} +0 -0
- /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
|
+
}
|