import { Fragment, type ReactNode } from "react";
type Props = {
text: string;
className?: string;
};
/**
* 聊天泡泡用輕量 markdown(不引入 react-markdown)。
* 一般回覆:每個實際換行都獨立成行,不會擠成一團。
* 另支援:標題、粗斜體、刪除線、行內/區塊 code、清單、引用、連結。
* HTML 一律當純文字,連結僅允許 http(s)/mailto。
*/
export function MarkdownText({ text, className = "" }: Props) {
const source = normalizeNewlines(text ?? "");
if (!source.trim()) {
return
;
}
const blocks = parseBlocks(source);
return (
{blocks.map((block, i) => (
{renderBlock(block)}
))}
);
}
type Block =
| { type: "line"; text: string }
| { type: "gap" }
| { type: "h"; level: 1 | 2 | 3; text: string }
| { type: "ul"; items: string[] }
| { type: "ol"; items: string[] }
| { type: "quote"; lines: string[] }
| { type: "code"; lang: string; code: string }
| { type: "hr" };
function normalizeNewlines(source: string): string {
return source
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\u2028/g, "\n")
.replace(/\u2029/g, "\n\n");
}
function parseBlocks(source: string): Block[] {
const lines = source.split("\n");
const blocks: Block[] = [];
let i = 0;
/** 連續空行只算一段落間距一次,開頭不插 gap */
let pendingGap = false;
let sawContent = false;
const flushGap = () => {
if (pendingGap && sawContent) {
blocks.push({ type: "gap" });
}
pendingGap = false;
};
while (i < lines.length) {
const line = lines[i] ?? "";
// fenced code
const fence = line.match(/^```([\w-]*)\s*$/);
if (fence) {
flushGap();
const lang = fence[1] || "";
const body: string[] = [];
i += 1;
while (i < lines.length && !/^```\s*$/.test(lines[i] ?? "")) {
body.push(lines[i] ?? "");
i += 1;
}
if (i < lines.length) i += 1; // closing ```
blocks.push({ type: "code", lang, code: body.join("\n") });
sawContent = true;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
flushGap();
blocks.push({ type: "hr" });
sawContent = true;
i += 1;
continue;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushGap();
const level = heading[1]!.length as 1 | 2 | 3;
blocks.push({ type: "h", level, text: heading[2]!.trim() });
sawContent = true;
i += 1;
continue;
}
if (/^>\s?/.test(line)) {
flushGap();
const quoteLines: string[] = [];
while (i < lines.length && /^>\s?/.test(lines[i] ?? "")) {
quoteLines.push((lines[i] ?? "").replace(/^>\s?/, ""));
i += 1;
}
blocks.push({ type: "quote", lines: quoteLines });
sawContent = true;
continue;
}
if (/^\s*[-*+]\s+/.test(line)) {
flushGap();
const items: string[] = [];
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i] ?? "")) {
items.push((lines[i] ?? "").replace(/^\s*[-*+]\s+/, ""));
i += 1;
}
blocks.push({ type: "ul", items });
sawContent = true;
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
flushGap();
const items: string[] = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i] ?? "")) {
items.push((lines[i] ?? "").replace(/^\s*\d+\.\s+/, ""));
i += 1;
}
blocks.push({ type: "ol", items });
sawContent = true;
continue;
}
// 空行 → 段落間距(普通回覆的「空一行」)
if (!line.trim()) {
pendingGap = true;
i += 1;
continue;
}
// 一般文字:每一行獨立成行(聊天最重要)
flushGap();
blocks.push({ type: "line", text: line });
sawContent = true;
i += 1;
}
return blocks;
}
function renderBlock(block: Block): ReactNode {
switch (block.type) {
case "line":
return (
{block.text ? renderInline(block.text) : "\u00a0"}
);
case "gap":
return ;
case "h": {
const Tag = `h${block.level}` as "h1" | "h2" | "h3";
return (
{renderInline(block.text)}
);
}
case "ul":
return (
{block.items.map((item, j) => (
- {renderInline(item)}
))}
);
case "ol":
return (
{block.items.map((item, j) => (
- {renderInline(item)}
))}
);
case "quote":
return (
{block.lines.map((line, j) => (
{line ? renderInline(line) : "\u00a0"}
))}
);
case "code":
return (
{block.code}
);
case "hr":
return
;
default:
return null;
}
}
/** 行內:code → bold/italic/strike → links → plain */
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const codeSplit = text.split(/(`[^`\n]+`)/g);
codeSplit.forEach((chunk, ci) => {
if (!chunk) return;
if (chunk.startsWith("`") && chunk.endsWith("`") && chunk.length >= 2) {
nodes.push(
{chunk.slice(1, -1)}
,
);
return;
}
nodes.push(...renderInlineDecorated(chunk, `t${ci}`));
});
return nodes;
}
function renderInlineDecorated(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
const re =
/(\*\*[^*]+\*\*|\*[^*\n]+\*|~~[^~]+~~|\[([^\]]+)\]\(([^)\s]+)\))/g;
let last = 0;
let m: RegExpExecArray | null;
let idx = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
nodes.push(
{text.slice(last, m.index)},
);
}
const token = m[0];
if (token.startsWith("**") && token.endsWith("**")) {
nodes.push(
{token.slice(2, -2)},
);
} else if (token.startsWith("~~") && token.endsWith("~~")) {
nodes.push(
{token.slice(2, -2)},
);
} else if (token.startsWith("*") && token.endsWith("*")) {
nodes.push(
{token.slice(1, -1)},
);
} else if (m[2] != null && m[3] != null) {
const href = sanitizeHref(m[3]);
if (href) {
nodes.push(
{m[2]}
,
);
} else {
nodes.push(
{m[2]},
);
}
}
last = m.index + token.length;
}
if (last < text.length) {
nodes.push({text.slice(last)});
}
return nodes;
}
function sanitizeHref(raw: string): string | null {
const href = raw.trim();
if (/^https?:\/\//i.test(href)) return href;
if (/^mailto:/i.test(href)) return href;
return null;
}