feat: 实现 Markdown 渲染核心

This commit is contained in:
SkyJourney
2026-07-25 18:39:43 +08:00
parent ec48bce0a6
commit 7224dfdf60
11 changed files with 1550 additions and 11 deletions
+278
View File
@@ -0,0 +1,278 @@
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 { ThemeFeature } from "@md-to-pdf/core";
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 interface MarkdownDocumentMetadata {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
}
export interface RenderMarkdownOptions {
language?: string;
}
export interface RenderedMarkdown {
rendererVersion: typeof RENDERER_VERSION;
articleHtml: string;
bodyHtml: string;
metadata: MarkdownDocumentMetadata;
features: ThemeFeature[];
warnings: string[];
}
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 lengthStylePattern = /^-?\d+(?:\.\d+)?(?:em|ex|px|%)$/u;
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 `<div class="mermaid" data-mermaid-pending="true">${markdown.utils.escapeHtml(token.content)}</div>\n`;
}
if (defaultFenceRenderer) {
return defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
}
return renderFallbackFence(tokens, index, renderer);
};
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"],
img: ["src", "alt", "title", "width", "height"],
input: ["type", "checked", "disabled", "class"],
li: ["class", "value"],
ol: ["class", "start"],
span: ["class", "style"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan", "scope"]
},
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]
}
},
transformTags: {
a: sanitizeHtml.simpleTransform("a", {
rel: "noopener noreferrer"
})
}
};
export function renderMarkdown(
source: string,
options: RenderMarkdownOptions = {}
): RenderedMarkdown {
const warnings: string[] = [];
const parsed = parseFrontMatter(source);
const environment = {
warnings
};
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 = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</article>`;
return {
rendererVersion: RENDERER_VERSION,
articleHtml,
bodyHtml,
metadata,
features: detectFeatures(bodyHtml),
warnings
};
}
function parseFrontMatter(source: string) {
try {
return matter(source);
} catch (error) {
const message = error instanceof Error ? error.message : "未知错误";
throw new Error(`无法解析 Markdown Front Matter${message}`, {
cause: error
});
}
}
function normalizeMetadata(
data: Record<string, unknown>,
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", /<pre\b/u],
["table", /<table\b/u],
["task-list", /\bmd-task-list-item\b/u],
["footnote", /\bfootnote/u],
["katex", /\bkatex\b/u],
["mermaid", /\bmermaid\b/u]
];
return featureChecks
.filter(([, pattern]) => pattern.test(html))
.map(([feature]) => feature);
}
function renderFallbackFence(
tokens: Token[],
index: number,
renderer: Renderer
): string {
const token = tokens[index];
if (!token) {
return "";
}
return `<pre${renderer.renderAttrs(token)}><code>${markdown.utils.escapeHtml(token.content)}</code></pre>\n`;
}
function escapeAttribute(value: string): string {
return markdown.utils.escapeHtml(value);
}