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(
'(\\[\\[[^\\]]+\\]\\]|https?:\\/\\/[^\\s<>()\\[\\]]+[^\\s<>()\\[\\].,;:!?"\']|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + 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 (/^https?:\/\//.test(token)) {
const href = safeHref(token);
elements.push(
href ? (
e.stopPropagation()}
>
{token}
) : (
token
)
);
} else 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(
{code}
{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(2))}); } else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { renderedNodes.push(
{codeBlockLines.join('\n')}
);
}
return