Files
MorphDoc/packages/renderer/src/render-markdown.ts
T
SkyJourney 58087d0c7e release: 发布 v0.5.0
新增共享 Preview Engine,统一 Web 连续预览、快速分页、Playwright PDF 与 Electron PDF;实现稳定前缀复用和修改位置后的增量分页,保留媒体块按文档顺序串行回填与单次重排。

完善跨端链接与桌面文档工作流:Web 受控处理锚点和 HTTP/HTTPS 外链;Desktop 支持本地路径、file URI、系统协议、多窗口、同文件单例、Markdown 当前或新窗口打开,以及聚焦时外部文件变化提示。

统一四套内置主题名称并默认使用 Typora Github;修复连续预览双滚动条、ECharts 尺寸、PDF 本地链接、围栏代码块 Typora DOM 与重复行内样式;桌面发行链强制完整重建内嵌 Web,避免安装包携带陈旧资源。

发布 Web/Compose 与 Windows NSIS/ZIP:镜像 yixiong/md-to-pdf:v0.5.0 已健康部署;NSIS SHA-256 为 60992D1FDCA513F46346C78478537EB4159D8C0E76B41ECF3CDC25BE77707D92,ZIP SHA-256 为 D74F82293FB67126E583546CBA894569EFC9A0B1B6343FAC648CCC95F1D188D8,本机安装版已升级至 v0.5.0。

验证:全项目 238 项测试通过,类型检查、生产构建和 git diff --check 通过;Web 快速/连续/精确预览、Compose、Desktop 多窗口、窗口状态、文件关联、链接与代码块均完成真实环境验收。
2026-07-28 18:01:22 +08:00

478 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
classifyDocumentLink,
isSafeDocumentLink
} 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<string, string>;
warnings?: readonly string[];
}
export interface RenderedMarkdown
extends Omit<RenderedMarkdownDocument, "rendererVersion"> {
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 defaultValidateLink = markdown.validateLink.bind(markdown);
markdown.validateLink = (href) =>
defaultValidateLink(href) ||
classifyDocumentLink(href).scheme === "file";
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<string, string>;
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 '<figure class="md-document-image-block">\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 "</figure>\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 `<div class="mermaid" data-mermaid-pending="true">${markdown.utils.escapeHtml(token.content)}</div>\n`;
}
if (defaultFenceRenderer) {
const renderedFence = defaultFenceRenderer(
tokens,
index,
options,
environment,
renderer
);
const languageAttribute = language
? ` lang="${escapeAttribute(language)}"`
: "";
return renderedFence.replace(
/^<pre>/u,
`<pre class="md-fences"${languageAttribute}>`
);
}
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 +
`<figcaption class="md-document-image-caption">${markdown.utils.escapeHtml(token.content)}</figcaption>`
);
}
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", "lang"],
span: ["class", "style"],
td: ["colspan", "rowspan", "style"],
th: ["colspan", "rowspan", "scope", "style"]
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
img: ["http", "https", "data"]
},
allowedSchemesAppliedToAttributes: ["src", "cite"],
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(tagName, attributes) {
const href = attributes.href;
if (href && !isSafeDocumentLink(href)) {
delete attributes.href;
}
return {
tagName,
attribs: {
...attributes,
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 = `<article id="write" class="markdown-body" lang="${escapeAttribute(metadata.language)}">${bodyHtml}</article>`;
return {
rendererVersion: RENDERER_VERSION,
articleHtml,
bodyHtml,
metadata,
features: detectFeatures(bodyHtml),
warnings
};
}
function collectImageSources(tokens: readonly Token[], sources: Set<string>) {
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<string>();
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<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],
["echarts", /\bmd-echarts\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);
}