@liustack/mdpress 0.1.0 → 0.2.1
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/README.md +25 -5
- package/README.zh-CN.md +25 -5
- package/dist/clipboard-pLkenrrh.js +7 -0
- package/dist/main.js +234 -76
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
# mdpress
|
|
2
2
|
|
|
3
|
-
A CLI toolkit for AI agents to convert Markdown into
|
|
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
|
|
4
7
|
|
|
5
8
|
中文说明请见:[README.zh-CN.md](README.zh-CN.md)
|
|
6
9
|
|
|
7
10
|
## Features
|
|
8
11
|
|
|
9
12
|
- WeChat MP editor compatible HTML output
|
|
13
|
+
- X/Twitter Articles editor compatible HTML output
|
|
10
14
|
- All styles inlined (no external CSS or `<style>` tags)
|
|
11
15
|
- Local images compressed via sharp and embedded as base64 (≤ 2MB)
|
|
12
|
-
- Syntax highlighting with
|
|
16
|
+
- Syntax highlighting with default theme (inline colors)
|
|
13
17
|
- Mermaid diagram rendering to PNG (via Playwright, optional)
|
|
14
18
|
- External links converted to footnotes with References section
|
|
15
19
|
- Tag sanitization (whitelist-based, `div` → `section`, dangerous tags removed)
|
|
16
20
|
- GFM support (tables, strikethrough, task lists with ☑/☐)
|
|
17
|
-
-
|
|
21
|
+
- Minimalist default style
|
|
18
22
|
|
|
19
23
|
## Installation
|
|
20
24
|
|
|
@@ -45,6 +49,9 @@ npx skills add https://github.com/liustack/mdpress --skill mdpress
|
|
|
45
49
|
```bash
|
|
46
50
|
# Convert Markdown to WeChat-ready HTML
|
|
47
51
|
mdpress -i article.md -o output.html
|
|
52
|
+
|
|
53
|
+
# Convert Markdown to X/Twitter Articles editor-ready HTML
|
|
54
|
+
mdpress -i article.md -o output.html --target x
|
|
48
55
|
```
|
|
49
56
|
|
|
50
57
|
Output is JSON:
|
|
@@ -62,11 +69,11 @@ Output is JSON:
|
|
|
62
69
|
mdpress runs your Markdown through a unified (remark + rehype) pipeline that applies 6 transformations in order:
|
|
63
70
|
|
|
64
71
|
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
|
|
72
|
+
2. **Mermaid diagrams** — mermaid code blocks rendered to PNG via Playwright with minimalist theme (optional, requires `mermaid` + `playwright`)
|
|
66
73
|
3. **Base64 images** — local images compressed with sharp (PNG/GIF/SVG/JPEG) and embedded as data URIs (≤ 2MB limit)
|
|
67
74
|
4. **Code highlighting** — syntax highlighting via highlight.js with whitespace protection (`\n` → `<br>`, spaces → NBSP)
|
|
68
75
|
5. **Footnote links** — external links replaced with text + `<sup>[N]</sup>`, References section appended; `mp.weixin.qq.com` links preserved
|
|
69
|
-
6. **Inline styles** —
|
|
76
|
+
6. **Inline styles** — minimalist default styles injected per tag, hljs classes converted to inline colors, all `className` removed
|
|
70
77
|
|
|
71
78
|
The result is a self-contained HTML file that can be directly pasted into the WeChat Official Account editor.
|
|
72
79
|
|
|
@@ -74,6 +81,19 @@ The result is a self-contained HTML file that can be directly pasted into the We
|
|
|
74
81
|
|
|
75
82
|
- `-i, --input <path>` — Input Markdown file path (required)
|
|
76
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
|
|
77
97
|
|
|
78
98
|
## AI Agent Skill
|
|
79
99
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# mdpress
|
|
2
2
|
|
|
3
|
-
面向 AI Agent 的 Markdown 转换 CLI,可将 Markdown
|
|
3
|
+
面向 AI Agent 的 Markdown 转换 CLI,可将 Markdown 输出为编辑器可粘贴的 HTML:
|
|
4
|
+
|
|
5
|
+
- **微信公众号模式**:内联样式、base64 图片、语法高亮、标签清洗
|
|
6
|
+
- **X/Twitter Articles 模式**:语义化子集 HTML、图片占位文本、仅保留 HTTPS 链接
|
|
4
7
|
|
|
5
8
|
## 特性
|
|
6
9
|
|
|
7
10
|
- 输出微信公众号编辑器兼容的 HTML
|
|
11
|
+
- 输出 X/Twitter Articles 编辑器兼容的 HTML
|
|
8
12
|
- 所有样式内联(无外部 CSS 或 `<style>` 标签)
|
|
9
13
|
- 本地图片经 sharp 压缩后嵌入为 base64(单张 ≤ 2MB)
|
|
10
|
-
-
|
|
14
|
+
- 默认极简主题语法高亮(内联色值)
|
|
11
15
|
- Mermaid 流程图渲染为 PNG(通过 Playwright,可选)
|
|
12
16
|
- 外部链接自动转为脚注,文末附 References
|
|
13
17
|
- 基于白名单的标签清洗(`div` → `section`,危险标签移除)
|
|
14
18
|
- 支持 GFM(表格、删除线、任务列表 ☑/☐)
|
|
15
|
-
-
|
|
19
|
+
- 默认极简风格样式
|
|
16
20
|
|
|
17
21
|
## 安装
|
|
18
22
|
|
|
@@ -43,6 +47,9 @@ npx skills add https://github.com/liustack/mdpress --skill mdpress
|
|
|
43
47
|
```bash
|
|
44
48
|
# 将 Markdown 转换为公众号可用的 HTML
|
|
45
49
|
mdpress -i article.md -o output.html
|
|
50
|
+
|
|
51
|
+
# 将 Markdown 转换为 X/Twitter Articles 可粘贴的 HTML
|
|
52
|
+
mdpress -i article.md -o output.html --target x
|
|
46
53
|
```
|
|
47
54
|
|
|
48
55
|
输出为 JSON 格式:
|
|
@@ -60,11 +67,11 @@ mdpress -i article.md -o output.html
|
|
|
60
67
|
mdpress 使用 unified(remark + rehype)管线,依次执行 6 个转换:
|
|
61
68
|
|
|
62
69
|
1. **标签清洗** — 基于白名单过滤标签,`div` → `section`,checkbox → Unicode ☑/☐,移除 `id` 和事件处理器
|
|
63
|
-
2. **Mermaid 渲染** — mermaid 代码块通过 Playwright 渲染为 PNG
|
|
70
|
+
2. **Mermaid 渲染** — mermaid 代码块通过 Playwright 渲染为 PNG(极简风格主题,可选,需安装 `mermaid` + `playwright`)
|
|
64
71
|
3. **图片 base64** — 本地图片经 sharp 压缩(PNG/GIF/SVG/JPEG),嵌入为 data URI(≤ 2MB)
|
|
65
72
|
4. **代码高亮** — 基于 highlight.js 的语法高亮,配合空白保护(`\n` → `<br>`,空格 → NBSP)
|
|
66
73
|
5. **链接脚注** — 外部链接替换为文本 + `<sup>[N]</sup>`,文末追加 References 区域;保留 `mp.weixin.qq.com` 链接
|
|
67
|
-
6. **样式内联** —
|
|
74
|
+
6. **样式内联** — 默认极简风格按标签注入样式,hljs 类名转为内联色值,移除所有 `className`
|
|
68
75
|
|
|
69
76
|
输出的 HTML 可直接粘贴到微信公众号编辑器中使用。
|
|
70
77
|
|
|
@@ -72,6 +79,19 @@ mdpress 使用 unified(remark + rehype)管线,依次执行 6 个转换:
|
|
|
72
79
|
|
|
73
80
|
- `-i, --input <path>` — 输入 Markdown 文件路径(必填)
|
|
74
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`、相对路径、锚点)降级为纯文本
|
|
75
95
|
|
|
76
96
|
## AI Agent Skill
|
|
77
97
|
|
package/dist/main.js
CHANGED
|
@@ -11,9 +11,8 @@ import rehypeStringify from "rehype-stringify";
|
|
|
11
11
|
import { visit, SKIP } from "unist-util-visit";
|
|
12
12
|
import sharp from "sharp";
|
|
13
13
|
import rehypeHighlight from "rehype-highlight";
|
|
14
|
-
const ALLOWED_TAGS = /* @__PURE__ */ new Set([
|
|
14
|
+
const ALLOWED_TAGS$1 = /* @__PURE__ */ new Set([
|
|
15
15
|
// Block
|
|
16
|
-
"h1",
|
|
17
16
|
"h2",
|
|
18
17
|
"h3",
|
|
19
18
|
"h4",
|
|
@@ -75,7 +74,7 @@ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
|
|
|
75
74
|
"desc",
|
|
76
75
|
"foreignObject"
|
|
77
76
|
]);
|
|
78
|
-
const REMOVE_WITH_CHILDREN = /* @__PURE__ */ new Set([
|
|
77
|
+
const REMOVE_WITH_CHILDREN$1 = /* @__PURE__ */ new Set([
|
|
79
78
|
"script",
|
|
80
79
|
"style",
|
|
81
80
|
"link",
|
|
@@ -98,7 +97,6 @@ const REMOVE_WITH_CHILDREN = /* @__PURE__ */ new Set([
|
|
|
98
97
|
]);
|
|
99
98
|
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
100
99
|
"p",
|
|
101
|
-
"h1",
|
|
102
100
|
"h2",
|
|
103
101
|
"h3",
|
|
104
102
|
"h4",
|
|
@@ -188,7 +186,7 @@ const rehypeSanitizeTags = () => {
|
|
|
188
186
|
return (tree) => {
|
|
189
187
|
visit(tree, "element", (node, index, parent) => {
|
|
190
188
|
if (index === void 0 || !parent) return;
|
|
191
|
-
if (REMOVE_WITH_CHILDREN.has(node.tagName)) {
|
|
189
|
+
if (REMOVE_WITH_CHILDREN$1.has(node.tagName)) {
|
|
192
190
|
parent.children.splice(index, 1);
|
|
193
191
|
return [SKIP, index];
|
|
194
192
|
}
|
|
@@ -206,10 +204,13 @@ const rehypeSanitizeTags = () => {
|
|
|
206
204
|
parent.children.splice(index, 1);
|
|
207
205
|
return [SKIP, index];
|
|
208
206
|
}
|
|
207
|
+
if (node.tagName === "h1") {
|
|
208
|
+
node.tagName = "h2";
|
|
209
|
+
}
|
|
209
210
|
if (node.tagName === "div") {
|
|
210
211
|
node.tagName = "section";
|
|
211
212
|
}
|
|
212
|
-
if (!ALLOWED_TAGS.has(node.tagName)) {
|
|
213
|
+
if (!ALLOWED_TAGS$1.has(node.tagName)) {
|
|
213
214
|
const children = node.children || [];
|
|
214
215
|
parent.children.splice(index, 1, ...children);
|
|
215
216
|
return [SKIP, index];
|
|
@@ -294,6 +295,8 @@ const rehypeSanitizeTags = () => {
|
|
|
294
295
|
};
|
|
295
296
|
};
|
|
296
297
|
const MAX_SIZE = 2 * 1024 * 1024;
|
|
298
|
+
const MIN_FILE_SIZE = 1 * 1024;
|
|
299
|
+
const MIN_DIMENSION = 120;
|
|
297
300
|
function classifySource(src) {
|
|
298
301
|
if (src.startsWith("data:")) return "data-uri";
|
|
299
302
|
if (src.startsWith("http://") || src.startsWith("https://")) return "remote";
|
|
@@ -314,17 +317,31 @@ function detectFormat(ext) {
|
|
|
314
317
|
return map[ext] || "image/png";
|
|
315
318
|
}
|
|
316
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
|
+
}
|
|
317
333
|
if (mime === "image/svg+xml") {
|
|
318
|
-
|
|
334
|
+
const pngBuffer = await sharp(buffer).png().toBuffer();
|
|
335
|
+
return `data:image/png;base64,${pngBuffer.toString("base64")}`;
|
|
319
336
|
}
|
|
320
337
|
if (mime === "image/gif") {
|
|
321
338
|
let result2 = buffer;
|
|
322
339
|
if (result2.length > maxSize) {
|
|
323
|
-
let
|
|
324
|
-
while (
|
|
325
|
-
result2 = await sharp(buffer, { animated: true }).resize({ width:
|
|
340
|
+
let gifWidth = 1080;
|
|
341
|
+
while (gifWidth >= 100) {
|
|
342
|
+
result2 = await sharp(buffer, { animated: true }).resize({ width: gifWidth, withoutEnlargement: true }).gif().toBuffer();
|
|
326
343
|
if (result2.length <= maxSize) break;
|
|
327
|
-
|
|
344
|
+
gifWidth = Math.floor(gifWidth * 0.7);
|
|
328
345
|
}
|
|
329
346
|
}
|
|
330
347
|
if (result2.length > maxSize) {
|
|
@@ -334,14 +351,13 @@ async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
|
|
|
334
351
|
}
|
|
335
352
|
return `data:image/gif;base64,${result2.toString("base64")}`;
|
|
336
353
|
}
|
|
337
|
-
const
|
|
338
|
-
const width = metadata.width || 1920;
|
|
354
|
+
const origWidth = width || 1920;
|
|
339
355
|
let result;
|
|
340
356
|
result = await sharp(buffer).png({ compressionLevel: 6 }).toBuffer();
|
|
341
357
|
if (result.length <= maxSize) {
|
|
342
358
|
return `data:image/png;base64,${result.toString("base64")}`;
|
|
343
359
|
}
|
|
344
|
-
let currentWidth =
|
|
360
|
+
let currentWidth = origWidth;
|
|
345
361
|
while (currentWidth >= 100) {
|
|
346
362
|
currentWidth = Math.floor(currentWidth * 0.8);
|
|
347
363
|
result = await sharp(buffer).resize({ width: currentWidth, withoutEnlargement: true }).png({ compressionLevel: 6 }).toBuffer();
|
|
@@ -349,7 +365,7 @@ async function compressToDataUri(buffer, mime, maxSize, sourcePath) {
|
|
|
349
365
|
return `data:image/png;base64,${result.toString("base64")}`;
|
|
350
366
|
}
|
|
351
367
|
}
|
|
352
|
-
for (let jpegWidth =
|
|
368
|
+
for (let jpegWidth = origWidth; jpegWidth >= 100; jpegWidth = Math.floor(jpegWidth * 0.8)) {
|
|
353
369
|
for (const quality of [85, 70, 50]) {
|
|
354
370
|
result = await sharp(buffer).flatten({ background: { r: 255, g: 255, b: 255 } }).resize({ width: jpegWidth, withoutEnlargement: true }).jpeg({ quality }).toBuffer();
|
|
355
371
|
if (result.length <= maxSize) {
|
|
@@ -405,7 +421,7 @@ function protectWhitespace(children) {
|
|
|
405
421
|
}
|
|
406
422
|
let text = parts[i];
|
|
407
423
|
text = text.replace(/\t/g, " ");
|
|
408
|
-
text = text.replace(
|
|
424
|
+
text = text.replace(/ /g, " ");
|
|
409
425
|
if (text) {
|
|
410
426
|
result.push({ type: "text", value: text });
|
|
411
427
|
}
|
|
@@ -435,22 +451,22 @@ const rehypeCodeHighlight = () => {
|
|
|
435
451
|
});
|
|
436
452
|
};
|
|
437
453
|
};
|
|
438
|
-
function
|
|
454
|
+
function classifyLink(href) {
|
|
439
455
|
if (href.startsWith("//")) {
|
|
440
|
-
return
|
|
456
|
+
return "external";
|
|
441
457
|
}
|
|
442
458
|
if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) {
|
|
443
|
-
return
|
|
459
|
+
return "anchor";
|
|
444
460
|
}
|
|
445
461
|
try {
|
|
446
462
|
const url = new URL(href);
|
|
447
463
|
if (url.hostname === "mp.weixin.qq.com" || url.hostname.endsWith(".mp.weixin.qq.com")) {
|
|
448
|
-
return
|
|
464
|
+
return "wechat";
|
|
449
465
|
}
|
|
450
466
|
} catch {
|
|
451
|
-
return
|
|
467
|
+
return "anchor";
|
|
452
468
|
}
|
|
453
|
-
return
|
|
469
|
+
return "external";
|
|
454
470
|
}
|
|
455
471
|
const rehypeFootnoteLinks = () => {
|
|
456
472
|
return (tree) => {
|
|
@@ -461,7 +477,12 @@ const rehypeFootnoteLinks = () => {
|
|
|
461
477
|
if (node.tagName !== "a" || index === void 0 || !parent) return;
|
|
462
478
|
const href = node.properties?.href;
|
|
463
479
|
if (typeof href !== "string") return;
|
|
464
|
-
|
|
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
|
+
}
|
|
465
486
|
let num = urlMap.get(href);
|
|
466
487
|
if (num === void 0) {
|
|
467
488
|
counter++;
|
|
@@ -483,35 +504,27 @@ const rehypeFootnoteLinks = () => {
|
|
|
483
504
|
return [SKIP, index + replacement.length];
|
|
484
505
|
});
|
|
485
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
|
+
}
|
|
486
515
|
const referencesSection = {
|
|
487
516
|
type: "element",
|
|
488
517
|
tagName: "section",
|
|
489
|
-
properties: {},
|
|
518
|
+
properties: { style: "color: #86868b; font-size: 13px; line-height: 1.75; margin-top: 2em; word-break: break-all;" },
|
|
490
519
|
children: [
|
|
491
|
-
{ type: "element", tagName: "hr", properties: {}, children: [] },
|
|
492
520
|
{
|
|
493
521
|
type: "element",
|
|
494
|
-
tagName: "
|
|
495
|
-
properties: {},
|
|
496
|
-
children: [
|
|
497
|
-
{
|
|
498
|
-
type: "element",
|
|
499
|
-
tagName: "strong",
|
|
500
|
-
properties: {},
|
|
501
|
-
children: [{ type: "text", value: "References" }]
|
|
502
|
-
}
|
|
503
|
-
]
|
|
522
|
+
tagName: "strong",
|
|
523
|
+
properties: { style: "color: #86868b;" },
|
|
524
|
+
children: [{ type: "text", value: "References:" }]
|
|
504
525
|
},
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
type: "element",
|
|
508
|
-
tagName: "p",
|
|
509
|
-
properties: {},
|
|
510
|
-
children: [
|
|
511
|
-
{ type: "text", value: `[${fn.index}] ${fn.text}: ${fn.url}` }
|
|
512
|
-
]
|
|
513
|
-
})
|
|
514
|
-
)
|
|
526
|
+
{ type: "element", tagName: "br", properties: {}, children: [] },
|
|
527
|
+
...footnoteChildren
|
|
515
528
|
]
|
|
516
529
|
};
|
|
517
530
|
tree.children.push(referencesSection);
|
|
@@ -529,35 +542,38 @@ function extractText(node) {
|
|
|
529
542
|
}
|
|
530
543
|
return text;
|
|
531
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};`;
|
|
532
549
|
const defaultStyles = {
|
|
533
|
-
// Headings —
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
p: "font-size: 15px; line-height: 1.75; margin: 0 0 1.25em; color: #1d1d1f;",
|
|
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;`,
|
|
542
558
|
// Blockquote — restrained: left border + italic only, no background
|
|
543
|
-
blockquote:
|
|
544
|
-
// Inline code — subtle gray background
|
|
545
|
-
code:
|
|
546
|
-
// Code block — GitHub-style light gray bg, no border
|
|
547
|
-
"pre code":
|
|
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;`,
|
|
548
564
|
pre: "margin: 1.5em 0;",
|
|
549
565
|
// Lists
|
|
550
|
-
ul:
|
|
551
|
-
ol:
|
|
552
|
-
li:
|
|
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;",
|
|
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;`,
|
|
557
569
|
// Links — WeChat blue
|
|
558
570
|
a: "color: #576b95; text-decoration: none;",
|
|
559
571
|
// Images
|
|
560
|
-
img: "max-width: 100%; height: auto; display: block; margin: 1.5em auto; border-radius:
|
|
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;`,
|
|
561
577
|
// Horizontal rule
|
|
562
578
|
hr: "border: none; height: 1px; background: #d2d2d7; margin: 2em 0;",
|
|
563
579
|
// Inline formatting
|
|
@@ -569,12 +585,12 @@ const defaultStyles = {
|
|
|
569
585
|
sup: "font-size: 0.75em; vertical-align: super; color: #576b95;",
|
|
570
586
|
sub: "font-size: 0.75em; vertical-align: sub;",
|
|
571
587
|
// Section / figure
|
|
572
|
-
section:
|
|
573
|
-
figcaption:
|
|
588
|
+
section: `${F} margin: 0.5em 0;`,
|
|
589
|
+
figcaption: `${F} font-size: 13px; color: #86868b; text-align: center; margin-top: 8px;`,
|
|
574
590
|
figure: "margin: 1.5em 0; text-align: center;",
|
|
575
591
|
// Mark
|
|
576
592
|
mark: "background: #fff3b0; padding: 0.1em 0.3em; border-radius: 2px;",
|
|
577
|
-
// hljs —
|
|
593
|
+
// hljs — default syntax highlighting theme (matching light code block background)
|
|
578
594
|
"hljs-keyword": "color: #9b2393; font-weight: 600;",
|
|
579
595
|
"hljs-string": "color: #c41a16;",
|
|
580
596
|
"hljs-number": "color: #1c00cf;",
|
|
@@ -850,7 +866,7 @@ const rehypeMermaid = (options = {}) => {
|
|
|
850
866
|
}
|
|
851
867
|
};
|
|
852
868
|
};
|
|
853
|
-
async function render(options) {
|
|
869
|
+
async function render$1(options) {
|
|
854
870
|
const { input, output } = options;
|
|
855
871
|
const inputPath = path.resolve(input);
|
|
856
872
|
if (!fs.existsSync(inputPath)) {
|
|
@@ -867,20 +883,162 @@ async function render(options) {
|
|
|
867
883
|
const outputPath = path.resolve(output);
|
|
868
884
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
869
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
|
+
}
|
|
870
892
|
return {
|
|
871
893
|
input: inputPath,
|
|
872
894
|
output: outputPath,
|
|
873
|
-
size: Buffer.byteLength(html, "utf-8")
|
|
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 } : {}
|
|
874
1023
|
};
|
|
875
1024
|
}
|
|
876
1025
|
const program = new Command();
|
|
877
|
-
|
|
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("mdpress").description("Convert Markdown into editor-ready HTML (WeChat MP or X Articles)").version("0.2.1").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) => {
|
|
878
1033
|
try {
|
|
879
|
-
const
|
|
1034
|
+
const target = normalizeTarget(options.target);
|
|
1035
|
+
const renderer = target === "wechat" ? render$1 : render;
|
|
1036
|
+
const result = await renderer({
|
|
880
1037
|
input: options.input,
|
|
881
|
-
output: options.output
|
|
1038
|
+
output: options.output,
|
|
1039
|
+
copy: options.copy
|
|
882
1040
|
});
|
|
883
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1041
|
+
console.log(JSON.stringify({ target, ...result }, null, 2));
|
|
884
1042
|
} catch (error) {
|
|
885
1043
|
console.error("Error:", error instanceof Error ? error.message : error);
|
|
886
1044
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liustack/mdpress",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI for AI agents to convert Markdown into WeChat MP-ready HTML with inline styles, base64 images, and tag sanitization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"node": ">=18"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@crosscopy/clipboard": "^0.3.6",
|
|
35
36
|
"@types/hast": "^3.0.4",
|
|
36
37
|
"commander": "^13.1.0",
|
|
37
38
|
"rehype-highlight": "^7.0.2",
|
|
@@ -42,9 +43,7 @@
|
|
|
42
43
|
"remark-rehype": "^11.1.1",
|
|
43
44
|
"sharp": "^0.34.5",
|
|
44
45
|
"unified": "^11.0.5",
|
|
45
|
-
"unist-util-visit": "^5.0.0"
|
|
46
|
-
},
|
|
47
|
-
"optionalDependencies": {
|
|
46
|
+
"unist-util-visit": "^5.0.0",
|
|
48
47
|
"mermaid": "^11.4.1",
|
|
49
48
|
"playwright": "^1.52.0"
|
|
50
49
|
},
|