293 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|