llm-to-agent/apps/web/src/features/messages/markdown-content.ts
2026-08-14 16:10:22 +08:00

175 lines
4.8 KiB
TypeScript

import van from "vanjs-core";
import "./markdown-content.css";
const {
a,
blockquote,
button,
code,
div,
li,
ol,
p,
pre,
span,
table,
tbody,
td,
th,
thead,
tr,
ul,
} = van.tags;
export function MarkdownContent(content: string): HTMLElement {
return div({ class: "markdown-content" }, ...parseBlocks(content));
}
function parseBlocks(content: string): HTMLElement[] {
const lines = content.replaceAll("\r\n", "\n").split("\n");
const blocks: HTMLElement[] = [];
for (let index = 0; index < lines.length; ) {
const line = lines[index] ?? "";
if (!line.trim()) {
index++;
continue;
}
if (line.startsWith("```")) {
const language = line.slice(3).trim();
const codeLines: string[] = [];
index++;
while (index < lines.length && !lines[index]?.startsWith("```"))
codeLines.push(lines[index++] ?? "");
if (index < lines.length) index++;
blocks.push(CodeBlock(codeLines.join("\n"), language));
continue;
}
if (isTable(lines, index)) {
const headers = cells(line);
index += 2;
const rows: string[][] = [];
while (index < lines.length && (lines[index] ?? "").includes("|"))
rows.push(cells(lines[index++] ?? ""));
blocks.push(
table(
thead(tr(...headers.map((cell) => th(...inline(cell))))),
tbody(
...rows.map((row) => tr(...row.map((cell) => td(...inline(cell))))),
),
),
);
continue;
}
if (/^\s*[-*] /u.test(line) || /^\s*\d+\. /u.test(line)) {
const ordered = /^\s*\d+\. /u.test(line);
const items: HTMLElement[] = [];
const matcher = ordered ? /^\s*\d+\.\s+/u : /^\s*[-*]\s+/u;
while (index < lines.length && matcher.test(lines[index] ?? ""))
items.push(li(...inline((lines[index++] ?? "").replace(matcher, ""))));
blocks.push(ordered ? ol(...items) : ul(...items));
continue;
}
if (line.startsWith(">")) {
const quoted: string[] = [];
while (index < lines.length && lines[index]?.startsWith(">"))
quoted.push((lines[index++] ?? "").replace(/^>\s?/u, ""));
blocks.push(blockquote(...inline(quoted.join("\n"))));
continue;
}
const paragraph: string[] = [line];
index++;
while (
index < lines.length &&
lines[index]?.trim() &&
!startsBlock(lines, index)
)
paragraph.push(lines[index++] ?? "");
blocks.push(p(...inline(paragraph.join("\n"))));
}
return blocks;
}
function CodeBlock(content: string, language: string): HTMLElement {
return div(
{ class: "code-block" },
div(
{ class: "code-header" },
span(language || "文本"),
button(
{
type: "button",
onclick: async (event: Event) => {
const target = event.currentTarget as HTMLButtonElement;
try {
await navigator.clipboard.writeText(content);
target.textContent = "已复制";
} catch {
target.textContent = "复制失败";
}
setTimeout(() => {
target.textContent = "复制";
}, 1_200);
},
},
"复制",
),
),
pre(code({ class: language ? `language-${language}` : "" }, content)),
);
}
function inline(value: string): (string | HTMLElement)[] {
const result: (string | HTMLElement)[] = [];
const pattern = /(`[^`]+`|\[[^\]]+\]\([^\s)]+\)|\*\*[^*]+\*\*)/gu;
let offset = 0;
for (const match of value.matchAll(pattern)) {
const start = match.index ?? 0;
if (start > offset) result.push(value.slice(offset, start));
const token = match[0];
if (token.startsWith("`")) result.push(code(token.slice(1, -1)));
else if (token.startsWith("**"))
result.push(span({ class: "strong" }, token.slice(2, -2)));
else {
const parts = /^\[([^\]]+)\]\(([^)]+)\)$/u.exec(token);
const href = parts?.[2] ?? "";
result.push(
isSafeLink(href)
? a({ href, target: "_blank", rel: "noreferrer" }, parts?.[1] ?? href)
: token,
);
}
offset = start + token.length;
}
if (offset < value.length) result.push(value.slice(offset));
return result;
}
function startsBlock(lines: string[], index: number): boolean {
const line = lines[index] ?? "";
return (
line.startsWith("```") ||
line.startsWith(">") ||
/^\s*([-*]|\d+\.) /u.test(line) ||
isTable(lines, index)
);
}
function isTable(lines: string[], index: number): boolean {
return (
(lines[index] ?? "").includes("|") &&
/^\s*\|?\s*:?-{3,}/u.test(lines[index + 1] ?? "")
);
}
function cells(line: string): string[] {
return line
.trim()
.replace(/^\||\|$/gu, "")
.split("|")
.map((cell) => cell.trim());
}
function isSafeLink(value: string): boolean {
return /^(https?:\/\/|\/)/u.test(value);
}