"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 ? (
) : (
<>
>
)}
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 ? (
) : (
renderedContent
)}
{isMarkdownContent && (isLoadingContent || isLoadingStructure) && (
{[130, 170, 190, 220, 110, 200, 80, 100, 120, 140, 90].map((w, i) => (
))}
)}
{showStickyNav &&
content &&
stickyNavSections.length > 0 &&
!isLoadingContent && (
)}
)}
)
// `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}
)
}