import type { ReactNode } from "react";
const SAFE_URL_RE = /^(https?:\/\/|mailto:)/i;
const isSafeUrl = (url: string): boolean => SAFE_URL_RE.test(url.trim());
const inlineTokenRe = /(`[^`\n]+`|\[[^\]\n]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|_[^_\n]+_|~~[^~\n]+~~)/g;
const parseInline = (text: string, keyPrefix: string): ReactNode[] => {
const out: ReactNode[] = [];
let lastIndex = 0;
let tokenIndex = 0;
for (const match of text.matchAll(inlineTokenRe)) {
const token = match[0];
const idx = match.index ?? 0;
if (idx > lastIndex) {
out.push(text.slice(lastIndex, idx));
}
const key = `${keyPrefix}-t-${tokenIndex++}`;
if (token.startsWith("`") && token.endsWith("`")) {
out.push({token.slice(1, -1)});
} else if (token.startsWith("**") && token.endsWith("**")) {
out.push({token.slice(2, -2)});
} else if (token.startsWith("__") && token.endsWith("__")) {
out.push({token.slice(2, -2)});
} else if (token.startsWith("*") && token.endsWith("*")) {
out.push({token.slice(1, -1)});
} else if (token.startsWith("_") && token.endsWith("_")) {
out.push({token.slice(1, -1)});
} else if (token.startsWith("~~") && token.endsWith("~~")) {
out.push({token.slice(2, -2)});
} else if (token.startsWith("[") && token.includes("](") && token.endsWith(")")) {
const linkMatch = token.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
if (!linkMatch) {
out.push(token);
} else {
const label = linkMatch[1];
const href = linkMatch[2];
if (isSafeUrl(href)) {
out.push(
{label}
,
);
} else {
out.push(label);
}
}
} else {
out.push(token);
}
lastIndex = idx + token.length;
}
if (lastIndex < text.length) {
out.push(text.slice(lastIndex));
}
return out;
};
const renderInlineLines = (text: string, keyPrefix: string): ReactNode[] => {
const lines = text.split("\n");
const out: ReactNode[] = [];
lines.forEach((line, idx) => {
if (idx > 0) out.push(
);
out.push(...parseInline(line, `${keyPrefix}-l-${idx}`));
});
return out;
};
const isUnorderedListItem = (line: string): boolean => /^\s*[-*+]\s+/.test(line);
const isOrderedListItem = (line: string): boolean => /^\s*\d+\.\s+/.test(line);
const MarkdownText = ({ text }: { text: string }) => {
const source = text.replace(/\r\n?/g, "\n");
const lines = source.split("\n");
const nodes: ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
if (!trimmed) {
i += 1;
continue;
}
if (trimmed.startsWith("```")) {
const lang = trimmed.slice(3).trim();
const codeLines: string[] = [];
i += 1;
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i += 1;
}
if (i < lines.length && lines[i].trim().startsWith("```")) i += 1;
nodes.push(
{codeLines.join("\n")}
,
);
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const content = headingMatch[2];
const key = `h-${nodes.length}`;
if (level === 1) nodes.push({renderInlineLines(content, key)}); continue; } if (isUnorderedListItem(line) || isOrderedListItem(line)) { const ordered = isOrderedListItem(line); const items: string[] = []; while (i < lines.length) { const candidate = lines[i]; if (ordered && isOrderedListItem(candidate)) { items.push(candidate.replace(/^\s*\d+\.\s+/, "")); i += 1; continue; } if (!ordered && isUnorderedListItem(candidate)) { items.push(candidate.replace(/^\s*[-*+]\s+/, "")); i += 1; continue; } if (!candidate.trim()) { i += 1; break; } break; } const key = `list-${nodes.length}`; if (ordered) { nodes.push(
{renderInlineLines(content, key)}
); } return