md2any 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,864 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { Marked } = require('marked');
6
+ const cheerio = require('cheerio');
7
+ const hljs = require('highlight.js');
8
+ const juice = require('juice');
9
+ const matter = require('gray-matter');
10
+ const katex = require('katex');
11
+
12
+ // ─────────────────────────────────────────────
13
+ // 模块级缓存(避免重复磁盘读取)
14
+ // ─────────────────────────────────────────────
15
+
16
+ const HL_CSS = fs.readFileSync(require.resolve('highlight.js/styles/github.css'), 'utf8');
17
+ const HL_MIN_CSS = fs.readFileSync(require.resolve('highlight.js/styles/github.min.css'), 'utf8');
18
+ const KATEX_CSS_RAW = fs.readFileSync(require.resolve('katex/dist/katex.min.css'), 'utf8');
19
+ const KATEX_CSS_DIR = path.dirname(require.resolve('katex/dist/katex.min.css'));
20
+
21
+ // 预计算 KaTeX CSS(去 @font-face)供 buildFullHtml 使用
22
+ const KATEX_CSS_NO_FONT = KATEX_CSS_RAW.replace(/@font-face\s*\{[\s\S]*?\}/g, '');
23
+
24
+ // KaTeX CSS + base64 字体缓存(延迟计算)
25
+ let _katexCssBase64 = null;
26
+ function getKatexCssBase64() {
27
+ if (_katexCssBase64) return _katexCssBase64;
28
+ _katexCssBase64 = KATEX_CSS_RAW.replace(/url\(["']?([^)"'\s]+)["']?\)/g, (match, url) => {
29
+ if (url.startsWith('data:')) return match;
30
+ const fontPath = path.resolve(KATEX_CSS_DIR, url);
31
+ try {
32
+ if (fs.existsSync(fontPath)) {
33
+ const ext = path.extname(fontPath).toLowerCase();
34
+ const mimeMap = { '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.otf': 'font/otf', '.eot': 'application/vnd.ms-fontobject' };
35
+ const mime = mimeMap[ext] || 'font/woff2';
36
+ return `url(data:${mime};base64,${fs.readFileSync(fontPath).toString('base64')})`;
37
+ }
38
+ } catch (_) {}
39
+ return match;
40
+ });
41
+ return _katexCssBase64;
42
+ }
43
+
44
+ // ─────────────────────────────────────────────
45
+ // juice + NBSP/BR 共用辅助函数
46
+ // ─────────────────────────────────────────────
47
+
48
+ const NBSP_PLACEHOLDER = '\u200B__NBSP__\u200B';
49
+ const BR_PLACEHOLDER = '\u200B__BR__\u200B';
50
+ const NBSP_RE = new RegExp(NBSP_PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
51
+ const BR_RE = new RegExp(BR_PLACEHOLDER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
52
+
53
+ // juice 内部用 cheerio 重新序列化,会把输入里的 < & 等实体再次编码成
54
+ // < &(无论 decodeEntities 真假)。为彻底避免,先把所有 & 换成
55
+ // 哨兵字符串,juice 之后再还原 —— 这样 juice 完全看不到任何实体。
56
+ const AMP_SENTINEL = '​__AMP__​';
57
+ const AMP_SENTINEL_RE = new RegExp(AMP_SENTINEL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
58
+
59
+ function juiceWithNbsp(html, juiceOpts) {
60
+ const processed = html
61
+ .replace(/ /g, NBSP_PLACEHOLDER)
62
+ .replace(/<br\s*\/?>/gi, BR_PLACEHOLDER)
63
+ .replace(/&/g, AMP_SENTINEL);
64
+ const result = juice(processed, juiceOpts || {
65
+ removeStyleTags: false,
66
+ applyStyleTags: true,
67
+ preserveImportant: true,
68
+ xmlMode: false,
69
+ decodeEntities: false,
70
+ });
71
+ return result
72
+ .replace(AMP_SENTINEL_RE, '&')
73
+ .replace(NBSP_RE, '&nbsp;')
74
+ .replace(BR_RE, '<br>');
75
+ }
76
+
77
+ // ─────────────────────────────────────────────
78
+ // 公式转 SVG data URI(微信/小红书共用)
79
+ // ─────────────────────────────────────────────
80
+
81
+ function convertMathToSvgDataUri($) {
82
+ $('[data-math]').each((_, elem) => {
83
+ const $el = $(elem);
84
+ const latex = $el.attr('data-math');
85
+ const isDisplay = $el.attr('data-display') === 'true';
86
+
87
+ try {
88
+ const svgStr = mathToSvg(latex, isDisplay);
89
+ // 用 mdnice 格式内联 SVG:微信编辑器识别此结构并原样保留
90
+ // 不依赖外部请求,不会被服务端清洗
91
+ if (isDisplay) {
92
+ $el.replaceWith(
93
+ `<section data-tools="mdnice编辑器" data-id="88" style="text-align:center;margin:1.2em 0;">` +
94
+ `<span class="block-equation" data-formula="${escapeHtml(latex)}" style="display:block;text-align:center;">` +
95
+ svgStr +
96
+ `</span></section>`
97
+ );
98
+ } else {
99
+ // 提取 SVG 的 vertical-align 保持行内对齐
100
+ const vaMatch = svgStr.match(/style="[^"]*vertical-align:\s*(-?[\d.]+\w*)/);
101
+ const va = vaMatch ? vaMatch[1] : '-0.1em';
102
+ $el.replaceWith(
103
+ `<span class="inline-equation" data-formula="${escapeHtml(latex)}" style="display:inline-block;vertical-align:${va};">` +
104
+ svgStr +
105
+ `</span>`
106
+ );
107
+ }
108
+ } catch (_) {
109
+ // 降级:用知乎公式图片服务
110
+ const imgUrl = `https://www.zhihu.com/equation?tex=${encodeURIComponent(latex)}`;
111
+ if (isDisplay) {
112
+ $el.replaceWith(
113
+ `<div style="text-align:center;margin:1.2em 0;"><img src="${imgUrl}" alt="${escapeHtml(latex)}"></div>`
114
+ );
115
+ } else {
116
+ $el.replaceWith(`<img src="${imgUrl}" style="display:inline-block;vertical-align:-0.1em;" alt="${escapeHtml(latex)}">`);
117
+ }
118
+ }
119
+ });
120
+ }
121
+
122
+ // ─────────────────────────────────────────────
123
+ // 工具函数
124
+ // ─────────────────────────────────────────────
125
+
126
+ function escapeHtml(text) {
127
+ return String(text)
128
+ .replace(/&/g, '&amp;')
129
+ .replace(/</g, '&lt;')
130
+ .replace(/>/g, '&gt;')
131
+ .replace(/"/g, '&quot;');
132
+ }
133
+
134
+ // ─────────────────────────────────────────────
135
+ // marked 扩展:Block 公式 $$...$$
136
+ // ─────────────────────────────────────────────
137
+
138
+ const blockMathExt = {
139
+ name: 'blockMath',
140
+ level: 'block',
141
+ start(src) {
142
+ const idx = src.indexOf('$$');
143
+ return idx >= 0 ? idx : undefined;
144
+ },
145
+ tokenizer(src) {
146
+ // 匹配 $$...$$ (greedy 最短)
147
+ const match = src.match(/^\$\$([\s\S]+?)\$\$/);
148
+ if (match) {
149
+ return {
150
+ type: 'blockMath',
151
+ raw: match[0],
152
+ math: match[1].trim(),
153
+ };
154
+ }
155
+ },
156
+ renderer(token) {
157
+ try {
158
+ const html = katex.renderToString(token.math, {
159
+ displayMode: true,
160
+ throwOnError: false,
161
+ output: 'html',
162
+ strict: false,
163
+ });
164
+ return (
165
+ `<div class="math-block" data-math="${escapeHtml(token.math)}" data-display="true" style="text-align:center;overflow-x:auto;` +
166
+ `margin:1.2em 0;padding:0.5em 0;">${html}</div>\n`
167
+ );
168
+ } catch (e) {
169
+ return `<pre><code class="math">${escapeHtml(token.math)}</code></pre>\n`;
170
+ }
171
+ },
172
+ };
173
+
174
+ // ─────────────────────────────────────────────
175
+ // marked 扩展:Inline 公式 $...$
176
+ // ─────────────────────────────────────────────
177
+
178
+ const inlineMathExt = {
179
+ name: 'inlineMath',
180
+ level: 'inline',
181
+ start(src) {
182
+ // 找到下一个单 $ 的位置(跳过 $$)
183
+ let i = src.indexOf('$');
184
+ while (i !== -1 && src[i + 1] === '$') {
185
+ i = src.indexOf('$', i + 2);
186
+ }
187
+ return i >= 0 ? i : undefined;
188
+ },
189
+ tokenizer(src) {
190
+ // 必须以单 $ 开头(不是 $$)
191
+ if (src.startsWith('$$')) return undefined;
192
+ const match = src.match(/^\$([^\$\n]+?)\$/);
193
+ if (match) {
194
+ return {
195
+ type: 'inlineMath',
196
+ raw: match[0],
197
+ math: match[1],
198
+ };
199
+ }
200
+ },
201
+ renderer(token) {
202
+ try {
203
+ const html = katex.renderToString(token.math, {
204
+ displayMode: false,
205
+ throwOnError: false,
206
+ output: 'html',
207
+ strict: false,
208
+ });
209
+ return `<span class="math-inline" data-math="${escapeHtml(token.math)}">${html}</span>`;
210
+ } catch (e) {
211
+ return `<code class="math">${escapeHtml(token.math)}</code>`;
212
+ }
213
+ },
214
+ };
215
+
216
+ // ─────────────────────────────────────────────
217
+ // 创建 marked 实例(不污染全局)
218
+ // ─────────────────────────────────────────────
219
+
220
+ const markedInstance = new Marked();
221
+
222
+ markedInstance.use({
223
+ gfm: true,
224
+ breaks: false,
225
+ extensions: [blockMathExt, inlineMathExt],
226
+ renderer: {
227
+ // 任务列表(GFM task list):移除 disabled,使复选框可交互
228
+ listitem(text, task, checked) {
229
+ if (task) {
230
+ // marked 会在 text 里自动插入 <input disabled ...>,先剥离它
231
+ const cleanText = text.replace(/<input\b[^>]*>/i, '').trim();
232
+ return `<li class="task-list-item"><input type="checkbox" class="task-checkbox"${checked ? ' checked' : ''}> ${cleanText}</li>\n`;
233
+ }
234
+ return `<li>${text}</li>\n`;
235
+ },
236
+ // 图片渲染:支持 caption(alt 文本作为说明文字)
237
+ image(href, title, text) {
238
+ const alt = text || '';
239
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
240
+ const imgStyle =
241
+ 'max-width:100%;display:block;margin:0 auto;';
242
+ const img = `<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}"${titleAttr} style="${imgStyle}">`;
243
+
244
+ const captionStyle =
245
+ 'display:block;text-align:center;color:#999;font-size:14px;' +
246
+ 'margin-top:8px;line-height:1.5;font-style:normal;';
247
+ const figStyle = 'margin:1.5em auto;text-align:center;';
248
+
249
+ if (alt) {
250
+ return (
251
+ `<figure style="${figStyle}">` +
252
+ img +
253
+ `<figcaption style="${captionStyle}">${alt}</figcaption>` +
254
+ `</figure>\n`
255
+ );
256
+ }
257
+ return `<figure style="${figStyle}">${img}</figure>\n`;
258
+ },
259
+ },
260
+ });
261
+
262
+ // ─────────────────────────────────────────────
263
+ // 图片转 Base64
264
+ // ─────────────────────────────────────────────
265
+
266
+ function convertImagesToBase64($, baseDir) {
267
+ $('img').each((_, elem) => {
268
+ const img = $(elem);
269
+ const src = img.attr('src');
270
+ if (!src || src.startsWith('data:') || /^https?:\/\//.test(src) || src.startsWith('//')) {
271
+ return;
272
+ }
273
+ const imagePath = path.isAbsolute(src)
274
+ ? path.join(baseDir, src)
275
+ : path.resolve(baseDir, src);
276
+ try {
277
+ if (!fs.existsSync(imagePath)) return;
278
+ const buf = fs.readFileSync(imagePath);
279
+ const ext = path.extname(imagePath).toLowerCase();
280
+ const mimeMap = {
281
+ '.jpg': 'image/jpeg',
282
+ '.jpeg': 'image/jpeg',
283
+ '.png': 'image/png',
284
+ '.gif': 'image/gif',
285
+ '.webp': 'image/webp',
286
+ '.svg': 'image/svg+xml',
287
+ };
288
+ const mime = mimeMap[ext] || 'image/jpeg';
289
+ img.attr('src', `data:${mime};base64,${buf.toString('base64')}`);
290
+ } catch (_) {
291
+ // 忽略,继续处理
292
+ }
293
+ });
294
+ }
295
+
296
+ // ─────────────────────────────────────────────
297
+ // 代码块高亮(macOS 风格)
298
+ // ─────────────────────────────────────────────
299
+
300
+ /**
301
+ * 对 HTML 字符串中的代码块做高亮处理,返回新的 HTML 字符串。
302
+ * 在 juice 之前调用:只注入 CSS 到 <head>,不修改 <pre> DOM,
303
+ * 让 juice 把高亮颜色内联到 span 上。
304
+ *
305
+ * 对于微信场景(forWechat=true):juice 之后再调用 applyCodeBlocksForWechat,
306
+ * 用正则直接替换 HTML 字符串,完全绕开 cheerio 的二次 escape 问题。
307
+ */
308
+ function injectCodeHighlightCss($) {
309
+ if ($('head').length === 0) $('html').prepend('<head></head>');
310
+ $('head').append(`<style>${HL_CSS}</style>`);
311
+ const macStyle = `
312
+ pre.mac-code { font-size:90%;overflow-x:auto;border-radius:8px;padding:0;line-height:1.5;margin:10px 8px;background-color:#f6f8fa;border:1px solid #eaedf0; }
313
+ pre.mac-code code.hljs { display:block;padding:0.5em 1em 1em;overflow-x:auto;text-indent:0;color:inherit;background:none;white-space:pre-wrap;word-break:break-all;margin:0; }
314
+ `;
315
+ $('head').append(`<style>${macStyle}</style>`);
316
+ }
317
+
318
+ const svgDotsHtml =
319
+ `<svg width="52" height="12" viewBox="0 0 52 12" fill="none" xmlns="http://www.w3.org/2000/svg">` +
320
+ `<circle cx="6" cy="6" r="6" fill="#FF5F56"/>` +
321
+ `<circle cx="26" cy="6" r="6" fill="#FFBD2E"/>` +
322
+ `<circle cx="46" cy="6" r="6" fill="#27C93F"/></svg>`;
323
+
324
+ // highlight.js github 主题的 class → 内联 color 映射。
325
+ // 微信复制场景下 juice 已经结束,applyCodeBlocksForWechat 新生成的 hljs span
326
+ // 拿不到 CSS 着色,必须在这里手动把颜色内联到 style,否则代码没有高亮。
327
+ const HLJS_COLOR_MAP = {
328
+ 'hljs-doctag': '#d73a49', 'hljs-keyword': '#d73a49', 'hljs-meta': '#d73a49',
329
+ 'hljs-template-tag': '#d73a49', 'hljs-template-variable': '#d73a49',
330
+ 'hljs-type': '#d73a49', 'hljs-variable.language_': '#d73a49',
331
+ 'hljs-title': '#6f42c1', 'hljs-title.class_': '#6f42c1', 'hljs-title.function_': '#6f42c1',
332
+ 'hljs-attr': '#005cc5', 'hljs-attribute': '#005cc5', 'hljs-literal': '#005cc5',
333
+ 'hljs-number': '#005cc5', 'hljs-operator': '#005cc5', 'hljs-variable': '#005cc5',
334
+ 'hljs-selector-attr': '#005cc5', 'hljs-selector-class': '#005cc5', 'hljs-selector-id': '#005cc5',
335
+ 'hljs-regexp': '#032f62', 'hljs-string': '#032f62',
336
+ 'hljs-built_in': '#e36209', 'hljs-symbol': '#e36209',
337
+ 'hljs-comment': '#6a737d', 'hljs-code': '#6a737d', 'hljs-formula': '#6a737d',
338
+ 'hljs-name': '#22863a', 'hljs-quote': '#22863a',
339
+ 'hljs-selector-tag': '#22863a', 'hljs-selector-pseudo': '#22863a',
340
+ 'hljs-subst': '#24292e',
341
+ 'hljs-section': '#005cc5', 'hljs-bullet': '#735c0f',
342
+ 'hljs-addition': '#22863a', 'hljs-deletion': '#b31d28',
343
+ };
344
+
345
+ /**
346
+ * 把 hljs 高亮结果里的 <span class="hljs-xxx"> 转成带内联 color 的 span。
347
+ * 处理复合类名(如 "hljs-title function_"):取首个能命中的颜色。
348
+ */
349
+ function inlineHljsColors(highlighted) {
350
+ return highlighted.replace(
351
+ /<span class="([^"]+)">/g,
352
+ (m, classes) => {
353
+ const classList = classes.trim().split(/\s+/);
354
+ // 先试完整复合 key(hljs-title.function_),再退化到单类
355
+ let color = HLJS_COLOR_MAP[classList.join('.')];
356
+ if (!color) {
357
+ for (const c of classList) {
358
+ if (HLJS_COLOR_MAP[c]) { color = HLJS_COLOR_MAP[c]; break; }
359
+ }
360
+ }
361
+ return color ? `<span style="color:${color};">` : `<span>`;
362
+ }
363
+ );
364
+ }
365
+
366
+ /**
367
+ * juice 之后调用(微信专用)。
368
+ * 直接在 HTML 字符串上做正则替换,完全绕开 cheerio DOM 操作,
369
+ * 避免任何二次 escape 问题。
370
+ */
371
+ function applyCodeBlocksForWechat(html) {
372
+ // 匹配 <pre>...</pre>,内部可能有 <span class="mac-dots"> 等结构
373
+ // enhanceCodeBlocks 已将代码块转为:
374
+ // <pre class="mac-code" ...>
375
+ // <span class="mac-dots" ...>...</span>
376
+ // <code class="hljs lang" ...>highlighted content</code>
377
+ // </pre>
378
+ // 所以不能用 \s*<code 来匹配,需要跳过中间的 span
379
+ return html.replace(
380
+ /<pre[^>]*>([\s\S]*?)<\/pre>/g,
381
+ (fullMatch, preContent) => {
382
+ const codeMatch = preContent.match(/<code([^>]*)>([\s\S]*?)<\/code>/);
383
+ if (!codeMatch) return fullMatch;
384
+ const [, codeAttrs, codeContent] = codeMatch;
385
+
386
+ // 提取语言
387
+ const langMatch = codeAttrs.match(/class="[^"]*(?:hljs\s+|language-)([\w-]+)/);
388
+ const language = langMatch ? langMatch[1] : 'plaintext';
389
+
390
+ // 获取纯文本(反转 hljs 的 HTML 转义,再重新高亮)
391
+ // 注意:&amp; 必须最先处理,否则 &amp;lt; 会在 &lt; → < 之后变成 &lt; 而非 <
392
+ const rawCode = codeContent
393
+ .replace(/<[^>]+>/g, '') // 去掉所有 HTML 标签(hljs span)
394
+ .replace(/&amp;/g, '&')
395
+ .replace(/&lt;/g, '<')
396
+ .replace(/&gt;/g, '>')
397
+ .replace(/&quot;/g, '"')
398
+ .replace(/&#39;/g, "'")
399
+ .replace(/&nbsp;/g, ' ')
400
+ .replace(/<br\s*\/?>/gi, '\n');
401
+
402
+ let highlighted;
403
+ try {
404
+ highlighted = hljs.highlight(rawCode, { language }).value;
405
+ } catch (_) {
406
+ highlighted = hljs.highlightAuto(rawCode).value;
407
+ }
408
+
409
+ // juice 已结束,hljs class 拿不到 CSS 着色,手动把颜色内联到 span
410
+ highlighted = inlineHljsColors(highlighted);
411
+
412
+ // 把换行转 <br>,空格转 &nbsp;,只操作文本节点(tag 之间)
413
+ highlighted = highlighted.replace(/(<[^>]*>)|([^<]+)/g, (m, tag, txt) => {
414
+ if (tag) return tag;
415
+ return txt.replace(/\r\n|\r|\n/g, '<br>').replace(/ /g, '&nbsp;');
416
+ });
417
+
418
+ return (
419
+ `<pre class="mac-code" style="font-size:90%;overflow-x:auto;border-radius:8px;padding:0;line-height:1.5;margin:10px 8px;background-color:#f6f8fa;border:1px solid #eaedf0;">` +
420
+ `<span class="mac-dots" style="display:block;margin:12px 16px 0;">${svgDotsHtml}</span>` +
421
+ `<code class="hljs ${language}" style="display:block;padding:0.5em 1em 1em;overflow-x:auto;text-indent:0;color:inherit;background:none;white-space:pre-wrap;word-break:break-all;margin:0;">${highlighted}</code>` +
422
+ `</pre>`
423
+ );
424
+ }
425
+ );
426
+ }
427
+
428
+ // 预览/非微信场景保留原有 enhanceCodeBlocks(DOM 操作,不会二次 escape)
429
+ function enhanceCodeBlocks($) {
430
+ injectCodeHighlightCss($);
431
+
432
+ const svgDots =
433
+ `<svg width="52" height="12" viewBox="0 0 52 12" fill="none" xmlns="http://www.w3.org/2000/svg">` +
434
+ `<circle cx="6" cy="6" r="6" fill="#FF5F56"/>` +
435
+ `<circle cx="26" cy="6" r="6" fill="#FFBD2E"/>` +
436
+ `<circle cx="46" cy="6" r="6" fill="#27C93F"/></svg>`;
437
+
438
+ $('pre').each((_, elem) => {
439
+ const pre = $(elem);
440
+ const code = pre.find('code');
441
+ if (code.length === 0 || pre.hasClass('mac-code')) return;
442
+
443
+ const rawCode = code.text();
444
+ let language = 'plaintext';
445
+ const classes = (pre.attr('class') || '') + ' ' + (code.attr('class') || '');
446
+ const langMatch = classes.match(/language-([\w-]+)/);
447
+ if (langMatch && hljs.getLanguage(langMatch[1])) language = langMatch[1];
448
+
449
+ let highlighted;
450
+ try { highlighted = hljs.highlight(rawCode, { language }).value; }
451
+ catch (_) { highlighted = hljs.highlightAuto(rawCode).value; }
452
+
453
+ pre.replaceWith(
454
+ `<pre class="mac-code" style="font-size:90%;overflow-x:auto;border-radius:8px;padding:0;line-height:1.5;margin:10px 8px;background-color:#f6f8fa;border:1px solid #eaedf0;">` +
455
+ `<span class="mac-dots" style="display:block;margin:12px 16px 0;">${svgDots}</span>` +
456
+ `<code class="hljs ${language}" style="display:block;padding:0.5em 1em 1em;overflow-x:auto;text-indent:0;color:inherit;background:none;white-space:pre-wrap;word-break:break-all;margin:0;">${highlighted}</code></pre>`
457
+ );
458
+ });
459
+ }
460
+
461
+ // ─────────────────────────────────────────────
462
+ // 核心渲染函数:Markdown → 文章 body HTML
463
+ // ─────────────────────────────────────────────
464
+
465
+ /**
466
+ * 解析 Markdown 文件,返回文章 body HTML + 元数据。
467
+ * @param {string} mdFilePath 绝对路径
468
+ * @returns {{ bodyHtml: string, title: string, rawMarkdown: string }}
469
+ */
470
+ function renderMarkdown(mdFilePath) {
471
+ const fullPath = path.isAbsolute(mdFilePath)
472
+ ? mdFilePath
473
+ : path.join(process.cwd(), mdFilePath);
474
+
475
+ if (!fs.existsSync(fullPath)) {
476
+ throw new Error(`Markdown 文件不存在: ${fullPath}`);
477
+ }
478
+
479
+ const raw = fs.readFileSync(fullPath, 'utf8');
480
+ const { content: mdContent, data: frontmatter } = matter(raw);
481
+
482
+ // Markdown → HTML
483
+ const parsedHtml = markedInstance.parse(mdContent);
484
+
485
+ // cheerio 处理
486
+ const $ = cheerio.load(parsedHtml, { decodeEntities: false });
487
+ const baseDir = path.dirname(fullPath);
488
+
489
+ convertImagesToBase64($, baseDir);
490
+ enhanceCodeBlocks($);
491
+
492
+ // 提取 body HTML(去掉 cheerio 自动包裹的 <html><body> 等标签)
493
+ const bodyHtml = $('body').html() || $.html();
494
+
495
+ // 从 frontmatter 或第一个 h1 提取标题
496
+ let title = frontmatter.title || '';
497
+ if (!title) {
498
+ const firstH1 = $('h1').first().text();
499
+ title = firstH1 || path.basename(fullPath, '.md');
500
+ }
501
+
502
+ return { bodyHtml, title, rawMarkdown: raw };
503
+ }
504
+
505
+ // ─────────────────────────────────────────────
506
+ // 构建完整 HTML(供导出/保存用)
507
+ // ─────────────────────────────────────────────
508
+
509
+ /**
510
+ * 将 body HTML 包裹进模板并内联 CSS(for WeChat 粘贴)。
511
+ * @param {string} bodyHtml
512
+ * @param {string} templatePath
513
+ * @returns {string} 完整 HTML
514
+ */
515
+ function buildFullHtml(bodyHtml, templatePath) {
516
+ let template = fs.readFileSync(templatePath, 'utf8');
517
+ template = template.replace('{{body}}', bodyHtml);
518
+
519
+ const $ = cheerio.load(template, { decodeEntities: false });
520
+
521
+ // 加入 KaTeX CSS(去掉 @font-face,避免字体路径问题)
522
+ $('head').append(`<style>${KATEX_CSS_NO_FONT}</style>`);
523
+
524
+ return juiceWithNbsp($.html());
525
+ }
526
+
527
+ // ─────────────────────────────────────────────
528
+ // 高级入口:一步完成转换并写文件
529
+ // ─────────────────────────────────────────────
530
+
531
+ /**
532
+ * @param {string} mdFilePath
533
+ * @param {string} templatePath
534
+ * @param {string} outputPath
535
+ * @returns {string} 输出文件路径
536
+ */
537
+ function convertMarkdownToWeChat(mdFilePath, templatePath, outputPath) {
538
+ const { bodyHtml } = renderMarkdown(mdFilePath);
539
+ const finalHtml = buildFullHtml(bodyHtml, templatePath);
540
+
541
+ const outputDir = path.dirname(outputPath);
542
+ if (!fs.existsSync(outputDir)) {
543
+ fs.mkdirSync(outputDir, { recursive: true });
544
+ }
545
+ fs.writeFileSync(outputPath, finalHtml, 'utf8');
546
+ return outputPath;
547
+ }
548
+
549
+ // ─────────────────────────────────────────────
550
+ // 构建小红书截图用 HTML(供 Playwright 渲染)
551
+ // ─────────────────────────────────────────────
552
+
553
+ /**
554
+ * 生成一个完全独立的 HTML 文件,供 Playwright 截图。
555
+ * - 内嵌 KaTeX CSS(带 base64 字体)
556
+ * - 内嵌 highlight.js CSS
557
+ * - 本地图片转 base64;远程图片保持原 URL(Playwright 可直接加载)
558
+ * - 应用主题 CSS
559
+ * @param {string} bodyHtml - renderMarkdown 返回的 bodyHtml
560
+ * @param {string} baseDir - MD 文件所在目录(用于解析本地图片路径)
561
+ * @param {object} theme - THEMES 中的主题对象
562
+ * @returns {string} 完整 HTML 字符串
563
+ */
564
+ function buildXhsRenderHtml(bodyHtml, baseDir, theme) {
565
+ // 1. KaTeX CSS(带 base64 字体,模块级缓存)
566
+ const katexCss = getKatexCssBase64();
567
+
568
+ // 2. highlight.js CSS(模块级缓存)
569
+ const hlCss = HL_MIN_CSS;
570
+
571
+ // 3. 本地图片转 base64
572
+ const $ = cheerio.load(bodyHtml, { decodeEntities: false });
573
+ $('img').each((_, el) => {
574
+ const src = $(el).attr('src') || '';
575
+ if (!src || src.startsWith('data:') || /^https?:\/\//.test(src)) return;
576
+ const imgPath = path.isAbsolute(src) ? src : path.join(baseDir, src);
577
+ if (!fs.existsSync(imgPath)) return;
578
+ const ext = path.extname(imgPath).slice(1).toLowerCase();
579
+ const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml' };
580
+ const mime = mimeMap[ext] || 'image/png';
581
+ const b64 = fs.readFileSync(imgPath).toString('base64');
582
+ $(el).attr('src', `data:${mime};base64,${b64}`);
583
+ });
584
+ const processedBody = $.html();
585
+
586
+ // 4. 主题 CSS(wrapperBg 作为背景色)
587
+ const themeCss = theme ? theme.css || '' : '';
588
+ const bgColor = theme ? (theme.wrapperBg || '#ffffff') : '#ffffff';
589
+
590
+ return `<!DOCTYPE html>
591
+ <html lang="zh-CN">
592
+ <head>
593
+ <meta charset="UTF-8">
594
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
595
+ <style>
596
+ * { box-sizing: border-box; margin: 0; padding: 0; }
597
+ html, body {
598
+ background: ${bgColor};
599
+ margin: 0;
600
+ padding: 0;
601
+ }
602
+ .article-wrapper {
603
+ width: 100%;
604
+ padding: 32px 28px;
605
+ background: ${bgColor};
606
+ font-family: system-ui, -apple-system, BlinkMacSystemFont,
607
+ 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB',
608
+ 'Microsoft YaHei UI', 'Microsoft YaHei', Arial, sans-serif;
609
+ font-size: 16px;
610
+ line-height: 1.75;
611
+ color: #3f3f3f;
612
+ }
613
+ /* ── 文章基础样式 ── */
614
+ .article-wrapper p { margin: 1.3em 0; }
615
+ .article-wrapper img { max-width: 100%; display: block; margin: 0 auto; }
616
+ .article-wrapper strong { font-weight: 600; color: rgb(0, 122, 170); }
617
+ .article-wrapper a { color: orange; }
618
+ .article-wrapper h1 { font-size: 140%; color: #de7456; text-align: center; font-weight: normal; margin: 0.8em 0; }
619
+ .article-wrapper h2 { font-size: 120%; font-weight: bold; color: #de7456; text-align: center; line-height: 2; border-bottom: 1px solid #de7456; margin: 1em 0; padding-bottom: 4px; }
620
+ .article-wrapper h3 { font-size: 110%; color: rgb(0, 122, 170); border-left: 3px solid rgb(0, 122, 170); padding-left: 10px; margin: 1em 0; }
621
+ .article-wrapper h4, .article-wrapper h5, .article-wrapper h6 { font-size: 100%; color: #555; margin: 0.8em 0; }
622
+ .article-wrapper blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; background: #fafafa; }
623
+ .article-wrapper table { border-collapse: collapse; width: 100%; margin: 1em 0; }
624
+ .article-wrapper table td, .article-wrapper table th { border: 1px solid #999; padding: 8px; }
625
+ .article-wrapper table th { background: #f2f2f2; font-weight: bold; text-align: center; }
626
+ .article-wrapper ul, .article-wrapper ol { padding-left: 1.5em; }
627
+ .article-wrapper li { margin: 0.3em 0; }
628
+ .article-wrapper figure { margin: 1.5em auto; text-align: center; }
629
+ .article-wrapper figcaption { text-align: center; color: #999; font-size: 14px; margin-top: 8px; }
630
+ .article-wrapper code:not([class]) { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 14px; }
631
+ .article-wrapper pre.mac-code { border-radius: 8px; background: #f6f8fa; border: 1px solid #eaedf0; overflow-x: auto; margin: 10px 0; }
632
+ .article-wrapper pre.mac-code code.hljs { padding: 10px 16px; display: block; }
633
+ .article-wrapper .math-block { text-align: center; overflow-x: auto; margin: 1.2em 0; }
634
+ .article-wrapper .math-inline { display: inline; }
635
+ ${themeCss}
636
+ </style>
637
+ <style>${katexCss}</style>
638
+ <style>${hlCss}</style>
639
+ </head>
640
+ <body>
641
+ <div class="article-wrapper">${processedBody}</div>
642
+ </body>
643
+ </html>`;
644
+ }
645
+
646
+ // ─────────────────────────────────────────────
647
+ // MathJax SVG 渲染(微信复制专用,纯路径无字体依赖)
648
+ // ─────────────────────────────────────────────
649
+
650
+ let _mjxState = null;
651
+
652
+ function getMjxState() {
653
+ if (_mjxState) return _mjxState;
654
+ const { mathjax } = require('mathjax-full/js/mathjax.js');
655
+ const { TeX } = require('mathjax-full/js/input/tex.js');
656
+ const { SVG } = require('mathjax-full/js/output/svg.js');
657
+ const { liteAdaptor } = require('mathjax-full/js/adaptors/liteAdaptor.js');
658
+ const { RegisterHTMLHandler } = require('mathjax-full/js/handlers/html.js');
659
+ const { AllPackages } = require('mathjax-full/js/input/tex/AllPackages.js');
660
+
661
+ const adaptor = liteAdaptor();
662
+ RegisterHTMLHandler(adaptor);
663
+
664
+ _mjxState = {
665
+ adaptor,
666
+ doc: mathjax.document('', {
667
+ InputJax: new TeX({ packages: AllPackages }),
668
+ OutputJax: new SVG({ fontCache: 'none' }),
669
+ }),
670
+ };
671
+ return _mjxState;
672
+ }
673
+
674
+ function mathToSvg(latex, displayMode) {
675
+ const { doc, adaptor } = getMjxState();
676
+ const node = doc.convert(latex, { display: displayMode, em: 16, ex: 8, containerWidth: 600 });
677
+ const outerHtml = adaptor.outerHTML(node);
678
+ // 提取 <svg>...</svg>(去掉 mjx-container 包装),保证 data URI 是合法 SVG 文档
679
+ const svgMatch = outerHtml.match(/<svg[\s\S]*<\/svg>/);
680
+ if (!svgMatch) throw new Error('MathJax 未能生成 SVG');
681
+ return svgMatch[0];
682
+ }
683
+
684
+ // ─────────────────────────────────────────────
685
+ // 微信复制专用 HTML(公式转 SVG,其余内联 CSS)
686
+ // ─────────────────────────────────────────────
687
+
688
+ /**
689
+ * 将 bodyHtml 处理成微信编辑器可直接粘贴的 HTML 片段。
690
+ * 关键:把 KaTeX CSS 通过 juice 内联到每个元素的 style 属性,
691
+ * 这样复制到微信时公式布局不依赖外部样式表。
692
+ * 字体文件转为 base64 data URL,避免路径失效。
693
+ * @param {string} bodyHtml
694
+ * @param {string|null} templatePath
695
+ * @param {{ css?: string, wrapperBg?: string }|null} theme
696
+ * @returns {string} 适合粘贴的 HTML 片段
697
+ */
698
+ function buildWechatCopyHtml(bodyHtml, templatePath, theme) {
699
+ // 用一个带 id 的容器包裹,方便最后只取 body 内容
700
+ const $ = cheerio.load(
701
+ `<html><head></head><body><div id="wechat-body">${bodyHtml}</div></body></html>`,
702
+ { decodeEntities: false },
703
+ );
704
+
705
+ // 0. 公式内联 SVG(mdnice 格式,微信原样保留)
706
+ convertMathToSvgDataUri($);
707
+
708
+ // 1. juice 前只注入高亮 CSS(让 juice 把颜色内联到 span),不做 DOM 替换
709
+ // DOM 替换放到 juice 之后用字符串正则处理,彻底避免 cheerio 二次 escape
710
+ injectCodeHighlightCss($);
711
+
712
+ // 2. 注入模板里的 <style>
713
+ if (templatePath && fs.existsSync(templatePath)) {
714
+ const $tmpl = cheerio.load(fs.readFileSync(templatePath, 'utf8'), { decodeEntities: false });
715
+ $tmpl('style').each((_, el) => {
716
+ $('head').append($tmpl.html(el));
717
+ });
718
+ }
719
+
720
+ // 2b. 注入主题 CSS
721
+ if (theme && theme.css) {
722
+ $('head').append(`<style>${theme.css}</style>`);
723
+ }
724
+
725
+ // 3. juice 内联 CSS
726
+ let finalHtml = juiceWithNbsp($.html());
727
+
728
+ // 4. juice 之后用纯字符串正则替换代码块,完全绕开 cheerio 二次 escape
729
+ finalHtml = applyCodeBlocksForWechat(finalHtml);
730
+
731
+ // 5. 只返回文章内容片段(用正则提取,避免 cheerio 再次 escape)
732
+ const innerMatch = finalHtml.match(/<div id="wechat-body">([\s\S]*?)<\/div>\s*<\/body>/);
733
+ if (innerMatch) return innerMatch[1];
734
+ const bodyMatch = finalHtml.match(/<body[^>]*>([\s\S]*?)<\/body>/);
735
+ return bodyMatch ? bodyMatch[1] : finalHtml;
736
+ }
737
+
738
+ // ─────────────────────────────────────────────
739
+ // 知乎复制专用 HTML(保留 KaTeX HTML,内联 CSS)
740
+ // ─────────────────────────────────────────────
741
+
742
+ /**
743
+ * 将 bodyHtml 处理成知乎编辑器可直接粘贴的 HTML 片段。
744
+ * 公式用知乎自带的公式图片服务渲染(https://www.zhihu.com/equation?tex=...),
745
+ * 图片由知乎 CDN 托管,粘贴时不会被拒绝。
746
+ * @param {string} bodyHtml
747
+ * @param {string|null} templatePath
748
+ * @param {{ css?: string }|null} theme
749
+ * @returns {string}
750
+ */
751
+ function buildZhihuCopyHtml(bodyHtml, templatePath, theme) {
752
+ const $ = cheerio.load(
753
+ `<html><head></head><body><div id="zhihu-body">${bodyHtml}</div></body></html>`,
754
+ { decodeEntities: false },
755
+ );
756
+
757
+ // 0. 将公式替换为知乎公式图片(使用知乎自家的 eeimg="1" 标记,不依赖 CSS)
758
+ // inline: <img eeimg="1" alt="..." src="...">
759
+ // block : <img eeimg="1" alt="\\..." src="..."> (alt 以 \\ 开头表示 block)
760
+ $('[data-math]').each((_, elem) => {
761
+ const $el = $(elem);
762
+ const latex = $el.attr('data-math');
763
+ const isDisplay = $el.attr('data-display') === 'true';
764
+ if (isDisplay) {
765
+ const encodedTex = encodeURIComponent(latex);
766
+ const imgUrl = `https://www.zhihu.com/equation?tex=${encodedTex}`;
767
+ // alt 以 \\ 开头是知乎块公式约定
768
+ $el.replaceWith(
769
+ `<p><img eeimg="1" src="${imgUrl}" alt="\\\\${escapeHtml(latex)}"></p>`
770
+ );
771
+ } else {
772
+ const encodedTex = encodeURIComponent(latex);
773
+ const imgUrl = `https://www.zhihu.com/equation?tex=${encodedTex}`;
774
+ // 行内:包在 <span> 中(不是 <p>),不加任何样式,只靠 eeimg 标记
775
+ $el.replaceWith(
776
+ `<img eeimg="1" src="${imgUrl}" alt="${escapeHtml(latex)}">`
777
+ );
778
+ }
779
+ });
780
+
781
+ // 1. 注入模板样式
782
+ if (templatePath && fs.existsSync(templatePath)) {
783
+ const $tmpl = cheerio.load(fs.readFileSync(templatePath, 'utf8'), { decodeEntities: false });
784
+ $tmpl('style').each((_, el) => $('head').append($tmpl.html(el)));
785
+ }
786
+
787
+ // 2. 注入主题 CSS
788
+ if (theme && theme.css) {
789
+ $('head').append(`<style>${theme.css}</style>`);
790
+ }
791
+
792
+ // 3. juice 内联 CSS(SVG 内部不需要 KaTeX CSS,路径已内联到路径数据)
793
+ const final = juiceWithNbsp($.html());
794
+
795
+ const $f = cheerio.load(final, { decodeEntities: false });
796
+ return $f('#zhihu-body').html() || $f('body').html() || final;
797
+ }
798
+
799
+ // ─────────────────────────────────────────────
800
+ // 小红书长文复制 HTML
801
+ // ─────────────────────────────────────────────
802
+
803
+ /**
804
+ * 将 bodyHtml 处理成适合粘贴到小红书长文编辑器的 HTML 片段。
805
+ * - 使用简洁、移动端友好的内联样式
806
+ * - 图片保持 data URI(已由 renderMarkdown 转换好)
807
+ * - 公式用 SVG data URI 嵌入(同微信)
808
+ * @param {string} bodyHtml
809
+ * @param {{ css?: string, wrapperBg?: string }|null} theme
810
+ * @returns {string}
811
+ */
812
+ function buildXhsCopyHtml(bodyHtml, theme) {
813
+ const $ = cheerio.load(
814
+ `<html><head></head><body><div id="xhs-body">${bodyHtml}</div></body></html>`,
815
+ { decodeEntities: false },
816
+ );
817
+
818
+ // 0. 公式转 SVG data URI(同微信,避免依赖 CSS)
819
+ convertMathToSvgDataUri($);
820
+
821
+ // 1. 注入主题 CSS(或默认小红书友好样式)
822
+ const xhsDefaultCss = `
823
+ p, li, td, th {
824
+ color: #2c2c2c; line-height: 1.9em;
825
+ font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', system-ui, sans-serif;
826
+ font-size: 17px;
827
+ }
828
+ strong { font-weight: 700; color: #ff2442; }
829
+ h1 { font-size: 1.5em; font-weight: 800; text-align: center; margin: 1em 0; }
830
+ h2 { font-size: 1.2em; font-weight: 700; margin: 1.5em 0 0.6em; border-left: 4px solid #ff2442; padding-left: 12px; }
831
+ h3 { font-size: 1.05em; font-weight: 700; margin: 1.2em 0 0.5em; color: #ff2442; }
832
+ blockquote { border-left: 3px solid #ffb3c1; margin: 1em 0; padding: 0.5em 1em; color: #666; background: #fff8f9; }
833
+ blockquote p { margin: 0; }
834
+ code { background: #fff0f2; padding: 2px 6px; border-radius: 4px; font-size: 14px; color: #cc1f3c; }
835
+ table { border-collapse: collapse; width: 100%; margin: 1em 0; }
836
+ table td, table th { border: 1px solid #ffd6dc; padding: 8px 12px; }
837
+ table th { background: #ffe4e8; font-weight: 700; }
838
+ ul, ol { padding-left: 1.6em; }
839
+ img { max-width: 100%; border-radius: 8px; }
840
+ figure { margin: 1.5em auto; text-align: center; }
841
+ figcaption { display: block; text-align: center; color: #aaa; font-size: 13px; margin-top: 6px; }
842
+ p { margin: 0.9em 0; }
843
+ hr { border: none; border-top: 2px solid #ffd6dc; margin: 2em 0; }
844
+ `;
845
+
846
+ const cssToUse = (theme && theme.css) ? theme.css : xhsDefaultCss;
847
+ $('head').append(`<style>${cssToUse}</style>`);
848
+
849
+ // 2. juice 内联 CSS
850
+ const final = juiceWithNbsp($.html());
851
+
852
+ const $f = cheerio.load(final, { decodeEntities: false });
853
+ return $f('#xhs-body').html() || $f('body').html() || final;
854
+ }
855
+
856
+ module.exports = {
857
+ renderMarkdown,
858
+ buildFullHtml,
859
+ buildWechatCopyHtml,
860
+ buildZhihuCopyHtml,
861
+ buildXhsCopyHtml,
862
+ convertMarkdownToWeChat,
863
+ buildXhsRenderHtml,
864
+ };