@wenyan-md/cli 1.0.11 → 2.0.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.docker.md +31 -0
- package/README.md +71 -199
- package/dist/cli.js +126 -1
- package/dist/commands/serve.js +225 -0
- package/dist/index.js +1 -43
- package/dist/types/cli.d.ts +2 -1
- package/dist/types/commands/serve.d.ts +6 -0
- package/dist/types/index.d.ts +1 -2
- package/dist/types/utils.d.ts +4 -1
- package/dist/utils.js +17 -23
- package/package.json +10 -4
- package/dist/commands/publish.js +0 -27
- package/dist/commands/render.js +0 -71
- package/dist/commands/theme.js +0 -78
- package/dist/types/commands/publish.d.ts +0 -2
- package/dist/types/commands/render.d.ts +0 -7
- package/dist/types/commands/theme.d.ts +0 -2
- package/dist/types/types.d.ts +0 -15
- package/dist/types.js +0 -1
package/README.docker.md
CHANGED
|
@@ -125,6 +125,37 @@ WECHAT_APP_SECRET=yyy
|
|
|
125
125
|
- Runtime: Node.js (bundled)
|
|
126
126
|
- Architecture: `linux/amd64`, `linux/arm64`
|
|
127
127
|
|
|
128
|
+
## Server Mode
|
|
129
|
+
|
|
130
|
+
Deploy on a cloud server with fixed IP to solve WeChat API whitelist requirements:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
docker run -d --name wenyan-server \
|
|
134
|
+
-p 3000:3000 \
|
|
135
|
+
--env-file .env \
|
|
136
|
+
caol64/wenyan-cli \
|
|
137
|
+
serve --port 3000
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then call the REST API from your local machine:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Health check
|
|
144
|
+
curl http://your-server-ip:3000/health
|
|
145
|
+
|
|
146
|
+
# Render
|
|
147
|
+
curl -X POST http://your-server-ip:3000/render \
|
|
148
|
+
-H "Content-Type: application/json" \
|
|
149
|
+
-d '{"content": "# Hello World", "theme": "default"}'
|
|
150
|
+
|
|
151
|
+
# Publish
|
|
152
|
+
curl -X POST http://your-server-ip:3000/publish \
|
|
153
|
+
-H "Content-Type: application/json" \
|
|
154
|
+
-d '{"file": "/mnt/host-downloads/article.md"}'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
> **Note:** Add your server's public IP to WeChat Official Account whitelist once, and it works permanently.
|
|
158
|
+
|
|
128
159
|
## License
|
|
129
160
|
|
|
130
161
|
Apache License Version 2.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img alt
|
|
2
|
+
<img alt="logo" src="https://raw.githubusercontent.com/caol64/wenyan/main/Data/256-mac.png" width="128" />
|
|
3
3
|
</div>
|
|
4
4
|
|
|
5
5
|
# 文颜 CLI
|
|
@@ -27,215 +27,64 @@
|
|
|
27
27
|
|
|
28
28
|
- [macOS App Store 版](https://github.com/caol64/wenyan) - MAC 桌面应用
|
|
29
29
|
- [跨平台桌面版](https://github.com/caol64/wenyan-pc) - Windows/Linux
|
|
30
|
-
- 👉
|
|
30
|
+
- 👉[CLI 版本](https://github.com/caol64/wenyan-cli) - 本项目
|
|
31
31
|
- [MCP 版本](https://github.com/caol64/wenyan-mcp) - AI 自动发文
|
|
32
|
-
- [核心库](https://github.com/caol64/wenyan-core) - 嵌入 Node / Web 项目
|
|
33
32
|
|
|
34
|
-
##
|
|
33
|
+
## 特性
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
- 一键发布 Markdown 到微信公众号草稿箱
|
|
36
|
+
- 自动上传本地图片与封面
|
|
37
|
+
- 支持远程 Server 发布(绕过 IP 白名单限制)
|
|
38
|
+
- 内置多套精美排版主题
|
|
39
|
+
- 支持自定义主题
|
|
40
|
+
- 可作为 CI/CD 自动发文工具
|
|
41
|
+
- 可集成 AI Agent 自动发布
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
npm install -g @wenyan-md/cli
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
安装完成后即可使用:
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
wenyan --help
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### 方式二:Docker(无需 Node 环境)
|
|
49
|
-
|
|
50
|
-
如果你不想在本地安装 Node.js,也可以直接使用 Docker。
|
|
51
|
-
|
|
52
|
-
**拉取镜像**
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
docker pull caol64/wenyan-cli
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
**查看帮助**
|
|
43
|
+
## 快速开始
|
|
59
44
|
|
|
60
45
|
```bash
|
|
61
|
-
|
|
46
|
+
# 安装
|
|
47
|
+
npm install -g @wenyan-md/cli
|
|
48
|
+
# 发布文章到公众号
|
|
49
|
+
wenyan publish -f article.md
|
|
62
50
|
```
|
|
63
51
|
|
|
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
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
> 说明:
|
|
76
|
-
>
|
|
77
|
-
> - 使用 `-e` 传入环境变量
|
|
78
|
-
> - 使用 `-v` 挂载本地 Markdown 文件
|
|
79
|
-
> - 容器启动即执行 `wenyan` 命令
|
|
80
|
-
|
|
81
|
-
## 基本用法
|
|
82
|
-
|
|
83
|
-
CLI 主命令:
|
|
52
|
+
## 命令概览
|
|
84
53
|
|
|
85
54
|
```bash
|
|
86
55
|
wenyan <command> [options]
|
|
87
56
|
```
|
|
88
57
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
### `publish`
|
|
97
|
-
|
|
98
|
-
将 Markdown 转换为适配微信公众号的富文本 HTML,并上传到草稿箱。
|
|
99
|
-
|
|
100
|
-
#### 参数
|
|
101
|
-
|
|
102
|
-
- `<input-content>`
|
|
103
|
-
|
|
104
|
-
Markdown 内容,可以:
|
|
105
|
-
|
|
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
|
-
直接传入内容:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
wenyan publish "# Hello, Wenyan" -t lapis -h solarized-light
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
从管道读取:
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
cat example.md | wenyan publish -t lapis -h solarized-light --no-mac-style
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
从文件读取:
|
|
135
|
-
|
|
136
|
-
```bash
|
|
137
|
-
wenyan publish -f "./example.md" -t lapis -h solarized-light --no-mac-style
|
|
138
|
-
```
|
|
139
|
-
|
|
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
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
安装自定义主题
|
|
165
|
-
|
|
166
|
-
```bash
|
|
167
|
-
wenyan theme --add --name new-theme --path https://wenyan.yuzhi.tech/manhua.css
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
删除自定义主题
|
|
171
|
-
|
|
172
|
-
```bash
|
|
173
|
-
wenyan theme --rm new-theme
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## 使用自定义主题
|
|
177
|
-
|
|
178
|
-
你可以通过两种途径使用自定义主题:
|
|
179
|
-
|
|
180
|
-
- 不安装直接使用
|
|
181
|
-
|
|
182
|
-
```bash
|
|
183
|
-
wenyan publish -f "./example.md" -c "/path/to/theme" -h solarized-light --no-mac-style
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
- 先安装再使用:
|
|
58
|
+
| 命令 | 说明 |
|
|
59
|
+
| ------- | --------- |
|
|
60
|
+
| [publish](docs/publish.md) | 发布文章 |
|
|
61
|
+
| render | 渲染 HTML |
|
|
62
|
+
| [theme](docs/theme.md) | 管理主题 |
|
|
63
|
+
| [serve](docs/server.md) | 启动 Server |
|
|
187
64
|
|
|
188
|
-
|
|
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
|
-
```
|
|
65
|
+
## 概念
|
|
192
66
|
|
|
193
|
-
|
|
67
|
+
### 内容输入
|
|
194
68
|
|
|
195
|
-
|
|
69
|
+
内容输入是指如何把 Markdown 文章分发给 `wenyan-cli`,支持以下四种方式:
|
|
196
70
|
|
|
197
|
-
|
|
71
|
+
| 方式 | 示例 | 说明 |
|
|
72
|
+
| ------- | --------- |--------- |
|
|
73
|
+
| 本地路径(推荐) | `wenyan publish -f article.md` |`cli`直接读取磁盘上的文章 |
|
|
74
|
+
| URL | `wenyan publish -f http://test.md` |`cli`直接读取网络上的文章 |
|
|
75
|
+
| 参数 | `wenyan publish "# 文章"` |适用于快速发布短内容 |
|
|
76
|
+
| 管道 | `cat article.md \| wenyan publish` |适用于 CI/CD,脚本批量发布 |
|
|
198
77
|
|
|
199
|
-
|
|
200
|
-
- 网络路径(如:`https://example.com/image.jpg`)
|
|
78
|
+
### 环境变量配置
|
|
201
79
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
80
|
+
> [!IMPORTANT]
|
|
81
|
+
>
|
|
82
|
+
> 请确保运行文颜的机器已配置如下环境变量,否则上传接口将调用失败。
|
|
205
83
|
|
|
206
84
|
- `WECHAT_APP_ID`
|
|
207
85
|
- `WECHAT_APP_SECRET`
|
|
208
86
|
|
|
209
|
-
###
|
|
210
|
-
|
|
211
|
-
临时使用:
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
WECHAT_APP_ID=xxx WECHAT_APP_SECRET=yyy wenyan publish "your markdown"
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
永久配置(推荐):
|
|
218
|
-
|
|
219
|
-
```bash
|
|
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
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
永久设置(在环境变量里添加):
|
|
235
|
-
|
|
236
|
-
控制面板 → 系统和安全 → 系统 → 高级系统设置 → 环境变量 → 添加 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。
|
|
237
|
-
|
|
238
|
-
## 微信公众号 IP 白名单
|
|
87
|
+
### 微信公众号 IP 白名单
|
|
239
88
|
|
|
240
89
|
> [!IMPORTANT]
|
|
241
90
|
>
|
|
@@ -243,9 +92,9 @@ wenyan publish example.md
|
|
|
243
92
|
|
|
244
93
|
配置说明文档:[https://yuzhi.tech/docs/wenyan/upload](https://yuzhi.tech/docs/wenyan/upload)
|
|
245
94
|
|
|
246
|
-
|
|
95
|
+
### 文章格式
|
|
247
96
|
|
|
248
|
-
为了正确上传文章,每篇 Markdown
|
|
97
|
+
为了正确上传文章,每篇 Markdown 顶部需要包含一段 `frontmatter`:
|
|
249
98
|
|
|
250
99
|
```md
|
|
251
100
|
---
|
|
@@ -265,23 +114,46 @@ source_url: http://
|
|
|
265
114
|
- `author` 文章作者
|
|
266
115
|
- `source_url` 原文地址
|
|
267
116
|
|
|
268
|
-
|
|
117
|
+
**[示例文章](tests/publish.md)**
|
|
269
118
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
119
|
+
### 文内图片和文章封面
|
|
120
|
+
|
|
121
|
+
把文章发布到公众号之前,文颜会按照微信要求自动处理文章内的所有图片,将其上传到公众号素材库。目前文颜对于以下两种图片都能很好的支持:
|
|
122
|
+
|
|
123
|
+
- 本地硬盘绝对路径(如:`/Users/xxx/image.jpg`)
|
|
124
|
+
- 网络路径(如:`https://example.com/image.jpg`)
|
|
125
|
+
|
|
126
|
+
仅当“内容输入”方式为“本地路径”时,以下路径也能完美支持:
|
|
127
|
+
|
|
128
|
+
- 当前文章的相对路径(如:`./assets/image.png`)
|
|
275
129
|
|
|
276
|
-
|
|
130
|
+
## Server 模式
|
|
277
131
|
|
|
278
|
-
|
|
132
|
+
相较于纯本地运行的**本地模式(Local Mode)**,`wenyan-cli`还提供了 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
|
|
279
133
|
|
|
280
|
-
|
|
134
|
+
在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
|
|
281
135
|
|
|
282
|
-
|
|
136
|
+
```mermaid
|
|
137
|
+
flowchart LR
|
|
138
|
+
CLI[Wenyan CLI] --> Wechat[公众号 API]
|
|
283
139
|
```
|
|
284
140
|
|
|
141
|
+
在远程客户端模式下,CLI 作为客户端,将发布请求发送到部署在云服务器上的 Wenyan Server,由 Server 完成微信公众号 API 调用。
|
|
142
|
+
|
|
143
|
+
```mermaid
|
|
144
|
+
flowchart LR
|
|
145
|
+
CLI[Wenyan CLI] --> Server[Wenyan Server] --> Wechat[公众号 API]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**适用于:**
|
|
149
|
+
|
|
150
|
+
* 无本地固定 IP,需频繁添加IP 白名单的用户
|
|
151
|
+
* 需团队协作的用户
|
|
152
|
+
* 支持 CI/CD 自动发布
|
|
153
|
+
* 支持 AI Agent 自动发布
|
|
154
|
+
|
|
155
|
+
**[Server 模式部署](docs/server.md)**
|
|
156
|
+
|
|
285
157
|
## 赞助
|
|
286
158
|
|
|
287
159
|
如果你觉得文颜对你有帮助,可以给我家猫咪买点罐头 ❤️
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,129 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import pkg from "../package.json" with { type: "json" };
|
|
4
|
+
import { addTheme, listThemes, prepareRenderContext, removeTheme, renderAndPublish, renderAndPublishToServer, } from "@wenyan-md/core/wrapper";
|
|
5
|
+
import { getInputContent } from "./utils.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 or web URL")
|
|
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
|
+
// 先添加公共选项,再追加 publish 专属选项
|
|
31
|
+
addCommonOptions(pubCmd)
|
|
32
|
+
.option("--server <url>", "Server URL to publish through (e.g. https://api.yourdomain.com)")
|
|
33
|
+
.option("--api-key <apiKey>", "API key for the remote server")
|
|
34
|
+
.action(async (inputContent, options) => {
|
|
35
|
+
await runCommandWrapper(async () => {
|
|
36
|
+
// 如果传入了 --server,则走客户端(远程)模式
|
|
37
|
+
if (options.server) {
|
|
38
|
+
options.clientVersion = version; // 将 CLI 版本传递给服务器,便于调试和兼容性处理
|
|
39
|
+
const mediaId = await renderAndPublishToServer(inputContent, options, getInputContent);
|
|
40
|
+
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
// 走原有的本地直接发布模式
|
|
44
|
+
const mediaId = await renderAndPublish(inputContent, options, getInputContent);
|
|
45
|
+
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
|
|
50
|
+
addCommonOptions(renderCmd).action(async (inputContent, options) => {
|
|
51
|
+
await runCommandWrapper(async () => {
|
|
52
|
+
const { gzhContent } = await prepareRenderContext(inputContent, options, getInputContent);
|
|
53
|
+
console.log(gzhContent.content);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
program
|
|
57
|
+
.command("theme")
|
|
58
|
+
.description("Manage themes")
|
|
59
|
+
.option("-l, --list", "List all available themes")
|
|
60
|
+
.option("--add", "Add a new custom theme")
|
|
61
|
+
.option("--name <name>", "Name of the new custom theme")
|
|
62
|
+
.option("--path <path>", "Path to the new custom theme CSS file")
|
|
63
|
+
.option("--rm <name>", "Name of the custom theme to remove")
|
|
64
|
+
.action(async (options) => {
|
|
65
|
+
await runCommandWrapper(async () => {
|
|
66
|
+
const { list, add, name, path, rm } = options;
|
|
67
|
+
if (list) {
|
|
68
|
+
const themes = await listThemes();
|
|
69
|
+
console.log("内置主题:");
|
|
70
|
+
themes
|
|
71
|
+
.filter((theme) => theme.isBuiltin)
|
|
72
|
+
.forEach((theme) => {
|
|
73
|
+
console.log(`- ${theme.id}: ${theme.description ?? ""}`);
|
|
74
|
+
});
|
|
75
|
+
const customThemes = themes.filter((theme) => !theme.isBuiltin);
|
|
76
|
+
if (customThemes.length > 0) {
|
|
77
|
+
console.log("\n自定义主题:");
|
|
78
|
+
customThemes.forEach((theme) => {
|
|
79
|
+
console.log(`- ${theme.id}: ${theme.description ?? ""}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (add) {
|
|
85
|
+
await addTheme(name, path);
|
|
86
|
+
console.log(`主题 "${name}" 已添加`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (rm) {
|
|
90
|
+
await removeTheme(rm);
|
|
91
|
+
console.log(`主题 "${rm}" 已删除`);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
program
|
|
96
|
+
.command("serve")
|
|
97
|
+
.description("Start a server to provide HTTP API for rendering and publishing")
|
|
98
|
+
.option("-p, --port <port>", "Port to listen on (default: 3000)", "3000")
|
|
99
|
+
.option("--api-key <apiKey>", "API key for authentication")
|
|
100
|
+
.action(async (options) => {
|
|
101
|
+
try {
|
|
102
|
+
const { serveCommand } = await import("./commands/serve.js");
|
|
103
|
+
const port = options.port ? parseInt(options.port, 10) : 3000;
|
|
104
|
+
await serveCommand({ port, version, apiKey: options.apiKey });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
console.error(error.message);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
return program;
|
|
112
|
+
}
|
|
113
|
+
// --- 统一的错误处理包装器 ---
|
|
114
|
+
async function runCommandWrapper(action) {
|
|
115
|
+
try {
|
|
116
|
+
await action();
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof Error) {
|
|
120
|
+
console.error(error.message);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.error("An unexpected error occurred:", error);
|
|
124
|
+
}
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
3
128
|
const program = createProgram();
|
|
4
129
|
program.parse(process.argv);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { configDir } from "@wenyan-md/core/wrapper";
|
|
6
|
+
import multer from "multer";
|
|
7
|
+
import { publishToWechatDraft } from "@wenyan-md/core/publish";
|
|
8
|
+
class AppError extends Error {
|
|
9
|
+
message;
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.message = message;
|
|
13
|
+
this.name = "AppError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const UPLOAD_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
17
|
+
const UPLOAD_DIR = path.join(configDir, "uploads");
|
|
18
|
+
export async function serveCommand(options) {
|
|
19
|
+
// 确保临时目录存在
|
|
20
|
+
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
|
21
|
+
// 服务启动时立即执行一次后台清理
|
|
22
|
+
cleanupOldUploads();
|
|
23
|
+
// 定期清理过期的上传文件
|
|
24
|
+
setInterval(cleanupOldUploads, UPLOAD_TTL_MS).unref();
|
|
25
|
+
const app = express();
|
|
26
|
+
const port = options.port || 3000;
|
|
27
|
+
const auth = createAuthHandler(options);
|
|
28
|
+
app.use(express.json({ limit: "10mb" }));
|
|
29
|
+
const storage = multer.diskStorage({
|
|
30
|
+
destination: (req, file, cb) => {
|
|
31
|
+
cb(null, UPLOAD_DIR);
|
|
32
|
+
},
|
|
33
|
+
filename: (req, file, cb) => {
|
|
34
|
+
const fileId = crypto.randomUUID();
|
|
35
|
+
const ext = file.originalname.split(".").pop() || "";
|
|
36
|
+
cb(null, ext ? `${fileId}.${ext}` : fileId);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const upload = multer({
|
|
40
|
+
storage,
|
|
41
|
+
limits: {
|
|
42
|
+
fileSize: 10 * 1024 * 1024, // 10MB
|
|
43
|
+
},
|
|
44
|
+
fileFilter: (req, file, cb) => {
|
|
45
|
+
const ext = file.originalname.split(".").pop()?.toLowerCase();
|
|
46
|
+
// 1. 定义允许的图片类型
|
|
47
|
+
const allowedImageTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
|
|
48
|
+
const allowedImageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
|
|
49
|
+
// 2. 分别判断文件大类
|
|
50
|
+
const isImage = allowedImageTypes.includes(file.mimetype) || (ext && allowedImageExts.includes(ext));
|
|
51
|
+
const isMarkdown = ext === "md" || file.mimetype === "text/markdown" || file.mimetype === "text/plain";
|
|
52
|
+
const isCss = ext === "css" || file.mimetype === "text/css";
|
|
53
|
+
const isJson = ext === "json" || file.mimetype === "application/json";
|
|
54
|
+
// 3. 综合放行逻辑
|
|
55
|
+
if (isImage || isMarkdown || isCss || isJson) {
|
|
56
|
+
cb(null, true);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
cb(new AppError("不支持的文件类型,仅支持图片、Markdown、CSS 和 JSON 文件"));
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
// 健康检查
|
|
64
|
+
app.get("/health", (_req, res) => {
|
|
65
|
+
res.json({ status: "ok", service: "wenyan-cli", version: options.version || "unknown" });
|
|
66
|
+
});
|
|
67
|
+
// 鉴权探针
|
|
68
|
+
app.get("/verify", auth, (_req, res) => {
|
|
69
|
+
res.json({ success: true, message: "Authorized" });
|
|
70
|
+
});
|
|
71
|
+
// 发布接口 - 读取 json 文件内容并发布
|
|
72
|
+
app.post("/publish", auth, async (req, res) => {
|
|
73
|
+
const body = req.body;
|
|
74
|
+
validateRequest(body);
|
|
75
|
+
// 根据 fileId 去找刚上传的 json 文件并读取内容
|
|
76
|
+
const files = await fs.readdir(UPLOAD_DIR);
|
|
77
|
+
const matchedFile = files.find((f) => f === body.fileId);
|
|
78
|
+
if (!matchedFile) {
|
|
79
|
+
throw new AppError(`文件不存在或已过期,请重新上传 (ID: ${body.fileId})`);
|
|
80
|
+
}
|
|
81
|
+
// 简单的防呆校验,防止直接提交纯图片的 fileId 到发布接口
|
|
82
|
+
const ext = path.extname(matchedFile).toLowerCase();
|
|
83
|
+
if (ext !== ".json") {
|
|
84
|
+
throw new AppError("请提供 JSON 文件的 fileId,不能直接发布图片文件");
|
|
85
|
+
}
|
|
86
|
+
// 找到上传文件并提取文本内容
|
|
87
|
+
const filePath = path.join(UPLOAD_DIR, matchedFile);
|
|
88
|
+
const fileContent = await fs.readFile(filePath, "utf-8");
|
|
89
|
+
const gzhContent = JSON.parse(fileContent);
|
|
90
|
+
if (!gzhContent.title)
|
|
91
|
+
throw new AppError("未能找到文章标题");
|
|
92
|
+
// 公共的 asset:// 替换逻辑
|
|
93
|
+
const resolveAssetPath = (assetUrl) => {
|
|
94
|
+
const assetFileId = assetUrl.replace("asset://", "");
|
|
95
|
+
const matchedAsset = files.find((f) => f === assetFileId || path.parse(f).name === assetFileId);
|
|
96
|
+
return matchedAsset ? path.join(UPLOAD_DIR, matchedAsset) : assetUrl;
|
|
97
|
+
};
|
|
98
|
+
// 替换 HTML 内容里的 asset://
|
|
99
|
+
gzhContent.content = gzhContent.content.replace(/(<img\b[^>]*?\bsrc\s*=\s*["'])(asset:\/\/[^"']+)(["'])/gi, (_match, prefix, assetUrl, suffix) => prefix + resolveAssetPath(assetUrl) + suffix);
|
|
100
|
+
// 替换封面里的 asset://
|
|
101
|
+
if (gzhContent.cover && gzhContent.cover.startsWith("asset://")) {
|
|
102
|
+
gzhContent.cover = resolveAssetPath(gzhContent.cover);
|
|
103
|
+
}
|
|
104
|
+
const data = await publishToWechatDraft({
|
|
105
|
+
title: gzhContent.title,
|
|
106
|
+
content: gzhContent.content,
|
|
107
|
+
cover: gzhContent.cover,
|
|
108
|
+
author: gzhContent.author,
|
|
109
|
+
source_url: gzhContent.source_url,
|
|
110
|
+
});
|
|
111
|
+
if (data.media_id) {
|
|
112
|
+
res.json({
|
|
113
|
+
media_id: data.media_id,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
throw new AppError(`发布到微信公众号失败,\n${data}`);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
// 上传接口
|
|
121
|
+
app.post("/upload", auth, upload.single("file"), async (req, res) => {
|
|
122
|
+
if (!req.file) {
|
|
123
|
+
throw new AppError("未找到上传的文件");
|
|
124
|
+
}
|
|
125
|
+
const newFilename = req.file.filename;
|
|
126
|
+
res.json({
|
|
127
|
+
success: true,
|
|
128
|
+
data: {
|
|
129
|
+
fileId: newFilename,
|
|
130
|
+
originalFilename: req.file.originalname,
|
|
131
|
+
mimetype: req.file.mimetype,
|
|
132
|
+
size: req.file.size,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
app.use(errorHandler);
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const server = app.listen(port, () => {
|
|
139
|
+
console.log(`文颜 Server 已启动,监听端口 ${port}`);
|
|
140
|
+
console.log(`健康检查:http://localhost:${port}/health`);
|
|
141
|
+
console.log(`鉴权探针:http://localhost:${port}/verify`);
|
|
142
|
+
console.log(`发布接口:POST http://localhost:${port}/publish`);
|
|
143
|
+
console.log(`上传接口:POST http://localhost:${port}/upload`);
|
|
144
|
+
});
|
|
145
|
+
server.on("error", (err) => {
|
|
146
|
+
if (err.code === "EADDRINUSE") {
|
|
147
|
+
console.error(`端口 ${port} 已被占用`);
|
|
148
|
+
reject(new Error(`端口 ${port} 已被占用`));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
reject(err);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
process.on("SIGINT", () => {
|
|
155
|
+
console.log("\n正在关闭服务器...");
|
|
156
|
+
server.close(() => {
|
|
157
|
+
console.log("服务器已关闭");
|
|
158
|
+
resolve();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
process.on("SIGTERM", () => {
|
|
162
|
+
server.close(() => resolve());
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function errorHandler(error, _req, res, next) {
|
|
167
|
+
if (res.headersSent) {
|
|
168
|
+
return next(error);
|
|
169
|
+
}
|
|
170
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
171
|
+
// 修复:multer 抛出的文件限制错误(如超出大小),应判断为客户端 400 错误
|
|
172
|
+
const isAppError = error instanceof AppError;
|
|
173
|
+
const isMulterError = error.name === "MulterError";
|
|
174
|
+
const statusCode = isAppError || isMulterError ? 400 : 500;
|
|
175
|
+
if (statusCode === 500) {
|
|
176
|
+
console.error("[Server Error]:", error);
|
|
177
|
+
}
|
|
178
|
+
res.status(statusCode).json({
|
|
179
|
+
code: -1,
|
|
180
|
+
desc: message,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function createAuthHandler(config) {
|
|
184
|
+
return (req, res, next) => {
|
|
185
|
+
if (!config.apiKey) {
|
|
186
|
+
return next();
|
|
187
|
+
}
|
|
188
|
+
const clientApiKey = req.headers["x-api-key"];
|
|
189
|
+
if (clientApiKey === config.apiKey) {
|
|
190
|
+
next();
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
res.status(401).json({
|
|
194
|
+
code: -1,
|
|
195
|
+
desc: "Unauthorized: Invalid API Key",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function validateRequest(req) {
|
|
201
|
+
if (!req.fileId) {
|
|
202
|
+
throw new AppError("缺少必要参数:fileId");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function cleanupOldUploads() {
|
|
206
|
+
try {
|
|
207
|
+
const files = await fs.readdir(UPLOAD_DIR);
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
const filePath = path.join(UPLOAD_DIR, file);
|
|
211
|
+
try {
|
|
212
|
+
const stats = await fs.stat(filePath);
|
|
213
|
+
if (now - stats.mtimeMs > UPLOAD_TTL_MS) {
|
|
214
|
+
await fs.unlink(filePath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
// 忽略单个文件处理错误
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
console.error("Cleanup task error:", e);
|
|
224
|
+
}
|
|
225
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,43 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { publishCommand } from "./commands/publish.js";
|
|
3
|
-
import { renderCommand } from "./commands/render.js";
|
|
4
|
-
import pkg from "../package.json" with { type: "json" };
|
|
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
|
-
}
|
|
1
|
+
export {};
|
package/dist/types/cli.d.ts
CHANGED
package/dist/types/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function createProgram(version?: string): Command;
|
|
1
|
+
export {};
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export declare function readStdin(): Promise<string>;
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function getInputContent(inputContent?: string, file?: string): Promise<{
|
|
3
|
+
content: string;
|
|
4
|
+
absoluteDirPath: string | undefined;
|
|
5
|
+
}>;
|
package/dist/utils.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { getNormalizeFilePath } from "@wenyan-md/core/wrapper";
|
|
2
4
|
export async function readStdin() {
|
|
3
5
|
return new Promise((resolve, reject) => {
|
|
4
6
|
let data = "";
|
|
@@ -8,29 +10,21 @@ export async function readStdin() {
|
|
|
8
10
|
process.stdin.on("error", reject);
|
|
9
11
|
});
|
|
10
12
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
|
13
|
+
export async function getInputContent(inputContent, file) {
|
|
14
|
+
let absoluteDirPath = undefined;
|
|
15
|
+
// 1. 尝试从 Stdin 读取
|
|
16
|
+
if (!inputContent && !process.stdin.isTTY) {
|
|
17
|
+
inputContent = await readStdin();
|
|
18
|
+
}
|
|
19
|
+
// 2. 尝试从文件读取
|
|
20
|
+
if (!inputContent && file) {
|
|
21
|
+
const normalizePath = getNormalizeFilePath(file);
|
|
22
|
+
inputContent = await fs.readFile(normalizePath, "utf-8");
|
|
23
|
+
absoluteDirPath = path.dirname(normalizePath);
|
|
32
24
|
}
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
// 3. 校验输入
|
|
26
|
+
if (!inputContent) {
|
|
27
|
+
throw new Error("missing input-content (no argument, no stdin, and no file).");
|
|
35
28
|
}
|
|
29
|
+
return { content: inputContent, absoluteDirPath };
|
|
36
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenyan-md/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|
|
@@ -31,13 +31,17 @@
|
|
|
31
31
|
"url": "https://github.com/caol64/wenyan-cli/issues"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@wenyan-md/core": "^2.0.
|
|
34
|
+
"@wenyan-md/core": "^2.0.8",
|
|
35
35
|
"commander": "^14.0.0",
|
|
36
|
+
"express": "^5.2.1",
|
|
36
37
|
"form-data-encoder": "^4.1.0",
|
|
37
38
|
"formdata-node": "^6.0.3",
|
|
38
|
-
"jsdom": "^27.4.0"
|
|
39
|
+
"jsdom": "^27.4.0",
|
|
40
|
+
"multer": "^2.1.0"
|
|
39
41
|
},
|
|
40
42
|
"devDependencies": {
|
|
43
|
+
"@types/express": "^5.0.6",
|
|
44
|
+
"@types/multer": "^2.0.0",
|
|
41
45
|
"@types/node": "^24.3.0",
|
|
42
46
|
"dotenv-cli": "^10.0.0",
|
|
43
47
|
"tsx": "^4.21.0",
|
|
@@ -48,10 +52,12 @@
|
|
|
48
52
|
"scripts": {
|
|
49
53
|
"build": "tsc",
|
|
50
54
|
"upgrade:core": "pnpm update @wenyan-md/core",
|
|
55
|
+
"server": "pnpm build && pnpm dotenv -e .env.test -- node ./dist/cli.js serve",
|
|
51
56
|
"test:bin": "pnpm build && node ./dist/cli.js render -f tests/publish.md -c tests/manhua.css --no-mac-style",
|
|
52
57
|
"test:cli": "vitest run tests/cli.test.ts",
|
|
53
58
|
"test:render": "vitest run tests/render.test.ts",
|
|
54
59
|
"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"
|
|
60
|
+
"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",
|
|
61
|
+
"test:serverPublish": "pnpm build && node ./dist/cli.js publish -f tests/publish.md -c tests/manhua.css --no-mac-style --server http://localhost:3000"
|
|
56
62
|
}
|
|
57
63
|
}
|
package/dist/commands/publish.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { publishToWechatDraft } from "@wenyan-md/core/publish";
|
|
2
|
-
import { prepareRenderContext, runCommandWrapper } from "./render.js";
|
|
3
|
-
export async function publishCommand(inputContent, options) {
|
|
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 publishToWechatDraft({
|
|
11
|
-
title: gzhContent.title,
|
|
12
|
-
content: gzhContent.content,
|
|
13
|
-
cover: gzhContent.cover,
|
|
14
|
-
author: gzhContent.author,
|
|
15
|
-
source_url: gzhContent.source_url,
|
|
16
|
-
}, {
|
|
17
|
-
relativePath: absoluteDirPath,
|
|
18
|
-
});
|
|
19
|
-
if (data.media_id) {
|
|
20
|
-
console.log(`上传成功,media_id: ${data.media_id}`);
|
|
21
|
-
}
|
|
22
|
-
else {
|
|
23
|
-
console.error(`上传失败,\n${data}`);
|
|
24
|
-
process.exit(1);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
}
|
package/dist/commands/render.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
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) {
|
|
48
|
-
try {
|
|
49
|
-
await action();
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
if (error instanceof Error) {
|
|
53
|
-
if (error.message.startsWith("Error:")) {
|
|
54
|
-
console.error(error.message);
|
|
55
|
-
}
|
|
56
|
-
else {
|
|
57
|
-
console.error("An unexpected error occurred:", error.message);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
console.error("An unexpected error occurred:", error);
|
|
62
|
-
}
|
|
63
|
-
process.exit(1);
|
|
64
|
-
}
|
|
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
|
-
}
|
package/dist/commands/theme.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
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
|
-
export declare function renderCommand(inputContent: string | undefined, options: RenderOptions): Promise<void>;
|
package/dist/types/types.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|