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