@liustack/mdpress 0.2.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
 
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];
@@ -503,35 +504,27 @@ const rehypeFootnoteLinks = () => {
503
504
  return [SKIP, index + replacement.length];
504
505
  });
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
+ }
506
515
  const referencesSection = {
507
516
  type: "element",
508
517
  tagName: "section",
509
- properties: {},
518
+ properties: { style: "color: #86868b; font-size: 13px; line-height: 1.75; margin-top: 2em; word-break: break-all;" },
510
519
  children: [
511
- { type: "element", tagName: "hr", properties: {}, children: [] },
512
520
  {
513
521
  type: "element",
514
- tagName: "p",
515
- properties: {},
516
- children: [
517
- {
518
- type: "element",
519
- tagName: "strong",
520
- properties: {},
521
- children: [{ type: "text", value: "References" }]
522
- }
523
- ]
522
+ tagName: "strong",
523
+ properties: { style: "color: #86868b;" },
524
+ children: [{ type: "text", value: "References:" }]
524
525
  },
525
- ...footnotes.map(
526
- (fn) => ({
527
- type: "element",
528
- tagName: "p",
529
- properties: {},
530
- children: [
531
- { type: "text", value: `[${fn.index}] ${fn.text}: ${fn.url}` }
532
- ]
533
- })
534
- )
526
+ { type: "element", tagName: "br", properties: {}, children: [] },
527
+ ...footnoteChildren
535
528
  ]
536
529
  };
537
530
  tree.children.push(referencesSection);
@@ -554,13 +547,12 @@ const FONT_MONO = 'Menlo,Consolas,Monaco,"Courier New",monospace';
554
547
  const F = `font-family: ${FONT_BODY};`;
555
548
  const FM = `font-family: ${FONT_MONO};`;
556
549
  const defaultStyles = {
557
- // Headings — Apple style: 600 weight, tight letter-spacing
558
- h1: `${F} font-size: 24px; font-weight: 600; margin: 0 0 16px; line-height: 1.2; letter-spacing: -0.02em; color: #1d1d1f;`,
550
+ // Headings — 600 weight (h1 is downgraded to h2 by sanitize plugin)
559
551
  h2: `${F} font-size: 20px; font-weight: 600; margin: 32px 0 12px; line-height: 1.25; color: #1d1d1f;`,
560
552
  h3: `${F} font-size: 17px; font-weight: 600; margin: 24px 0 8px; line-height: 1.3; color: #1d1d1f;`,
561
- h4: `${F} font-size: 15px; font-weight: 600; margin: 20px 0 6px; line-height: 1.4; color: #6e6e73;`,
562
- h5: `${F} font-size: 14px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #6e6e73;`,
563
- h6: `${F} font-size: 13px; font-weight: 600; margin: 16px 0 4px; line-height: 1.4; color: #86868b;`,
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;`,
564
556
  // Paragraphs — 16px body, 1.75 line-height (comfortable for Chinese)
565
557
  p: `${F} font-size: 16px; line-height: 1.75; margin: 0 0 1.25em; color: #1d1d1f;`,
566
558
  // Blockquote — restrained: left border + italic only, no background
@@ -598,7 +590,7 @@ const defaultStyles = {
598
590
  figure: "margin: 1.5em 0; text-align: center;",
599
591
  // Mark
600
592
  mark: "background: #fff3b0; padding: 0.1em 0.3em; border-radius: 2px;",
601
- // hljs — Xcode Light theme (matching light code block background)
593
+ // hljs — default syntax highlighting theme (matching light code block background)
602
594
  "hljs-keyword": "color: #9b2393; font-weight: 600;",
603
595
  "hljs-string": "color: #c41a16;",
604
596
  "hljs-number": "color: #1c00cf;",
@@ -874,7 +866,7 @@ const rehypeMermaid = (options = {}) => {
874
866
  }
875
867
  };
876
868
  };
877
- async function render(options) {
869
+ async function render$1(options) {
878
870
  const { input, output } = options;
879
871
  const inputPath = path.resolve(input);
880
872
  if (!fs.existsSync(inputPath)) {
@@ -904,15 +896,149 @@ async function render(options) {
904
896
  ...copied ? { copied } : {}
905
897
  };
906
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
+ }
907
1025
  const program = new Command();
908
- program.name("mdpress").description("Convert Markdown into WeChat MP-ready HTML").version("0.2.0").requiredOption("-i, --input <path>", "Input Markdown file path").requiredOption("-o, --output <path>", "Output HTML file path").option("-c, --copy", "Copy rendered HTML to clipboard via Playwright").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) => {
909
1033
  try {
910
- const result = await render({
1034
+ const target = normalizeTarget(options.target);
1035
+ const renderer = target === "wechat" ? render$1 : render;
1036
+ const result = await renderer({
911
1037
  input: options.input,
912
1038
  output: options.output,
913
1039
  copy: options.copy
914
1040
  });
915
- console.log(JSON.stringify(result, null, 2));
1041
+ console.log(JSON.stringify({ target, ...result }, null, 2));
916
1042
  } catch (error) {
917
1043
  console.error("Error:", error instanceof Error ? error.message : error);
918
1044
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liustack/mdpress",
3
- "version": "0.2.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": {
@@ -43,9 +43,7 @@
43
43
  "remark-rehype": "^11.1.1",
44
44
  "sharp": "^0.34.5",
45
45
  "unified": "^11.0.5",
46
- "unist-util-visit": "^5.0.0"
47
- },
48
- "optionalDependencies": {
46
+ "unist-util-visit": "^5.0.0",
49
47
  "mermaid": "^11.4.1",
50
48
  "playwright": "^1.52.0"
51
49
  },