/** * T074: Markdown rendering configuration and wikilink handling * T019-T028: Wikilink preview tooltips with HoverCard * T039-T040: Heading ID generation for Table of Contents */ import React, { useState } from 'react'; import type { Components } from 'react-markdown'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import { getNote, searchNotes } from '@/services/api'; export interface WikilinkComponentProps { linkText: string; resolved: boolean; onClick?: (linkText: string) => void; } /** * T019: Preview cache for wikilink tooltips */ const previewCache = new Map(); /** * T039-T040: Track slugs to handle duplicates */ const slugCache = new Map(); /** * T040: Slugify heading text to create valid HTML IDs * Handles duplicates by appending -2, -3, etc. */ function slugify(text: string): string { // Basic slugification const baseSlug = text .toLowerCase() .replace(/\s+/g, '-') .replace(/[^\w-]/g, ''); // T050: Handle duplicates const count = slugCache.get(baseSlug) || 0; slugCache.set(baseSlug, count + 1); if (count === 0) { return baseSlug; } return `${baseSlug}-${count + 1}`; } /** * Reset slug cache (call when rendering a new document) */ export function resetSlugCache(): void { slugCache.clear(); } /** * T021-T026: Wikilink preview component with HoverCard */ function WikilinkPreview({ linkText, children, onClick }: { linkText: string; children: React.ReactNode; onClick?: () => void; }) { const [preview, setPreview] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isBroken, setIsBroken] = useState(false); const [isOpen, setIsOpen] = useState(false); // T023: Fetch preview when hover card opens React.useEffect(() => { if (!isOpen) return; // T028: Check cache first if (previewCache.has(linkText)) { setPreview(previewCache.get(linkText)!); setIsLoading(false); setIsBroken(false); return; } // Start loading setIsLoading(true); const fetchPreview = async () => { try { // First search for the note by title/content to find its actual path const searchResults = await searchNotes(linkText); if (searchResults.length === 0) { // No results found - broken link setIsBroken(true); setPreview(null); setIsLoading(false); return; } // Get the first matching note const notePath = searchResults[0].note_path; const note = await getNote(notePath); // T024: Extract first 150 characters from note body const previewText = note.body .replace(/\[\[([^\]]+)\]\]/g, '$1') // Remove wikilinks .replace(/[#*_~`]/g, '') // Remove markdown formatting .trim() .slice(0, 150); const finalPreview = previewText.length < note.body.length ? `${previewText}...` : previewText; // T019: Cache the preview previewCache.set(linkText, finalPreview); setPreview(finalPreview); setIsBroken(false); } catch (error) { // T026: Handle broken wikilinks setIsBroken(true); setPreview(null); } finally { setIsLoading(false); } }; fetchPreview(); }, [isOpen, linkText]); return ( {children} {isLoading ? ( // T025: Loading skeleton
) : isBroken ? ( // T026: Broken link message
Note not found
) : preview ? (
{preview}
) : null} ); } /** * Custom renderer for wikilinks in markdown */ export function createWikilinkComponent( onWikilinkClick?: (linkText: string) => void ): Components { return { // Style links a: ({ href, children, ...props }) => { if (href?.startsWith('wikilink:')) { const linkText = decodeURIComponent(href.replace('wikilink:', '')); return ( { e?.preventDefault(); onWikilinkClick?.(linkText); }} > { e.preventDefault(); onWikilinkClick?.(linkText); }} role="link" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onWikilinkClick?.(linkText); } }} title={`Go to ${linkText}`} > {children} ); } const isExternal = href?.startsWith('http'); return ( {children} ); }, // T039: Style headings with ID generation for TOC h1: ({ children, ...props }) => { const text = typeof children === 'string' ? children : ''; const id = text ? slugify(text) : undefined; return (

{children}

); }, h2: ({ children, ...props }) => { const text = typeof children === 'string' ? children : ''; const id = text ? slugify(text) : undefined; return (

{children}

); }, h3: ({ children, ...props }) => { const text = typeof children === 'string' ? children : ''; const id = text ? slugify(text) : undefined; return (

{children}

); }, // Style lists ul: ({ children, ...props }) => (
    {children}
), ol: ({ children, ...props }) => (
    {children}
), // Style blockquotes blockquote: ({ children, ...props }) => (
{children}
), // Style tables table: ({ children, ...props }) => (
{children}
), th: ({ children, ...props }) => ( {children} ), td: ({ children, ...props }) => ( {children} ), }; } /** * Render broken wikilinks with distinct styling */ export function renderBrokenWikilink( linkText: string, onCreate?: () => void ): React.ReactElement { return ( [[{linkText}]] ); }