thread-master/apps/web/src/components/ui/MarkdownText.tsx

290 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}