import { anchor } from "@mdit/plugin-anchor"; import { footnote } from "@mdit/plugin-footnote"; import { katex } from "@mdit/plugin-katex"; import { tasklist } from "@mdit/plugin-tasklist"; import type { MarkdownDocumentMetadata, RenderedMarkdownDocument, ThemeFeature } from "@md-to-pdf/core"; import { markdownItECharts, type MarkdownItEChartsErrorContext } from "@md-to-pdf/markdown-echarts"; import matter from "gray-matter"; import hljs from "highlight.js"; import MarkdownIt from "markdown-it"; import type { Options as MarkdownItOptions } from "markdown-it"; import type Renderer from "markdown-it/lib/renderer.mjs"; import type Token from "markdown-it/lib/token.mjs"; import sanitizeHtml from "sanitize-html"; export const RENDERER_VERSION = 1; export class MarkdownDocumentParseError extends Error { readonly code = "INVALID_FRONT_MATTER"; constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "MarkdownDocumentParseError"; } } export type { MarkdownDocumentMetadata } from "@md-to-pdf/core"; export interface RenderMarkdownOptions { language?: string; imageSourceMap?: ReadonlyMap; warnings?: readonly string[]; } export interface RenderedMarkdown extends Omit { rendererVersion: typeof RENDERER_VERSION; } const markdownOptions: MarkdownItOptions = { html: false, breaks: false, linkify: true, typographer: false, highlight(code, language) { const normalizedLanguage = language.trim().toLowerCase(); if (normalizedLanguage && hljs.getLanguage(normalizedLanguage)) { return hljs.highlight(code, { language: normalizedLanguage, ignoreIllegals: true }).value; } return MarkdownIt().utils.escapeHtml(code); } }; const markdown = new MarkdownIt(markdownOptions) .use(anchor, { level: 1, uniqueSlugStartIndex: 2, tabIndex: false }) .use(footnote) .use(tasklist, { disabled: true, label: true, containerClass: "contains-task-list", itemClass: "md-task-list-item", checkboxClass: "task-list-item-checkbox", labelClass: "task-list-item-label" }) .use(katex, { delimiters: "all", output: "html", throwOnError: false, strict: "warn", trust: false }); const defaultFenceRenderer = markdown.renderer.rules.fence; const defaultImageRenderer = markdown.renderer.rules.image; const defaultParagraphOpenRenderer = markdown.renderer.rules.paragraph_open; const defaultParagraphCloseRenderer = markdown.renderer.rules.paragraph_close; const lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u; const tableTextAlignStylePattern = /^(?:left|center|right)$/u; interface MarkdownRenderEnvironment { warnings?: string[]; imageSourceMap?: ReadonlyMap; standaloneImage?: boolean; } function getStandaloneImage( token: Token | undefined ): Token | undefined { if (token?.type !== "inline") { return undefined; } const meaningful = (token.children ?? []).filter( (child) => child.type !== "text" || child.content.trim().length > 0 ); return meaningful.length === 1 && meaningful[0]?.type === "image" ? meaningful[0] : undefined; } markdown.renderer.rules.paragraph_open = ( tokens, index, options, environment, renderer ) => { const standaloneImage = getStandaloneImage(tokens[index + 1]); (environment as MarkdownRenderEnvironment).standaloneImage = Boolean(standaloneImage); if (standaloneImage) { return '
\n'; } return defaultParagraphOpenRenderer ? defaultParagraphOpenRenderer( tokens, index, options, environment, renderer ) : renderer.renderToken(tokens, index, options); }; markdown.renderer.rules.paragraph_close = ( tokens, index, options, environment, renderer ) => { const renderEnvironment = environment as MarkdownRenderEnvironment; if (renderEnvironment.standaloneImage) { renderEnvironment.standaloneImage = false; return "
\n"; } return defaultParagraphCloseRenderer ? defaultParagraphCloseRenderer( tokens, index, options, environment, renderer ) : renderer.renderToken(tokens, index, options); }; markdown.renderer.rules.fence = ( tokens, index, options, environment, renderer ) => { const token = tokens[index]; const language = token?.info.trim().split(/\s+/u)[0]?.toLowerCase(); if (token && language === "mermaid") { return `
${markdown.utils.escapeHtml(token.content)}
\n`; } if (defaultFenceRenderer) { return defaultFenceRenderer( tokens, index, options, environment, renderer ); } return renderFallbackFence(tokens, index, renderer); }; markdown.renderer.rules.image = ( tokens, index, options, environment, renderer ) => { const token = tokens[index]; const renderEnvironment = environment as MarkdownRenderEnvironment; const imageSourceMap = renderEnvironment.imageSourceMap; const source = token?.attrGet("src"); const resolvedSource = source ? imageSourceMap?.get(source) : undefined; if (token) { if (resolvedSource) { token.attrSet("src", resolvedSource); } token.attrJoin("class", "md-document-image"); } const imageHtml = defaultImageRenderer ? defaultImageRenderer( tokens, index, options, environment, renderer ) : renderer.renderToken(tokens, index, options); if (renderEnvironment.standaloneImage && token) { return ( imageHtml + `
${markdown.utils.escapeHtml(token.content)}
` ); } return imageHtml; }; markdown.use(markdownItECharts, { onError(error, context: MarkdownItEChartsErrorContext) { const environment = context.environment as | { warnings?: string[] } | undefined; environment?.warnings?.push( `ECharts 图表配置无效:${error.message}` ); } }); const safeHtmlOptions: sanitizeHtml.IOptions = { allowedTags: [ ...sanitizeHtml.defaults.allowedTags, "article", "div", "figure", "figcaption", "img", "input", "mark", "section", "span", "sub", "summary", "sup" ], allowedAttributes: { "*": ["class", "id", "title", "aria-*", "role"], a: ["href", "name", "target", "rel"], div: ["class", "data-mermaid-pending"], figure: [ "class", "data-echarts-pending", "data-echarts-protocol" ], img: ["src", "alt", "title", "width", "height"], input: ["type", "checked", "disabled", "class"], li: ["class", "value"], ol: ["class", "start"], pre: ["class", "hidden"], span: ["class", "style"], td: ["colspan", "rowspan", "style"], th: ["colspan", "rowspan", "scope", "style"] }, allowedSchemes: ["http", "https", "mailto"], allowedSchemesByTag: { img: ["http", "https", "data"] }, allowedStyles: { span: { height: [lengthStylePattern], top: [lengthStylePattern], width: [lengthStylePattern], "min-width": [lengthStylePattern], "margin-left": [lengthStylePattern], "margin-right": [lengthStylePattern], "padding-left": [lengthStylePattern], "vertical-align": [lengthStylePattern], "border-bottom-width": [lengthStylePattern] }, td: { "text-align": [tableTextAlignStylePattern] }, th: { "text-align": [tableTextAlignStylePattern] } }, transformTags: { a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer" }) } }; export function renderMarkdown( source: string, options: RenderMarkdownOptions = {} ): RenderedMarkdown { const warnings = [...(options.warnings ?? [])]; const parsed = parseFrontMatter(source); const environment = { warnings, imageSourceMap: options.imageSourceMap }; const rendered = markdown.render(parsed.content, environment); const bodyHtml = sanitizeHtml(rendered, safeHtmlOptions); const metadata = normalizeMetadata( parsed.data, options.language ?? "zh-CN", extractFirstHeading(parsed.content) ); const articleHtml = `
${bodyHtml}
`; return { rendererVersion: RENDERER_VERSION, articleHtml, bodyHtml, metadata, features: detectFeatures(bodyHtml), warnings }; } function collectImageSources(tokens: readonly Token[], sources: Set) { for (const token of tokens) { if (token.type === "image") { const source = token.attrGet("src"); if (source) { sources.add(source); } } if (token.children) { collectImageSources(token.children, sources); } } } export function extractMarkdownImageSources(source: string) { const parsed = parseFrontMatter(source); const sources = new Set(); collectImageSources(markdown.parse(parsed.content, {}), sources); return [...sources]; } function parseFrontMatter(source: string) { try { return matter(source); } catch (error) { const message = error instanceof Error ? error.message : "未知错误"; throw new MarkdownDocumentParseError( `无法解析 Markdown Front Matter:${message}`, { cause: error } ); } } function normalizeMetadata( data: Record, fallbackLanguage: string, fallbackTitle: string ): MarkdownDocumentMetadata { return { title: readString(data.title) || fallbackTitle, author: readString(data.author), subject: readString(data.subject) || readString(data.description), keywords: readStringList(data.keywords ?? data.tags), language: readString(data.language ?? data.lang) || fallbackLanguage }; } function readString(value: unknown): string { if (typeof value === "string") { return value.trim(); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } return ""; } function readStringList(value: unknown): string[] { if (Array.isArray(value)) { return value.map(readString).filter(Boolean).slice(0, 30); } if (typeof value === "string") { return value .split(",") .map((item) => item.trim()) .filter(Boolean) .slice(0, 30); } return []; } function extractFirstHeading(source: string): string { const heading = source.match(/^#\s+(.+?)\s*#*\s*$/mu); return heading?.[1]?.trim() ?? ""; } function detectFeatures(html: string): ThemeFeature[] { const featureChecks: Array<[ThemeFeature, RegExp]> = [ ["code", / pattern.test(html)) .map(([feature]) => feature); } function renderFallbackFence( tokens: Token[], index: number, renderer: Renderer ): string { const token = tokens[index]; if (!token) { return ""; } return `${markdown.utils.escapeHtml(token.content)}\n`; } function escapeAttribute(value: string): string { return markdown.utils.escapeHtml(value); }