import React, { useEffect, useRef } from 'react'; import Markdown from 'marked-react'; import hljs from '@highlightjs/cdn-assets/es/highlight.min.js'; import type { ReactNode } from 'react'; import documentationContent from '../../../../DOCUMENTATION.md?raw'; import { useAppStore } from '../../store'; import { useI18n } from '../../i18n'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme'; import './DocumentationView.css'; const HEADING_LEVELS = new Set([1, 2, 3, 4, 5, 6]); function extractText(content: ReactNode): string { if (typeof content === 'string' || typeof content === 'number') { return String(content); } if (Array.isArray(content)) { return content.map(extractText).join(''); } if (React.isValidElement(content)) { return extractText((content.props as { children?: ReactNode }).children); } return ''; } function slugifyHeading(value: string): string { return value .normalize('NFKD') .toLowerCase() .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9\s-]/g, '') .trim() .replace(/\s+/g, '-') .replace(/-+/g, '-'); } function isScrollable(element: HTMLElement): boolean { const style = window.getComputedStyle(element); const overflowY = style.overflowY; const canScroll = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; return canScroll && element.scrollHeight > element.clientHeight; } function resolveScrollContainer(target: HTMLElement, preferred: HTMLElement | null): HTMLElement | null { if (preferred && preferred.contains(target)) { return preferred; } let current: HTMLElement | null = target.parentElement; while (current) { if (isScrollable(current)) { return current; } current = current.parentElement; } if (preferred) { return preferred; } const scrollingElement = document.scrollingElement; return scrollingElement instanceof HTMLElement ? scrollingElement : null; } function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: string): HTMLElement | null { for (const candidate of articleElement.querySelectorAll('[id]')) { if (candidate.id === targetId) { return candidate; } } const headingCandidates = articleElement.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (const heading of headingCandidates) { const headingSlug = slugifyHeading(heading.textContent ?? ''); if (headingSlug === targetId) { heading.id = targetId; return heading; } } return null; } export const DocumentationView: React.FC = () => { const { t: tr } = useI18n(); const { picoTheme } = useAppStore(); const resolvedTheme = getRendererPicoTheme(picoTheme); const scrollContainerRef = useRef(null); const articleRef = useRef(null); const headingSlugCounts = new Map(); let rendererKeyIndex = 0; const jumpToDocumentationHash = (href: string): boolean => { if (!href.startsWith('#') || href.length < 2) { return false; } const targetId = decodeURIComponent(href.slice(1)); const articleElement = articleRef.current; const preferredScrollContainer = scrollContainerRef.current; if (!articleElement) { return false; } const targetHeading = resolveTargetHeadingInArticle(articleElement, targetId); if (!targetHeading) { return false; } const scrollContainer = resolveScrollContainer(targetHeading, preferredScrollContainer); if (!scrollContainer) { return false; } const containerRect = scrollContainer.getBoundingClientRect(); const headingRect = targetHeading.getBoundingClientRect(); const targetTop = Math.max(0, scrollContainer.scrollTop + (headingRect.top - containerRect.top) - 12); scrollContainer.scrollTop = 0; scrollContainer.scrollTop = targetTop; window.location.hash = href; return true; }; const getRendererKey = (prefix: string): string => { rendererKeyIndex += 1; return `${prefix}-${rendererKeyIndex}`; }; const markdownRenderer = { heading(children: ReactNode, level: number) { const levelNumber = HEADING_LEVELS.has(level as 1 | 2 | 3 | 4 | 5 | 6) ? level : 2; const headingText = extractText(children); const baseId = slugifyHeading(headingText); const existingCount = headingSlugCounts.get(baseId) ?? 0; const nextCount = existingCount + 1; headingSlugCounts.set(baseId, nextCount); const headingId = existingCount === 0 ? baseId : `${baseId}-${nextCount}`; return React.createElement(`h${levelNumber}` as keyof JSX.IntrinsicElements, { id: headingId, key: getRendererKey('heading') }, children); }, link(href: string, text: ReactNode) { if (!href.startsWith('#')) { return {text}; } return ( { if (jumpToDocumentationHash(href)) { event.preventDefault(); } }} > {text} ); }, code(code: ReactNode, lang: string | undefined) { const normalizedLanguage = typeof lang === 'string' ? lang.trim().toLowerCase() : ''; const sourceCode = extractText(code); const codeBlockKey = getRendererKey('code-block'); let highlightedHtml = ''; let languageClass = ''; if (normalizedLanguage.length > 0 && hljs.getLanguage(normalizedLanguage)) { highlightedHtml = hljs.highlight(sourceCode, { language: normalizedLanguage }).value; languageClass = `language-${normalizedLanguage}`; } else { const autoDetected = hljs.highlightAuto(sourceCode); highlightedHtml = autoDetected.value; languageClass = autoDetected.language ? `language-${autoDetected.language}` : 'language-plaintext'; } return (
            
          
); }, }; useEffect(() => { ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => { console.error('Failed to load documentation theme stylesheet:', error); }); }, [resolvedTheme]); return (

{tr('docs.title')}

{tr('docs.subtitle')}

{documentationContent}
); };