@wenyan-md/cli 1.0.8 → 1.0.10

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.
@@ -0,0 +1,147 @@
1
+ <div align="center">
2
+ <img alt="logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" width="120" />
3
+ </div>
4
+
5
+ # wenyan-cli (Docker)
6
+
7
+ **Render Markdown to beautifully styled articles and publish to content platforms — powered by Docker.**
8
+
9
+ > This image bundles **wenyan CLI** and all required runtime dependencies.
10
+ > No local Node.js or npm environment required.
11
+
12
+ ## Quick Start
13
+
14
+ ### Pull image
15
+
16
+ ```bash
17
+ docker pull caol64/wenyan-cli
18
+ ```
19
+
20
+ ### Show help
21
+
22
+ ```bash
23
+ docker run --rm caol64/wenyan-cli --help
24
+ ```
25
+
26
+ ## Basic Usage
27
+
28
+ Render and publish a Markdown file to WeChat Official Account draft box:
29
+
30
+ ```bash
31
+ docker run --rm \
32
+ --env-file .env \
33
+ -e HOST_FILE_PATH=$(pwd) \
34
+ -v $(pwd):/mnt/host-downloads \
35
+ caol64/wenyan-cli \
36
+ publish -f ./article.md
37
+ ```
38
+
39
+ Render Markdown content directly:
40
+
41
+ ```bash
42
+ docker run --rm caol64/wenyan-cli \
43
+ render "# Hello Wenyan"
44
+ ```
45
+
46
+ ## Working with Local Files (Recommended)
47
+
48
+ When using local Markdown or image files, mount the current directory:
49
+
50
+ ```bash
51
+ docker run --rm \
52
+ -e HOST_FILE_PATH=$(pwd) \
53
+ -v $(pwd):/mnt/host-downloads \
54
+ caol64/wenyan-cli \
55
+ publish -f ./example.md
56
+ ```
57
+
58
+ **How it works:**
59
+
60
+ | Path | Description |
61
+ | --------------------- | ----------------------------- |
62
+ | `HOST_FILE_PATH` | Absolute path on host machine |
63
+ | `/mnt/host-downloads` | Mounted path inside container |
64
+
65
+ All file paths in Markdown (cover / images) should reference host paths.
66
+
67
+ ## Input Methods
68
+
69
+ `publish` supports **exactly one** input source:
70
+
71
+ - `-f <file>` — read Markdown from local file
72
+ - `<input-content>` — inline Markdown string
73
+ - `stdin` — pipe from another command
74
+
75
+ Examples:
76
+
77
+ ```bash
78
+ cat article.md | docker run --rm -i caol64/wenyan-cli render
79
+ ```
80
+
81
+ ```bash
82
+ docker run --rm caol64/wenyan-cli render "# Title"
83
+ ```
84
+
85
+ ## Options
86
+
87
+ Commonly used options:
88
+
89
+ - `-t, --theme` — theme ID (default: `default`)
90
+ - `-h, --highlight` — code highlight theme
91
+ - `--no-mac-style` — disable macOS-style code blocks
92
+ - `--no-footnote` — disable link-to-footnote conversion
93
+
94
+ ## Markdown Frontmatter (Required)
95
+
96
+ Each Markdown file must include frontmatter:
97
+
98
+ ```md
99
+ ---
100
+ title: My Article Title
101
+ cover: /absolute/path/to/cover.jpg
102
+ ---
103
+ ```
104
+
105
+ - `title` — article title (required)
106
+ - `cover` — optional cover image (local or remote)
107
+
108
+ ## Environment Variables
109
+
110
+ Publishing to WeChat requires:
111
+
112
+ - `WECHAT_APP_ID`
113
+ - `WECHAT_APP_SECRET`
114
+
115
+ Recommended usage with `.env` file:
116
+
117
+ ```env
118
+ WECHAT_APP_ID=xxx
119
+ WECHAT_APP_SECRET=yyy
120
+ ```
121
+
122
+ ## Image Details
123
+
124
+ - Entrypoint: `wenyan`
125
+ - Runtime: Node.js (bundled)
126
+ - Architecture: `linux/amd64`, `linux/arm64`
127
+
128
+ ## License
129
+
130
+ Apache License Version 2.0
131
+
132
+ ### Tip
133
+
134
+ For frequent usage, create an alias:
135
+
136
+ ```bash
137
+ alias wenyan='docker run --rm \
138
+ -e HOST_FILE_PATH=$(pwd) \
139
+ -v $(pwd):/mnt/host-downloads \
140
+ caol64/wenyan-cli'
141
+ ```
142
+
143
+ Then use it like a native CLI:
144
+
145
+ ```bash
146
+ wenyan publish -f article.md
147
+ ```
package/README.md CHANGED
@@ -7,165 +7,259 @@
7
7
  [![npm](https://img.shields.io/npm/v/@wenyan-md/cli)](https://www.npmjs.com/package/@wenyan-md/cli)
8
8
  [![License](https://img.shields.io/github/license/caol64/wenyan-cli)](LICENSE)
9
9
  ![NPM Downloads](https://img.shields.io/npm/dm/%40wenyan-md%2Fcli)
10
+ [![Docker Pulls](https://img.shields.io/docker/pulls/caol64/wenyan-cli)](https://hub.docker.com/r/caol64/wenyan-cli)
10
11
  [![Stars](https://img.shields.io/github/stars/caol64/wenyan-cli?style=social)](https://github.com/caol64/wenyan-cli)
11
12
 
12
- 「文颜」是一款多平台排版美化工具,让你将 Markdown 一键发布至微信公众号、知乎、今日头条等主流写作平台。
13
+ ## 简介
13
14
 
14
- **文颜**现已推出多个版本:
15
+ **[文颜(Wenyan)](https://wenyan.yuzhi.tech)** 是一款多平台 Markdown 排版与发布工具,支持将 Markdown 一键转换并发布至:
15
16
 
16
- * [macOS App Store 版](https://github.com/caol64/wenyan) - MAC 桌面应用
17
- * [跨平台版本](https://github.com/caol64/wenyan-pc) - Windows/Linux 跨平台桌面应用
18
- * [CLI 版本](https://github.com/caol64/wenyan-cli) - CI/CD 或脚本自动化发布公众号文章
19
- * [MCP 版本](https://github.com/caol64/wenyan-mcp) - 让 AI 自动发布公众号文章
20
- * [嵌入版本](https://github.com/caol64/wenyan-core) - 将文颜的核心功能嵌入 Node 或者 Web 项目
17
+ - 微信公众号
18
+ - 知乎
19
+ - 今日头条
20
+ - 以及其它内容平台(持续扩展中)
21
21
 
22
- 本项目是 **文颜的 CLI 版本**。
22
+ 文颜的目标是:**让写作者专注内容,而不是排版和平台适配**。
23
23
 
24
- ## 功能
24
+ ## 文颜的不同版本
25
25
 
26
- * 使用内置主题对 Markdown 内容排版
27
- * 支持图片自动上传
28
- * 支持数学公式渲染
29
- * 一键发布文章到微信公众号草稿箱
26
+ 文颜目前提供多种形态,覆盖不同使用场景:
30
27
 
31
- ## 主题效果
28
+ - [macOS App Store 版](https://github.com/caol64/wenyan) - MAC 桌面应用
29
+ - [跨平台桌面版](https://github.com/caol64/wenyan-pc) - Windows/Linux
30
+ - 👉 [CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
31
+ - [MCP 版本](https://github.com/caol64/wenyan-mcp) - AI 自动发文
32
+ - [核心库](https://github.com/caol64/wenyan-core) - 嵌入 Node / Web 项目
32
33
 
33
- 👉 [内置主题预览](https://yuzhi.tech/docs/wenyan/theme)
34
+ ## 安装方式
34
35
 
35
- 文颜采用了多个开源的 Typora 主题,在此向各位作者表示感谢:
36
+ ### 方式一:npm(推荐)
36
37
 
37
- - [Orange Heart](https://github.com/evgo2017/typora-theme-orange-heart)
38
- - [Rainbow](https://github.com/thezbm/typora-theme-rainbow)
39
- - [Lapis](https://github.com/YiNNx/typora-theme-lapis)
40
- - [Pie](https://github.com/kevinzhao2233/typora-theme-pie)
41
- - [Maize](https://github.com/BEATREE/typora-maize-theme)
42
- - [Purple](https://github.com/hliu202/typora-purple-theme)
43
- - [物理猫-薄荷](https://github.com/sumruler/typora-theme-phycat)
38
+ ```bash
39
+ npm install -g @wenyan-md/cli
40
+ ```
44
41
 
45
- ## 安装
42
+ 安装完成后即可使用:
46
43
 
44
+ ```bash
45
+ wenyan --help
47
46
  ```
48
- npm install -g @wenyan-md/cli
47
+
48
+ ### 方式二:Docker(无需 Node 环境)
49
+
50
+ 如果你不想在本地安装 Node.js,也可以直接使用 Docker。
51
+
52
+ **拉取镜像**
53
+
54
+ ```bash
55
+ docker pull caol64/wenyan-cli
56
+ ```
57
+
58
+ **查看帮助**
59
+
60
+ ```bash
61
+ docker run --rm caol64/wenyan-cli
62
+ ```
63
+
64
+ **发布文章示例**
65
+
66
+ ```bash
67
+ docker run --rm \
68
+ --env-file .env.test \
69
+ -e HOST_FILE_PATH=$(pwd) \
70
+ -v $(pwd):/mnt/host-downloads \
71
+ caol64/wenyan-cli \
72
+ publish -f ./test/publish.md -t phycat
49
73
  ```
50
74
 
75
+ > 说明:
76
+ >
77
+ > - 使用 `-e` 传入环境变量
78
+ > - 使用 `-v` 挂载本地 Markdown 文件
79
+ > - 容器启动即执行 `wenyan` 命令
80
+
51
81
  ## 基本用法
52
82
 
53
- 主命令为:
83
+ CLI 主命令:
54
84
 
55
85
  ```bash
56
86
  wenyan <command> [options]
57
87
  ```
58
88
 
59
- ## 环境变量
89
+ 目前支持的子命令有
90
+ - `publish` 排版并发布到公众号草稿箱
91
+ - `render` 仅排版,用做测试
92
+ - `theme` 主题管理
60
93
 
61
- 某些功能(如发布到微信公众号)需要配置以下环境变量:
94
+ ## 子命令
62
95
 
63
- * `WECHAT_APP_ID`
64
- * `WECHAT_APP_SECRET`
96
+ ### `publish`
65
97
 
66
- ### macOS / Linux
98
+ Markdown 转换为适配微信公众号的富文本 HTML,并上传到草稿箱。
99
+
100
+ #### 参数
101
+
102
+ - `<input-content>`
103
+
104
+ Markdown 内容,可以:
67
105
 
68
- 可在命令前临时设置:
106
+ - 直接作为参数传入
107
+ - 通过 stdin 管道输入
108
+
109
+ #### 常用选项
110
+
111
+ - `-t <theme-id>`:主题id(默认 `default`),可以是内置主题,也可以是通过`theme --add`添加的自定义主题
112
+ - [内置主题](https://github.com/caol64/wenyan-core/tree/main/src/assets/themes)
113
+ - `-h <highlight-theme-id>`:代码高亮主题(默认 `solarized-light`)
114
+ - atom-one-dark / atom-one-light / dracula / github-dark / github / monokai / solarized-dark / solarized-light / xcode
115
+ - `--no-mac-style`:关闭代码块 Mac 风格
116
+ - `--no-footnote`:关闭链接转脚注
117
+ - `-f <path>`:指定本地 Markdown 文件路径
118
+ - `-c <path>`:指定临时自定义主题路径,优先级大于`-t`
119
+
120
+ #### 使用示例
121
+
122
+ 直接传入内容:
69
123
 
70
124
  ```bash
71
- WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy wenyan publish "your markdown"
125
+ wenyan publish "# Hello, Wenyan" -t lapis -h solarized-light
72
126
  ```
73
127
 
74
- 或在 `~/.bashrc` / `~/.zshrc` 中永久添加:
128
+ 从管道读取:
75
129
 
76
130
  ```bash
77
- export WECHAT_APP_ID=xxx
78
- export WECHAT_APP_SECRET=yyy
131
+ cat example.md | wenyan publish -t lapis -h solarized-light --no-mac-style
79
132
  ```
80
133
 
81
- ### Windows (PowerShell)
134
+ 从文件读取:
82
135
 
83
- 临时设置:
136
+ ```bash
137
+ wenyan publish -f "./example.md" -t lapis -h solarized-light --no-mac-style
138
+ ```
84
139
 
85
- ```powershell
86
- $env:WECHAT_APP_ID="xxx"; $env:WECHAT_APP_SECRET="yyy"; wenyan publish "your markdown"
140
+ ### `theme`
141
+
142
+ 主题管理,浏览内置主题、添加/删除自定义主题。
143
+
144
+ #### 参数
145
+
146
+ 无。
147
+
148
+ #### 常用选项
149
+
150
+ - `-l`:列出所有可用主题
151
+ - `--add`:添加自定义主题(永久)
152
+ - `--name <name>`:主题名称
153
+ - `--path <path>`:主题路径(本地或网络)
154
+ - `--rm <name>`:删除自定义主题
155
+
156
+ #### 使用示例
157
+
158
+ 列出可用主题:
159
+
160
+ ```bash
161
+ wenyan theme -l
87
162
  ```
88
163
 
89
- 永久设置(在环境变量里添加):
164
+ 安装自定义主题
90
165
 
91
- 控制面板 → 系统和安全 → 系统 → 高级系统设置 → 环境变量 → 添加 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
166
+ ```bash
167
+ wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
168
+ ```
92
169
 
93
- ## 子命令
170
+ 删除自定义主题
94
171
 
95
- `publish`
172
+ ```bash
173
+ wenyan theme --rm new-theme
174
+ ```
96
175
 
97
- Markdown 转换为适配微信公众号的富文本 HTML 并上传到公众号。
176
+ ## 使用自定义主题
98
177
 
99
- ### 参数
178
+ 你可以通过两种途径使用自定义主题:
100
179
 
101
- - `<input-content>`,要转换的 Markdown 内容。可直接作为参数传入,或通过管道/重定向从 `stdin` 读取
180
+ - 不安装直接使用
102
181
 
103
- ### 选项
182
+ ```bash
183
+ wenyan publish -f "./example.md" -c "/path/to/theme" -h solarized-light --no-mac-style
184
+ ```
104
185
 
105
- - `-t`,主题id,默认`default`
106
- - default
107
- - orangeheart
108
- - rainbow
109
- - lapis
110
- - pie
111
- - maize
112
- - purple
113
- - phycat
114
- - `-h`,代码高亮主题,默认`solarized-light`
115
- - atom-one-dark
116
- - atom-one-light
117
- - dracula
118
- - github-dark
119
- - github
120
- - monokai
121
- - solarized-dark
122
- - solarized-light
123
- - xcode
124
- - 代码块默认使用 Mac 风格,如要关闭:`--no-mac-style`
125
- - 链接默认转脚注,如要关闭:`--no-footnote`
186
+ - 先安装再使用:
126
187
 
127
- ## 示例
188
+ ```bash
189
+ wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
190
+ wenyan publish -f "./example.md" -t new-theme -h solarized-light --no-mac-style
191
+ ```
128
192
 
129
- 直接传入内容:
193
+ 区别在于,安装后的主题永久有效。
194
+
195
+ ## 关于图片自动上传
196
+
197
+ 支持以下图片来源:
198
+
199
+ - 本地路径(如:`/Users/xxx/image.jpg`)
200
+ - 网络路径(如:`https://example.com/image.jpg`)
201
+
202
+ ## 环境变量配置
203
+
204
+ 部分功能(如发布微信公众号)需要配置以下环境变量:
205
+
206
+ - `WECHAT_APP_ID`
207
+ - `WECHAT_APP_SECRET`
208
+
209
+ ### macOS / Linux
210
+
211
+ 临时使用:
130
212
 
131
213
  ```bash
132
- wenyan publish "# Hello, Wenyan" -t lapis -h solarized-light
214
+ WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy wenyan publish "your markdown"
133
215
  ```
134
216
 
135
- 从文件读取:
217
+ 永久配置(推荐):
136
218
 
137
219
  ```bash
138
- cat example.md | wenyan publish -t lapis -h solarized-light --no-mac-style
220
+ export WECHAT_APP_ID=xxx
221
+ export WECHAT_APP_SECRET=yyy
222
+ ```
223
+
224
+ ### Windows (PowerShell)
225
+
226
+ 临时使用:
227
+
228
+ ```powershell
229
+ $env:WECHAT_APP_ID="xxx"
230
+ $env:WECHAT_APP_SECRET="yyy"
231
+ wenyan publish example.md
139
232
  ```
140
233
 
234
+ 永久设置(在环境变量里添加):
235
+
236
+ 控制面板 → 系统和安全 → 系统 → 高级系统设置 → 环境变量 → 添加 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
237
+
141
238
  ## 微信公众号 IP 白名单
142
239
 
143
- 请务必将服务器 IP 加入公众号平台的 IP 白名单,以确保上传接口调用成功。
144
- 详细配置说明请参考:[https://yuzhi.tech/docs/wenyan/upload](https://yuzhi.tech/docs/wenyan/upload)
240
+ > [!IMPORTANT]
241
+ >
242
+ > 请确保运行文颜的机器 IP 已加入微信公众号后台的 IP 白名单,否则上传接口将调用失败。
243
+
244
+ 配置说明文档:[https://yuzhi.tech/docs/wenyan/upload](https://yuzhi.tech/docs/wenyan/upload)
145
245
 
146
- ## 配置说明(Frontmatter
246
+ ## Markdown Frontmatter 说明(必读)
147
247
 
148
- 为了可以正确上传文章,需要在每一篇 Markdown 文章的开头添加一段`frontmatter`,提供`title`、`cover`两个字段:
248
+ 为了正确上传文章,每篇 Markdown 顶部需要包含 frontmatter
149
249
 
150
250
  ```md
151
251
  ---
152
252
  title: 在本地跑一个大语言模型(2) - 给模型提供外部知识库
153
- cover: /Users/lei/Downloads/result_image.jpg
253
+ cover: /Users/xxx/image.jpg
154
254
  ---
155
255
  ```
156
256
 
157
- * `title` 是文章标题,必填。
158
- * `cover` 是文章封面,支持本地路径和网络图片:
257
+ 字段说明:
159
258
 
160
- * 如果正文有至少一张图片,可省略,此时将使用其中一张作为封面;
161
- * 如果正文无图片,则必须提供 cover。
162
-
163
- ## 关于图片自动上传
164
-
165
- * 支持图片路径:
166
-
167
- * 本地路径(如:`/Users/lei/Downloads/result_image.jpg`)
168
- * 网络路径(如:`https://example.com/image.jpg`)
259
+ - `title` 文章标题(必填)
260
+ - `cover` 文章封面
261
+ - 本地路径或网络图片
262
+ - 如果正文中已有图片,可省略
169
263
 
170
264
  ## 示例文章格式
171
265
 
@@ -186,7 +280,9 @@ cover: /Users/lei/Downloads/result_image.jpg
186
280
 
187
281
  ## 赞助
188
282
 
189
- 如果您觉得不错,可以给我家猫咪买点罐头吃。[喂猫❤️](https://yuzhi.tech/sponsor)
283
+ 如果你觉得文颜对你有帮助,可以给我家猫咪买点罐头 ❤️
284
+
285
+ [https://yuzhi.tech/sponsor](https://yuzhi.tech/sponsor)
190
286
 
191
287
  ## License
192
288
 
package/dist/cli.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { createProgram } from "./index.js";
3
+ const program = createProgram();
4
+ program.parse(process.argv);
@@ -1,40 +1,21 @@
1
- import { getGzhContent } from "@wenyan-md/core/wrapper";
2
1
  import { publishToDraft } from "@wenyan-md/core/publish";
3
- import { readStdin } from "../utils.js";
2
+ import { prepareRenderContext, runCommandWrapper } from "./render.js";
4
3
  export async function publishCommand(inputContent, options) {
5
- try {
6
- if (!inputContent) {
7
- if (process.stdin.isTTY) {
8
- console.error("Error: missing input-content (no argument and no stdin).");
9
- process.exit(1);
10
- }
11
- inputContent = await readStdin();
12
- }
13
- const gzhContent = await getGzhContent(inputContent, options["theme"], options["highlight"], options["macStyle"], options["footnote"]);
14
- if (!gzhContent.title) {
15
- console.error("未能找到文章标题");
16
- process.exit(1);
17
- }
18
- if (!gzhContent.cover) {
19
- console.error("未能找到文章封面");
20
- process.exit(1);
21
- }
22
- const data = await publishToDraft(gzhContent.title, gzhContent.content, gzhContent.cover);
4
+ await runCommandWrapper(async () => {
5
+ const { gzhContent, absoluteDirPath } = await prepareRenderContext(inputContent, options);
6
+ if (!gzhContent.title)
7
+ throw new Error("Error: 未能找到文章标题");
8
+ if (!gzhContent.cover)
9
+ throw new Error("Error: 未能找到文章封面");
10
+ const data = await publishToDraft(gzhContent.title, gzhContent.content, gzhContent.cover, {
11
+ relativePath: absoluteDirPath,
12
+ });
23
13
  if (data.media_id) {
24
14
  console.log(`上传成功,media_id: ${data.media_id}`);
25
15
  }
26
16
  else {
27
17
  console.error(`上传失败,\n${data}`);
18
+ process.exit(1);
28
19
  }
29
- }
30
- catch (error) {
31
- if (error instanceof Error) {
32
- console.error("An unexpected error occurred during publishing:");
33
- console.error(error.message);
34
- }
35
- else {
36
- console.error("An unexpected error occurred:", error);
37
- }
38
- process.exit(1);
39
- }
20
+ });
40
21
  }
@@ -1,22 +1,61 @@
1
- import { getGzhContent } from "@wenyan-md/core/wrapper";
2
- import { readStdin } from "../utils.js";
3
- export async function renderCommand(inputContent, options) {
1
+ import { configStore, renderStyledContent } from "@wenyan-md/core/wrapper";
2
+ import { getNormalizeFilePath, readStdin } from "../utils.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ // --- 处理输入源、文件路径和主题 ---
6
+ export async function prepareRenderContext(inputContent, options) {
7
+ const { file, theme, customTheme, highlight, macStyle, footnote } = options;
8
+ let absoluteDirPath = undefined;
9
+ // 1. 尝试从 Stdin 读取
10
+ if (!inputContent && !process.stdin.isTTY) {
11
+ inputContent = await readStdin();
12
+ }
13
+ // 2. 尝试从文件读取
14
+ if (!inputContent && file) {
15
+ const normalizePath = getNormalizeFilePath(file);
16
+ inputContent = await fs.readFile(normalizePath, "utf-8");
17
+ absoluteDirPath = path.dirname(normalizePath);
18
+ }
19
+ // 3. 校验输入
20
+ if (!inputContent) {
21
+ throw new Error("Error: missing input-content (no argument, no stdin, and no file).");
22
+ }
23
+ let handledCustomTheme = customTheme;
24
+ // 4. 当用户传入自定义主题路径时,优先级最高
25
+ if (customTheme) {
26
+ const normalizePath = getNormalizeFilePath(customTheme);
27
+ handledCustomTheme = await fs.readFile(normalizePath, "utf-8");
28
+ }
29
+ else if (theme) {
30
+ // 否则尝试读取配置中的自定义主题
31
+ handledCustomTheme = configStore.getThemeById(theme);
32
+ }
33
+ if (!handledCustomTheme && !theme) {
34
+ throw new Error(`Error: theme "${theme}" not found.`);
35
+ }
36
+ // 5. 执行核心渲染
37
+ const gzhContent = await renderStyledContent(inputContent, {
38
+ themeId: theme,
39
+ hlThemeId: highlight,
40
+ isMacStyle: macStyle,
41
+ isAddFootnote: footnote,
42
+ themeCss: handledCustomTheme,
43
+ });
44
+ return { gzhContent, absoluteDirPath };
45
+ }
46
+ // --- 统一的错误处理包装器 ---
47
+ export async function runCommandWrapper(action) {
4
48
  try {
5
- if (!inputContent) {
6
- if (process.stdin.isTTY) {
7
- console.error("Error: missing input-content (no argument and no stdin).");
8
- process.exit(1);
9
- }
10
- inputContent = await readStdin();
11
- }
12
- const gzhContent = await getGzhContent(inputContent, options["theme"], options["highlight"], options["macStyle"], options["footnote"]);
13
- console.log(gzhContent.content);
14
- // process.exit(0);
49
+ await action();
15
50
  }
16
51
  catch (error) {
17
52
  if (error instanceof Error) {
18
- console.error("An unexpected error occurred during publishing:");
19
- console.error(error.message);
53
+ if (error.message.startsWith("Error:")) {
54
+ console.error(error.message);
55
+ }
56
+ else {
57
+ console.error("An unexpected error occurred:", error.message);
58
+ }
20
59
  }
21
60
  else {
22
61
  console.error("An unexpected error occurred:", error);
@@ -24,3 +63,9 @@ export async function renderCommand(inputContent, options) {
24
63
  process.exit(1);
25
64
  }
26
65
  }
66
+ export async function renderCommand(inputContent, options) {
67
+ await runCommandWrapper(async () => {
68
+ const { gzhContent } = await prepareRenderContext(inputContent, options);
69
+ console.log(gzhContent.content);
70
+ });
71
+ }
@@ -0,0 +1,78 @@
1
+ import { getAllGzhThemes } from "@wenyan-md/core";
2
+ import { getNormalizeFilePath } from "../utils.js";
3
+ import fs from "node:fs/promises";
4
+ import { configStore } from "@wenyan-md/core/wrapper";
5
+ export async function themeCommand(options) {
6
+ const { list, add, name, path, rm } = options;
7
+ if (list) {
8
+ listThemes();
9
+ return;
10
+ }
11
+ if (add) {
12
+ await addTheme(name, path);
13
+ return;
14
+ }
15
+ if (rm) {
16
+ await removeTheme(rm);
17
+ return;
18
+ }
19
+ }
20
+ function listThemes() {
21
+ const themes = getAllGzhThemes();
22
+ console.log("\n内置主题:");
23
+ themes.forEach((theme) => console.log(`- ${theme.meta.id}: ${theme.meta.description}`));
24
+ const customThemes = configStore.getThemes();
25
+ if (customThemes.length > 0) {
26
+ console.log("\n自定义主题:");
27
+ customThemes.forEach((theme) => {
28
+ console.log(`- ${theme.id}: ${theme.description ?? ""}`);
29
+ });
30
+ }
31
+ console.log("");
32
+ }
33
+ async function addTheme(name, path) {
34
+ if (!name || !path) {
35
+ console.log("❌ 添加主题时必须提供名称(name)和路径(path)\n");
36
+ return;
37
+ }
38
+ if (checkThemeExists(name) || checkCustomThemeExists(name)) {
39
+ console.log(`❌ 主题 "${name}" 已存在\n`);
40
+ return;
41
+ }
42
+ if (path.startsWith("http")) {
43
+ console.log(`⏳ 正在从远程获取主题: ${path} ...`);
44
+ const response = await fetch(path);
45
+ if (!response.ok) {
46
+ console.log(`❌ 无法从远程获取主题: ${response.statusText}\n`);
47
+ return;
48
+ }
49
+ const content = await response.text();
50
+ configStore.addThemeToConfig(name, content);
51
+ }
52
+ else {
53
+ const normalizePath = getNormalizeFilePath(path);
54
+ const content = await fs.readFile(normalizePath, "utf-8");
55
+ configStore.addThemeToConfig(name, content);
56
+ }
57
+ console.log(`✅ 主题 "${name}" 已添加\n`);
58
+ }
59
+ async function removeTheme(name) {
60
+ if (checkThemeExists(name)) {
61
+ console.log(`❌ 默认主题 "${name}" 不能删除\n`);
62
+ return;
63
+ }
64
+ if (!checkCustomThemeExists(name)) {
65
+ console.log(`❌ 自定义主题 "${name}" 不存在\n`);
66
+ return;
67
+ }
68
+ configStore.deleteThemeFromConfig(name);
69
+ console.log(`✅ 主题 "${name}" 已删除\n`);
70
+ }
71
+ function checkThemeExists(themeId) {
72
+ const themes = getAllGzhThemes();
73
+ return themes.some((theme) => theme.meta.id === themeId);
74
+ }
75
+ function checkCustomThemeExists(themeId) {
76
+ const customThemes = configStore.getThemes();
77
+ return customThemes.some((theme) => theme.id === themeId);
78
+ }
package/dist/index.js CHANGED
@@ -2,35 +2,42 @@ import { Command } from "commander";
2
2
  import { publishCommand } from "./commands/publish.js";
3
3
  import { renderCommand } from "./commands/render.js";
4
4
  import pkg from "../package.json" with { type: "json" };
5
- const program = new Command();
6
- program
7
- .name("wenyan")
8
- .description("A CLI for WenYan Markdown Render.")
9
- .version(pkg.version, "-v, --version", "output the current version")
10
- .action(() => {
11
- program.outputHelp();
12
- return;
13
- });
14
- program
15
- .command("publish")
16
- .description("Render a markdown file to styled HTML and publish to wechat GZH")
17
- .argument("[input-content]", "content of the input markdown file")
18
- .option("-t, --theme <theme-id>", "ID of the theme to use", "default")
19
- .option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
20
- .option("--mac-style", "display codeblock with mac style", true)
21
- .option("--no-mac-style", "disable mac style")
22
- .option("--footnote", "convert link to footnote", true)
23
- .option("--no-footnote", "disable footnote")
24
- .action(publishCommand);
25
- program
26
- .command("render")
27
- .description("Render a markdown file to styled HTML")
28
- .argument("[input-content]", "content of the input markdown file")
29
- .option("-t, --theme <theme-id>", "ID of the theme to use", "default")
30
- .option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
31
- .option("--mac-style", "display codeblock with mac style", true)
32
- .option("--no-mac-style", "disable mac style")
33
- .option("--footnote", "convert link to footnote", true)
34
- .option("--no-footnote", "disable footnote")
35
- .action(renderCommand);
36
- program.parse();
5
+ import { themeCommand } from "./commands/theme.js";
6
+ export function createProgram(version = pkg.version) {
7
+ const program = new Command();
8
+ program
9
+ .name("wenyan")
10
+ .description("A CLI for WenYan Markdown Render.")
11
+ .version(version, "-v, --version", "output the current version")
12
+ .action(() => {
13
+ program.outputHelp();
14
+ });
15
+ const addCommonOptions = (cmd) => {
16
+ return cmd
17
+ .argument("[input-content]", "markdown content (string input)")
18
+ .option("-f, --file <path>", "read markdown content from local file")
19
+ .option("-t, --theme <theme-id>", "ID of the theme to use", "default")
20
+ .option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
21
+ .option("-c, --custom-theme <path>", "path to custom theme CSS file")
22
+ .option("--mac-style", "display codeblock with mac style", true)
23
+ .option("--no-mac-style", "disable mac style")
24
+ .option("--footnote", "convert link to footnote", true)
25
+ .option("--no-footnote", "disable footnote");
26
+ };
27
+ const pubCmd = program
28
+ .command("publish")
29
+ .description("Render a markdown file to styled HTML and publish to wechat GZH");
30
+ addCommonOptions(pubCmd).action(publishCommand);
31
+ const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
32
+ addCommonOptions(renderCmd).action(renderCommand);
33
+ program
34
+ .command("theme")
35
+ .description("Manage themes")
36
+ .option("-l, --list", "List all available themes")
37
+ .option("--add", "Add a new custom theme")
38
+ .option("--name <name>", "Name of the new custom theme")
39
+ .option("--path <path>", "Path to the new custom theme CSS file")
40
+ .option("--rm <name>", "Name of the custom theme to remove")
41
+ .action(themeCommand);
42
+ return program;
43
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -1,8 +1,2 @@
1
- interface RenderOptions {
2
- output?: string;
3
- theme: string;
4
- highlight: string;
5
- macStyle: boolean;
6
- }
7
- export declare function publishCommand(inputContent: string, options: RenderOptions): Promise<void>;
8
- export {};
1
+ import { RenderOptions } from "../types.js";
2
+ export declare function publishCommand(inputContent: string | undefined, options: RenderOptions): Promise<void>;
@@ -1,8 +1,7 @@
1
- interface RenderOptions {
2
- output?: string;
3
- theme: string;
4
- highlight: string;
5
- macStyle: boolean;
6
- }
1
+ import { RenderOptions } from "../types.js";
2
+ export declare function prepareRenderContext(inputContent: string | undefined, options: RenderOptions): Promise<{
3
+ gzhContent: import("@wenyan-md/core/wrapper").StyledContent;
4
+ absoluteDirPath: string | undefined;
5
+ }>;
6
+ export declare function runCommandWrapper(action: () => Promise<void>): Promise<void>;
7
7
  export declare function renderCommand(inputContent: string | undefined, options: RenderOptions): Promise<void>;
8
- export {};
@@ -0,0 +1,2 @@
1
+ import { ThemeOptions } from "../types.js";
2
+ export declare function themeCommand(options: ThemeOptions): Promise<void>;
@@ -1 +1,2 @@
1
- export {};
1
+ import { Command } from "commander";
2
+ export declare function createProgram(version?: string): Command;
@@ -0,0 +1,15 @@
1
+ export interface RenderOptions {
2
+ file?: string;
3
+ theme?: string;
4
+ customTheme?: string;
5
+ highlight: string;
6
+ macStyle: boolean;
7
+ footnote: boolean;
8
+ }
9
+ export interface ThemeOptions {
10
+ list?: boolean;
11
+ add?: boolean;
12
+ name?: string;
13
+ path?: string;
14
+ rm?: string;
15
+ }
@@ -1 +1,2 @@
1
1
  export declare function readStdin(): Promise<string>;
2
+ export declare function getNormalizeFilePath(inputPath: string): string;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/utils.js CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  export async function readStdin() {
2
3
  return new Promise((resolve, reject) => {
3
4
  let data = "";
@@ -7,3 +8,29 @@ export async function readStdin() {
7
8
  process.stdin.on("error", reject);
8
9
  });
9
10
  }
11
+ /**
12
+ * 路径标准化工具函数
13
+ * 将 Windows 的反斜杠 \ 转换为正斜杠 /,并去除末尾斜杠
14
+ * 目的:在 Linux 容器内也能正确处理 Windows 路径字符串
15
+ */
16
+ function normalizePath(p) {
17
+ return p.replace(/\\/g, "/").replace(/\/+$/, "");
18
+ }
19
+ export function getNormalizeFilePath(inputPath) {
20
+ const isContainer = !!process.env.CONTAINERIZED;
21
+ if (isContainer) {
22
+ const hostFilePath = normalizePath(process.env.HOST_FILE_PATH || "");
23
+ const containerFilePath = normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads");
24
+ let relativePart = normalizePath(inputPath);
25
+ if (relativePart.startsWith(hostFilePath)) {
26
+ relativePart = relativePart.slice(hostFilePath.length);
27
+ }
28
+ if (!relativePart.startsWith("/")) {
29
+ relativePart = "/" + relativePart;
30
+ }
31
+ return containerFilePath + relativePart;
32
+ }
33
+ else {
34
+ return path.resolve(inputPath);
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenyan-md/cli",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "A CLI tool for Wenyan markdown rendering & publishing",
5
5
  "author": "Lei <caol64@gmail.com> (https://github.com/caol64)",
6
6
  "license": "Apache-2.0",
@@ -15,13 +15,12 @@
15
15
  ],
16
16
  "type": "module",
17
17
  "bin": {
18
- "wenyan": "./bin/cli.js"
18
+ "wenyan": "./dist/cli.js"
19
19
  },
20
20
  "main": "./dist/index.js",
21
21
  "types": "./dist/types/index.d.ts",
22
22
  "files": [
23
- "dist",
24
- "bin"
23
+ "dist"
25
24
  ],
26
25
  "homepage": "https://github.com/caol64/wenyan-cli#readme",
27
26
  "repository": {
@@ -32,20 +31,27 @@
32
31
  "url": "https://github.com/caol64/wenyan-cli/issues"
33
32
  },
34
33
  "dependencies": {
35
- "@wenyan-md/core": "^1.0.14",
36
- "commander": "^14.0.0"
34
+ "@wenyan-md/core": "^2.0.2",
35
+ "commander": "^14.0.0",
36
+ "form-data-encoder": "^4.1.0",
37
+ "formdata-node": "^6.0.3",
38
+ "jsdom": "^27.4.0"
37
39
  },
38
40
  "devDependencies": {
39
41
  "@types/node": "^24.3.0",
40
42
  "dotenv-cli": "^10.0.0",
41
- "tsx": "^4.20.5",
42
- "typescript": "^5.9.2"
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.9.2",
45
+ "vite": "^7.3.1",
46
+ "vitest": "^4.0.18"
43
47
  },
44
48
  "scripts": {
45
49
  "build": "tsc",
46
- "dev": "tsx src/index.ts",
47
50
  "upgrade:core": "pnpm update @wenyan-md/core",
48
- "test:render": "pnpm build && cat test/publish.md | node ./bin/cli.js render",
49
- "test:publish": "pnpm build && cat test/publish.md | pnpm dotenv -e .env.test -- node ./bin/cli.js publish -t lapis"
51
+ "test:bin": "pnpm build && node ./dist/cli.js render -f tests/publish.md -c tests/manhua.css --no-mac-style",
52
+ "test:cli": "vitest run tests/cli.test.ts",
53
+ "test:render": "vitest run tests/render.test.ts",
54
+ "test:publish": "vitest run tests/publish.test.ts",
55
+ "test:realPublish": "pnpm build && pnpm dotenv -e .env.test -- node ./dist/cli.js publish -f tests/publish.md -c tests/manhua.css --no-mac-style"
50
56
  }
51
57
  }
package/bin/cli.js DELETED
@@ -1,9 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { fileURLToPath, pathToFileURL } from 'url';
4
- import { dirname, join } from 'path';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const mainModulePath = join(__dirname, "..", "dist", 'index.js');
8
-
9
- import(pathToFileURL(mainModulePath).href);