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
+2 -1
View File
@@ -10,10 +10,11 @@
- Fastify 后端; - Fastify 后端;
- 共享导出配置模型; - 共享导出配置模型;
- 版本化主题清单接口; - 版本化主题清单接口;
- Markdown、Front Matter、安全过滤和扩展语法渲染核心;
- 内置 Typora 风格主题目录; - 内置 Typora 风格主题目录;
- Docker 与测试目录预留。 - Docker 与测试目录预留。
Markdown 渲染和 PDF 生成将在后续阶段实现。 网页预览和 PDF 生成将在后续阶段实现。
## 本地开发 ## 本地开发
+1
View File
@@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@md-to-pdf/core": "0.1.0", "@md-to-pdf/core": "0.1.0",
"@md-to-pdf/renderer": "0.1.0",
"fastify": "^5.6.2" "fastify": "^5.6.2"
}, },
"devDependencies": { "devDependencies": {
+10 -3
View File
@@ -4,6 +4,7 @@ import {
defaultExportConfig, defaultExportConfig,
supportedPaperFormats supportedPaperFormats
} from "@md-to-pdf/core"; } from "@md-to-pdf/core";
import { RENDERER_VERSION } from "@md-to-pdf/renderer";
const port = Number.parseInt(process.env.PORT ?? "3001", 10); const port = Number.parseInt(process.env.PORT ?? "3001", 10);
const host = process.env.HOST ?? "0.0.0.0"; const host = process.env.HOST ?? "0.0.0.0";
@@ -15,14 +16,20 @@ const app = Fastify({
app.get("/api/health", async () => ({ app.get("/api/health", async () => ({
status: "ok", status: "ok",
service: "md-to-pdf", service: "md-to-pdf",
configVersion: EXPORT_CONFIG_VERSION configVersion: EXPORT_CONFIG_VERSION,
rendererVersion: RENDERER_VERSION
})); }));
app.get("/api/capabilities", async () => ({ app.get("/api/capabilities", async () => ({
defaultExportConfig, defaultExportConfig,
supportedPaperFormats, supportedPaperFormats,
implemented: ["project-skeleton", "export-config", "theme-manifest"], implemented: [
planned: ["markdown-render", "html-preview", "pdf-export"] "project-skeleton",
"export-config",
"theme-manifest",
"markdown-render"
],
planned: ["html-preview", "pdf-export"]
})); }));
async function start() { async function start() {
+1041 -4
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -8,12 +8,14 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"build": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server", "build": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/web && npm run build -w @md-to-pdf/server",
"dev": "npm run build -w @md-to-pdf/core && concurrently -k -n core,server,web \"npm:dev:core\" \"npm:dev:server\" \"npm:dev:web\"", "dev": "npm run build -w @md-to-pdf/core && npm run build -w @md-to-pdf/renderer && concurrently -k -n core,renderer,server,web \"npm:dev:core\" \"npm:dev:renderer\" \"npm:dev:server\" \"npm:dev:web\"",
"dev:core": "npm run dev -w @md-to-pdf/core", "dev:core": "npm run dev -w @md-to-pdf/core",
"dev:renderer": "npm run dev -w @md-to-pdf/renderer",
"dev:server": "npm run dev -w @md-to-pdf/server", "dev:server": "npm run dev -w @md-to-pdf/server",
"dev:web": "npm run dev -w @md-to-pdf/web", "dev:web": "npm run dev -w @md-to-pdf/web",
"typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server" "test": "npm run test -w @md-to-pdf/renderer",
"typecheck": "npm run typecheck -w @md-to-pdf/core && npm run build -w @md-to-pdf/core && npm run typecheck -w @md-to-pdf/renderer && npm run build -w @md-to-pdf/renderer && npm run typecheck -w @md-to-pdf/web && npm run typecheck -w @md-to-pdf/server"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
+39
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./render-markdown.js";
+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);
}
@@ -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("&lt;script&gt;");
});
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"');
});
});
+16
View File
@@ -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"
]
}
+55
View File
@@ -83,6 +83,27 @@
background: transparent; background: transparent;
} }
#write .hljs-keyword,
#write .hljs-selector-tag,
#write .hljs-literal {
color: #9a3412;
}
#write .hljs-string,
#write .hljs-attr {
color: #166534;
}
#write .hljs-title,
#write .hljs-function {
color: #1d4ed8;
}
#write .hljs-comment {
color: #7c838a;
font-style: italic;
}
#write table { #write table {
width: 100%; width: 100%;
margin: 1.3em 0; margin: 1.3em 0;
@@ -106,3 +127,37 @@
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
#write .contains-task-list {
padding-left: 0.25em;
list-style: none;
}
#write .md-task-list-item {
list-style: none;
}
#write .task-list-item-checkbox {
margin: 0 0.55em 0.2em 0;
vertical-align: middle;
}
#write .footnotes {
margin-top: 2.5em;
padding-top: 1em;
border-top: 1px solid var(--md-border);
color: var(--md-muted);
font-size: 0.9em;
}
#write .katex-display {
margin: 1.4em 0;
overflow-x: auto;
overflow-y: hidden;
text-align: center;
}
#write .mermaid {
margin: 1.5em auto;
text-align: center;
}