yuanflow-cli 0.1.27 → 0.1.29

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.
Files changed (36) hide show
  1. package/package.json +2 -4
  2. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/SKILL.md +136 -0
  3. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/scripts/wechat_format.py +391 -0
  4. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/bauhaus.json +352 -0
  5. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/bold-blue.json +338 -0
  6. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/bold-green.json +338 -0
  7. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/bold-navy.json +338 -0
  8. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/bytedance.json +346 -0
  9. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/chinese.json +340 -0
  10. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/coffee-house.json +335 -0
  11. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/elegant-blue.json +336 -0
  12. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/elegant-green.json +336 -0
  13. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/elegant-navy.json +336 -0
  14. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/focus-blue.json +336 -0
  15. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/focus-gold.json +336 -0
  16. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/focus-red.json +336 -0
  17. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/fresh-card.json +299 -0
  18. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/github.json +336 -0
  19. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/ink.json +329 -0
  20. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/lavender-dream.json +334 -0
  21. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/magazine.json +333 -0
  22. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/midnight.json +341 -0
  23. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/minimal-blue.json +320 -0
  24. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/minimal-gold.json +320 -0
  25. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/minimal-gray.json +320 -0
  26. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/minimal-navy.json +320 -0
  27. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/minimal-red.json +320 -0
  28. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/mint-fresh.json +338 -0
  29. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/newspaper.json +341 -0
  30. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/ocean-card.json +299 -0
  31. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/sports.json +341 -0
  32. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/sspai.json +337 -0
  33. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/sunset-amber.json +336 -0
  34. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/terracotta.json +335 -0
  35. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/warm-card.json +299 -0
  36. package/skills/yuanflow-skill//345/205/254/344/274/227/345/217/267/347/224/237/346/210/220/344/270/216/345/217/221/345/270/203/themes/wechat-native.json +336 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yuanflow-cli",
3
- "version": "0.1.27",
4
- "description": "YuanFlow 自媒体 API CLI 与 Skill 安装器。",
3
+ "version": "0.1.29",
4
+ "description": "YuanFlow 自媒体 API CLI 与 Skill 安装器。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -71,5 +71,3 @@
71
71
  "douyin"
72
72
  ]
73
73
  }
