"use client" import React, { useMemo } from "react" import { MultiLevelNavigation, MobileNavigationDropdown } from "../navigation/multi-level-navigation" import { PageLayout } from "../layout/page-layout" import { PageShell } from "../layout/article-detail-layout" import { useRouter } from "../../embed-shims/next-navigation" import { PersistentSidebar, PersistentMobileDropdown } from "../persistent-filter-controls" import { CategorySidebarSkeleton } from "../loading/page-layout-skeleton" import { DocSearchBar, useDocSearch } from "../shared/doc-search" import { StickySectionNav } from "../navigation/sticky-section-nav" import { useDocumentTree } from "./use-document-tree" import { useScrollSpy } from "./use-scroll-spy" import { useDocNavigation } from "./doc-navigation-context" import { findDocNodeByPath } from "../../utils/doc-tree-nav" import type { DocContent, DocNode, DocRenderHandlers, DocSourceId } from "../../types/doc-source" import { useDocsResolveLink } from "./use-docs-resolve-link" /** Color tokens for the doc-viewer chrome. Hub-side `DocViewer` callers share * this constant; no need to override per source — the palette is intentionally * uniform across knowledge-base + data-room (both use ODS dark tokens). */ export const DEFAULT_DOC_VIEWER_PALETTE = { background: "bg-ods-bg", containerBackground: "transparent", headerText: "text-ods-text-primary", primaryText: "text-ods-text-primary", secondaryText: "text-ods-text-secondary", accent: "var(--ods-accent)", border: "border-ods-border", cardBackground: "bg-ods-card", } as const export interface DocViewerProps { /** * Registry source id (`'openframe-docs'`, `'data-room-docs'`, …). Flowed through * `renderContent`'s handlers for `/api/docs/resolve-link` POSTs. */ sourceId: DocSourceId /** * Render the content body. The page shell owns this — it picks the markdown * renderer, dispatches PDF/Figma/Sheets/file branches, etc. No renderer * interface in between. */ renderContent: (content: DocContent, handlers: DocRenderHandlers) => React.ReactNode /** * Render the loading skeleton. Receives the selected node's `documentType` * (undefined while structure is still loading) so the caller can return a * markdown-shaped skeleton vs an embed-shaped skeleton. */ renderSkeleton: (documentType: DocNode['documentType']) => React.ReactNode /** * Chat-source identifier — passed in by the page shell from server-side * `currentPlatform()`. Lib has no platform context; the page shell is the * trusted boundary that wires this. NEVER pass user input here. */ chatSource: string /** Page title — rendered as the inline hero `

` (same DOM * ``'s hero uses) so the doc-viewer chrome matches the * dev-section pages. ReactNode is intentionally not supported here — * every consumer renders the same typography. */ title?: string /** Optional icon rendered inline before the title text — same slot * ``'s hero uses (Map for Roadmap, Rocket for Releases, * etc.). Pass a pre-rendered React element styled with * `SECTION_HERO_ICON_CLASS` (`h-10 w-10 text-ods-accent`) for visual * parity with other lib pages. */ titleIcon?: React.ReactNode /** Subtitle (h6, secondary text) rendered beneath the title. */ subtitle?: string /** Render a yellow accent dot (`.`) after the title — same flag as * the hub's legacy `` so the docs-hub * surface keeps its existing accent styling after the migration. */ accentDot?: boolean /** Override the default ODS palette. Optional — most callers should omit. */ colorPalette?: typeof DEFAULT_DOC_VIEWER_PALETTE className?: string /** Render the standalone `` (own `
` + bg + max-width). Default * true. Pass false when the host layout already provides the page container — * only the padding box renders, avoiding a nested `
`. */ shell?: boolean /** Initial doc path (URL `[...path]`). */ docPath?: string /** Sidebar header copy (`'DOCUMENTATION'`, `'DATA ROOM'`). */ sidebarLabel?: string /** * API endpoint for fetching the document tree structure. Defaults to the * dispatcher path `/api/docs/sources/${sourceId}/structure`. Override only * if hosting the viewer behind a different route. */ structureEndpoint?: string /** Same shape as `structureEndpoint`. Defaults to `/api/docs/sources/${sourceId}/content`. */ contentEndpoint?: string /** RAG-search endpoint that backs the in-source search bar (when `showAIChat` * is on). Defaults to `/api/docs/search`. Override for proxy-prefix embeds — * same injectability pattern as `structureEndpoint` / `contentEndpoint`. */ searchEndpoint?: string /** POST internal-link resolver. The viewer threads an async `onResolveLink` * into `renderContent`'s `handlers` that posts `{ link, currentPath, source }` * here. Defaults to `/api/docs/resolve-link`. Override for proxy-prefix embeds — * same injectability pattern as `structureEndpoint` / `contentEndpoint` / * `searchEndpoint`, with `ChatRuntime.endpoints.docsResolveLinkUrl` as a * runtime fallback (prop → runtime → default). */ resolveLinkEndpoint?: string /** Base route path for URL navigation. */ baseRoute: string /** Empty state copy when no doc is selected. */ emptyStateText?: string /** Whether to render the doc-search bar (bound to chat). */ showAIChat?: boolean /** Folder-index filename (default `'README.md'`). */ folderIndexFile?: string /** Back-button shown above the title. Mirrors `` / * `` / `` so every embeddable surface * shares the same chrome. Defaults to `{ label: 'Back to home', href: '/' }`. * Pass `false` to hide; pass `{ href: '/docs' }` etc. when the embed's * home isn't `/`. */ backButton?: { label?: string; href?: string } | false } export function DocViewer(props: DocViewerProps) { return } function DocViewerContent({ sourceId, renderContent, renderSkeleton, chatSource, title, subtitle, colorPalette = DEFAULT_DOC_VIEWER_PALETTE, className = "", shell = true, docPath, sidebarLabel = "DOCUMENTATION", structureEndpoint, contentEndpoint, searchEndpoint, resolveLinkEndpoint, baseRoute, emptyStateText, showAIChat = false, folderIndexFile, backButton, }: DocViewerProps) { // Default endpoints derived from sourceId. Hub callers omit the props in 99% // of cases; the override is for embed contexts where the doc-viewer sits // behind a non-standard route. const resolvedStructureEndpoint = structureEndpoint ?? `/api/docs/sources/${sourceId}/structure` const resolvedContentEndpoint = contentEndpoint ?? `/api/docs/sources/${sourceId}/content` // Resolve-link endpoint chain (prop → ChatRuntime.endpoints → hub default) // + the full fetch + JSON-parse pipeline live in `useDocsResolveLink`. // Keeping it factored out as a proper hook makes the contract reusable // by any embedder rendering doc content outside `` (custom // markdown renderers, link-resolver previews, etc.) and keeps this // component focused on layout + state. const resolveLink = useDocsResolveLink(sourceId, resolveLinkEndpoint) const { structure, selectedPath, content, isLoadingStructure, isLoadingContent, error, expandedNodes, selectNode, toggleNode, navigateToDoc, } = useDocumentTree( { structureEndpoint: resolvedStructureEndpoint, contentEndpoint: resolvedContentEndpoint, baseRoute, folderIndexFile, }, docPath, ) const { activeSection, handleSectionClick } = useScrollSpy(content?.sections) const docNav = useDocNavigation() // Back-button config — mirrors `` so the docs surface // matches every other embeddable page's chrome. Default target is `/` // (the embed's home); pass `backButton: false` to hide entirely, or // override the href when the embed's home isn't `/`. const router = useRouter() const backCfg = backButton === false ? null : { label: backButton?.label ?? 'Back to home', onClick: () => router.push(backButton?.href ?? '/'), } const docSearch = useDocSearch({ source: chatSource, baseRoute, searchEndpoint, onNavigate: (path) => navigateToDoc(path, { fromInternalLink: true }), onInPageSwap: (path) => docNav.navigate(path), }) const renderedContent = useMemo(() => { if (!content) return null return renderContent(content, { onInternalLinkClick: navigateToDoc, // Relative-link base = the RENDERED document's path, NOT `selectedPath`. // They diverge for a no-README folder: selection stays on the folder // (e.g. `repo/diagrams`) while the body is its first descendant doc // (e.g. `repo/diagrams/architecture/README.md`, via `findFirstDocPath`). // Resolving `./sibling.mmd` against the folder would 404; resolving it // against `content.path` lands in the descendant's directory. The DAL // sets `content.path` to the served doc in all cases (file / README // folder / first-child fallback), so this is correct everywhere. currentPath: content.path, sourceId, onResolveLink: resolveLink, }) }, [content, renderContent, navigateToDoc, sourceId, resolveLink]) // Selected node's documentType drives: // - which skeleton the caller renders during fetch (markdown vs embed) // - the article max-width + sticky-nav visibility (markdown only) // `undefined` documentType is treated as `'markdown'` (per the DocNode // discriminator's documented default). const selectedNodeDocType = selectedPath && structure.length > 0 ? findDocNodeByPath(selectedPath, structure)?.documentType : undefined // During loading, the in-flight content's type isn't known yet — fall back // to the selected node's type (or markdown if neither is set). const activeDocType = content?.documentType ?? selectedNodeDocType const isMarkdownContent = !activeDocType || activeDocType === 'markdown' const showStickyNav = isMarkdownContent const stickyNavSections = content?.sections?.map((s) => ({ id: s.id, label: s.title })) ?? [] const isColorValue = colorPalette.background.startsWith('#') || colorPalette.background.startsWith('rgb') || colorPalette.background.startsWith('var(') const bgStyle = isColorValue ? { backgroundColor: colorPalette.background } : {} const bgClass = !isColorValue ? colorPalette.background : '' const containerBgStyle = colorPalette.containerBackground !== 'transparent' ? { backgroundColor: colorPalette.containerBackground } : {} const defaultEmptyText = structure.length > 0 ? 'Select a document from the sidebar to view' : 'No documents yet. Add content from the admin panel.' const resolvedEmptyText = emptyStateText || defaultEmptyText // Unified header: title/subtitle route through the canonical (frozen) // `PageLayout` `TitleBlock` (text-h2) — same as every other help-center page — // so the docs hub shares one header. The `gap-10` column then holds the search // bar + content grid. `colorPalette` / `className` / `bgStyle` flow through the // shell's contentClassName + an inner style-passthrough wrapper. const inner = (
{showAIChat && ( )} {error && (

Error Loading Documents

{error}. Please try again later.

)} {!error && (
{isLoadingStructure ? ( ) : ( <>

{sidebarLabel}

)}
0) || isLoadingContent || isLoadingStructure) ? 'lg:grid-cols-[1fr_280px]' : '' } gap-8`} > {/* min-w-0: grid items default to min-width:auto, which would let a long unbreakable token push this column past the track width. Pair with the inherited overflow-wrap:anywhere (app-globals.css) so content wraps instead of overflowing. */}
{(isLoadingContent || isLoadingStructure) ? ( renderSkeleton(selectedNodeDocType) ) : !content ? (

{resolvedEmptyText}

) : ( renderedContent )}
{isMarkdownContent && (isLoadingContent || isLoadingStructure) && (
{[130, 170, 190, 220, 110, 200, 80, 100, 120, 140, 90].map((w, i) => (
))}
)} {showStickyNav && content && stickyNavSections.length > 0 && !isLoadingContent && (

ON THIS PAGE

)}
)}
) // `shell` true → standalone ``; false → padding-only box (no nested //
) for hosts whose layout already provides the container. Both carry the // palette/className via the same `page-shell-content` styling hook. return shell ? ( {inner} ) : (
{inner}
) }