feat: 实现 Markdown 渲染核心
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@md-to-pdf/renderer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdit/plugin-anchor": "^1.1.1",
|
||||
"@mdit/plugin-footnote": "^1.0.1",
|
||||
"@mdit/plugin-katex": "^1.0.1",
|
||||
"@mdit/plugin-tasklist": "^1.0.1",
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"markdown-it": "^14.3.0",
|
||||
"sanitize-html": "^2.17.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./render-markdown.js";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderMarkdown } from "../src/render-markdown.js";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
it("渲染常用 Markdown 扩展并识别能力", () => {
|
||||
const result = renderMarkdown(`
|
||||
# 示例文档
|
||||
|
||||
| 名称 | 状态 |
|
||||
| --- | --- |
|
||||
| PDF | 可用 |
|
||||
|
||||
- [x] 已完成
|
||||
- [ ] 待处理
|
||||
|
||||
\`\`\`ts
|
||||
const answer = 42;
|
||||
\`\`\`
|
||||
|
||||
脚注引用[^note]。
|
||||
|
||||
[^note]: 脚注正文。
|
||||
`);
|
||||
|
||||
expect(result.articleHtml).toContain('id="write"');
|
||||
expect(result.bodyHtml).toContain("<table>");
|
||||
expect(result.bodyHtml).toContain("md-task-list-item");
|
||||
expect(result.bodyHtml).toContain("hljs-keyword");
|
||||
expect(result.bodyHtml).toContain("footnote");
|
||||
expect(result.metadata.title).toBe("示例文档");
|
||||
expect(result.features).toEqual(
|
||||
expect.arrayContaining(["code", "table", "task-list", "footnote"])
|
||||
);
|
||||
});
|
||||
|
||||
it("读取并规范化 Front Matter 元数据", () => {
|
||||
const result = renderMarkdown(`---
|
||||
title: 项目报告
|
||||
author: 张三
|
||||
subject: 技术方案
|
||||
keywords:
|
||||
- Markdown
|
||||
- PDF
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# 不作为标题
|
||||
`);
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
title: "项目报告",
|
||||
author: "张三",
|
||||
subject: "技术方案",
|
||||
keywords: ["Markdown", "PDF"],
|
||||
language: "zh-CN"
|
||||
});
|
||||
});
|
||||
|
||||
it("服务端渲染公式并保留 Mermaid 安全占位", () => {
|
||||
const result = renderMarkdown(`
|
||||
内联公式 $E=mc^2$。
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[开始] --> B[结束]
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).toContain("katex");
|
||||
expect(result.bodyHtml).toContain('class="mermaid"');
|
||||
expect(result.bodyHtml).toContain("data-mermaid-pending");
|
||||
expect(result.features).toEqual(
|
||||
expect.arrayContaining(["katex", "mermaid"])
|
||||
);
|
||||
});
|
||||
|
||||
it("阻止原始 HTML 和危险链接形成可执行内容", () => {
|
||||
const result = renderMarkdown(`
|
||||
<script>alert("xss")</script>
|
||||
|
||||
[危险链接](javascript:alert("xss"))
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).not.toContain("<script");
|
||||
expect(result.bodyHtml).not.toMatch(/href=["']javascript:/u);
|
||||
expect(result.bodyHtml).toContain("<script>");
|
||||
});
|
||||
|
||||
it("为重复中文标题生成唯一锚点", () => {
|
||||
const result = renderMarkdown(`
|
||||
# 章节
|
||||
|
||||
## 章节
|
||||
|
||||
## 章节
|
||||
`);
|
||||
|
||||
expect(result.bodyHtml).toContain('id="%E7%AB%A0%E8%8A%82"');
|
||||
expect(result.bodyHtml).toContain('id="%E7%AB%A0%E8%8A%82-2"');
|
||||
expect(result.bodyHtml).toContain('id="%E7%AB%A0%E8%8A%82-3"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user