175 lines
4.8 KiB
TypeScript
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);
|
|
}
|