// ============================================================================= // Shared Document Tree Builder // ============================================================================= // Generic 3-pass algorithm for building hierarchical trees from flat records. // Used by all doc-source DALs (openframe-docs, data-room-docs, future). /** * Base interface for tree nodes. Consumers extend this with additional fields. */ export interface TreeNodeBase { id: string; name: string; path: string; type: 'file' | 'folder'; sortOrder?: number; children?: TreeNodeBase[]; } /** * Format a document name for display: remove .md extension and capitalize. */ export function formatDocName(name: string): string { let displayName = name; if (displayName.endsWith('.md')) { displayName = displayName.slice(0, -3); } if (displayName.toLowerCase() === 'readme') { return 'README'; } return displayName.charAt(0).toUpperCase() + displayName.slice(1).replace(/-/g, ' '); } /** * Sort children: README first, then folders, then files — each group by sort_order then alphabetical. * Recurses into folder children to ensure consistent ordering at all levels. */ export function sortTreeChildren(nodes: TNode[]): TNode[] { nodes.sort((a, b) => { const aIsReadme = a.name.toLowerCase() === 'readme' || a.name.toLowerCase() === 'readme.md'; const bIsReadme = b.name.toLowerCase() === 'readme' || b.name.toLowerCase() === 'readme.md'; if (aIsReadme && !bIsReadme) return -1; if (!aIsReadme && bIsReadme) return 1; if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; const aOrder = a.sortOrder ?? Infinity; const bOrder = b.sortOrder ?? Infinity; if (aOrder !== bOrder) return aOrder - bOrder; return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); }); for (const node of nodes) { if (node.type === 'folder' && node.children) { node.children = sortTreeChildren(node.children as TNode[]); } } return nodes; } /** * Build a hierarchical document tree from a flat list of records. * * 3-pass algorithm: * 1. Create node map from flat records using the provided `mapFn` * 2. Build parent-child hierarchy using `getParentPath` * 3. Sort children (folders first, README, then others) */ export function buildDocumentTree( docs: TDoc[], mapFn: (doc: TDoc) => TNode, getParentPath: (doc: TDoc) => string | null, getPath: (doc: TDoc) => string, ): TNode[] { const nodeMap = new Map(); const rootNodes: TNode[] = []; for (const doc of docs) { const node = mapFn(doc); nodeMap.set(getPath(doc), node); } for (const doc of docs) { // Pass 1 inserted every doc under this exact key, so a miss is // unreachable — but if a `getPath` implementation ever became // non-deterministic, dropping one node beats throwing away the tree. const node = nodeMap.get(getPath(doc)); if (!node) continue; let resolvedParentPath = getParentPath(doc); if (!resolvedParentPath) { const nodePath = getPath(doc); const lastSlash = nodePath.lastIndexOf('/'); if (lastSlash > 0) { resolvedParentPath = nodePath.substring(0, lastSlash); } } if (resolvedParentPath) { const parent = nodeMap.get(resolvedParentPath); if (parent) { // Lazily initialize `children` so callers whose `mapFn` doesn't // pre-seed it still get a proper tree (previously a missing // `parent.children` silently flattened the child to the root). if (!parent.children) parent.children = []; (parent.children as TNode[]).push(node); } else { rootNodes.push(node); } } else { rootNodes.push(node); } } return sortTreeChildren(rootNodes); }