Files
2026-09-29 01:58:28 +08:00

293 lines
10 KiB
TypeScript

import { Fragment, type ReactNode } from "react";
import { cn } from "~/lib/utils.ts";
// 模型汇报的 Markdown 渲染(AG-3):只支持安全子集——标题、段落、列表、引用、分隔线、代码块、表格,
// 行内粗体 / 斜体 / 删除线 / 行内代码 / 链接。直接生成 React 元素,不用 innerHTML:原文里的 HTML 标签
// 按普通文字显示;链接只放行 http(s) 与站内相对路径,其余协议(javascript:、data: 等)按文字显示。
export type MdBlock =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string }
| { type: "list"; ordered: boolean; start: number; items: string[] }
| { type: "quote"; text: string }
| { type: "code"; lang: string; text: string }
| { type: "table"; head: string[]; align: ("left" | "center" | "right" | null)[]; rows: string[][] }
| { type: "hr" };
const FENCE = /^\s*(```|~~~)\s*([\w+-]*)\s*$/;
const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const UL = /^\s{0,3}[-*+]\s+(.*)$/;
const OL = /^\s{0,3}(\d{1,9})[.)]\s+(.*)$/;
const QUOTE = /^\s{0,3}>\s?(.*)$/;
const TABLE_SEP = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
function splitRow(line: string): string[] {
let s = line.trim();
if (s.startsWith("|")) s = s.slice(1);
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
const out: string[] = [];
let cur = "";
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === "\\" && s[i + 1] === "|") {
cur += "|";
i++;
} else if (ch === "|") {
out.push(cur.trim());
cur = "";
} else cur += ch;
}
out.push(cur.trim());
return out;
}
/** 块级解析(纯函数,便于测试) */
export function parseMarkdown(src: string): MdBlock[] {
const lines = src.replace(/\r\n?/g, "\n").split("\n");
const blocks: MdBlock[] = [];
let i = 0;
const isBlockStart = (l: string, next?: string) =>
FENCE.test(l) ||
HEADING.test(l) ||
HR.test(l) ||
UL.test(l) ||
OL.test(l) ||
QUOTE.test(l) ||
(l.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-"));
while (i < lines.length) {
const line = lines[i] ?? "";
if (!line.trim()) {
i++;
continue;
}
const fence = FENCE.exec(line);
if (fence) {
const body: string[] = [];
i++;
while (i < lines.length && !(lines[i] ?? "").trim().startsWith(fence[1] ?? "```")) body.push(lines[i++] ?? "");
i++; // 结束围栏(缺失时读到文末)
blocks.push({ type: "code", lang: fence[2] ?? "", text: body.join("\n") });
continue;
}
const h = HEADING.exec(line);
if (h) {
blocks.push({ type: "heading", level: h[1]?.length ?? 1, text: h[2] ?? "" });
i++;
continue;
}
if (HR.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
const next = lines[i + 1];
if (line.includes("|") && next !== undefined && TABLE_SEP.test(next) && next.includes("-")) {
const head = splitRow(line);
const align = splitRow(next).map((c) =>
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : null,
);
const rows: string[][] = [];
i += 2;
while (i < lines.length && (lines[i] ?? "").includes("|") && (lines[i] ?? "").trim()) {
const cells = splitRow(lines[i] ?? "");
rows.push(head.map((_, k) => cells[k] ?? ""));
i++;
}
blocks.push({ type: "table", head, align, rows });
continue;
}
if (QUOTE.test(line)) {
const body: string[] = [];
while (i < lines.length && QUOTE.test(lines[i] ?? "")) body.push(QUOTE.exec(lines[i++] ?? "")?.[1] ?? "");
blocks.push({ type: "quote", text: body.join("\n") });
continue;
}
const ul = UL.exec(line);
const ol = OL.exec(line);
if (ul || ol) {
const ordered = !ul;
const re = ordered ? OL : UL;
const items: string[] = [];
while (i < lines.length) {
const l = lines[i] ?? "";
const m = re.exec(l);
if (m) {
items.push((ordered ? m[2] : m[1]) ?? "");
i++;
} else if (l.trim() && /^\s{2,}\S/.test(l) && items.length) {
// 缩进的续行 / 子项:并入上一项
items[items.length - 1] += `\n${l.trim()}`;
i++;
} else break;
}
blocks.push({ type: "list", ordered, start: ordered ? Number(ol?.[1] ?? 1) : 1, items });
continue;
}
const para: string[] = [];
while (i < lines.length && (lines[i] ?? "").trim() && !(para.length && isBlockStart(lines[i] ?? "", lines[i + 1])))
para.push(lines[i++] ?? "");
blocks.push({ type: "paragraph", text: para.join("\n") });
}
return blocks;
}
/** 链接只放行 http(s) 与站内相对路径 */
export function safeHref(href: string): string | null {
const h = href.trim();
if (/^https?:\/\//i.test(h)) return h;
if (/^\/(?!\/)/.test(h)) return h;
return null;
}
// 行内:`code`、**粗体**、*斜体* / _斜体_、~~删除线~~、[文字](链接)
const INLINE =
/(`+)([\s\S]*?[^`])\1(?!`)|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|~~([\s\S]+?)~~|\[([^\]]+)\]\(([^)\s]+)\)|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])/g;
export function renderInline(text: string, keyBase = "i"): ReactNode[] {
const out: ReactNode[] = [];
let last = 0;
let n = 0;
for (const m of text.matchAll(INLINE)) {
const at = m.index ?? 0;
if (at > last) out.push(...withBreaks(text.slice(last, at), `${keyBase}-t${n}`));
const key = `${keyBase}-${n++}`;
if (m[1])
out.push(
<code key={key} className="rounded-tight bg-foreground/[0.07] px-1 py-px font-mono text-meta">
{m[2]}
</code>,
);
else if (m[3] !== undefined || m[4] !== undefined)
out.push(
<strong key={key} className="font-semibold text-foreground">
{renderInline(m[3] ?? m[4] ?? "", key)}
</strong>,
);
else if (m[5] !== undefined) out.push(<del key={key}>{renderInline(m[5], key)}</del>);
else if (m[6] !== undefined) {
const href = safeHref(m[7] ?? "");
out.push(
href ? (
<a
key={key}
href={href}
target={href.startsWith("/") ? undefined : "_blank"}
rel="noopener noreferrer nofollow"
className="text-primary underline underline-offset-2"
>
{renderInline(m[6], key)}
</a>
) : (
<Fragment key={key}>{m[0]}</Fragment>
),
);
} else out.push(<em key={key}>{renderInline(m[8] ?? m[9] ?? "", key)}</em>);
last = at + m[0].length;
}
if (last < text.length) out.push(...withBreaks(text.slice(last), `${keyBase}-t${n}`));
return out;
}
function withBreaks(s: string, key: string): ReactNode[] {
const parts = s.split("\n");
return parts.flatMap((p, k) => (k === 0 ? [p] : [<br key={`${key}-${k}`} />, p]));
}
const H_CLASS = ["text-lead font-semibold", "text-body font-semibold", "text-caption font-semibold"];
/** 渲染安全子集 Markdown;空文本返回 null */
export function Markdown({ text, className }: { text: string; className?: string }) {
const blocks = parseMarkdown(text);
if (blocks.length === 0) return null;
return (
<div data-slot="markdown" className={cn("space-y-2 whitespace-normal", className)}>
{blocks.map((b, k) => {
const key = `b${k}`;
switch (b.type) {
case "heading": {
const Tag = `h${Math.min(6, b.level + 2)}` as "h3";
return (
<Tag key={key} className={cn("text-foreground", H_CLASS[Math.min(2, b.level - 1)])}>
{renderInline(b.text, key)}
</Tag>
);
}
case "paragraph":
return <p key={key}>{renderInline(b.text, key)}</p>;
case "quote":
return (
<blockquote key={key} className="border-l-2 border-border pl-3 text-muted-foreground">
{renderInline(b.text, key)}
</blockquote>
);
case "hr":
return <hr key={key} className="border-border/70" />;
case "code":
return (
<pre
key={key}
className="overflow-x-auto rounded-lg bg-foreground/[0.05] p-2 font-mono text-meta leading-5"
>
<code>{b.text}</code>
</pre>
);
case "list": {
const items = b.items.map((it, j) => (
<li key={`${key}-${j}`} className="pl-0.5">
{renderInline(it, `${key}-${j}`)}
</li>
));
return b.ordered ? (
<ol key={key} start={b.start} className="list-decimal space-y-0.5 pl-5">
{items}
</ol>
) : (
<ul key={key} className="list-disc space-y-0.5 pl-5">
{items}
</ul>
);
}
case "table":
return (
<div key={key} className="overflow-x-auto">
<table className="w-full border-collapse text-meta">
<thead>
<tr>
{b.head.map((h, j) => (
<th
key={`${key}-h${j}`}
style={{ textAlign: b.align[j] ?? "left" }}
className="border-b border-border px-2 py-1 font-semibold text-foreground"
>
{renderInline(h, `${key}-h${j}`)}
</th>
))}
</tr>
</thead>
<tbody>
{b.rows.map((r, j) => (
<tr key={`${key}-r${j}`} className="border-b border-border/50">
{r.map((c, x) => (
<td
key={`${key}-r${j}-${x}`}
style={{ textAlign: b.align[x] ?? "left" }}
className="px-2 py-1 align-top"
>
{renderInline(c, `${key}-r${j}-${x}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
default:
return null;
}
})}
</div>
);
}