feat: 实现 Markdown 渲染核心
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user