@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 CHANGED
@@ -1,20 +1,24 @@
1
1
  # mdpress
2
2
 
3
- A CLI toolkit for AI agents to convert Markdown into WeChat MP-ready HTML with inline styles, base64 images, and tag sanitization.
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 Xcode Light theme (inline colors)
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
- - Apple-inspired minimalist default style
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 Apple-style theme (optional, requires `mermaid` + `playwright`)
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** — Apple-inspired minimalist styles injected per tag, hljs classes converted to inline colors (Xcode Light theme), all `className` removed
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 文件输出为微信公众号编辑器兼容的 HTML,包含内联样式、base64 图片和标签清洗。
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
- - Xcode Light 主题语法高亮(内联色值)
14
+ - 默认极简主题语法高亮(内联色值)
11
15
  - Mermaid 流程图渲染为 PNG(通过 Playwright,可选)
12
16
  - 外部链接自动转为脚注,文末附 References
13
17
  - 基于白名单的标签清洗(`div` → `section`,危险标签移除)
14
18
  - 支持 GFM(表格、删除线、任务列表 ☑/☐)
15
- - Apple 极简风格默认样式
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(Apple 风格主题,可选,需安装 `mermaid` + `playwright`)
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. **样式内联** — Apple 极简风格按标签注入默认样式,hljs 类名转为内联色值(Xcode Light 主题),移除所有 `className`
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
 
@@ -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 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
- return `data:${mime};base64,${buffer.toString("base64")}`;
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 width2 = 1080;
324
- while (width2 >= 100) {
325
- result2 = await sharp(buffer, { animated: true }).resize({ width: width2, withoutEnlargement: true }).gif().toBuffer();
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
- width2 = Math.floor(width2 * 0.7);
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 metadata = await sharp(buffer).metadata();
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 = width;
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 = width; jpegWidth >= 100; jpegWidth = Math.floor(jpegWidth * 0.8)) {
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(/^( +)/, (match) => " ".repeat(match.length));
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 isInternalLink(href) {
454
+ function classifyLink(href) {
439
455
  if (href.startsWith("//")) {
440
- return false;
456
+ return "external";
441
457
  }
442
458
  if (href.startsWith("#") || href.startsWith("/") || href.startsWith("./") || href.startsWith("../")) {
443
- return true;
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 true;
464
+ return "wechat";
449
465
  }
450
466
  } catch {
451
- return true;
467
+ return "anchor";
452
468
  }
453
- return false;
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
- if (isInternalLink(href)) 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
+ }
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: "p",
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
- ...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
- )
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 — 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;",
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: "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;',
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: "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;",
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: 8px;",
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: "margin: 0.5em 0;",
573
- figcaption: "font-size: 13px; color: #86868b; text-align: center; margin-top: 8px;",
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 — Xcode Light theme (matching light code block background)
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
- 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) => {
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 result = await render({
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.0",
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
  },