import { renderMermaidSVG } from "beautiful-mermaid"; import { marked, Renderer, type Token, type Tokens, type TokensList, } from "marked"; import { escapeHtml, highlightHtml } from "./syntax-highlight.js"; import type { MarkdownReviewBlock } from "./types.js"; const MARKED_OPTIONS = { gfm: true, breaks: false } as const; function safeHref(value: string): string | undefined { if (value.startsWith("#")) return value; try { const url = new URL(value); return ["http:", "https:", "mailto:"].includes(url.protocol) ? value : undefined; } catch { return undefined; } } function codeBlock(text: string, language?: string): string { const name = language?.trim().split(/\s+/, 1)[0]?.toLowerCase(); const className = name ? ` class="language-${escapeHtml(name)}"` : ""; return `
${highlightHtml(text, name)}\n
`; } function localSvg(svg: string): string { if (!svg.trimStart().startsWith("Mermaid 图`; } catch { return `${codeBlock(text, "mermaid")}

Mermaid 图无法渲染

`; } } function createSafeRenderer(): Renderer { const renderer = new Renderer(); renderer.code = ({ text, lang }: Tokens.Code) => lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() === "mermaid" ? mermaidBlock(text) : codeBlock(text, lang); renderer.html = ({ text }: Tokens.HTML | Tokens.Tag) => `
${escapeHtml(text)}
`; renderer.image = ({ text, title }: Tokens.Image) => { const label = text || "未命名图片"; const tooltip = title ? ` title="${escapeHtml(title)}"` : ""; return `[图片: ${escapeHtml(label)}]`; }; renderer.link = function ({ href, title, tokens }: Tokens.Link) { const text = this.parser.parseInline(tokens) as string; const safe = safeHref(href); if (!safe) return text; const tooltip = title ? ` title="${escapeHtml(title)}"` : ""; return `${text}`; }; return renderer; } function lineCount(value: string): number { return (value.match(/\n/g) ?? []).length; } function tokenRange( markdown: string, raw: string, cursor: number, ): { startLine: number; endLine: number; nextCursor: number } { const found = markdown.indexOf(raw, cursor); const start = found >= 0 ? found : cursor; const visibleRaw = raw.replace(/(?:\r?\n)+$/, ""); const startLine = lineCount(markdown.slice(0, start)); return { startLine, endLine: startLine + lineCount(visibleRaw), nextCursor: start + raw.length, }; } function singleTokenList(token: Token, links: TokensList["links"]): TokensList { const list = [token] as TokensList; list.links = links; return list; } function parseTokenHtml( token: Token, links: TokensList["links"], renderer: Renderer, ): string { return marked.parser(singleTokenList(token, links), { ...MARKED_OPTIONS, renderer, }); } function listItemStart(list: Tokens.List, index: number): Tokens.List["start"] { if (!list.ordered || typeof list.start !== "number") return list.start; return list.start + index; } function listItemBlocks( markdown: string, list: Tokens.List, cursor: number, links: TokensList["links"], renderer: Renderer, ): MarkdownReviewBlock[] { const blocks: MarkdownReviewBlock[] = []; let itemCursor = cursor; list.items.forEach((item, index) => { const range = tokenRange(markdown, item.raw, itemCursor); itemCursor = range.nextCursor; const html = parseTokenHtml( { ...list, items: [item], start: listItemStart(list, index), raw: item.raw }, links, renderer, ); if (!html.trim()) return; blocks.push({ startLine: range.startLine, endLine: range.endLine, html }); }); return blocks; } export function renderMarkdownBlocks(markdown: string): MarkdownReviewBlock[] { const tokens = marked.lexer(markdown, MARKED_OPTIONS); const renderer = createSafeRenderer(); const blocks: MarkdownReviewBlock[] = []; let cursor = 0; for (const token of tokens) { const range = tokenRange(markdown, token.raw, cursor); if (token.type === "space" || token.type === "def") { cursor = range.nextCursor; continue; } if (token.type === "list") { const listStart = markdown.indexOf(token.raw, cursor); blocks.push( ...listItemBlocks( markdown, token, listStart >= 0 ? listStart : cursor, tokens.links, renderer, ), ); cursor = range.nextCursor; continue; } cursor = range.nextCursor; const html = parseTokenHtml(token, tokens.links, renderer); if (!html.trim()) continue; blocks.push({ startLine: range.startLine, endLine: range.endLine, html }); } return blocks; }