aiostreams / packages /frontend /src /components /shared /markdown-lite.tsx
f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
5.21 kB
import React from 'react';
import { toast } from 'sonner';
import { copyToClipboard } from '@/utils/clipboard';
interface MarkdownLiteProps {
children: string;
className?: string;
stopPropagation?: boolean;
}
// Supports [text](url), `code`, and line breaks (both \n and actual newlines)
const MarkdownLite: React.FC<MarkdownLiteProps> = ({
children,
className,
stopPropagation = false,
}) => {
if (!children) return null;
// Handle both literal \n strings and actual newlines
let lines: string[];
if (children.includes('\\n')) {
lines = children.split('\\n');
} else {
lines = children.split('\n');
}
// Group consecutive bullet points together
const processedLines: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/);
const isBulletPoint = trimmed.startsWith('* ') && !trimmed.startsWith('**');
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2];
const headingClasses = [
'text-base font-bold mt-3 mb-1',
'text-sm font-semibold mt-2.5 mb-1',
'text-sm font-medium mt-2 mb-0.5',
];
const tag = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
processedLines.push(
React.createElement(
tag,
{ key: `h-${i}`, className: headingClasses[level - 1] },
processInlineMarkdown(text, stopPropagation)
)
);
i++;
} else if (isBulletPoint) {
// Collect all consecutive bullet points
const bulletPoints: string[] = [];
while (
i < lines.length &&
lines[i].trim().startsWith('* ') &&
!lines[i].trim().startsWith('**')
) {
bulletPoints.push(lines[i]);
i++;
}
// Render as a single list
processedLines.push(
<ul key={`bullets-${i}`} className="list-none space-y-1 mt-2 mb-1">
{bulletPoints.map((bullet, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-gray-400"></span>
<span>
{processInlineMarkdown(bullet.slice(2), stopPropagation)}
</span>
</li>
))}
</ul>
);
} else {
// Regular line
if (i > 0) {
processedLines.push(<br key={`br-${i}`} />);
}
processedLines.push(processLine(line, stopPropagation));
i++;
}
}
return <span className={className}>{processedLines}</span>;
};
// Helper function to process a single line for markdown
function processLine(text: string, stopPropagation: boolean) {
// Process regular line with inline markdown
return processInlineMarkdown(text, stopPropagation);
}
// Helper function to process inline markdown (bold, italic, code, links)
function processInlineMarkdown(text: string, stopPropagation: boolean) {
// Regex for **bold**, *italic*, `code`, and [text](url)
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^\)]+\))/g;
const parts = text.split(regex);
const matches = text.match(regex) || [];
let matchIdx = 0;
return parts.map((part, i) => {
if (i === parts.length - 1 && part === '') return null;
if (i % 2 === 0) {
// Plain text
return part;
} else {
const match = matches[matchIdx++];
if (!match) return null;
if (match.startsWith('**') && match.endsWith('**')) {
// Bold text
return (
<strong key={i} className="font-semibold">
{match.slice(2, -2)}
</strong>
);
} else if (
match.startsWith('*') &&
match.endsWith('*') &&
!match.startsWith('**')
) {
// Italic text
return (
<em key={i} className="italic">
{match.slice(1, -1)}
</em>
);
} else if (match.startsWith('`') && match.endsWith('`')) {
// Inline code
return (
<code
key={i}
onClick={async (e) => {
if (stopPropagation) {
e.stopPropagation();
}
await copyToClipboard(match.slice(1, -1), {
onSuccess: () => toast.success('Copied to clipboard'),
onError: () => toast.error('Failed to copy to clipboard'),
});
}}
className="bg-muted px-1 py-0.5 rounded text-[--brand] font-mono text-xs break-all cursor-pointer"
>
{match.slice(1, -1)}
</code>
);
} else {
// Link
const linkMatch = match.match(/\[([^\]]+)\]\(([^\)]+)\)/);
if (linkMatch) {
const [, text, url] = linkMatch;
return (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-[--brand] hover:underline"
onClick={(e) => stopPropagation && e.stopPropagation()}
>
{text}
</a>
);
}
}
return match;
}
});
}
export default MarkdownLite;