import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; // Open external links in new tabs marked.use({ renderer: { link({ href, title, tokens }) { const text = this.parser.parseInline(tokens ?? []); const titleAttr = title ? ` title="${title}"` : ''; if (href && /^https?:\/\//.test(href)) { return `${text}`; } return `${text}`; }, }, }); interface Heading { level: number; text: string; id: string; children: Heading[]; } interface HelpTab { label: string; type: string; content: string; } // ── Parse headings from markdown source ────────────────────────────── function parseHeadings(md: string): Heading[] { if (!md) return []; const headings: Heading[] = []; const lines = md.split('\n'); for (const line of lines) { const m = line.match(/^(#{1,6})\s+(.+)/); if (m) { const text = m[2].replace(/[*_`~[\]]/g, '').trim(); const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, ''); headings.push({ level: m[1].length, text, id, children: [] }); } } return headings; } // ── Inject id attributes into rendered HTML headings ───────────────── function injectHeadingIds(html: string, headings: Heading[]) { let idx = 0; return html.replace(/<(h[1-6])>/gi, (match: string, tag: string) => { if (idx < headings.length) { return `<${tag} id="${headings[idx++].id}">`; } return match; }); } // ── Build a tree from flat heading list ────────────────────────────── function buildTocTree(headings: Heading[]): Heading[] { const root: { children: Heading[] } = { children: [] }; const stack: { node: { children: Heading[] }; level: number }[] = [{ node: root, level: 0 }]; for (const h of headings) { const item = { ...h, children: [] }; while (stack.length > 1 && stack[stack.length - 1].level >= h.level) stack.pop(); stack[stack.length - 1].node.children.push(item); stack.push({ node: item, level: h.level }); } return root.children; } // ── TOC sidebar component ──────────────────────────────────────────── interface TocItemProps { item: Heading; collapsed: Record; onToggle: (id: string) => void; onNavigate: (id: string) => void; } function TocItem({ item, collapsed, onToggle, onNavigate }: TocItemProps) { const hasChildren = item.children.length > 0; const isCollapsed = collapsed[item.id]; return (
  • {hasChildren ? ( ) : ( )} { e.preventDefault(); onNavigate(item.id); }} > {item.text}
    {hasChildren && !isCollapsed && ( )}
  • ); } interface TocProps { headings: Heading[]; contentRef: React.RefObject; } function Toc({ headings, contentRef }: TocProps) { const [collapsed, setCollapsed] = useState>({}); const tree = useMemo(() => buildTocTree(headings), [headings]); const onToggle = (id: string) => setCollapsed((prev) => ({ ...prev, [id]: !prev[id] })); const onNavigate = (id: string) => { const el = contentRef.current?.querySelector(`#${CSS.escape(id)}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; if (tree.length === 0) return null; return ( ); } // ── Click handler for .md links ────────────────────────────────────── function useMdLinkHandler(onOpenDoc: (filename: string) => void) { return (e: React.MouseEvent) => { const a = (e.target as HTMLElement).closest('a[href]'); if (!a) return; const href = a.getAttribute('href'); if (href && /\.md$/i.test(href) && !href.startsWith('http')) { e.preventDefault(); const filename = href.split('/').pop() ?? href; onOpenDoc(filename); } }; } // ── Content pane with TOC ──────────────────────────────────────────── interface HelpContentProps { content: string; onOpenDoc: (filename: string) => void; } function HelpContent({ content, onOpenDoc }: HelpContentProps) { const contentRef = useRef(null); const handleClick = useMdLinkHandler(onOpenDoc); const md = content || '*Loading…*'; const headings = useMemo(() => parseHeadings(md), [md]); const html = useMemo(() => { let rendered: string; try { rendered = DOMPurify.sanitize(marked.parse(md) as string); } catch { rendered = md; } return injectHeadingIds(rendered, headings); }, [md, headings]); return (
    ); } // ── Journal tab ────────────────────────────────────────────────────── interface JournalTabProps { content: string; onChange: (value: string) => void; onOpenDoc: (filename: string) => void; } function JournalTab({ content, onChange, onOpenDoc }: JournalTabProps) { const [isEditing, setIsEditing] = useState(false); const contentRef = useRef(null); const handleClick = useMdLinkHandler(onOpenDoc); let renderedHtml = ''; let headings: Heading[] = []; if (!isEditing && content?.trim()) { headings = parseHeadings(content); try { renderedHtml = injectHeadingIds(DOMPurify.sanitize(marked.parse(content) as string), headings); } catch { renderedHtml = content; } } return (
    {isEditing && ( Esc or Ctrl+Enter to preview )}
    {isEditing ? (