290 lines
7.7 KiB
TypeScript
290 lines
7.7 KiB
TypeScript
|
|
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 <div className={`hb-md ${className}`.trim()} />;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const blocks = parseBlocks(source);
|
|||
|
|
return (
|
|||
|
|
<div className={`hb-md ${className}`.trim()}>
|
|||
|
|
{blocks.map((block, i) => (
|
|||
|
|
<Fragment key={i}>{renderBlock(block)}</Fragment>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<div className="hb-md__line">
|
|||
|
|
{block.text ? renderInline(block.text) : "\u00a0"}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
case "gap":
|
|||
|
|
return <div className="hb-md__gap" aria-hidden />;
|
|||
|
|
case "h": {
|
|||
|
|
const Tag = `h${block.level}` as "h1" | "h2" | "h3";
|
|||
|
|
return (
|
|||
|
|
<Tag className={`hb-md__h hb-md__h--${block.level}`}>
|
|||
|
|
{renderInline(block.text)}
|
|||
|
|
</Tag>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
case "ul":
|
|||
|
|
return (
|
|||
|
|
<ul className="hb-md__ul">
|
|||
|
|
{block.items.map((item, j) => (
|
|||
|
|
<li key={j}>{renderInline(item)}</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
);
|
|||
|
|
case "ol":
|
|||
|
|
return (
|
|||
|
|
<ol className="hb-md__ol">
|
|||
|
|
{block.items.map((item, j) => (
|
|||
|
|
<li key={j}>{renderInline(item)}</li>
|
|||
|
|
))}
|
|||
|
|
</ol>
|
|||
|
|
);
|
|||
|
|
case "quote":
|
|||
|
|
return (
|
|||
|
|
<blockquote className="hb-md__quote">
|
|||
|
|
{block.lines.map((line, j) => (
|
|||
|
|
<div key={j} className="hb-md__line">
|
|||
|
|
{line ? renderInline(line) : "\u00a0"}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</blockquote>
|
|||
|
|
);
|
|||
|
|
case "code":
|
|||
|
|
return (
|
|||
|
|
<pre className="hb-md__pre" data-lang={block.lang || undefined}>
|
|||
|
|
<code>{block.code}</code>
|
|||
|
|
</pre>
|
|||
|
|
);
|
|||
|
|
case "hr":
|
|||
|
|
return <hr className="hb-md__hr" />;
|
|||
|
|
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(
|
|||
|
|
<code key={`c${ci}`} className="hb-md__code">
|
|||
|
|
{chunk.slice(1, -1)}
|
|||
|
|
</code>,
|
|||
|
|
);
|
|||
|
|
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(
|
|||
|
|
<Fragment key={`${keyPrefix}-p${idx++}`}>{text.slice(last, m.index)}</Fragment>,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const token = m[0];
|
|||
|
|
if (token.startsWith("**") && token.endsWith("**")) {
|
|||
|
|
nodes.push(
|
|||
|
|
<strong key={`${keyPrefix}-b${idx++}`}>{token.slice(2, -2)}</strong>,
|
|||
|
|
);
|
|||
|
|
} else if (token.startsWith("~~") && token.endsWith("~~")) {
|
|||
|
|
nodes.push(
|
|||
|
|
<del key={`${keyPrefix}-d${idx++}`}>{token.slice(2, -2)}</del>,
|
|||
|
|
);
|
|||
|
|
} else if (token.startsWith("*") && token.endsWith("*")) {
|
|||
|
|
nodes.push(
|
|||
|
|
<em key={`${keyPrefix}-i${idx++}`}>{token.slice(1, -1)}</em>,
|
|||
|
|
);
|
|||
|
|
} else if (m[2] != null && m[3] != null) {
|
|||
|
|
const href = sanitizeHref(m[3]);
|
|||
|
|
if (href) {
|
|||
|
|
nodes.push(
|
|||
|
|
<a
|
|||
|
|
key={`${keyPrefix}-a${idx++}`}
|
|||
|
|
href={href}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="hb-md__a"
|
|||
|
|
>
|
|||
|
|
{m[2]}
|
|||
|
|
</a>,
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
nodes.push(
|
|||
|
|
<Fragment key={`${keyPrefix}-al${idx++}`}>{m[2]}</Fragment>,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
last = m.index + token.length;
|
|||
|
|
}
|
|||
|
|
if (last < text.length) {
|
|||
|
|
nodes.push(<Fragment key={`${keyPrefix}-e`}>{text.slice(last)}</Fragment>);
|
|||
|
|
}
|
|||
|
|
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;
|
|||
|
|
}
|