BillTracker/client/components/MarkdownText.tsx

69 lines
1.7 KiB
TypeScript

import { Fragment, isValidElement, type ReactNode } from 'react';
const TOKEN_RE = /(\*\*[^*\n][\s\S]*?[^*\n]\*\*|`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\))/g;
function renderToken(token: string, key: string): ReactNode {
if (token.startsWith('**') && token.endsWith('**')) {
return (
<strong key={key} className="font-semibold text-foreground">
{token.slice(2, -2)}
</strong>
);
}
if (token.startsWith('`') && token.endsWith('`')) {
return (
<code key={key} className="rounded bg-muted px-1 py-0.5 font-mono text-[0.92em] text-foreground">
{token.slice(1, -1)}
</code>
);
}
const link = token.match(/^\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)$/);
if (link) {
return (
<a
key={key}
href={link[2]}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary underline-offset-4 hover:underline"
>
{link[1]}
</a>
);
}
return token;
}
export function renderInlineMarkdown(text: string | null | undefined): ReactNode {
if (!text) return null;
const parts: ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(TOKEN_RE)) {
const mi = match.index ?? 0;
if (mi > lastIndex) {
parts.push(text.slice(lastIndex, mi));
}
parts.push(renderToken(match[0], `md-${mi}`));
lastIndex = mi + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.map((part, index) => (
<Fragment key={isValidElement(part) ? (part.key ?? index) : `text-${index}`}>
{part}
</Fragment>
));
}
export function MarkdownText({ text }: { text?: string | null }): ReactNode {
return renderInlineMarkdown(text);
}