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