Vault.MCP / frontend /src /lib /markdown.tsx
bigwolfeman
bugs
7068559
/**
* 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<string, string>();
/**
* T039-T040: Track slugs to handle duplicates
*/
const slugCache = new Map<string, number>();
/**
* 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<string | null>(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 (
<HoverCard openDelay={500} closeDelay={100} onOpenChange={setIsOpen}>
<HoverCardTrigger asChild>
<span onClick={onClick}>
{children}
</span>
</HoverCardTrigger>
<HoverCardContent className="w-80">
{isLoading ? (
// T025: Loading skeleton
<div className="space-y-2">
<div className="h-4 bg-muted animate-pulse rounded" />
<div className="h-4 bg-muted animate-pulse rounded w-5/6" />
<div className="h-4 bg-muted animate-pulse rounded w-4/6" />
</div>
) : isBroken ? (
// T026: Broken link message
<div className="text-sm text-destructive">
Note not found
</div>
) : preview ? (
<div className="text-sm text-muted-foreground">
{preview}
</div>
) : null}
</HoverCardContent>
</HoverCard>
);
}
/**
* 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 (
<WikilinkPreview
linkText={linkText}
onClick={(e?: React.MouseEvent) => {
e?.preventDefault();
onWikilinkClick?.(linkText);
}}
>
<span
className="wikilink cursor-pointer text-primary hover:underline font-medium text-blue-500 dark:text-blue-400"
onClick={(e) => {
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}
</span>
</WikilinkPreview>
);
}
const isExternal = href?.startsWith('http');
return (
<a
href={href}
className="text-primary hover:underline"
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
{...props}
>
{children}
</a>
);
},
// T039: Style headings with ID generation for TOC
h1: ({ children, ...props }) => {
const text = typeof children === 'string' ? children : '';
const id = text ? slugify(text) : undefined;
return (
<h1 id={id} className="text-3xl font-bold mt-6 mb-4" {...props}>
{children}
</h1>
);
},
h2: ({ children, ...props }) => {
const text = typeof children === 'string' ? children : '';
const id = text ? slugify(text) : undefined;
return (
<h2 id={id} className="text-2xl font-semibold mt-5 mb-3" {...props}>
{children}
</h2>
);
},
h3: ({ children, ...props }) => {
const text = typeof children === 'string' ? children : '';
const id = text ? slugify(text) : undefined;
return (
<h3 id={id} className="text-xl font-semibold mt-4 mb-2" {...props}>
{children}
</h3>
);
},
// Style lists
ul: ({ children, ...props }) => (
<ul className="list-disc list-inside my-2 space-y-1" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="list-decimal list-inside my-2 space-y-1" {...props}>
{children}
</ol>
),
// Style blockquotes
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-muted-foreground pl-4 italic my-4" {...props}>
{children}
</blockquote>
),
// Style tables
table: ({ children, ...props }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-border" {...props}>
{children}
</table>
</div>
),
th: ({ children, ...props }) => (
<th className="border border-border px-4 py-2 bg-muted font-semibold text-left" {...props}>
{children}
</th>
),
td: ({ children, ...props }) => (
<td className="border border-border px-4 py-2" {...props}>
{children}
</td>
),
};
}
/**
* Render broken wikilinks with distinct styling
*/
export function renderBrokenWikilink(
linkText: string,
onCreate?: () => void
): React.ReactElement {
return (
<span
className="wikilink-broken text-destructive border-b border-dashed border-destructive cursor-pointer hover:bg-destructive/10"
onClick={onCreate}
role="link"
tabIndex={0}
title={`Note "${linkText}" not found. Click to create.`}
>
[[{linkText}]]
</span>
);
}