import React from 'react'; import { safeHref } from './safeUrl'; import { parseWikiLink, wikiLinkText } from './wikiLink'; /** * Parses inline markdown: * - **bold** or __bold__ * - *italic* or _italic_ * - ~~strikethrough~~ * - `code` * - [label](url) * - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip * - [[wiki link]] shown by its label (clickable with `options.onWikiLinkClick`) */ export interface InlineMarkdownOptions { onTagClick?: (tag: string) => void; onWikiLinkClick?: (title: string, blockId?: string) => void; } function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] { if (!text) return []; const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : ''; const regex = new RegExp( '(\\[\\[[^\\]]+\\]\\]|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')', 'g' ); const elements: React.ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { elements.push(text.slice(lastIndex, match.index)); } const token = match[0]; const key = `${match.index}-${token}`; if (token.startsWith('[[') && token.endsWith(']]')) { const link = parseWikiLink(token.slice(2, -2)); const onClick = options?.onWikiLinkClick; elements.push( onClick ? ( { e.stopPropagation(); onClick(link.title, link.blockId); }} className="text-status-info underline decoration-status-info/40 underline-offset-2 hover:decoration-status-info cursor-pointer" title={link.blockId ? `Zum Punkt in „${link.title}“` : `Notiz öffnen: ${link.title}`} > {wikiLinkText(link)} ) : ( {wikiLinkText(link)} ) ); } else if (token.startsWith('#')) { const prevChar = match.index > 0 ? text[match.index - 1] : ''; if (prevChar && !/[\s(]/.test(prevChar)) { // e.g. an URL fragment like https://x/#anchor: keep as plain text elements.push(token); } else { const tag = token.slice(1); elements.push( { e.stopPropagation(); options?.onTagClick?.(tag); }} className="text-accent bg-accent-dim hover:bg-accent/20 rounded-btn px-0.5 cursor-pointer transition-colors" title={`Nach #${tag} filtern`} > {token} ); } } else if (token.startsWith('`') && token.endsWith('`')) { elements.push( {token.slice(1, -1)} ); } else if ( (token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__')) ) { elements.push( {renderInlineTokens(token.slice(2, -2), options)} ); } else if ( (token.startsWith('*') && token.endsWith('*')) || (token.startsWith('_') && token.endsWith('_')) ) { elements.push( {renderInlineTokens(token.slice(1, -1), options)} ); } else if (token.startsWith('~~') && token.endsWith('~~')) { elements.push( {renderInlineTokens(token.slice(2, -2), options)} ); } else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) { const label = match[2]; const url = safeHref(match[3]) as string; elements.push( e.stopPropagation()} > {label} ); } else { elements.push(token); } lastIndex = regex.lastIndex; } if (lastIndex < text.length) { elements.push(text.slice(lastIndex)); } return elements; } /** * Parses inline markdown and fenced code blocks: * - ```lang\ncode\n``` * - **bold** or __bold__ * - *italic* or _italic_ * - ~~strikethrough~~ * - `code` * - [label](url) */ export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] { if (!text) return []; if (text.includes('```')) { const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g; const parts = text.split(codeBlockRegex); const nodes: React.ReactNode[] = []; parts.forEach((part, partIdx) => { if (part.startsWith('```') && part.endsWith('```')) { const lines = part.split(/\r?\n/); const firstLine = lines[0].slice(3).trim(); const codeLines = lines.slice(1, lines.length - 1); const code = codeLines.join('\n'); nodes.push(
{firstLine && (
{firstLine}
)}
              {code}
            
); } else if (part) { nodes.push(...renderInlineTokens(part, options)); } }); return nodes; } return renderInlineTokens(text, options); } export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) { if (!content) return null; const lines = content.split('\n'); const renderedNodes: React.ReactNode[] = []; let inCodeBlock = false; let codeBlockLines: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.trim().startsWith('```')) { if (inCodeBlock) { renderedNodes.push(
            {codeBlockLines.join('\n')}
          
); codeBlockLines = []; inCodeBlock = false; } else { inCodeBlock = true; codeBlockLines = []; } continue; } if (inCodeBlock) { codeBlockLines.push(line); continue; } const trimmed = line.trim(); if (trimmed.startsWith('### ')) { renderedNodes.push(

{renderInlineMarkdown(trimmed.slice(4))}

); } else if (trimmed.startsWith('## ')) { renderedNodes.push(

{renderInlineMarkdown(trimmed.slice(3))}

); } else if (trimmed.startsWith('# ')) { renderedNodes.push(

{renderInlineMarkdown(trimmed.slice(2))}

); } else if (trimmed.startsWith('> ')) { renderedNodes.push(
{renderInlineMarkdown(trimmed.slice(2))}
); } else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { renderedNodes.push(
• {renderInlineMarkdown(trimmed.slice(2))}
); } else if (/^\d+\.\s/.test(trimmed)) { const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/); renderedNodes.push(
{matchNum ? matchNum[1] + '.' : '•'} {renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}
); } else if (trimmed === '') { renderedNodes.push(
); } else { renderedNodes.push(
{renderInlineMarkdown(line)}
); } } if (inCodeBlock && codeBlockLines.length > 0) { renderedNodes.push(
        {codeBlockLines.join('\n')}
      
); } return
{renderedNodes}
; }