@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single home for doc-source tree + content types.
|
|
3
|
+
*
|
|
4
|
+
* Consumed by every doc-viewer consumer (knowledge base, data room, future
|
|
5
|
+
* sources). Replaces the per-source `OpenFrameDocNode` / `OpenFrameDocContent`
|
|
6
|
+
* / `DataRoomDocNode` / `DocNavNode` types that previously lived in the hub.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A single node in a doc-source tree. The `documentType` discriminator
|
|
11
|
+
* defaults to `'markdown'` when undefined; rich-content viewers handle
|
|
12
|
+
* `'pdf' | 'google_sheet' | 'figma' | 'file'`.
|
|
13
|
+
*/
|
|
14
|
+
export interface DocNode {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
slug: string
|
|
18
|
+
path: string
|
|
19
|
+
type: 'file' | 'folder'
|
|
20
|
+
hasReadme?: boolean
|
|
21
|
+
/** Per-row sort key — populated by data-room rows, undefined elsewhere. */
|
|
22
|
+
sortOrder?: number
|
|
23
|
+
/** Discriminator for the rich renderer. `undefined` is treated as `'markdown'`. */
|
|
24
|
+
documentType?: 'markdown' | 'pdf' | 'google_sheet' | 'figma' | 'file'
|
|
25
|
+
children?: DocNode[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Content payload returned by a doc-source DAL's `getContent` call. Carries
|
|
30
|
+
* everything any consumer's renderer might need; markdown-only fields and
|
|
31
|
+
* rich-only fields are both optional.
|
|
32
|
+
*/
|
|
33
|
+
export interface DocContent {
|
|
34
|
+
content: string
|
|
35
|
+
sections: Array<{ id: string; title: string; level: number }>
|
|
36
|
+
path: string
|
|
37
|
+
/** Defaults to `'markdown'` when undefined. */
|
|
38
|
+
documentType?: DocNode['documentType']
|
|
39
|
+
// Markdown-only
|
|
40
|
+
brokenLinks?: string[]
|
|
41
|
+
// Rich-content (data-room) — optional
|
|
42
|
+
fileUrl?: string
|
|
43
|
+
externalUrl?: string
|
|
44
|
+
mimeType?: string
|
|
45
|
+
fileName?: string
|
|
46
|
+
fileSize?: number
|
|
47
|
+
/** ISO timestamps for SEO `datePublished` / `dateModified` — DAL populates
|
|
48
|
+
* from the underlying row's sync/update timestamp. Undefined when unknown. */
|
|
49
|
+
publishedAt?: string
|
|
50
|
+
updatedAt?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Doc-source data-access contract. Each doc source's hub-side DAL conforms
|
|
55
|
+
* to this. Generic over the Supabase-client type so the lib doesn't pull in
|
|
56
|
+
* `@supabase/supabase-js`; hub callers tighten `Client` to `SupabaseClient`.
|
|
57
|
+
*/
|
|
58
|
+
export interface DocSourceDal<Client = unknown> {
|
|
59
|
+
getStructure(client: Client): Promise<DocNode[]>
|
|
60
|
+
getContent(client: Client, path: string): Promise<DocContent | null>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Doc-source registry id. The lib's `<DocViewer>` is generic over this union
|
|
65
|
+
* — the hub's `DOC_SOURCES` registry defines the actual ids. Listed here as a
|
|
66
|
+
* type-only union so the lib can flow the id into `DocRenderHandlers.sourceId`
|
|
67
|
+
* without callers having to cast `string` → `'openframe-docs' | 'data-room-docs'`.
|
|
68
|
+
*
|
|
69
|
+
* Adding a new source = add the id here AND register it in the hub. The lib
|
|
70
|
+
* never reaches into the registry — this is purely a type-narrowing handle.
|
|
71
|
+
*/
|
|
72
|
+
export type DocSourceId = 'openframe-docs' | 'data-room-docs'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Handlers the viewer passes to a consumer's `renderContent` callback.
|
|
76
|
+
* The page shell wires `renderContent` directly — no `DocContentRenderer`
|
|
77
|
+
* interface layer between the viewer and the consumer's render logic.
|
|
78
|
+
*/
|
|
79
|
+
export interface DocRenderHandlers {
|
|
80
|
+
onInternalLinkClick: (
|
|
81
|
+
path: string,
|
|
82
|
+
options?: { expandFolder?: boolean; fromInternalLink?: boolean },
|
|
83
|
+
) => void
|
|
84
|
+
currentPath: string
|
|
85
|
+
/** Registry source id (e.g. `'openframe-docs'`, `'data-room-docs'`) — used by
|
|
86
|
+
* the consumer's `/api/resolve-link` POST to disambiguate the doc source. */
|
|
87
|
+
sourceId: DocSourceId
|
|
88
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
|
|
4
4
|
// Core platform types
|
|
5
5
|
export * from './announcement'
|
|
6
|
+
// Doc-source viewer types — DocNode / DocContent / DocSourceDal /
|
|
7
|
+
// DocRenderHandlers. Consumed by knowledge-base, data-room, and future
|
|
8
|
+
// doc-source viewers.
|
|
9
|
+
export * from './doc-source'
|
|
6
10
|
export type { Category, CategoryCardProps, RealCategoryCardProps } from './categories'
|
|
7
11
|
export * from './category'
|
|
8
12
|
export * from './media'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared document path utilities.
|
|
3
|
+
* Single source of truth for path/slug operations used by all doc-source viewers
|
|
4
|
+
* (knowledge base, data room, future).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_FOLDER_INDEX_FILE } from './doc-tree-nav'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a document name to a URL-safe path slug.
|
|
11
|
+
* Used when creating, moving, or renaming documents.
|
|
12
|
+
* e.g., "Investor Thesis" → "investor-thesis"
|
|
13
|
+
*/
|
|
14
|
+
export function toDocSlug(name: string): string {
|
|
15
|
+
return name.trim().toLowerCase().replace(/\s+/g, '-')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a full document path to a DOM-safe node ID.
|
|
20
|
+
* Used for tree node IDs and sidebar expand/collapse state.
|
|
21
|
+
* e.g., "legal/cap-table/safe.md" → "legal-cap-table-safe.md"
|
|
22
|
+
*/
|
|
23
|
+
export function pathToNodeId(path: string): string {
|
|
24
|
+
return path.replace(/[\/\s]/g, '-').toLowerCase()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a raw URL path from a route handler.
|
|
29
|
+
* Strips folder-index suffixes (default README.md) and lowercases segments.
|
|
30
|
+
* Used by both data-room and knowledge-base [...path] route handlers.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeDocPath(
|
|
33
|
+
segments: string[],
|
|
34
|
+
folderIndexFile: string = DEFAULT_FOLDER_INDEX_FILE,
|
|
35
|
+
): string {
|
|
36
|
+
// Lowercase BOTH sides BEFORE comparison so paths from URL segments
|
|
37
|
+
// (`readme.md`) and the canonical folder-index constant (`README.md`)
|
|
38
|
+
// strip uniformly. Lowercasing only at the end (the previous shape) left
|
|
39
|
+
// lowercase URL inputs unstripped → bogus content lookups.
|
|
40
|
+
const lowerPath = (segments?.join('/') || '').toLowerCase()
|
|
41
|
+
const lowerIndex = folderIndexFile.toLowerCase()
|
|
42
|
+
|
|
43
|
+
const suffix = `/${lowerIndex}`
|
|
44
|
+
if (lowerPath.endsWith(suffix)) return lowerPath.slice(0, -suffix.length)
|
|
45
|
+
if (lowerPath === lowerIndex) return ''
|
|
46
|
+
return lowerPath
|
|
47
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for doc-source tree navigation — shared by client hook and RSC SSR.
|
|
3
|
+
* README convention (folder-index file) is configurable but defaults to 'README.md'.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DocNode } from '../types/doc-source'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Canonical folder-index filename. Single SSOT consumed by `doc-tree-nav`
|
|
10
|
+
* pure helpers, `useDocumentTree` (default config), `DocSeoContent` (fallback
|
|
11
|
+
* fetch path), and `multi-level-navigation`'s visual-selection logic. Stays
|
|
12
|
+
* in canonical case here; consumers that do case-insensitive comparison
|
|
13
|
+
* (the navigation) lowercase locally.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_FOLDER_INDEX_FILE = 'README.md'
|
|
16
|
+
|
|
17
|
+
/** @deprecated Re-exported under the new name; remove after consumers migrate. */
|
|
18
|
+
const DEFAULT_FOLDER_INDEX = DEFAULT_FOLDER_INDEX_FILE
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip the folder-index filename from a path, returning the folder path.
|
|
22
|
+
* For default 'README.md': 'folder/README.md' → 'folder', 'README.md' → '', 'folder/file.md' → 'folder/file.md'
|
|
23
|
+
*/
|
|
24
|
+
export function stripFolderIndexFromPath(
|
|
25
|
+
path: string,
|
|
26
|
+
folderIndexFile: string = DEFAULT_FOLDER_INDEX,
|
|
27
|
+
): string {
|
|
28
|
+
const suffix = `/${folderIndexFile}`
|
|
29
|
+
if (path.endsWith(suffix)) return path.slice(0, -suffix.length)
|
|
30
|
+
if (path === folderIndexFile) return ''
|
|
31
|
+
return path
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function findDocNodeByPath(path: string, nodes: DocNode[]): DocNode | null {
|
|
35
|
+
for (const node of nodes) {
|
|
36
|
+
if (node.path === path) return node
|
|
37
|
+
if (node.children) {
|
|
38
|
+
const found = findDocNodeByPath(path, node.children)
|
|
39
|
+
if (found) return found
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Given a node path like "folder1/subfolder2", return DOM-style node IDs for each segment
|
|
47
|
+
* (accordion sidebar — matches use-document-tree).
|
|
48
|
+
*/
|
|
49
|
+
export function getDocAncestorNodeIds(nodePath: string): string[] {
|
|
50
|
+
const parts = nodePath.split('/')
|
|
51
|
+
const ids: string[] = []
|
|
52
|
+
let current = ''
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
current = current ? `${current}-${part}` : part
|
|
55
|
+
ids.push(current.toLowerCase())
|
|
56
|
+
}
|
|
57
|
+
return ids
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve which storage path to load (matches use-document-tree content effect).
|
|
62
|
+
*/
|
|
63
|
+
export function resolveContentFetchPath(
|
|
64
|
+
selectedPath: string,
|
|
65
|
+
structure: DocNode[],
|
|
66
|
+
folderIndexFile: string = DEFAULT_FOLDER_INDEX,
|
|
67
|
+
): string | null {
|
|
68
|
+
if (selectedPath === '') {
|
|
69
|
+
return folderIndexFile
|
|
70
|
+
}
|
|
71
|
+
const node = findDocNodeByPath(selectedPath, structure)
|
|
72
|
+
if (node && node.type === 'folder' && !node.hasReadme) {
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
let pathToFetch = selectedPath
|
|
76
|
+
if (node && node.type === 'folder' && node.hasReadme) {
|
|
77
|
+
pathToFetch = `${selectedPath}/${folderIndexFile}`
|
|
78
|
+
}
|
|
79
|
+
return pathToFetch
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getInitialExpandedNodeIds(cleanInitialPath: string): string[] {
|
|
83
|
+
if (!cleanInitialPath) return []
|
|
84
|
+
const pathForExpansion = cleanInitialPath.includes('.')
|
|
85
|
+
? cleanInitialPath.substring(0, cleanInitialPath.lastIndexOf('/'))
|
|
86
|
+
: cleanInitialPath
|
|
87
|
+
if (!pathForExpansion) return []
|
|
88
|
+
return getDocAncestorNodeIds(pathForExpansion)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* When a doc source's root has no top-level folder-index file, navigate to the first
|
|
93
|
+
* folder that does. Matches the historical knowledge-base default.
|
|
94
|
+
*/
|
|
95
|
+
export function getDocSourceDefaultPath(
|
|
96
|
+
structure: DocNode[],
|
|
97
|
+
folderIndexFile: string = DEFAULT_FOLDER_INDEX,
|
|
98
|
+
): string | null {
|
|
99
|
+
if (!structure.length) return null
|
|
100
|
+
const hasRootIndex = structure.some(
|
|
101
|
+
(node) => node.type === 'file' && node.path === folderIndexFile
|
|
102
|
+
)
|
|
103
|
+
if (hasRootIndex) return null
|
|
104
|
+
// Scan ALL root nodes for the first folder with a README — not just
|
|
105
|
+
// structure[0]. Otherwise a leading non-folder root (file / folder without
|
|
106
|
+
// README) silently returns null even when a later root folder has one.
|
|
107
|
+
const firstFolderWithReadme = structure.find(
|
|
108
|
+
(node) => node.type === 'folder' && node.hasReadme && !!node.path,
|
|
109
|
+
)
|
|
110
|
+
return firstFolderWithReadme?.path ?? null
|
|
111
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for converting external service URLs to embeddable formats.
|
|
3
|
+
* Used by lib's embed components and the hub's admin document editor.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function toGoogleSheetsEmbedUrl(url: string): string {
|
|
7
|
+
if (url.includes('/htmlembed')) return url
|
|
8
|
+
|
|
9
|
+
const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/)
|
|
10
|
+
if (!match) return url
|
|
11
|
+
|
|
12
|
+
const gidMatch = url.match(/[#?&]gid=(\d+)/)
|
|
13
|
+
const gid = gidMatch ? gidMatch[1] : '0'
|
|
14
|
+
|
|
15
|
+
return `https://docs.google.com/spreadsheets/d/${match[1]}/htmlembed?widget=true&chrome=false&headers=false&gid=${gid}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toGoogleSheetsOriginalUrl(url: string): string {
|
|
19
|
+
const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/)
|
|
20
|
+
if (!match) return url
|
|
21
|
+
|
|
22
|
+
const gidMatch = url.match(/[#?&]gid=(\d+)/)
|
|
23
|
+
const gid = gidMatch ? gidMatch[1] : '0'
|
|
24
|
+
|
|
25
|
+
return `https://docs.google.com/spreadsheets/d/${match[1]}/edit#gid=${gid}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert a Figma URL to an embeddable URL.
|
|
30
|
+
* Slides/deck URLs map to `deck` (present) by default; `slidesView: 'browse'` switches to `slides` (browse).
|
|
31
|
+
*/
|
|
32
|
+
export function toFigmaEmbedUrl(
|
|
33
|
+
url: string,
|
|
34
|
+
opts?: { slidesView?: 'present' | 'browse' }
|
|
35
|
+
): string {
|
|
36
|
+
if (url.includes('embed.figma.com')) return url
|
|
37
|
+
if (url.includes('figma.com/embed')) return url
|
|
38
|
+
|
|
39
|
+
const match = url.match(
|
|
40
|
+
/figma\.com\/(design|file|proto|board|slides|deck)\/([a-zA-Z0-9]+)(?:\/([^?]*))?(\?.*)?$/
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if (match) {
|
|
44
|
+
const [, urlType, fileKey, titleSlug, queryString] = match
|
|
45
|
+
const isSlides = urlType === 'slides' || urlType === 'deck'
|
|
46
|
+
const embedType =
|
|
47
|
+
urlType === 'proto' ? 'proto'
|
|
48
|
+
: isSlides ? (opts?.slidesView === 'browse' ? 'slides' : 'deck')
|
|
49
|
+
: 'design'
|
|
50
|
+
const pathSuffix = titleSlug ? `/${titleSlug}` : ''
|
|
51
|
+
|
|
52
|
+
const params = new URLSearchParams(queryString?.replace(/^\?/, '') || '')
|
|
53
|
+
if (!params.has('embed-host')) {
|
|
54
|
+
params.set('embed-host', 'flamingo')
|
|
55
|
+
}
|
|
56
|
+
const clientId = process.env.NEXT_PUBLIC_FIGMA_CLIENT_ID
|
|
57
|
+
if (clientId && !params.has('client-id')) {
|
|
58
|
+
params.set('client-id', clientId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return `https://embed.figma.com/${embedType}/${fileKey}${pathSuffix}?${params.toString()}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return `https://www.figma.com/embed?embed-host=flamingo&url=${encodeURIComponent(url)}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isFigmaSlidesUrl(url: string): boolean {
|
|
68
|
+
if (!url) return false
|
|
69
|
+
return /(?:www\.|embed\.)?figma\.com\/(?:slides|deck)\/[a-zA-Z0-9]+/.test(url)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function toFigmaOriginalUrl(url: string): string {
|
|
73
|
+
if (url.includes('embed.figma.com')) {
|
|
74
|
+
return url.replace('embed.figma.com', 'www.figma.com').replace(/\?.*$/, '')
|
|
75
|
+
}
|
|
76
|
+
return url.replace(/\?.*$/, '')
|
|
77
|
+
}
|
package/src/utils/format.ts
CHANGED
|
@@ -81,6 +81,22 @@ export function formatBytesShort(bytes: number): string {
|
|
|
81
81
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* File-size formatter (1 decimal place, `'B'` unit, caps at `GB`).
|
|
86
|
+
* Hub historical semantics — distinct from `formatBytesShort` (2dp) and
|
|
87
|
+
* `formatBytes` (2dp / `'Bytes'`). Lifted from the hub during the doc-viewer
|
|
88
|
+
* unification so all upload UIs, publication cards, and data-room file-size
|
|
89
|
+
* displays render the same way.
|
|
90
|
+
* @example formatFileSize(0) → "0 B"; formatFileSize(1500) → "1.5 KB"
|
|
91
|
+
*/
|
|
92
|
+
export function formatFileSize(bytes: number): string {
|
|
93
|
+
if (bytes === 0) return '0 B'
|
|
94
|
+
const k = 1024
|
|
95
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
96
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
97
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
/**
|
|
85
101
|
* Format large numbers to abbreviated form (K, M, B) with no decimal points
|
|
86
102
|
* @param num - The number to format
|
package/src/utils/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ export {
|
|
|
102
102
|
formatWholeDollars,
|
|
103
103
|
formatLegalDate,
|
|
104
104
|
formatBytesShort,
|
|
105
|
+
formatFileSize,
|
|
105
106
|
formatDateTimeAt,
|
|
106
107
|
formatDurationFromMs,
|
|
107
108
|
type MetricFormat,
|
|
@@ -283,3 +284,13 @@ export {
|
|
|
283
284
|
// decision fn the lib forms feed. Also exported via the granular subpath
|
|
284
285
|
// `./utils/humanity-signals` for server-only consumers.
|
|
285
286
|
export * from './humanity-signals'
|
|
287
|
+
|
|
288
|
+
// Doc-source viewer utilities (path parsing, tree building, section extraction,
|
|
289
|
+
// embed-URL conversion) — single home for all doc-viewer pure helpers across
|
|
290
|
+
// hub + lib consumers (knowledge-base, data-room, and future sources).
|
|
291
|
+
export * from './doc-path-utils'
|
|
292
|
+
export * from './doc-tree-nav'
|
|
293
|
+
export * from './tree-builder'
|
|
294
|
+
export * from './markdown-section-extractor'
|
|
295
|
+
export * from './markdown-to-plain'
|
|
296
|
+
export * from './embed-url-converters'
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared markdown section extractor
|
|
3
|
+
* Extracts heading sections from markdown content for navigation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { stripInlineMarkdown } from './markdown-to-plain'
|
|
7
|
+
|
|
8
|
+
export interface MarkdownSection {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
level: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExtractSectionsOptions {
|
|
15
|
+
/** Maximum heading level to include (default: 2 = H1-H2) */
|
|
16
|
+
maxLevel?: number
|
|
17
|
+
/** Remove emoji characters from IDs (default: true) */
|
|
18
|
+
removeEmojis?: boolean
|
|
19
|
+
/** Handle duplicate IDs by appending a counter suffix (default: true) */
|
|
20
|
+
handleDuplicateIds?: boolean
|
|
21
|
+
/** Strip bold, italic, and inline code markers from titles (default: true) */
|
|
22
|
+
stripFormattingMarkers?: boolean
|
|
23
|
+
/** Skip code blocks and YAML frontmatter (default: true) */
|
|
24
|
+
skipCodeAndYamlBlocks?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_OPTIONS: Required<ExtractSectionsOptions> = {
|
|
28
|
+
maxLevel: 2,
|
|
29
|
+
removeEmojis: true,
|
|
30
|
+
handleDuplicateIds: true,
|
|
31
|
+
stripFormattingMarkers: true,
|
|
32
|
+
skipCodeAndYamlBlocks: true,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractSections(
|
|
36
|
+
markdown: string,
|
|
37
|
+
options: ExtractSectionsOptions = {},
|
|
38
|
+
): MarkdownSection[] {
|
|
39
|
+
const opts = { ...DEFAULT_OPTIONS, ...options }
|
|
40
|
+
const sections: MarkdownSection[] = []
|
|
41
|
+
const lines = markdown.split('\n')
|
|
42
|
+
const idCounts: Record<string, number> = {}
|
|
43
|
+
|
|
44
|
+
let inCodeBlock = false
|
|
45
|
+
let inYamlBlock = false
|
|
46
|
+
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (opts.skipCodeAndYamlBlocks) {
|
|
49
|
+
if (line.startsWith('```')) {
|
|
50
|
+
inCodeBlock = !inCodeBlock
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (line === '---') {
|
|
54
|
+
inYamlBlock = !inYamlBlock
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (inCodeBlock || inYamlBlock) continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pattern = new RegExp(`^(#{1,${opts.maxLevel}})\\s+(.+)`)
|
|
61
|
+
const match = line.match(pattern)
|
|
62
|
+
if (!match) continue
|
|
63
|
+
|
|
64
|
+
const level = match[1].length
|
|
65
|
+
if (level > opts.maxLevel) continue
|
|
66
|
+
|
|
67
|
+
let title = match[2].trim()
|
|
68
|
+
|
|
69
|
+
if (opts.stripFormattingMarkers) {
|
|
70
|
+
title = stripInlineMarkdown(title).trim()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let baseId = title
|
|
74
|
+
|
|
75
|
+
if (opts.removeEmojis) {
|
|
76
|
+
baseId = baseId
|
|
77
|
+
.replace(
|
|
78
|
+
/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu,
|
|
79
|
+
'',
|
|
80
|
+
)
|
|
81
|
+
.trim()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
baseId = baseId
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(/[^\w\s-]/g, '')
|
|
87
|
+
.replace(/\s+/g, '-')
|
|
88
|
+
.replace(/^-+|-+$/g, '')
|
|
89
|
+
|
|
90
|
+
const cleanId = baseId || `section-${sections.length + 1}`
|
|
91
|
+
|
|
92
|
+
let id = cleanId
|
|
93
|
+
if (opts.handleDuplicateIds) {
|
|
94
|
+
if (idCounts[cleanId]) {
|
|
95
|
+
idCounts[cleanId]++
|
|
96
|
+
id = `${cleanId}-${idCounts[cleanId]}`
|
|
97
|
+
} else {
|
|
98
|
+
idCounts[cleanId] = 1
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
sections.push({ id, title, level })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return sections
|
|
106
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert markdown source to plain text. Used by both the SEO server component
|
|
3
|
+
* (article-body echo in JSON-LD) and chat entity-card preview (clamped previews
|
|
4
|
+
* inside chat cards).
|
|
5
|
+
*
|
|
6
|
+
* The two consumers differ only in pre/post processing — `mode: 'article'`
|
|
7
|
+
* strips code blocks + images + headings + list bullets and preserves paragraph
|
|
8
|
+
* breaks; `mode: 'preview'` collapses everything to a single line. Both share
|
|
9
|
+
* the same inline-marker strip (link / bold / italic / inline-code).
|
|
10
|
+
*
|
|
11
|
+
* All regex patterns are linear (negated character classes, non-greedy with
|
|
12
|
+
* bounded targets) — no nested quantifiers, no catastrophic backtracking on
|
|
13
|
+
* pathological input.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface MarkdownToPlainOptions {
|
|
17
|
+
/**
|
|
18
|
+
* - `'article'` (default): preserve paragraph structure (`\n\n` as separator),
|
|
19
|
+
* strip code blocks + images + headings + list bullets. Used for the
|
|
20
|
+
* article-body echo on SEO surfaces.
|
|
21
|
+
* - `'preview'`: collapse all whitespace to single spaces; result is a single
|
|
22
|
+
* line. Used for chat-card previews / OG-card snippets where line breaks
|
|
23
|
+
* would wrap awkwardly.
|
|
24
|
+
*/
|
|
25
|
+
mode?: 'article' | 'preview'
|
|
26
|
+
/** Optional max-character cap. Truncates with no ellipsis (caller-decided). */
|
|
27
|
+
maxChars?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Strip inline markdown markers (bold, italic, inline-code, links). Shared
|
|
31
|
+
* by `extractSections`'s `stripFormattingMarkers` and `markdownToPlainText`. */
|
|
32
|
+
export function stripInlineMarkdown(s: string): string {
|
|
33
|
+
return s
|
|
34
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // [text](url) → text
|
|
35
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1') // **bold** → bold
|
|
36
|
+
.replace(/\*([^*]+)\*/g, '$1') // *italic* → italic
|
|
37
|
+
.replace(/`([^`]+)`/g, '$1') // `inline code` → inline code
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function markdownToPlainText(
|
|
41
|
+
markdown: string,
|
|
42
|
+
options: MarkdownToPlainOptions = {},
|
|
43
|
+
): string {
|
|
44
|
+
const { mode = 'article', maxChars } = options
|
|
45
|
+
|
|
46
|
+
let out = markdown
|
|
47
|
+
// Fenced code blocks — must run BEFORE inline strippers (otherwise the
|
|
48
|
+
// backtick-grave strip below grazes them).
|
|
49
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
50
|
+
// Images — `` → empty (we drop them entirely in plain text).
|
|
51
|
+
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
|
52
|
+
|
|
53
|
+
out = stripInlineMarkdown(out)
|
|
54
|
+
// Heading markers (any depth).
|
|
55
|
+
.replace(/^#+\s*/gm, '')
|
|
56
|
+
// List bullets at start-of-line.
|
|
57
|
+
.replace(/^\s*[-*+]\s+/gm, '')
|
|
58
|
+
|
|
59
|
+
if (mode === 'preview') {
|
|
60
|
+
out = out.replace(/\s+/g, ' ').trim()
|
|
61
|
+
} else {
|
|
62
|
+
out = out.replace(/\n{3,}/g, '\n\n').trim()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (maxChars != null && out.length > maxChars) {
|
|
66
|
+
out = out.slice(0, maxChars)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Shared Document Tree Builder
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Generic 3-pass algorithm for building hierarchical trees from flat records.
|
|
5
|
+
// Used by all doc-source DALs (openframe-docs, data-room-docs, future).
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base interface for tree nodes. Consumers extend this with additional fields.
|
|
9
|
+
*/
|
|
10
|
+
export interface TreeNodeBase {
|
|
11
|
+
id: string
|
|
12
|
+
name: string
|
|
13
|
+
path: string
|
|
14
|
+
type: 'file' | 'folder'
|
|
15
|
+
sortOrder?: number
|
|
16
|
+
children?: TreeNodeBase[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Format a document name for display: remove .md extension and capitalize.
|
|
21
|
+
*/
|
|
22
|
+
export function formatDocName(name: string): string {
|
|
23
|
+
let displayName = name
|
|
24
|
+
if (displayName.endsWith('.md')) {
|
|
25
|
+
displayName = displayName.slice(0, -3)
|
|
26
|
+
}
|
|
27
|
+
if (displayName.toLowerCase() === 'readme') {
|
|
28
|
+
return 'README'
|
|
29
|
+
}
|
|
30
|
+
return displayName.charAt(0).toUpperCase() + displayName.slice(1).replace(/-/g, ' ')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sort children: README first, then folders, then files — each group by sort_order then alphabetical.
|
|
35
|
+
* Recurses into folder children to ensure consistent ordering at all levels.
|
|
36
|
+
*/
|
|
37
|
+
export function sortTreeChildren<TNode extends TreeNodeBase>(nodes: TNode[]): TNode[] {
|
|
38
|
+
nodes.sort((a, b) => {
|
|
39
|
+
const aIsReadme = a.name.toLowerCase() === 'readme' || a.name.toLowerCase() === 'readme.md'
|
|
40
|
+
const bIsReadme = b.name.toLowerCase() === 'readme' || b.name.toLowerCase() === 'readme.md'
|
|
41
|
+
if (aIsReadme && !bIsReadme) return -1
|
|
42
|
+
if (!aIsReadme && bIsReadme) return 1
|
|
43
|
+
|
|
44
|
+
if (a.type === 'folder' && b.type !== 'folder') return -1
|
|
45
|
+
if (a.type !== 'folder' && b.type === 'folder') return 1
|
|
46
|
+
|
|
47
|
+
const aOrder = a.sortOrder ?? Infinity
|
|
48
|
+
const bOrder = b.sortOrder ?? Infinity
|
|
49
|
+
if (aOrder !== bOrder) return aOrder - bOrder
|
|
50
|
+
|
|
51
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
if (node.type === 'folder' && node.children) {
|
|
56
|
+
node.children = sortTreeChildren(node.children as TNode[])
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return nodes
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build a hierarchical document tree from a flat list of records.
|
|
65
|
+
*
|
|
66
|
+
* 3-pass algorithm:
|
|
67
|
+
* 1. Create node map from flat records using the provided `mapFn`
|
|
68
|
+
* 2. Build parent-child hierarchy using `getParentPath`
|
|
69
|
+
* 3. Sort children (folders first, README, then others)
|
|
70
|
+
*/
|
|
71
|
+
export function buildDocumentTree<TNode extends TreeNodeBase, TDoc>(
|
|
72
|
+
docs: TDoc[],
|
|
73
|
+
mapFn: (doc: TDoc) => TNode,
|
|
74
|
+
getParentPath: (doc: TDoc) => string | null,
|
|
75
|
+
getPath: (doc: TDoc) => string
|
|
76
|
+
): TNode[] {
|
|
77
|
+
const nodeMap = new Map<string, TNode>()
|
|
78
|
+
const rootNodes: TNode[] = []
|
|
79
|
+
|
|
80
|
+
for (const doc of docs) {
|
|
81
|
+
const node = mapFn(doc)
|
|
82
|
+
nodeMap.set(getPath(doc), node)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const doc of docs) {
|
|
86
|
+
const node = nodeMap.get(getPath(doc))!
|
|
87
|
+
let resolvedParentPath = getParentPath(doc)
|
|
88
|
+
|
|
89
|
+
if (!resolvedParentPath) {
|
|
90
|
+
const nodePath = getPath(doc)
|
|
91
|
+
const lastSlash = nodePath.lastIndexOf('/')
|
|
92
|
+
if (lastSlash > 0) {
|
|
93
|
+
resolvedParentPath = nodePath.substring(0, lastSlash)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (resolvedParentPath) {
|
|
98
|
+
const parent = nodeMap.get(resolvedParentPath)
|
|
99
|
+
if (parent) {
|
|
100
|
+
// Lazily initialize `children` so callers whose `mapFn` doesn't
|
|
101
|
+
// pre-seed it still get a proper tree (previously a missing
|
|
102
|
+
// `parent.children` silently flattened the child to the root).
|
|
103
|
+
if (!parent.children) parent.children = []
|
|
104
|
+
;(parent.children as TNode[]).push(node)
|
|
105
|
+
} else {
|
|
106
|
+
rootNodes.push(node)
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
rootNodes.push(node)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return sortTreeChildren(rootNodes)
|
|
114
|
+
}
|