74
-
75
-
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: 公众号生成与发布
3
+ description: 当用户需要公众号文章创作、文章改写、Markdown 排版、主题预览、HTML 导出或推送到微信公众号草稿箱时使用。
4
+ ---
5
+
6
+ # 公众号生成与发布
7
+
8
+ 本 Skill 用于把公众号文章从“需求确认、内容生成、排版预览、导出 HTML、推送草稿箱”串成一个可控流程。它可以一次性完成全流程,也可以只做其中一段,例如只写文章、只生成封面建议、只做排版预览或只推送草稿箱。
9
+
10
+ ## 分类
11
+
12
+ 自媒体技能区
13
+
14
+ ## 开始前必须确认
15
+
16
+ 1. 用户是在已有文章上处理,还是完全重新创作。
17
+ 2. 用户本轮要做完整流程,还是只做写作、排版、封面建议、导出或草稿箱推送中的一部分。
18
+ 3. 排版结果使用 Markdown 还是 HTML。
19
+ - 如果选择 HTML,会把 Markdown 转成微信公众号编辑器可粘贴的内联样式 HTML。
20
+ 4. 是否需要推送到微信公众号草稿箱。草稿箱只负责生成草稿,正式发布必须由用户自行在公众号后台确认。
21
+
22
+ ## 内容创作流程
23
+
24
+ ### 已有文章处理
25
+
26
+ 1. 读取用户提供的原文、素材或 Markdown。
27
+ 2. 判断是否需要改写、润色、补结构、提炼标题、生成摘要或只做排版。
28
+ 3. 保留用户原文中的事实、数字、引用和语气约束,不擅自新增未证实的信息。
29
+
30
+ ### 完全重新创作
31
+
32
+ 1. 先明确主题、读者、账号定位、文章目的和期望风格。
33
+ 2. 使用 `自媒体知识库` 查询公众号创作相关知识点,根据主题构造 `domain` 和 `content_goal`。
34
+ 3. 结合知识库结果输出文章结构、标题候选和正文。
35
+ 4. 用户确认正文后再进入排版、导出或草稿箱流程。
36
+
37
+ ## AI 内容增强
38
+
39
+ Agent 在排版前应做结构化增强,但不能改变事实:
40
+
41
+ - 连续对话内容转成 `dialogue` 容器。
42
+ - 连续图片转成 `gallery` 容器。
43
+ - 金句、关键引用、核心观点转成 `callout` 容器。
44
+ - 步骤、时间线、对比、清单转成对应结构容器。
45
+ - 外部链接转脚注,避免微信编辑器粘贴后结构混乱。
46
+
47
+ ## 确定性排版脚本
48
+
49
+ 当前 Skill 内置 `scripts/wechat_format.py`,用于把 Markdown 转成微信可粘贴的内联样式 HTML。
50
+
51
+ ### 主题选择强制闸门
52
+
53
+ 当用户要求进入排版、预览、导出 HTML 或全流程时,Agent 必须先进入 `theme_select` 主题选择态。
54
+
55
+ 除非用户在本轮已明确指定某个主题 ID,否则不得直接执行 `wechat_format.py --theme ... --output ...`。正确顺序是:
56
+
57
+ 1. 文章正文确认或生成后,先写入 Markdown 源文件。
58
+ 2. 执行主题画廊命令,基于正文生成 20 个核心主题预览:
59
+
60
+ ```bash
61
+ python scripts/wechat_format.py --input article.md --theme-gallery --gallery-limit 20 --json
62
+ ```
63
+
64
+ 3. 使用 `content_workbench_update` 写入右侧 `内容创作` 工作台:
65
+ - `status`: `collecting`
66
+ - `activeStep`: `theme_select`
67
+ - `steps`: 必须包含 `主题选择`
68
+ - `views`: 至少包含脚本返回的 `option_grid` 主题卡片;可以同时附带 `editor_panel` 显示 Markdown 摘要。
69
+ 4. 明确告诉用户:请在右侧工作台选择一个主题,然后发送输入框上方出现的待执行任务。
70
+ 5. 只有拿到用户选择的主题后,才执行:
71
+
72
+ ```bash
73
+ python scripts/wechat_format.py --input article.md --theme <selected_theme> --output dist/wechat.html --preview dist/preview.html --json
74
+ ```
75
+
76
+ 主题选择不是装饰步骤,是排版导出前的执行闸门。用户说“完整执行”也不能跳过,除非用户同时明确指定主题。
77
+
78
+ 执行方式示例:
79
+
80
+ ```bash
81
+ python scripts/wechat_format.py --input article.md --theme newspaper --output dist/wechat.html --json
82
+ ```
83
+
84
+ 常用参数:
85
+
86
+ - `--input`:Markdown 文件路径。
87
+ - `--theme`:主题名,默认 `wechat-native`。主题来自 `themes/*.json`。
88
+ - `--output`:输出 HTML 文件路径。
89
+ - `--json`:同时输出结构化执行结果,便于 Agent 写入右侧工作台。
90
+ - `--list-themes`:输出所有主题元数据。
91
+ - `--theme-gallery`:基于文章生成可写入工作台 `option_grid` 的主题画廊 JSON。
92
+ - `--gallery-limit`:主题画廊数量,默认 20。
93
+
94
+ ## 工作台展示
95
+
96
+ 当右侧工作台存在 `内容创作` Tab 时,应使用 `content_workbench_update` 写入工作台,而不是写入数据面板。
97
+
98
+ 推荐写入结构:
99
+
100
+ - `appId`: `wechat_official_account`
101
+ - `title`: `公众号生成与发布`
102
+ - `status`: `started` / `collecting` / `completed` / `failed`
103
+ - `views`: 可组合使用 `stepper`、`option_grid`、`preview_frame`、`editor_panel`、`asset_gallery`、`action_bar`、`status_timeline`、`result_card`
104
+
105
+ 典型展示:
106
+
107
+ 1. `stepper`:需求确认、内容创作、主题选择、预览、导出、草稿箱。
108
+ 2. `option_grid`:主题卡片和文章预览结果。主题卡片必须带 `intent: select_wechat_theme`、`payload.theme` 和 `instruction`,让用户点击后形成待执行任务。
109
+ 3. `preview_frame`:手机样式 HTML 预览。
110
+ 4. `editor_panel`:Markdown 或 HTML 源码。
111
+ 5. `action_bar`:导出 HTML、推送草稿箱、继续改写等动作。
112
+
113
+ ## 公众号凭证
114
+
115
+ 如果用户首次要求推送草稿箱,需要配置微信公众号官方接口凭证:
116
+
117
+ - 官方入口:https://developers.weixin.qq.com/platform
118
+ - 必需信息:AppID、AppSecret
119
+ - 凭证由 YuanFlow Runtime 保存到用户数据目录的本地 secrets/config 中,Skill 不自行写入凭证文件,也不把密钥打印到对话里。
120
+
121
+ 如果当前环境没有可用的受控凭证保存工具,应提示用户先在程序设置或 Runtime 受控入口配置凭证,再继续草稿箱流程。
122
+
123
+ ## 封面
124
+
125
+ 封面不使用第三方生成接口。需要封面时,使用 YuanFlow 内置生图技能生成。
126
+
127
+ 建议比例:
128
+
129
+ - 横向封面:2.35:1
130
+ - 方图辅助:1:1
131
+
132
+ ## 输出要求
133
+
134
+ - 默认先给用户可确认的阶段性结果,不要一次性跳过用户选择。
135
+ - HTML 结果要适合复制到微信公众号编辑器。
136
+ - 草稿箱流程完成后只说明“已生成草稿箱草稿”,不要声称已发布。
@@ -0,0 +1,391 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import html
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ THEMES_DIR = ROOT / "themes"
13
+ CORE_THEME_IDS = [
14
+ "wechat-native",
15
+ "newspaper",
16
+ "magazine",
17
+ "sspai",
18
+ "github",
19
+ "chinese",
20
+ "ink",
21
+ "bauhaus",
22
+ "bytedance",
23
+ "sports",
24
+ "midnight",
25
+ "coffee-house",
26
+ "terracotta",
27
+ "mint-fresh",
28
+ "lavender-dream",
29
+ "fresh-card",
30
+ "ocean-card",
31
+ "warm-card",
32
+ "focus-gold",
33
+ "sunset-amber",
34
+ ]
35
+
36
+
37
+ def _style(style_map: dict[str, Any]) -> str:
38
+ parts: list[str] = []
39
+ for key, value in style_map.items():
40
+ css_key = key.replace("_", "-")
41
+ parts.append(f"{css_key}:{value}")
42
+ return ";".join(parts)
43
+
44
+
45
+ def _load_theme(theme_name: str) -> dict[str, Any]:
46
+ safe_name = re.sub(r"[^a-zA-Z0-9_-]", "", theme_name or "wechat-native")
47
+ path = THEMES_DIR / f"{safe_name}.json"
48
+ if not path.exists():
49
+ path = THEMES_DIR / "wechat-native.json"
50
+ if not path.exists():
51
+ return {"name": "YuanFlow", "styles": {}}
52
+ return json.loads(path.read_text(encoding="utf-8"))
53
+
54
+
55
+ def _theme_id_from_path(path: Path) -> str:
56
+ return path.stem
57
+
58
+
59
+ def _load_theme_by_id(theme_id: str) -> dict[str, Any]:
60
+ path = THEMES_DIR / f"{theme_id}.json"
61
+ if not path.exists():
62
+ return {"name": theme_id, "description": "", "styles": {}, "colors": {}}
63
+ return json.loads(path.read_text(encoding="utf-8"))
64
+
65
+
66
+ def list_themes(*, core_only: bool = False, limit: int | None = None) -> list[dict[str, Any]]:
67
+ if core_only:
68
+ theme_ids = [theme_id for theme_id in CORE_THEME_IDS if (THEMES_DIR / f"{theme_id}.json").exists()]
69
+ else:
70
+ theme_ids = sorted(_theme_id_from_path(path) for path in THEMES_DIR.glob("*.json"))
71
+ if limit and limit > 0:
72
+ theme_ids = theme_ids[:limit]
73
+
74
+ items: list[dict[str, Any]] = []
75
+ for theme_id in theme_ids:
76
+ theme = _load_theme_by_id(theme_id)
77
+ colors = theme.get("colors") if isinstance(theme.get("colors"), dict) else {}
78
+ items.append(
79
+ {
80
+ "id": theme_id,
81
+ "name": theme.get("name") or theme_id,
82
+ "description": theme.get("description") or "",
83
+ "accent": colors.get("accent") or colors.get("primary") or "#1d4ed8",
84
+ "background": colors.get("background") or "#ffffff",
85
+ }
86
+ )
87
+ return items
88
+
89
+
90
+ def _gallery_markdown_sample(markdown: str) -> str:
91
+ content = markdown.strip()
92
+ if len(content) <= 1200:
93
+ return content
94
+ return f"{content[:1200].rstrip()}\n\n> 预览仅截取正文开头,完整排版会使用全文。"
95
+
96
+
97
+ def build_theme_gallery(markdown: str, title: str, *, limit: int = 20) -> dict[str, Any]:
98
+ sample = _gallery_markdown_sample(markdown)
99
+ themes = []
100
+ for theme_meta in list_themes(core_only=True, limit=limit):
101
+ theme_id = theme_meta["id"]
102
+ theme = _load_theme_by_id(theme_id)
103
+ preview_body = markdown_to_wechat_html(sample, theme)
104
+ preview_html = build_preview(preview_body, f"{theme_meta['name']} · 主题预览")
105
+ themes.append(
106
+ {
107
+ "id": theme_id,
108
+ "label": theme_meta["name"],
109
+ "title": theme_meta["name"],
110
+ "summary": theme_meta["description"],
111
+ "description": theme_meta["description"],
112
+ "accent": theme_meta["accent"],
113
+ "background": theme_meta["background"],
114
+ "previewHtml": preview_html,
115
+ "intent": "select_wechat_theme",
116
+ "payload": {
117
+ "theme": theme_id,
118
+ "themeName": theme_meta["name"],
119
+ "title": title,
120
+ },
121
+ "instruction": (
122
+ f"选择公众号主题「{theme_meta['name']}」(theme={theme_id}),"
123
+ "继续执行排版并导出 HTML。"
124
+ ),
125
+ }
126
+ )
127
+ return {
128
+ "ok": True,
129
+ "title": title,
130
+ "themes": themes,
131
+ "views": [
132
+ {
133
+ "id": "wechat-theme-gallery",
134
+ "type": "option_grid",
135
+ "title": "选择公众号排版主题",
136
+ "summary": "点击一个主题卡片后,发送输入框上方的待执行任务,Agent 才能继续排版导出。",
137
+ "items": themes,
138
+ }
139
+ ],
140
+ }
141
+
142
+
143
+ def _paragraph(text: str, styles: dict[str, Any]) -> str:
144
+ return f'<p style="{_style(styles.get("p", {}))}">{html.escape(text)}</p>'
145
+
146
+
147
+ def _render_list(items: list[str], ordered: bool, styles: dict[str, Any]) -> str:
148
+ tag = "ol" if ordered else "ul"
149
+ li_style = _style(styles.get("li", styles.get("p", {})))
150
+ rendered = "".join(f'<li style="{li_style}">{html.escape(item)}</li>' for item in items)
151
+ return f'<{tag} style="{_style(styles.get(tag, {}))}">{rendered}</{tag}>'
152
+
153
+
154
+ def _render_image(line: str, styles: dict[str, Any]) -> str | None:
155
+ match = re.match(r"!\[([^\]]*)\]\(([^)]+)\)", line.strip())
156
+ if not match:
157
+ return None
158
+ alt, src = match.groups()
159
+ img_style = _style(
160
+ styles.get(
161
+ "img",
162
+ {
163
+ "max_width": "100%",
164
+ "border_radius": "8px",
165
+ "display": "block",
166
+ "margin": "16px auto",
167
+ },
168
+ )
169
+ )
170
+ return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}" style="{img_style}" />'
171
+
172
+
173
+ def _flush_paragraph(buffer: list[str], output: list[str], styles: dict[str, Any]) -> None:
174
+ if not buffer:
175
+ return
176
+ output.append(_paragraph(" ".join(buffer).strip(), styles))
177
+ buffer.clear()
178
+
179
+
180
+ def markdown_to_wechat_html(markdown: str, theme: dict[str, Any]) -> str:
181
+ styles = theme.get("styles") if isinstance(theme.get("styles"), dict) else {}
182
+ wrapper_style = _style(
183
+ styles.get(
184
+ "wrapper",
185
+ {
186
+ "background_color": "#ffffff",
187
+ "padding": "16px",
188
+ "font_family": "-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif",
189
+ },
190
+ )
191
+ )
192
+ output: list[str] = [f'<section data-yuanflow="wechat-article" style="{wrapper_style}">']
193
+ lines = markdown.splitlines()
194
+ if lines and lines[0].strip() == "---":
195
+ for index, line in enumerate(lines[1:], start=1):
196
+ if line.strip() == "---":
197
+ lines = lines[index + 1 :]
198
+ break
199
+ paragraph_buffer: list[str] = []
200
+ unordered: list[str] = []
201
+ ordered: list[str] = []
202
+ in_code = False
203
+ code_lines: list[str] = []
204
+
205
+ def flush_lists() -> None:
206
+ if unordered:
207
+ output.append(_render_list(unordered, False, styles))
208
+ unordered.clear()
209
+ if ordered:
210
+ output.append(_render_list(ordered, True, styles))
211
+ ordered.clear()
212
+
213
+ for raw_line in lines:
214
+ line = raw_line.rstrip()
215
+ stripped = line.strip()
216
+
217
+ if stripped.startswith("```"):
218
+ _flush_paragraph(paragraph_buffer, output, styles)
219
+ flush_lists()
220
+ if in_code:
221
+ code_style = _style(styles.get("code_block", styles.get("pre", {})))
222
+ output.append(
223
+ f'<pre style="{code_style}"><code>{html.escape(chr(10).join(code_lines))}</code></pre>'
224
+ )
225
+ code_lines.clear()
226
+ in_code = False
227
+ else:
228
+ in_code = True
229
+ continue
230
+
231
+ if in_code:
232
+ code_lines.append(line)
233
+ continue
234
+
235
+ if not stripped:
236
+ _flush_paragraph(paragraph_buffer, output, styles)
237
+ flush_lists()
238
+ continue
239
+
240
+ image_html = _render_image(stripped, styles)
241
+ if image_html:
242
+ _flush_paragraph(paragraph_buffer, output, styles)
243
+ flush_lists()
244
+ output.append(image_html)
245
+ continue
246
+
247
+ heading = re.match(r"^(#{1,3})\s+(.+)$", stripped)
248
+ if heading:
249
+ _flush_paragraph(paragraph_buffer, output, styles)
250
+ flush_lists()
251
+ level = len(heading.group(1))
252
+ tag = f"h{level}"
253
+ text = html.escape(heading.group(2).strip())
254
+ output.append(f'<{tag} style="{_style(styles.get(tag, {}))}">{text}</{tag}>')
255
+ continue
256
+
257
+ quote = re.match(r"^>\s?(.+)$", stripped)
258
+ if quote:
259
+ _flush_paragraph(paragraph_buffer, output, styles)
260
+ flush_lists()
261
+ output.append(
262
+ f'<blockquote style="{_style(styles.get("blockquote", {}))}">{html.escape(quote.group(1))}</blockquote>'
263
+ )
264
+ continue
265
+
266
+ ordered_match = re.match(r"^\d+\.\s+(.+)$", stripped)
267
+ if ordered_match:
268
+ _flush_paragraph(paragraph_buffer, output, styles)
269
+ if unordered:
270
+ output.append(_render_list(unordered, False, styles))
271
+ unordered.clear()
272
+ ordered.append(ordered_match.group(1))
273
+ continue
274
+
275
+ unordered_match = re.match(r"^[-*]\s+(.+)$", stripped)
276
+ if unordered_match:
277
+ _flush_paragraph(paragraph_buffer, output, styles)
278
+ if ordered:
279
+ output.append(_render_list(ordered, True, styles))
280
+ ordered.clear()
281
+ unordered.append(unordered_match.group(1))
282
+ continue
283
+
284
+ flush_lists()
285
+ paragraph_buffer.append(stripped)
286
+
287
+ _flush_paragraph(paragraph_buffer, output, styles)
288
+ flush_lists()
289
+ output.append("</section>")
290
+ return "\n".join(output)
291
+
292
+
293
+ def build_preview(html_body: str, title: str) -> str:
294
+ escaped_title = html.escape(title or "公众号预览")
295
+ return f"""<!doctype html>
296
+ <html lang="zh-CN">
297
+ <head>
298
+ <meta charset="utf-8" />
299
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
300
+ <title>{escaped_title}</title>
301
+ <style>
302
+ body {{ margin: 0; background: #f4f6f8; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
303
+ .phone {{ width: min(430px, 100%); min-height: 100vh; margin: 0 auto; background: #fff; box-shadow: 0 12px 32px rgba(15, 23, 42, .12); }}
304
+ .bar {{ padding: 14px 18px; border-bottom: 1px solid #eef2f7; font-weight: 700; }}
305
+ .content {{ padding: 14px 18px 28px; }}
306
+ </style>
307
+ </head>
308
+ <body>
309
+ <main class="phone">
310
+ <div class="bar">{escaped_title}</div>
311
+ <div class="content">{html_body}</div>
312
+ </main>
313
+ </body>
314
+ </html>"""
315
+
316
+
317
+ def main() -> int:
318
+ parser = argparse.ArgumentParser(description="YuanFlow 微信公众号 Markdown 排版工具")
319
+ parser.add_argument("--input", default="", help="Markdown 输入文件")
320
+ parser.add_argument("--theme", default="wechat-native", help="主题名称")
321
+ parser.add_argument("--output", default="", help="HTML 输出文件")
322
+ parser.add_argument("--title", default="公众号生成与发布", help="预览标题")
323
+ parser.add_argument("--preview", default="", help="可选预览 HTML 输出文件")
324
+ parser.add_argument("--list-themes", action="store_true", help="输出可用主题列表")
325
+ parser.add_argument("--theme-gallery", action="store_true", help="基于文章生成主题选择画廊 JSON")
326
+ parser.add_argument("--gallery-limit", type=int, default=20, help="主题画廊数量,默认 20")
327
+ parser.add_argument("--json", action="store_true", help="输出 JSON 执行结果")
328
+ args = parser.parse_args()
329
+
330
+ if args.list_themes:
331
+ payload = {"ok": True, "themes": list_themes()}
332
+ print(json.dumps(payload, ensure_ascii=False))
333
+ return 0
334
+
335
+ if not args.input:
336
+ raise SystemExit("--input is required unless --list-themes is used")
337
+
338
+ input_path = Path(args.input).resolve()
339
+ markdown = input_path.read_text(encoding="utf-8")
340
+
341
+ if args.theme_gallery:
342
+ payload = build_theme_gallery(markdown, args.title, limit=args.gallery_limit)
343
+ if args.output:
344
+ output_path = Path(args.output).resolve()
345
+ output_path.parent.mkdir(parents=True, exist_ok=True)
346
+ output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
347
+ payload["output"] = str(output_path)
348
+ if args.json or not args.output:
349
+ print(json.dumps(payload, ensure_ascii=False))
350
+ return 0
351
+
352
+ if not args.output:
353
+ raise SystemExit("--output is required for HTML export")
354
+
355
+ output_path = Path(args.output).resolve()
356
+ theme = _load_theme(args.theme)
357
+ html_body = markdown_to_wechat_html(markdown, theme)
358
+ output_path.parent.mkdir(parents=True, exist_ok=True)
359
+ output_path.write_text(html_body, encoding="utf-8")
360
+
361
+ preview_path = Path(args.preview).resolve() if args.preview else output_path.with_name("preview.html")
362
+ preview_path.write_text(build_preview(html_body, args.title), encoding="utf-8")
363
+
364
+ result = {
365
+ "ok": True,
366
+ "theme": args.theme,
367
+ "themeName": theme.get("name") or args.theme,
368
+ "output": str(output_path),
369
+ "preview": str(preview_path),
370
+ "views": [
371
+ {
372
+ "id": "preview",
373
+ "type": "preview_frame",
374
+ "title": "公众号手机预览",
375
+ "html": preview_path.read_text(encoding="utf-8"),
376
+ },
377
+ {
378
+ "id": "html",
379
+ "type": "editor_panel",
380
+ "title": "微信内联 HTML",
381
+ "content": html_body,
382
+ },
383
+ ],
384
+ }
385
+ if args.json:
386
+ print(json.dumps(result, ensure_ascii=False))
387
+ return 0
388
+
389
+
390
+ if __name__ == "__main__":
391
+ raise SystemExit(main())