@liustack/mdpress 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leon Liu (liustack)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # mdpress
2
+
3
+ A CLI toolkit for AI agents to convert Markdown into WeChat MP-ready HTML with inline styles, base64 images, and tag sanitization.
4
+
5
+ 中文说明请见:[README.zh-CN.md](README.zh-CN.md)
6
+
7
+ ## Features
8
+
9
+ - WeChat MP editor compatible HTML output
10
+ - All styles inlined (no external CSS or `<style>` tags)
11
+ - Local images compressed via sharp and embedded as base64 (≤ 2MB)
12
+ - Syntax highlighting with Xcode Light theme (inline colors)
13
+ - Mermaid diagram rendering to PNG (via Playwright, optional)
14
+ - External links converted to footnotes with References section
15
+ - Tag sanitization (whitelist-based, `div` → `section`, dangerous tags removed)
16
+ - GFM support (tables, strikethrough, task lists with ☑/☐)
17
+ - Apple-inspired minimalist default style
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install -g @liustack/mdpress
23
+ ```
24
+
25
+ Or run with `npx`:
26
+
27
+ ```bash
28
+ npx @liustack/mdpress [options]
29
+ ```
30
+
31
+ Or install as an **Agent Skill** — tell any AI coding tool that supports agent skills (Claude Code, Codex, OpenCode, Cursor, Antigravity, etc.):
32
+
33
+ ```
34
+ Install the skill from https://github.com/liustack/mdpress
35
+ ```
36
+
37
+ Or use the `skills` CLI directly:
38
+
39
+ ```bash
40
+ npx skills add https://github.com/liustack/mdpress --skill mdpress
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Convert Markdown to WeChat-ready HTML
47
+ mdpress -i article.md -o output.html
48
+ ```
49
+
50
+ Output is JSON:
51
+
52
+ ```json
53
+ {
54
+ "input": "/path/to/article.md",
55
+ "output": "/path/to/output.html",
56
+ "size": 12345
57
+ }
58
+ ```
59
+
60
+ ## What It Does
61
+
62
+ mdpress runs your Markdown through a unified (remark + rehype) pipeline that applies 6 transformations in order:
63
+
64
+ 1. **Sanitize tags** — whitelist-based tag filtering, `div` → `section`, checkbox → Unicode ☑/☐, remove `id` and event handlers
65
+ 2. **Mermaid diagrams** — mermaid code blocks rendered to PNG via Playwright with Apple-style theme (optional, requires `mermaid` + `playwright`)
66
+ 3. **Base64 images** — local images compressed with sharp (PNG/GIF/SVG/JPEG) and embedded as data URIs (≤ 2MB limit)
67
+ 4. **Code highlighting** — syntax highlighting via highlight.js with whitespace protection (`\n` → `<br>`, spaces → NBSP)
68
+ 5. **Footnote links** — external links replaced with text + `<sup>[N]</sup>`, References section appended; `mp.weixin.qq.com` links preserved
69
+ 6. **Inline styles** — Apple-inspired minimalist styles injected per tag, hljs classes converted to inline colors (Xcode Light theme), all `className` removed
70
+
71
+ The result is a self-contained HTML file that can be directly pasted into the WeChat Official Account editor.
72
+
73
+ ## Options
74
+
75
+ - `-i, --input <path>` — Input Markdown file path (required)
76
+ - `-o, --output <path>` — Output HTML file path (required)
77
+
78
+ ## AI Agent Skill
79
+
80
+ - [mdpress/SKILL.md](skills/mdpress/SKILL.md)
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,82 @@
1
+ # mdpress
2
+
3
+ 面向 AI Agent 的 Markdown 转换 CLI,可将 Markdown 文件输出为微信公众号编辑器兼容的 HTML,包含内联样式、base64 图片和标签清洗。
4
+
5
+ ## 特性
6
+
7
+ - 输出微信公众号编辑器兼容的 HTML
8
+ - 所有样式内联(无外部 CSS 或 `<style>` 标签)
9
+ - 本地图片经 sharp 压缩后嵌入为 base64(单张 ≤ 2MB)
10
+ - Xcode Light 主题语法高亮(内联色值)
11
+ - Mermaid 流程图渲染为 PNG(通过 Playwright,可选)
12
+ - 外部链接自动转为脚注,文末附 References
13
+ - 基于白名单的标签清洗(`div` → `section`,危险标签移除)
14
+ - 支持 GFM(表格、删除线、任务列表 ☑/☐)
15
+ - Apple 极简风格默认样式
16
+
17
+ ## 安装
18
+
19
+ ```bash
20
+ npm install -g @liustack/mdpress
21
+ ```
22
+
23
+ 或使用 `npx`:
24
+
25
+ ```bash
26
+ npx @liustack/mdpress [options]
27
+ ```
28
+
29
+ 也可以作为 **Agent Skill** 安装 — 在任何支持 Agent Skill 的 AI 编程工具(Claude Code、Codex、OpenCode、Cursor、Antigravity 等)中输入:
30
+
31
+ ```
32
+ 帮我安装这个 skill:https://github.com/liustack/mdpress
33
+ ```
34
+
35
+ 或使用 `skills` CLI 直接安装:
36
+
37
+ ```bash
38
+ npx skills add https://github.com/liustack/mdpress --skill mdpress
39
+ ```
40
+
41
+ ## 用法
42
+
43
+ ```bash
44
+ # 将 Markdown 转换为公众号可用的 HTML
45
+ mdpress -i article.md -o output.html
46
+ ```
47
+
48
+ 输出为 JSON 格式:
49
+
50
+ ```json
51
+ {
52
+ "input": "/path/to/article.md",
53
+ "output": "/path/to/output.html",
54
+ "size": 12345
55
+ }
56
+ ```
57
+
58
+ ## 处理流程
59
+
60
+ mdpress 使用 unified(remark + rehype)管线,依次执行 6 个转换:
61
+
62
+ 1. **标签清洗** — 基于白名单过滤标签,`div` → `section`,checkbox → Unicode ☑/☐,移除 `id` 和事件处理器
63
+ 2. **Mermaid 渲染** — mermaid 代码块通过 Playwright 渲染为 PNG(Apple 风格主题,可选,需安装 `mermaid` + `playwright`)
64
+ 3. **图片 base64** — 本地图片经 sharp 压缩(PNG/GIF/SVG/JPEG),嵌入为 data URI(≤ 2MB)
65
+ 4. **代码高亮** — 基于 highlight.js 的语法高亮,配合空白保护(`\n` → `<br>`,空格 → NBSP)
66
+ 5. **链接脚注** — 外部链接替换为文本 + `<sup>[N]</sup>`,文末追加 References 区域;保留 `mp.weixin.qq.com` 链接
67
+ 6. **样式内联** — Apple 极简风格按标签注入默认样式,hljs 类名转为内联色值(Xcode Light 主题),移除所有 `className`
68
+
69
+ 输出的 HTML 可直接粘贴到微信公众号编辑器中使用。
70
+
71
+ ## 参数
72
+
73
+ - `-i, --input <path>` — 输入 Markdown 文件路径(必填)
74
+ - `-o, --output <path>` — 输出 HTML 文件路径(必填)
75
+
76
+ ## AI Agent Skill
77
+
78
+ - [mdpress/SKILL.md](skills/mdpress/SKILL.md)
79
+
80
+ ## License
81
+
82
+ MIT
package/dist/main.js ADDED
@@ -0,0 +1,889 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { unified } from "unified";
6
+ import remarkParse from "remark-parse";
7
+ import remarkGfm from "remark-gfm";
8
+ import remarkRehype from "remark-rehype";
9
+ import rehypeRaw from "rehype-raw";
10
+ import rehypeStringify from "rehype-stringify";
11
+ import { visit, SKIP } from "unist-util-visit";
12
+ import sharp from "sharp";
13
+ import rehypeHighlight from "rehype-highlight";
14
+ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
15
+ // Block
16
+ "h1",
17
+ "h2",
18
+ "h3",
19
+ "h4",
20
+ "h5",
21
+ "h6",
22
+ "p",
23
+ "blockquote",
24
+ "pre",
25
+ "code",
26
+ "ul",
27
+ "ol",
28
+ "li",
29
+ "table",
30
+ "thead",
31
+ "tbody",
32
+ "tr",
33
+ "th",
34
+ "td",
35
+ "section",
36
+ "figure",
37
+ "figcaption",
38
+ "hr",
39
+ "br",
40
+ "img",
41
+ // Inline
42
+ "a",
43
+ "strong",
44
+ "em",
45
+ "b",
46
+ "i",
47
+ "del",
48
+ "s",
49
+ "span",
50
+ "sup",
51
+ "sub",
52
+ "mark",
53
+ // SVG
54
+ "svg",
55
+ "path",
56
+ "circle",
57
+ "rect",
58
+ "line",
59
+ "polyline",
60
+ "polygon",
61
+ "ellipse",
62
+ "g",
63
+ "defs",
64
+ "clipPath",
65
+ "use",
66
+ "text",
67
+ "tspan",
68
+ "linearGradient",
69
+ "radialGradient",
70
+ "stop",
71
+ "mask",
72
+ "pattern",
73
+ "symbol",
74
+ "title",
75
+ "desc",
76
+ "foreignObject"
77
+ ]);
78
+ const REMOVE_WITH_CHILDREN = /* @__PURE__ */ new Set([
79
+ "script",
80
+ "style",
81
+ "link",
82
+ "iframe",
83
+ "frame",
84
+ "frameset",
85
+ "form",
86
+ "textarea",
87
+ "select",
88
+ "button",
89
+ "audio",
90
+ "video",
91
+ "source",
92
+ "track",
93
+ "object",
94
+ "embed",
95
+ "canvas",
96
+ "noscript",
97
+ "template"
98
+ ]);
99
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
100
+ "p",
101
+ "h1",
102
+ "h2",
103
+ "h3",
104
+ "h4",
105
+ "h5",
106
+ "h6",
107
+ "blockquote",
108
+ "li",
109
+ "td",
110
+ "th",
111
+ "figcaption",
112
+ "section"
113
+ ]);
114
+ const INLINE_TAGS = /* @__PURE__ */ new Set([
115
+ "a",
116
+ "strong",
117
+ "em",
118
+ "b",
119
+ "i",
120
+ "del",
121
+ "s",
122
+ "span",
123
+ "sup",
124
+ "sub",
125
+ "mark",
126
+ "code",
127
+ "br",
128
+ "img"
129
+ ]);
130
+ const ALLOWED_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
131
+ const DISALLOWED_STYLE_PROPS = /* @__PURE__ */ new Set(["position", "font-family"]);
132
+ const DISALLOWED_STYLE_VALUE_PATTERNS = [
133
+ /url\s*\(/i,
134
+ /expression\s*\(/i,
135
+ /javascript:/i,
136
+ /vbscript:/i,
137
+ /data:/i
138
+ ];
139
+ function hasExplicitScheme(value) {
140
+ return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value);
141
+ }
142
+ function sanitizeUrl(value, attrName) {
143
+ const trimmed = value.trim();
144
+ if (!trimmed) return null;
145
+ if (trimmed.startsWith("#")) return trimmed;
146
+ if (trimmed.startsWith("./") || trimmed.startsWith("../")) return trimmed;
147
+ if (trimmed.startsWith("/")) return trimmed;
148
+ if (!hasExplicitScheme(trimmed)) {
149
+ return trimmed;
150
+ }
151
+ if ((attrName === "src" || attrName === "xLinkHref" || attrName === "xlinkHref") && trimmed.startsWith("data:")) {
152
+ return /^data:image\/[a-zA-Z0-9.+-]+;base64,/i.test(trimmed) ? trimmed : null;
153
+ }
154
+ try {
155
+ const parsed = new URL(trimmed);
156
+ if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) {
157
+ return null;
158
+ }
159
+ return trimmed;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+ function sanitizeStyle(style) {
165
+ const kept = [];
166
+ for (const decl of style.split(";")) {
167
+ const trimmed = decl.trim();
168
+ if (!trimmed) continue;
169
+ const colonIdx = trimmed.indexOf(":");
170
+ if (colonIdx === -1) continue;
171
+ const prop = trimmed.slice(0, colonIdx).trim().toLowerCase();
172
+ const value = trimmed.slice(colonIdx + 1).trim();
173
+ if (!prop || !value) continue;
174
+ if (DISALLOWED_STYLE_PROPS.has(prop)) continue;
175
+ if (DISALLOWED_STYLE_VALUE_PATTERNS.some((pattern) => pattern.test(value))) continue;
176
+ kept.push(`${prop}: ${value}`);
177
+ }
178
+ if (kept.length === 0) return null;
179
+ return `${kept.join("; ")};`;
180
+ }
181
+ function isElement$2(node) {
182
+ return node && node.type === "element";
183
+ }
184
+ function isText$1(node) {
185
+ return node && node.type === "text";
186
+ }
187
+ const rehypeSanitizeTags = () => {
188
+ return (tree) => {
189
+ visit(tree, "element", (node, index, parent) => {
190
+ if (index === void 0 || !parent) return;
191
+ if (REMOVE_WITH_CHILDREN.has(node.tagName)) {
192
+ parent.children.splice(index, 1);
193
+ return [SKIP, index];
194
+ }
195
+ if (node.tagName === "input") {
196
+ const props = node.properties || {};
197
+ if (props.type === "checkbox") {
198
+ const checked = props.checked === true || props.checked === "";
199
+ const text = {
200
+ type: "text",
201
+ value: checked ? "☑ " : "☐ "
202
+ };
203
+ parent.children.splice(index, 1, text);
204
+ return [SKIP, index];
205
+ }
206
+ parent.children.splice(index, 1);
207
+ return [SKIP, index];
208
+ }
209
+ if (node.tagName === "div") {
210
+ node.tagName = "section";
211
+ }
212
+ if (!ALLOWED_TAGS.has(node.tagName)) {
213
+ const children = node.children || [];
214
+ parent.children.splice(index, 1, ...children);
215
+ return [SKIP, index];
216
+ }
217
+ if (node.properties) {
218
+ const props = node.properties;
219
+ delete props.id;
220
+ for (const key of Object.keys(props)) {
221
+ if (key.startsWith("on") && key.length > 2) {
222
+ delete props[key];
223
+ }
224
+ }
225
+ for (const attr of ["href", "src", "xLinkHref", "xlinkHref"]) {
226
+ if (typeof props[attr] !== "string") continue;
227
+ const sanitized = sanitizeUrl(props[attr], attr);
228
+ if (sanitized === null) {
229
+ delete props[attr];
230
+ } else {
231
+ props[attr] = sanitized;
232
+ }
233
+ }
234
+ if (typeof props.style === "string") {
235
+ const sanitizedStyle = sanitizeStyle(props.style);
236
+ if (sanitizedStyle === null) {
237
+ delete props.style;
238
+ } else {
239
+ props.style = sanitizedStyle;
240
+ }
241
+ }
242
+ }
243
+ });
244
+ visit(tree, "element", (node) => {
245
+ if (!BLOCK_TAGS.has(node.tagName)) return;
246
+ const newChildren = [];
247
+ let needsWrapping = false;
248
+ for (const child of node.children) {
249
+ if (isText$1(child) && child.value.trim() !== "") {
250
+ const hasInlineOnly = node.children.every(
251
+ (c) => isText$1(c) || isElement$2(c) && INLINE_TAGS.has(c.tagName)
252
+ );
253
+ if (hasInlineOnly) {
254
+ needsWrapping = true;
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ if (!needsWrapping) return;
260
+ if (node.children.length === 1 && isElement$2(node.children[0]) && node.children[0].tagName === "span") {
261
+ return;
262
+ }
263
+ const hasBareText = node.children.some(
264
+ (c) => isText$1(c) && c.value.trim() !== ""
265
+ );
266
+ if (!hasBareText) return;
267
+ let group = [];
268
+ const flushGroup = () => {
269
+ if (group.length === 0) return;
270
+ const groupHasText = group.some((c) => isText$1(c));
271
+ if (groupHasText) {
272
+ newChildren.push({
273
+ type: "element",
274
+ tagName: "span",
275
+ properties: {},
276
+ children: group
277
+ });
278
+ } else {
279
+ newChildren.push(...group);
280
+ }
281
+ group = [];
282
+ };
283
+ for (const child of node.children) {
284
+ if (isText$1(child) || isElement$2(child) && INLINE_TAGS.has(child.tagName)) {
285
+ group.push(child);
286
+ } else {
287
+ flushGroup();
288
+ newChildren.push(child);
289
+ }
290
+ }
291
+ flushGroup();
292
+ node.children = newChildren;
293
+ });
294
+ };
295
+ };
296
+ const MAX_SIZE = 2 * 1024 * 1024;
297
+ function classifySource(src) {
298
+ if (src.startsWith("data:")) return "data-uri";
299
+ if (src.startsWith("http://") || src.startsWith("https://")) return "remote";
300
+ return "local";
301
+ }
302
+ function detectFormat(ext) {
303
+ const map = {
304
+ png: "image/png",
305
+ jpg: "image/jpeg",
306
+ jpeg: "image/jpeg",
307
+ gif: "image/gif",
308
+ svg: "image/svg+xml",
309
+ webp: "image/webp",
310
+ avif: "image/avif",
311
+ tiff: "image/tiff",
312
+ tif: "image/tiff"
313
+ };
314
+ return map[ext] || "image/png";
315
+ }
316
+ async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
317
+ if (mime === "image/svg+xml") {
318
+ return `data:${mime};base64,${buffer.toString("base64")}`;
319
+ }
320
+ if (mime === "image/gif") {
321
+ let result2 = buffer;
322
+ if (result2.length > maxSize) {
323
+ let width2 = 1080;
324
+ while (width2 >= 100) {
325
+ result2 = await sharp(buffer, { animated: true }).resize({ width: width2, withoutEnlargement: true }).gif().toBuffer();
326
+ if (result2.length <= maxSize) break;
327
+ width2 = Math.floor(width2 * 0.7);
328
+ }
329
+ }
330
+ if (result2.length > maxSize) {
331
+ throw new Error(
332
+ `Image exceeds 2MB after compression: ${sourcePath} (${(result2.length / 1024 / 1024).toFixed(1)}MB)`
333
+ );
334
+ }
335
+ return `data:image/gif;base64,${result2.toString("base64")}`;
336
+ }
337
+ const metadata = await sharp(buffer).metadata();
338
+ const width = metadata.width || 1920;
339
+ let result;
340
+ result = await sharp(buffer).png({ compressionLevel: 6 }).toBuffer();
341
+ if (result.length <= maxSize) {
342
+ return `data:image/png;base64,${result.toString("base64")}`;
343
+ }
344
+ let currentWidth = width;
345
+ while (currentWidth >= 100) {
346
+ currentWidth = Math.floor(currentWidth * 0.8);
347
+ result = await sharp(buffer).resize({ width: currentWidth, withoutEnlargement: true }).png({ compressionLevel: 6 }).toBuffer();
348
+ if (result.length <= maxSize) {
349
+ return `data:image/png;base64,${result.toString("base64")}`;
350
+ }
351
+ }
352
+ for (let jpegWidth = width; jpegWidth >= 100; jpegWidth = Math.floor(jpegWidth * 0.8)) {
353
+ for (const quality of [85, 70, 50]) {
354
+ result = await sharp(buffer).flatten({ background: { r: 255, g: 255, b: 255 } }).resize({ width: jpegWidth, withoutEnlargement: true }).jpeg({ quality }).toBuffer();
355
+ if (result.length <= maxSize) {
356
+ return `data:image/jpeg;base64,${result.toString("base64")}`;
357
+ }
358
+ }
359
+ }
360
+ throw new Error(
361
+ `Image exceeds 2MB after all compression attempts: ${sourcePath} (${(result.length / 1024 / 1024).toFixed(1)}MB)`
362
+ );
363
+ }
364
+ const rehypeBase64Images = (options) => {
365
+ const { baseDir } = options;
366
+ return async (tree) => {
367
+ const tasks = [];
368
+ visit(tree, "element", (node) => {
369
+ if (node.tagName !== "img") return;
370
+ const src = node.properties?.src;
371
+ if (typeof src !== "string") return;
372
+ const sourceType = classifySource(src);
373
+ if (sourceType !== "local") return;
374
+ const imgPath = path.resolve(baseDir, src);
375
+ if (!fs.existsSync(imgPath)) {
376
+ throw new Error(`Image file not found: ${imgPath}`);
377
+ }
378
+ const ext = path.extname(imgPath).slice(1).toLowerCase();
379
+ const mime = detectFormat(ext);
380
+ tasks.push({ node, imgPath, mime });
381
+ });
382
+ await Promise.all(
383
+ tasks.map(async ({ node, imgPath, mime }) => {
384
+ const buffer = fs.readFileSync(imgPath);
385
+ const dataUri = await compressToDataUri(Buffer.from(buffer), mime, MAX_SIZE, imgPath);
386
+ node.properties.src = dataUri;
387
+ })
388
+ );
389
+ };
390
+ };
391
+ function isElement$1(node) {
392
+ return node && node.type === "element";
393
+ }
394
+ function isText(node) {
395
+ return node && node.type === "text";
396
+ }
397
+ function protectWhitespace(children) {
398
+ const result = [];
399
+ for (const child of children) {
400
+ if (isText(child)) {
401
+ const parts = child.value.split("\n");
402
+ for (let i = 0; i < parts.length; i++) {
403
+ if (i > 0) {
404
+ result.push({ type: "element", tagName: "br", properties: {}, children: [] });
405
+ }
406
+ let text = parts[i];
407
+ text = text.replace(/\t/g, "  ");
408
+ text = text.replace(/^( +)/, (match) => " ".repeat(match.length));
409
+ if (text) {
410
+ result.push({ type: "text", value: text });
411
+ }
412
+ }
413
+ } else if (isElement$1(child) && child.tagName === "span") {
414
+ result.push({
415
+ ...child,
416
+ children: protectWhitespace(child.children)
417
+ });
418
+ } else {
419
+ result.push(child);
420
+ }
421
+ }
422
+ return result;
423
+ }
424
+ const rehypeCodeHighlight = () => {
425
+ const highlightPlugin = rehypeHighlight({ detect: false });
426
+ return (tree) => {
427
+ highlightPlugin(tree);
428
+ visit(tree, "element", (node) => {
429
+ if (node.tagName !== "pre") return;
430
+ const codeChild = node.children.find(
431
+ (c) => isElement$1(c) && c.tagName === "code"
432
+ );
433
+ if (!codeChild) return;
434
+ codeChild.children = protectWhitespace(codeChild.children);
435
+ });
436
+ };
437
+ };
438
+ function isInternalLink(href) {
439
+ if (href.startsWith("//")) {
440
+ return false;
441
+ }
442
+ if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) {
443
+ return true;
444
+ }
445
+ try {
446
+ const url = new URL(href);
447
+ if (url.hostname === "mp.weixin.qq.com" || url.hostname.endsWith(".mp.weixin.qq.com")) {
448
+ return true;
449
+ }
450
+ } catch {
451
+ return true;
452
+ }
453
+ return false;
454
+ }
455
+ const rehypeFootnoteLinks = () => {
456
+ return (tree) => {
457
+ const urlMap = /* @__PURE__ */ new Map();
458
+ const footnotes = [];
459
+ let counter = 0;
460
+ visit(tree, "element", (node, index, parent) => {
461
+ if (node.tagName !== "a" || index === void 0 || !parent) return;
462
+ const href = node.properties?.href;
463
+ if (typeof href !== "string") return;
464
+ if (isInternalLink(href)) return;
465
+ let num = urlMap.get(href);
466
+ if (num === void 0) {
467
+ counter++;
468
+ num = counter;
469
+ urlMap.set(href, num);
470
+ const text = extractText(node);
471
+ footnotes.push({ index: num, url: href, text });
472
+ }
473
+ const replacement = [
474
+ ...node.children,
475
+ {
476
+ type: "element",
477
+ tagName: "sup",
478
+ properties: {},
479
+ children: [{ type: "text", value: `[${num}]` }]
480
+ }
481
+ ];
482
+ parent.children.splice(index, 1, ...replacement);
483
+ return [SKIP, index + replacement.length];
484
+ });
485
+ if (footnotes.length > 0) {
486
+ const referencesSection = {
487
+ type: "element",
488
+ tagName: "section",
489
+ properties: {},
490
+ children: [
491
+ { type: "element", tagName: "hr", properties: {}, children: [] },
492
+ {
493
+ type: "element",
494
+ tagName: "p",
495
+ properties: {},
496
+ children: [
497
+ {
498
+ type: "element",
499
+ tagName: "strong",
500
+ properties: {},
501
+ children: [{ type: "text", value: "References" }]
502
+ }
503
+ ]
504
+ },
505
+ ...footnotes.map(
506
+ (fn) => ({
507
+ type: "element",
508
+ tagName: "p",
509
+ properties: {},
510
+ children: [
511
+ { type: "text", value: `[${fn.index}] ${fn.text}: ${fn.url}` }
512
+ ]
513
+ })
514
+ )
515
+ ]
516
+ };
517
+ tree.children.push(referencesSection);
518
+ }
519
+ };
520
+ };
521
+ function extractText(node) {
522
+ let text = "";
523
+ for (const child of node.children) {
524
+ if (child.type === "text") {
525
+ text += child.value;
526
+ } else if (child.type === "element") {
527
+ text += extractText(child);
528
+ }
529
+ }
530
+ return text;
531
+ }
532
+ const defaultStyles = {
533
+ // Headings — Apple style: 600 weight, tight letter-spacing
534
+ h1: "font-size: 24px; font-weight: 600; margin: 0 0 16px; line-height: 1.2; letter-spacing: -0.02em; color: #1d1d1f;",
535
+ h2: "font-size: 20px; font-weight: 600; margin: 32px 0 12px; line-height: 1.25; color: #1d1d1f;",
536
+ h3: "font-size: 17px; font-weight: 600; margin: 24px 0 8px; line-height: 1.3; color: #1d1d1f;",
537
+ h4: "font-size: 15px; font-weight: 600; margin: 20px 0 6px; line-height: 1.4; color: #6e6e73;",
538
+ h5: "font-size: 14px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #6e6e73;",
539
+ h6: "font-size: 13px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #86868b;",
540
+ // Paragraphs — 15px body, 1.75 line-height (comfortable for Chinese)
541
+ p: "font-size: 15px; line-height: 1.75; margin: 0 0 1.25em; color: #1d1d1f;",
542
+ // Blockquote — restrained: left border + italic only, no background
543
+ blockquote: "margin: 1.5em 0; padding: 0 0 0 1em; border-left: 3px solid #d2d2d7; color: #6e6e73; font-style: italic;",
544
+ // Inline code — subtle gray background
545
+ code: 'font-size: 0.875em; font-family: "SF Mono", Menlo, Consolas, monospace; background: #f5f5f7; color: #1d1d1f; padding: 0.15em 0.4em; border-radius: 4px;',
546
+ // Code block — GitHub-style light gray bg, no border
547
+ "pre code": 'display: block; font-size: 13px; font-family: "SF Mono", Menlo, Consolas, monospace; background: #f6f8fa; color: #1d1d1f; padding: 20px 24px; border-radius: 8px; overflow-x: auto; line-height: 1.6;',
548
+ pre: "margin: 1.5em 0;",
549
+ // Lists
550
+ ul: "margin: 1em 0; padding-left: 1.5em; font-size: 15px; line-height: 1.75; color: #1d1d1f;",
551
+ ol: "margin: 1em 0; padding-left: 1.5em; font-size: 15px; line-height: 1.75; color: #1d1d1f;",
552
+ li: "margin: 0.4em 0;",
553
+ // Table — clean: bottom borders only, no vertical lines
554
+ table: "width: 100%; margin: 1.5em 0; font-size: 14px; border-collapse: collapse;",
555
+ th: "padding: 12px 16px; text-align: left; font-weight: 600; color: #1d1d1f; font-size: 14px; border-bottom: 1px solid #d2d2d7;",
556
+ td: "padding: 12px 16px; border-bottom: 1px solid #e5e5ea; color: #1d1d1f;",
557
+ // Links — WeChat blue
558
+ a: "color: #576b95; text-decoration: none;",
559
+ // Images
560
+ img: "max-width: 100%; height: auto; display: block; margin: 1.5em auto; border-radius: 8px;",
561
+ // Horizontal rule
562
+ hr: "border: none; height: 1px; background: #d2d2d7; margin: 2em 0;",
563
+ // Inline formatting
564
+ strong: "font-weight: 600; color: #1d1d1f;",
565
+ em: "font-style: italic;",
566
+ del: "text-decoration: line-through; color: #86868b;",
567
+ s: "text-decoration: line-through; color: #86868b;",
568
+ // Superscript / subscript
569
+ sup: "font-size: 0.75em; vertical-align: super; color: #576b95;",
570
+ sub: "font-size: 0.75em; vertical-align: sub;",
571
+ // Section / figure
572
+ section: "margin: 0.5em 0;",
573
+ figcaption: "font-size: 13px; color: #86868b; text-align: center; margin-top: 8px;",
574
+ figure: "margin: 1.5em 0; text-align: center;",
575
+ // Mark
576
+ mark: "background: #fff3b0; padding: 0.1em 0.3em; border-radius: 2px;",
577
+ // hljs — Xcode Light theme (matching light code block background)
578
+ "hljs-keyword": "color: #9b2393; font-weight: 600;",
579
+ "hljs-string": "color: #c41a16;",
580
+ "hljs-number": "color: #1c00cf;",
581
+ "hljs-comment": "color: #6e6e73; font-style: italic;",
582
+ "hljs-function": "color: #326d74;",
583
+ "hljs-title": "color: #326d74;",
584
+ "hljs-built_in": "color: #5c2699;",
585
+ "hljs-literal": "color: #1c00cf;",
586
+ "hljs-type": "color: #5c2699;",
587
+ "hljs-params": "color: #1d1d1f;",
588
+ "hljs-meta": "color: #643820;",
589
+ "hljs-attr": "color: #643820;",
590
+ "hljs-variable": "color: #326d74;",
591
+ "hljs-selector-tag": "color: #9b2393;",
592
+ "hljs-selector-class": "color: #643820;",
593
+ "hljs-regexp": "color: #c41a16;",
594
+ "hljs-symbol": "color: #326d74;",
595
+ "hljs-tag": "color: #9b2393;",
596
+ "hljs-name": "color: #9b2393;",
597
+ "hljs-attribute": "color: #643820;",
598
+ "hljs-addition": "color: #28a745; background: rgba(40, 167, 69, 0.1);",
599
+ "hljs-deletion": "color: #d73a49; background: rgba(215, 58, 73, 0.1);"
600
+ };
601
+ function isElement(node) {
602
+ return node && node.type === "element";
603
+ }
604
+ function mergeStyles(base, override) {
605
+ if (!base) return override;
606
+ if (!override) return base;
607
+ const props = /* @__PURE__ */ new Map();
608
+ for (const decl of base.split(";")) {
609
+ const trimmed = decl.trim();
610
+ if (!trimmed) continue;
611
+ const colonIdx = trimmed.indexOf(":");
612
+ if (colonIdx === -1) continue;
613
+ const prop = trimmed.slice(0, colonIdx).trim();
614
+ const val = trimmed.slice(colonIdx + 1).trim();
615
+ props.set(prop, val);
616
+ }
617
+ for (const decl of override.split(";")) {
618
+ const trimmed = decl.trim();
619
+ if (!trimmed) continue;
620
+ const colonIdx = trimmed.indexOf(":");
621
+ if (colonIdx === -1) continue;
622
+ const prop = trimmed.slice(0, colonIdx).trim();
623
+ const val = trimmed.slice(colonIdx + 1).trim();
624
+ props.set(prop, val);
625
+ }
626
+ return Array.from(props.entries()).map(([prop, val]) => `${prop}: ${val}`).join("; ") + ";";
627
+ }
628
+ const rehypeInlineStyles = (options) => {
629
+ const styles = options?.styles ?? defaultStyles;
630
+ return (tree) => {
631
+ visit(tree, "element", (node, _index, parent) => {
632
+ const props = node.properties || {};
633
+ node.properties = props;
634
+ let styleKey = node.tagName;
635
+ if (node.tagName === "code" && parent && isElement(parent) && parent.tagName === "pre") {
636
+ styleKey = "pre code";
637
+ }
638
+ let hljsStyle = "";
639
+ const classNames = props.className;
640
+ if (Array.isArray(classNames)) {
641
+ for (const cls of classNames) {
642
+ if (typeof cls === "string" && cls.startsWith("hljs-")) {
643
+ const hljsCss = styles[cls];
644
+ if (hljsCss) {
645
+ hljsStyle = hljsStyle ? mergeStyles(hljsStyle, hljsCss) : hljsCss;
646
+ }
647
+ }
648
+ }
649
+ }
650
+ const defaultStyle = styles[styleKey] || "";
651
+ const existingStyle = typeof props.style === "string" ? props.style : "";
652
+ let finalStyle = defaultStyle;
653
+ if (hljsStyle) {
654
+ finalStyle = mergeStyles(finalStyle, hljsStyle);
655
+ }
656
+ if (existingStyle) {
657
+ finalStyle = mergeStyles(finalStyle, existingStyle);
658
+ }
659
+ if (finalStyle) {
660
+ props.style = finalStyle;
661
+ }
662
+ delete props.className;
663
+ });
664
+ };
665
+ };
666
+ const MERMAID_THEME = {
667
+ primaryColor: "#f6f8fa",
668
+ primaryTextColor: "#1d1d1f",
669
+ primaryBorderColor: "#e5e5ea",
670
+ lineColor: "#86868b",
671
+ secondaryColor: "#f6f8fa",
672
+ tertiaryColor: "#ffffff",
673
+ background: "#ffffff",
674
+ mainBkg: "#f6f8fa",
675
+ nodeBorder: "#e5e5ea",
676
+ nodeTextColor: "#1d1d1f",
677
+ clusterBkg: "#f6f8fa",
678
+ clusterBorder: "#e5e5ea",
679
+ titleColor: "#1d1d1f",
680
+ edgeLabelBackground: "#f6f8fa",
681
+ noteBorderColor: "#e5e5ea",
682
+ noteBkgColor: "#f6f8fa"
683
+ };
684
+ const FLOWCHART_CONFIG = {
685
+ curve: "basis",
686
+ nodeSpacing: 40,
687
+ rankSpacing: 50,
688
+ htmlLabels: true,
689
+ useMaxWidth: true,
690
+ subGraphTitleMargin: { top: 15, bottom: 15 },
691
+ padding: 20
692
+ };
693
+ function collectMermaidBlocks(tree) {
694
+ const tasks = [];
695
+ visit(tree, "element", (node, index, parent) => {
696
+ if (node.tagName !== "pre" || !parent || index === void 0) return;
697
+ const codeChild = node.children.find(
698
+ (c) => c.type === "element" && c.tagName === "code"
699
+ );
700
+ if (!codeChild) return;
701
+ const classes = Array.isArray(codeChild.properties?.className) ? codeChild.properties.className : [];
702
+ if (!classes.includes("language-mermaid")) return;
703
+ const text = codeChild.children.filter((c) => c.type === "text").map((c) => c.value).join("");
704
+ if (text.trim()) {
705
+ tasks.push({ node, parent, index, definition: text.trim() });
706
+ }
707
+ });
708
+ return tasks;
709
+ }
710
+ const rehypeMermaid = (options = {}) => {
711
+ const { scale = 2, backgroundColor = "white" } = options;
712
+ return async (tree) => {
713
+ const tasks = collectMermaidBlocks(tree);
714
+ if (tasks.length === 0) return;
715
+ let chromium;
716
+ let mermaidPath;
717
+ try {
718
+ const pw = await import("playwright");
719
+ chromium = pw.chromium;
720
+ const { createRequire } = await import("module");
721
+ const req = createRequire(import.meta.url);
722
+ mermaidPath = req.resolve("mermaid/dist/mermaid.min.js");
723
+ } catch {
724
+ throw new Error(
725
+ "Mermaid diagrams detected but required dependencies are missing.\nRun: npm install mermaid playwright && npx playwright install chromium"
726
+ );
727
+ }
728
+ const browser = await chromium.launch();
729
+ try {
730
+ const context = await browser.newContext({
731
+ deviceScaleFactor: scale,
732
+ viewport: { width: 800, height: 600 }
733
+ });
734
+ const page = await context.newPage();
735
+ await page.setContent("<html><body></body></html>");
736
+ await page.addScriptTag({ path: mermaidPath });
737
+ await page.evaluate(
738
+ ({
739
+ theme,
740
+ flowchart
741
+ }) => {
742
+ window.mermaid.initialize({
743
+ startOnLoad: false,
744
+ theme: "base",
745
+ themeVariables: {
746
+ ...theme,
747
+ fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", sans-serif',
748
+ fontSize: "14px"
749
+ },
750
+ flowchart
751
+ });
752
+ },
753
+ { theme: MERMAID_THEME, flowchart: FLOWCHART_CONFIG }
754
+ );
755
+ for (const task of tasks) {
756
+ await page.evaluate((def) => {
757
+ const container = document.createElement("div");
758
+ container.className = "mermaid";
759
+ container.textContent = def;
760
+ container.id = "mermaid-target";
761
+ document.body.innerHTML = "";
762
+ document.body.appendChild(container);
763
+ }, task.definition);
764
+ await page.evaluate(() => window.mermaid.run());
765
+ await page.waitForSelector("#mermaid-target svg", { timeout: 1e4 });
766
+ await page.waitForTimeout(300);
767
+ await page.evaluate(() => {
768
+ document.querySelectorAll(".node rect").forEach((el) => {
769
+ el.setAttribute("rx", "12");
770
+ el.setAttribute("ry", "12");
771
+ });
772
+ document.querySelectorAll(".cluster rect").forEach((el) => {
773
+ el.setAttribute("rx", "12");
774
+ el.setAttribute("ry", "12");
775
+ });
776
+ document.querySelectorAll(".edgeLabel rect").forEach((el) => {
777
+ el.setAttribute("rx", "6");
778
+ el.setAttribute("ry", "6");
779
+ });
780
+ document.querySelectorAll(".labelBkg").forEach((el) => {
781
+ const div = el;
782
+ div.style.padding = "2px 8px";
783
+ div.style.borderRadius = "6px";
784
+ div.style.backgroundColor = "#f6f8fa";
785
+ });
786
+ document.querySelectorAll(".edgeLabel foreignObject").forEach((fo) => {
787
+ const w = parseFloat(fo.getAttribute("width") || "0");
788
+ const h = parseFloat(fo.getAttribute("height") || "0");
789
+ if (w === 0 || h === 0) return;
790
+ const px = 20, py = 8;
791
+ fo.setAttribute("width", String(w + px));
792
+ fo.setAttribute("height", String(h + py));
793
+ const label = fo.closest(".edgeLabel");
794
+ if (label) {
795
+ const g = label.querySelector("g.label");
796
+ if (g) {
797
+ const transform = g.getAttribute("transform");
798
+ if (transform) {
799
+ const match = transform.match(/translate\(([-\d.]+),\s*([-\d.]+)\)/);
800
+ if (match) {
801
+ const x = parseFloat(match[1]) - px / 2;
802
+ const y = parseFloat(match[2]) - py / 2;
803
+ g.setAttribute("transform", `translate(${x}, ${y})`);
804
+ }
805
+ }
806
+ }
807
+ }
808
+ });
809
+ document.querySelectorAll(".cluster-label").forEach((label) => {
810
+ const transform = label.getAttribute("transform");
811
+ if (transform) {
812
+ const match = transform.match(
813
+ /translate\(([\d.]+),\s*([\d.]+)\)/
814
+ );
815
+ if (match) {
816
+ const x = parseFloat(match[1]);
817
+ const y = parseFloat(match[2]) + 8;
818
+ label.setAttribute(
819
+ "transform",
820
+ `translate(${x}, ${y})`
821
+ );
822
+ }
823
+ }
824
+ });
825
+ });
826
+ const svgLocator = page.locator("#mermaid-target svg");
827
+ const cssBox = await svgLocator.boundingBox();
828
+ const cssWidth = cssBox ? Math.ceil(cssBox.width) : void 0;
829
+ const pngBuffer = await svgLocator.screenshot({
830
+ type: "png",
831
+ scale: "device",
832
+ omitBackground: backgroundColor === "transparent"
833
+ });
834
+ const base64 = Buffer.from(pngBuffer).toString("base64");
835
+ const parentChildren = task.parent.children || task.parent.children;
836
+ parentChildren[task.index] = {
837
+ type: "element",
838
+ tagName: "img",
839
+ properties: {
840
+ src: `data:image/png;base64,${base64}`,
841
+ alt: "mermaid diagram",
842
+ ...cssWidth ? { width: String(cssWidth) } : {},
843
+ style: "max-width: 100%; height: auto; display: block; margin: 1.5em auto;"
844
+ },
845
+ children: []
846
+ };
847
+ }
848
+ } finally {
849
+ await browser.close();
850
+ }
851
+ };
852
+ };
853
+ async function render(options) {
854
+ const { input, output } = options;
855
+ const inputPath = path.resolve(input);
856
+ if (!fs.existsSync(inputPath)) {
857
+ throw new Error(`Input file not found: ${inputPath}`);
858
+ }
859
+ const ext = path.extname(inputPath).toLowerCase();
860
+ if (ext !== ".md" && ext !== ".markdown") {
861
+ throw new Error(`Unsupported input format: ${ext}. Only .md and .markdown files are supported.`);
862
+ }
863
+ const markdown = fs.readFileSync(inputPath, "utf-8");
864
+ const inputDir = path.dirname(inputPath);
865
+ const file = await unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeSanitizeTags).use(rehypeMermaid).use(rehypeBase64Images, { baseDir: inputDir }).use(rehypeCodeHighlight).use(rehypeFootnoteLinks).use(rehypeInlineStyles).use(rehypeStringify, { allowDangerousHtml: true }).process(markdown);
866
+ const html = String(file);
867
+ const outputPath = path.resolve(output);
868
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
869
+ fs.writeFileSync(outputPath, html, "utf-8");
870
+ return {
871
+ input: inputPath,
872
+ output: outputPath,
873
+ size: Buffer.byteLength(html, "utf-8")
874
+ };
875
+ }
876
+ const program = new Command();
877
+ program.name("mdpress").description("Convert Markdown into WeChat MP-ready HTML").version("0.1.0").requiredOption("-i, --input <path>", "Input Markdown file path").requiredOption("-o, --output <path>", "Output HTML file path").action(async (options) => {
878
+ try {
879
+ const result = await render({
880
+ input: options.input,
881
+ output: options.output
882
+ });
883
+ console.log(JSON.stringify(result, null, 2));
884
+ } catch (error) {
885
+ console.error("Error:", error instanceof Error ? error.message : error);
886
+ process.exit(1);
887
+ }
888
+ });
889
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@liustack/mdpress",
3
+ "version": "0.1.0",
4
+ "description": "CLI for AI agents to convert Markdown into WeChat MP-ready HTML with inline styles, base64 images, and tag sanitization",
5
+ "type": "module",
6
+ "bin": {
7
+ "mdpress": "./dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "keywords": [
13
+ "markdown",
14
+ "wechat",
15
+ "wechat-mp",
16
+ "inline-styles",
17
+ "base64-images",
18
+ "cli",
19
+ "ai-agent"
20
+ ],
21
+ "author": "Leon Liu",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/liustack/mdpress.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/liustack/mdpress/issues"
29
+ },
30
+ "homepage": "https://github.com/liustack/mdpress#readme",
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "dependencies": {
35
+ "@types/hast": "^3.0.4",
36
+ "commander": "^13.1.0",
37
+ "rehype-highlight": "^7.0.2",
38
+ "rehype-raw": "^7.0.0",
39
+ "rehype-stringify": "^10.0.1",
40
+ "remark-gfm": "^4.0.0",
41
+ "remark-parse": "^11.0.0",
42
+ "remark-rehype": "^11.1.1",
43
+ "sharp": "^0.34.5",
44
+ "unified": "^11.0.5",
45
+ "unist-util-visit": "^5.0.0"
46
+ },
47
+ "optionalDependencies": {
48
+ "mermaid": "^11.4.1",
49
+ "playwright": "^1.52.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^22.19.7",
53
+ "typescript": "^5.9.3",
54
+ "vite": "^6.4.1",
55
+ "vitest": "^4.0.18"
56
+ },
57
+ "scripts": {
58
+ "dev": "vite build --watch",
59
+ "build": "vite build",
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "docs:list": "node scripts/docs-list.js"
64
+ }
65
+ }