@wenyan-md/cli 2.0.0 → 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.md +54 -89
- package/dist/cli.js +10 -13
- package/dist/commands/serve.js +19 -14
- package/dist/index.js +1 -6
- package/dist/types/cli.d.ts +2 -1
- package/dist/types/index.d.ts +1 -6
- package/dist/types/utils.d.ts +1 -3
- package/dist/utils.js +3 -30
- package/package.json +2 -9
- package/dist/commands/client.js +0 -208
- package/dist/commands/publish.js +0 -25
- package/dist/commands/render.js +0 -31
- package/dist/commands/theme.js +0 -67
- package/dist/types/commands/client.d.ts +0 -2
- package/dist/types/commands/publish.d.ts +0 -2
- package/dist/types/commands/render.d.ts +0 -8
- package/dist/types/commands/theme.d.ts +0 -16
- package/dist/types/types.d.ts +0 -17
- package/dist/types.js +0 -8
package/README.md
CHANGED
|
@@ -29,8 +29,6 @@
|
|
|
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
|
-
- [UI 库](https://github.com/caol64/wenyan-ui) - 桌面应用和 Web App 共用的 UI 层封装
|
|
33
|
-
- [核心库](https://github.com/caol64/wenyan-core) - 嵌入 Node / Web 项目
|
|
34
32
|
|
|
35
33
|
## 特性
|
|
36
34
|
|
|
@@ -39,81 +37,18 @@
|
|
|
39
37
|
- 支持远程 Server 发布(绕过 IP 白名单限制)
|
|
40
38
|
- 内置多套精美排版主题
|
|
41
39
|
- 支持自定义主题
|
|
42
|
-
- 支持 Docker 和 npm
|
|
43
40
|
- 可作为 CI/CD 自动发文工具
|
|
44
41
|
- 可集成 AI Agent 自动发布
|
|
45
42
|
|
|
46
|
-
##
|
|
47
|
-
|
|
48
|
-
文颜 CLI 支持两种运行模式:**本地模式(Local Mode)** 和 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
|
|
49
|
-
|
|
50
|
-
### 本地模式(Local Mode)
|
|
51
|
-
|
|
52
|
-
在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
|
|
53
|
-
|
|
54
|
-
```mermaid
|
|
55
|
-
flowchart LR
|
|
56
|
-
CLI[Wenyan CLI] --> Wechat[公众号 API]
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
**优点:**
|
|
60
|
-
|
|
61
|
-
* 无需额外服务器
|
|
62
|
-
* 架构简单,部署成本低
|
|
63
|
-
* 适合个人开发者和本地使用
|
|
64
|
-
|
|
65
|
-
**限制:**
|
|
66
|
-
|
|
67
|
-
> ⚠️ 微信公众号 API 要求调用方 IP 必须在白名单内。如果没有固定 IP,需要频繁添加白名单。
|
|
68
|
-
|
|
69
|
-
### 远程客户端模式(Client–Server Mode)
|
|
70
|
-
|
|
71
|
-
在此模式下,CLI 作为客户端,将发布请求发送到部署在云服务器上的 Wenyan Server,由 Server 完成微信公众号 API 调用。
|
|
72
|
-
|
|
73
|
-
```mermaid
|
|
74
|
-
flowchart LR
|
|
75
|
-
|
|
76
|
-
Client[Wenyan CLI Client]
|
|
77
|
-
Server[Wenyan Server]
|
|
78
|
-
Wechat[公众号 API]
|
|
79
|
-
|
|
80
|
-
Client -->|Markdown + Images| Server
|
|
81
|
-
Server -->|Upload Media| Wechat
|
|
82
|
-
Server -->|Create Draft| Wechat
|
|
83
|
-
Server -->|Result| Client
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**优点:**
|
|
87
|
-
|
|
88
|
-
* 无需本地固定 IP
|
|
89
|
-
* 完美绕过微信 IP 白名单限制
|
|
90
|
-
* 支持动态 IP 环境
|
|
91
|
-
* 支持团队协作
|
|
92
|
-
* 支持 CI/CD 自动发布
|
|
93
|
-
* 支持 AI Agent 自动发布
|
|
94
|
-
|
|
95
|
-
### Server 模式部署
|
|
96
|
-
|
|
97
|
-
[文档](docs/server.md)。
|
|
98
|
-
|
|
99
|
-
## 安装说明
|
|
100
|
-
|
|
101
|
-
### npm 安装(推荐)
|
|
43
|
+
## 快速开始
|
|
102
44
|
|
|
103
45
|
```bash
|
|
46
|
+
# 安装
|
|
104
47
|
npm install -g @wenyan-md/cli
|
|
48
|
+
# 发布文章到公众号
|
|
49
|
+
wenyan publish -f article.md
|
|
105
50
|
```
|
|
106
51
|
|
|
107
|
-
运行:
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
wenyan --help
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
### docker 安装
|
|
114
|
-
|
|
115
|
-
[文档](docs/docker.md)。
|
|
116
|
-
|
|
117
52
|
## 命令概览
|
|
118
53
|
|
|
119
54
|
```bash
|
|
@@ -127,22 +62,29 @@ wenyan <command> [options]
|
|
|
127
62
|
| [theme](docs/theme.md) | 管理主题 |
|
|
128
63
|
| [serve](docs/server.md) | 启动 Server |
|
|
129
64
|
|
|
130
|
-
##
|
|
65
|
+
## 概念
|
|
66
|
+
|
|
67
|
+
### 内容输入
|
|
131
68
|
|
|
132
|
-
|
|
69
|
+
内容输入是指如何把 Markdown 文章分发给 `wenyan-cli`,支持以下四种方式:
|
|
133
70
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
-
|
|
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,脚本批量发布 |
|
|
137
77
|
|
|
138
|
-
|
|
78
|
+
### 环境变量配置
|
|
139
79
|
|
|
140
|
-
|
|
80
|
+
> [!IMPORTANT]
|
|
81
|
+
>
|
|
82
|
+
> 请确保运行文颜的机器已配置如下环境变量,否则上传接口将调用失败。
|
|
141
83
|
|
|
142
84
|
- `WECHAT_APP_ID`
|
|
143
85
|
- `WECHAT_APP_SECRET`
|
|
144
86
|
|
|
145
|
-
|
|
87
|
+
### 微信公众号 IP 白名单
|
|
146
88
|
|
|
147
89
|
> [!IMPORTANT]
|
|
148
90
|
>
|
|
@@ -150,9 +92,9 @@ wenyan <command> [options]
|
|
|
150
92
|
|
|
151
93
|
配置说明文档:[https://yuzhi.tech/docs/wenyan/upload](https://yuzhi.tech/docs/wenyan/upload)
|
|
152
94
|
|
|
153
|
-
|
|
95
|
+
### 文章格式
|
|
154
96
|
|
|
155
|
-
为了正确上传文章,每篇 Markdown
|
|
97
|
+
为了正确上传文章,每篇 Markdown 顶部需要包含一段 `frontmatter`:
|
|
156
98
|
|
|
157
99
|
```md
|
|
158
100
|
---
|
|
@@ -172,23 +114,46 @@ source_url: http://
|
|
|
172
114
|
- `author` 文章作者
|
|
173
115
|
- `source_url` 原文地址
|
|
174
116
|
|
|
175
|
-
|
|
117
|
+
**[示例文章](tests/publish.md)**
|
|
176
118
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
119
|
+
### 文内图片和文章封面
|
|
120
|
+
|
|
121
|
+
把文章发布到公众号之前,文颜会按照微信要求自动处理文章内的所有图片,将其上传到公众号素材库。目前文颜对于以下两种图片都能很好的支持:
|
|
122
|
+
|
|
123
|
+
- 本地硬盘绝对路径(如:`/Users/xxx/image.jpg`)
|
|
124
|
+
- 网络路径(如:`https://example.com/image.jpg`)
|
|
125
|
+
|
|
126
|
+
仅当“内容输入”方式为“本地路径”时,以下路径也能完美支持:
|
|
182
127
|
|
|
183
|
-
|
|
128
|
+
- 当前文章的相对路径(如:`./assets/image.png`)
|
|
184
129
|
|
|
185
|
-
##
|
|
130
|
+
## Server 模式
|
|
186
131
|
|
|
187
|
-
|
|
132
|
+
相较于纯本地运行的**本地模式(Local Mode)**,`wenyan-cli`还提供了 **远程客户端模式(Client–Server Mode)**。两种模式运行效果完全一致,你可以根据运行环境和网络条件选择最合适的方式。
|
|
188
133
|
|
|
189
|
-
|
|
134
|
+
在本地模式下,CLI 直接调用微信公众号 API 完成图片上传和草稿发布。
|
|
135
|
+
|
|
136
|
+
```mermaid
|
|
137
|
+
flowchart LR
|
|
138
|
+
CLI[Wenyan CLI] --> Wechat[公众号 API]
|
|
190
139
|
```
|
|
191
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
|
+
|
|
192
157
|
## 赞助
|
|
193
158
|
|
|
194
159
|
如果你觉得文颜对你有帮助,可以给我家猫咪买点罐头 ❤️
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { publishCommand } from "./commands/publish.js";
|
|
4
|
-
import { prepareRenderContext } from "./commands/render.js";
|
|
5
3
|
import pkg from "../package.json" with { type: "json" };
|
|
6
|
-
import { addTheme, listThemes, removeTheme } from "
|
|
7
|
-
|
|
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) {
|
|
8
7
|
const program = new Command();
|
|
9
8
|
program
|
|
10
9
|
.name("wenyan")
|
|
@@ -16,7 +15,7 @@ function createProgram(version = pkg.version) {
|
|
|
16
15
|
const addCommonOptions = (cmd) => {
|
|
17
16
|
return cmd
|
|
18
17
|
.argument("[input-content]", "markdown content (string input)")
|
|
19
|
-
.option("-f, --file <path>", "read markdown content from local file")
|
|
18
|
+
.option("-f, --file <path>", "read markdown content from local file or web URL")
|
|
20
19
|
.option("-t, --theme <theme-id>", "ID of the theme to use", "default")
|
|
21
20
|
.option("-h, --highlight <highlight-theme-id>", "ID of the code highlight theme to use", "solarized-light")
|
|
22
21
|
.option("-c, --custom-theme <path>", "path to custom theme CSS file")
|
|
@@ -37,13 +36,12 @@ function createProgram(version = pkg.version) {
|
|
|
37
36
|
// 如果传入了 --server,则走客户端(远程)模式
|
|
38
37
|
if (options.server) {
|
|
39
38
|
options.clientVersion = version; // 将 CLI 版本传递给服务器,便于调试和兼容性处理
|
|
40
|
-
const
|
|
41
|
-
const mediaId = await publishClient(inputContent, options);
|
|
39
|
+
const mediaId = await renderAndPublishToServer(inputContent, options, getInputContent);
|
|
42
40
|
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
43
41
|
}
|
|
44
42
|
else {
|
|
45
43
|
// 走原有的本地直接发布模式
|
|
46
|
-
const mediaId = await
|
|
44
|
+
const mediaId = await renderAndPublish(inputContent, options, getInputContent);
|
|
47
45
|
console.log(`发布成功,Media ID: ${mediaId}`);
|
|
48
46
|
}
|
|
49
47
|
});
|
|
@@ -51,7 +49,7 @@ function createProgram(version = pkg.version) {
|
|
|
51
49
|
const renderCmd = program.command("render").description("Render a markdown file to styled HTML");
|
|
52
50
|
addCommonOptions(renderCmd).action(async (inputContent, options) => {
|
|
53
51
|
await runCommandWrapper(async () => {
|
|
54
|
-
const { gzhContent } = await prepareRenderContext(inputContent, options);
|
|
52
|
+
const { gzhContent } = await prepareRenderContext(inputContent, options, getInputContent);
|
|
55
53
|
console.log(gzhContent.content);
|
|
56
54
|
});
|
|
57
55
|
});
|
|
@@ -67,7 +65,7 @@ function createProgram(version = pkg.version) {
|
|
|
67
65
|
await runCommandWrapper(async () => {
|
|
68
66
|
const { list, add, name, path, rm } = options;
|
|
69
67
|
if (list) {
|
|
70
|
-
const themes = listThemes();
|
|
68
|
+
const themes = await listThemes();
|
|
71
69
|
console.log("内置主题:");
|
|
72
70
|
themes
|
|
73
71
|
.filter((theme) => theme.isBuiltin)
|
|
@@ -81,17 +79,16 @@ function createProgram(version = pkg.version) {
|
|
|
81
79
|
console.log(`- ${theme.id}: ${theme.description ?? ""}`);
|
|
82
80
|
});
|
|
83
81
|
}
|
|
84
|
-
console.log("");
|
|
85
82
|
return;
|
|
86
83
|
}
|
|
87
84
|
if (add) {
|
|
88
85
|
await addTheme(name, path);
|
|
89
|
-
console.log(`主题 "${name}"
|
|
86
|
+
console.log(`主题 "${name}" 已添加`);
|
|
90
87
|
return;
|
|
91
88
|
}
|
|
92
89
|
if (rm) {
|
|
93
90
|
await removeTheme(rm);
|
|
94
|
-
console.log(`主题 "${rm}"
|
|
91
|
+
console.log(`主题 "${rm}" 已删除`);
|
|
95
92
|
}
|
|
96
93
|
});
|
|
97
94
|
});
|
package/dist/commands/serve.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import
|
|
3
|
-
import fs from "node:fs";
|
|
4
|
-
import fsPromises from "node:fs/promises";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
5
3
|
import path from "node:path";
|
|
6
4
|
import crypto from "node:crypto";
|
|
7
5
|
import { configDir } from "@wenyan-md/core/wrapper";
|
|
8
6
|
import multer from "multer";
|
|
9
|
-
import { getNormalizeFilePath } from "../utils.js";
|
|
10
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
|
+
}
|
|
11
16
|
const UPLOAD_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
12
17
|
const UPLOAD_DIR = path.join(configDir, "uploads");
|
|
13
18
|
export async function serveCommand(options) {
|
|
14
19
|
// 确保临时目录存在
|
|
15
|
-
|
|
16
|
-
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
17
|
-
}
|
|
20
|
+
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
|
18
21
|
// 服务启动时立即执行一次后台清理
|
|
19
22
|
cleanupOldUploads();
|
|
20
23
|
// 定期清理过期的上传文件
|
|
@@ -70,7 +73,7 @@ export async function serveCommand(options) {
|
|
|
70
73
|
const body = req.body;
|
|
71
74
|
validateRequest(body);
|
|
72
75
|
// 根据 fileId 去找刚上传的 json 文件并读取内容
|
|
73
|
-
const files = await
|
|
76
|
+
const files = await fs.readdir(UPLOAD_DIR);
|
|
74
77
|
const matchedFile = files.find((f) => f === body.fileId);
|
|
75
78
|
if (!matchedFile) {
|
|
76
79
|
throw new AppError(`文件不存在或已过期,请重新上传 (ID: ${body.fileId})`);
|
|
@@ -82,13 +85,15 @@ export async function serveCommand(options) {
|
|
|
82
85
|
}
|
|
83
86
|
// 找到上传文件并提取文本内容
|
|
84
87
|
const filePath = path.join(UPLOAD_DIR, matchedFile);
|
|
85
|
-
const fileContent = await
|
|
88
|
+
const fileContent = await fs.readFile(filePath, "utf-8");
|
|
86
89
|
const gzhContent = JSON.parse(fileContent);
|
|
90
|
+
if (!gzhContent.title)
|
|
91
|
+
throw new AppError("未能找到文章标题");
|
|
87
92
|
// 公共的 asset:// 替换逻辑
|
|
88
93
|
const resolveAssetPath = (assetUrl) => {
|
|
89
94
|
const assetFileId = assetUrl.replace("asset://", "");
|
|
90
95
|
const matchedAsset = files.find((f) => f === assetFileId || path.parse(f).name === assetFileId);
|
|
91
|
-
return matchedAsset ?
|
|
96
|
+
return matchedAsset ? path.join(UPLOAD_DIR, matchedAsset) : assetUrl;
|
|
92
97
|
};
|
|
93
98
|
// 替换 HTML 内容里的 asset://
|
|
94
99
|
gzhContent.content = gzhContent.content.replace(/(<img\b[^>]*?\bsrc\s*=\s*["'])(asset:\/\/[^"']+)(["'])/gi, (_match, prefix, assetUrl, suffix) => prefix + resolveAssetPath(assetUrl) + suffix);
|
|
@@ -109,7 +114,7 @@ export async function serveCommand(options) {
|
|
|
109
114
|
});
|
|
110
115
|
}
|
|
111
116
|
else {
|
|
112
|
-
throw new AppError(
|
|
117
|
+
throw new AppError(`发布到微信公众号失败,\n${data}`);
|
|
113
118
|
}
|
|
114
119
|
});
|
|
115
120
|
// 上传接口
|
|
@@ -199,14 +204,14 @@ function validateRequest(req) {
|
|
|
199
204
|
}
|
|
200
205
|
async function cleanupOldUploads() {
|
|
201
206
|
try {
|
|
202
|
-
const files = await
|
|
207
|
+
const files = await fs.readdir(UPLOAD_DIR);
|
|
203
208
|
const now = Date.now();
|
|
204
209
|
for (const file of files) {
|
|
205
210
|
const filePath = path.join(UPLOAD_DIR, file);
|
|
206
211
|
try {
|
|
207
|
-
const stats = await
|
|
212
|
+
const stats = await fs.stat(filePath);
|
|
208
213
|
if (now - stats.mtimeMs > UPLOAD_TTL_MS) {
|
|
209
|
-
await
|
|
214
|
+
await fs.unlink(filePath);
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
217
|
catch (e) {
|
package/dist/index.js
CHANGED
package/dist/types/cli.d.ts
CHANGED
package/dist/types/index.d.ts
CHANGED
package/dist/types/utils.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { RenderOptions } from "./types.js";
|
|
2
1
|
export declare function readStdin(): Promise<string>;
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function getInputContent(inputContent: string | undefined, options: RenderOptions): Promise<{
|
|
2
|
+
export declare function getInputContent(inputContent?: string, file?: string): Promise<{
|
|
5
3
|
content: string;
|
|
6
4
|
absoluteDirPath: string | undefined;
|
|
7
5
|
}>;
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { AppError } from "./types.js";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
3
|
+
import { getNormalizeFilePath } from "@wenyan-md/core/wrapper";
|
|
4
4
|
export async function readStdin() {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
6
|
let data = "";
|
|
@@ -10,34 +10,7 @@ export async function readStdin() {
|
|
|
10
10
|
process.stdin.on("error", reject);
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
* 路径标准化工具函数
|
|
15
|
-
* 将 Windows 的反斜杠 \ 转换为正斜杠 /,并去除末尾斜杠
|
|
16
|
-
* 目的:在 Linux 容器内也能正确处理 Windows 路径字符串
|
|
17
|
-
*/
|
|
18
|
-
function normalizePath(p) {
|
|
19
|
-
return p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
20
|
-
}
|
|
21
|
-
export function getNormalizeFilePath(inputPath) {
|
|
22
|
-
const isContainer = !!process.env.CONTAINERIZED;
|
|
23
|
-
const hostFilePath = normalizePath(process.env.HOST_FILE_PATH || "");
|
|
24
|
-
if (isContainer && hostFilePath) {
|
|
25
|
-
const containerFilePath = normalizePath(process.env.CONTAINER_FILE_PATH || "/mnt/host-downloads");
|
|
26
|
-
let relativePart = normalizePath(inputPath);
|
|
27
|
-
if (relativePart.startsWith(hostFilePath)) {
|
|
28
|
-
relativePart = relativePart.slice(hostFilePath.length);
|
|
29
|
-
}
|
|
30
|
-
if (!relativePart.startsWith("/")) {
|
|
31
|
-
relativePart = "/" + relativePart;
|
|
32
|
-
}
|
|
33
|
-
return containerFilePath + relativePart;
|
|
34
|
-
}
|
|
35
|
-
else {
|
|
36
|
-
return path.resolve(inputPath);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
export async function getInputContent(inputContent, options) {
|
|
40
|
-
const { file } = options;
|
|
13
|
+
export async function getInputContent(inputContent, file) {
|
|
41
14
|
let absoluteDirPath = undefined;
|
|
42
15
|
// 1. 尝试从 Stdin 读取
|
|
43
16
|
if (!inputContent && !process.stdin.isTTY) {
|
|
@@ -51,7 +24,7 @@ export async function getInputContent(inputContent, options) {
|
|
|
51
24
|
}
|
|
52
25
|
// 3. 校验输入
|
|
53
26
|
if (!inputContent) {
|
|
54
|
-
throw new
|
|
27
|
+
throw new Error("missing input-content (no argument, no stdin, and no file).");
|
|
55
28
|
}
|
|
56
29
|
return { content: inputContent, absoluteDirPath };
|
|
57
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenyan-md/cli",
|
|
3
|
-
"version": "2.0.
|
|
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",
|
|
@@ -22,12 +22,6 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"dist"
|
|
24
24
|
],
|
|
25
|
-
"exports": {
|
|
26
|
-
".": {
|
|
27
|
-
"import": "./dist/index.js",
|
|
28
|
-
"types": "./dist/types/index.d.ts"
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
25
|
"homepage": "https://github.com/caol64/wenyan-cli#readme",
|
|
32
26
|
"repository": {
|
|
33
27
|
"type": "git",
|
|
@@ -37,7 +31,7 @@
|
|
|
37
31
|
"url": "https://github.com/caol64/wenyan-cli/issues"
|
|
38
32
|
},
|
|
39
33
|
"dependencies": {
|
|
40
|
-
"@wenyan-md/core": "^2.0.
|
|
34
|
+
"@wenyan-md/core": "^2.0.8",
|
|
41
35
|
"commander": "^14.0.0",
|
|
42
36
|
"express": "^5.2.1",
|
|
43
37
|
"form-data-encoder": "^4.1.0",
|
|
@@ -46,7 +40,6 @@
|
|
|
46
40
|
"multer": "^2.1.0"
|
|
47
41
|
},
|
|
48
42
|
"devDependencies": {
|
|
49
|
-
"@types/jsdom": "^27.0.0",
|
|
50
43
|
"@types/express": "^5.0.6",
|
|
51
44
|
"@types/multer": "^2.0.0",
|
|
52
45
|
"@types/node": "^24.3.0",
|
package/dist/commands/client.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { Readable } from "node:stream";
|
|
4
|
-
import { FormData, File } from "formdata-node";
|
|
5
|
-
import { FormDataEncoder } from "form-data-encoder";
|
|
6
|
-
import { AppError } from "../types.js";
|
|
7
|
-
import { prepareRenderContext } from "./render.js";
|
|
8
|
-
import { JSDOM } from "jsdom";
|
|
9
|
-
export async function publishClient(inputContent, options) {
|
|
10
|
-
const { theme, customTheme, highlight, macStyle, footnote, apiKey, clientVersion, server } = options;
|
|
11
|
-
const serverUrl = server.replace(/\/$/, ""); // 移除末尾的斜杠
|
|
12
|
-
const headers = {};
|
|
13
|
-
if (clientVersion) {
|
|
14
|
-
headers["x-client-version"] = clientVersion;
|
|
15
|
-
}
|
|
16
|
-
if (apiKey) {
|
|
17
|
-
headers["x-api-key"] = apiKey;
|
|
18
|
-
}
|
|
19
|
-
// ==========================================
|
|
20
|
-
// 0. 连通性与鉴权测试 (Health & Auth Check)
|
|
21
|
-
// ==========================================
|
|
22
|
-
console.log(`[Client] Checking server connection at ${serverUrl} ...`);
|
|
23
|
-
try {
|
|
24
|
-
// 1. 物理连通性与服务指纹验证
|
|
25
|
-
const healthRes = await fetch(`${serverUrl}/health`, { method: "GET" });
|
|
26
|
-
if (!healthRes.ok) {
|
|
27
|
-
throw new Error(`HTTP Error: ${healthRes.status} ${healthRes.statusText}`);
|
|
28
|
-
}
|
|
29
|
-
const healthData = await healthRes.json();
|
|
30
|
-
if (healthData.status !== "ok" || healthData.service !== "wenyan-cli") {
|
|
31
|
-
throw new Error(`Invalid server response. Make sure the server URL is correct.`);
|
|
32
|
-
}
|
|
33
|
-
console.log(`[Client] Server connected successfully (version: ${healthData.version})`);
|
|
34
|
-
// 2. 鉴权探针测试
|
|
35
|
-
console.log(`[Client] Verifying authorization...`);
|
|
36
|
-
const verifyRes = await fetch(`${serverUrl}/verify`, {
|
|
37
|
-
method: "GET",
|
|
38
|
-
headers, // 携带 x-api-key 和 x-client-version
|
|
39
|
-
});
|
|
40
|
-
if (verifyRes.status === 401) {
|
|
41
|
-
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
42
|
-
}
|
|
43
|
-
if (!verifyRes.ok) {
|
|
44
|
-
throw new Error(`Verify Error: ${verifyRes.status} ${verifyRes.statusText}`);
|
|
45
|
-
}
|
|
46
|
-
console.log(`[Client] Authorization passed.`);
|
|
47
|
-
}
|
|
48
|
-
catch (error) {
|
|
49
|
-
if (error.message.includes("鉴权失败")) {
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
throw new Error(`Failed to connect to server (${serverUrl}). \nPlease check if the server is running and the network is accessible. \nDetails: ${error.message}`);
|
|
53
|
-
}
|
|
54
|
-
// ==========================================
|
|
55
|
-
// 1. 读取 markdown 文件,获取其所在目录(用于解析相对图片路径),并渲染成 HTML 格式
|
|
56
|
-
// ==========================================
|
|
57
|
-
const { gzhContent, absoluteDirPath } = await prepareRenderContext(inputContent, options);
|
|
58
|
-
if (!gzhContent.title)
|
|
59
|
-
throw new AppError("未能找到文章标题");
|
|
60
|
-
if (!gzhContent.cover)
|
|
61
|
-
throw new AppError("未能找到文章封面");
|
|
62
|
-
// ==========================================
|
|
63
|
-
// [内部辅助函数] 提取公共的本地图片上传逻辑
|
|
64
|
-
// ==========================================
|
|
65
|
-
const uploadLocalImage = async (originalUrl) => {
|
|
66
|
-
let imagePath = originalUrl;
|
|
67
|
-
if (!path.isAbsolute(imagePath)) {
|
|
68
|
-
// 如果是相对路径,以 markdown 文件所在目录为基准进行拼接
|
|
69
|
-
imagePath = path.resolve(absoluteDirPath || process.cwd(), imagePath);
|
|
70
|
-
}
|
|
71
|
-
if (!fs.existsSync(imagePath)) {
|
|
72
|
-
console.warn(`[Client] Warning: Local image not found: ${imagePath}`);
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
const fileBuffer = fs.readFileSync(imagePath);
|
|
77
|
-
const filename = path.basename(imagePath);
|
|
78
|
-
// 推断 Content-Type
|
|
79
|
-
const ext = path.extname(filename).toLowerCase();
|
|
80
|
-
const mimeTypes = {
|
|
81
|
-
".jpg": "image/jpeg",
|
|
82
|
-
".jpeg": "image/jpeg",
|
|
83
|
-
".png": "image/png",
|
|
84
|
-
".gif": "image/gif",
|
|
85
|
-
".webp": "image/webp",
|
|
86
|
-
".svg": "image/svg+xml",
|
|
87
|
-
};
|
|
88
|
-
const type = mimeTypes[ext] || "application/octet-stream";
|
|
89
|
-
// 构建图片上传表单
|
|
90
|
-
const form = new FormData();
|
|
91
|
-
form.append("file", new File([fileBuffer], filename, { type }));
|
|
92
|
-
const encoder = new FormDataEncoder(form);
|
|
93
|
-
console.log(`[Client] Uploading local image: ${filename} ...`);
|
|
94
|
-
const uploadRes = await fetch(`${serverUrl}/upload`, {
|
|
95
|
-
method: "POST",
|
|
96
|
-
headers: { ...headers, ...encoder.headers },
|
|
97
|
-
body: Readable.from(encoder),
|
|
98
|
-
duplex: "half",
|
|
99
|
-
});
|
|
100
|
-
if (uploadRes.status === 401) {
|
|
101
|
-
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
102
|
-
}
|
|
103
|
-
const uploadData = await uploadRes.json();
|
|
104
|
-
if (uploadRes.ok && uploadData.success) {
|
|
105
|
-
// 返回可供 Server 直接使用的协议路径
|
|
106
|
-
return `asset://${uploadData.data.fileId}`;
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
console.warn(`[Client] Warning: Failed to upload ${filename}: ${uploadData.error || uploadData.desc}`);
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
if (error.message.includes("鉴权失败") || error.message.includes("401")) {
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
117
|
-
console.warn(`[Client] Warning: Error uploading ${imagePath} - ${error.message}`);
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
// ==========================================
|
|
122
|
-
// 2. 解析 HTML 中的所有本地图片上传并替换为服务器可访问的 URL
|
|
123
|
-
// ==========================================
|
|
124
|
-
let modifiedContent = gzhContent.content;
|
|
125
|
-
if (modifiedContent.includes("<img")) {
|
|
126
|
-
const dom = new JSDOM(modifiedContent);
|
|
127
|
-
const document = dom.window.document;
|
|
128
|
-
const images = Array.from(document.querySelectorAll("img"));
|
|
129
|
-
// 并发上传所有插图
|
|
130
|
-
const uploadPromises = images.map(async (element) => {
|
|
131
|
-
const dataSrc = element.getAttribute("src");
|
|
132
|
-
if (dataSrc && needUpload(dataSrc)) {
|
|
133
|
-
const newUrl = await uploadLocalImage(dataSrc);
|
|
134
|
-
if (newUrl) {
|
|
135
|
-
element.setAttribute("src", newUrl); // 替换 DOM 中的属性
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
await Promise.all(uploadPromises);
|
|
140
|
-
modifiedContent = document.body.innerHTML;
|
|
141
|
-
gzhContent.content = modifiedContent;
|
|
142
|
-
}
|
|
143
|
-
// ==========================================
|
|
144
|
-
// 3. 处理封面图片
|
|
145
|
-
// ==========================================
|
|
146
|
-
const cover = gzhContent.cover;
|
|
147
|
-
if (cover && needUpload(cover)) {
|
|
148
|
-
console.log(`[Client] Processing cover image...`);
|
|
149
|
-
const newCoverUrl = await uploadLocalImage(cover);
|
|
150
|
-
if (newCoverUrl) {
|
|
151
|
-
gzhContent.cover = newCoverUrl; // 将封面路径替换为 asset://fileId
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// ==========================================
|
|
155
|
-
// 4. 将替换后的 content 保存成临时文件/流,并上传
|
|
156
|
-
// ==========================================
|
|
157
|
-
const mdFilename = "publish_target.json"; // 这个文件名对服务器来说没有实际意义,只是一个标识
|
|
158
|
-
const mdForm = new FormData();
|
|
159
|
-
mdForm.append("file", new File([Buffer.from(JSON.stringify(gzhContent), "utf-8")], mdFilename, { type: "application/json" }));
|
|
160
|
-
const mdEncoder = new FormDataEncoder(mdForm);
|
|
161
|
-
console.log(`[Client] Uploading compiled document ...`);
|
|
162
|
-
const mdUploadRes = await fetch(`${serverUrl}/upload`, {
|
|
163
|
-
method: "POST",
|
|
164
|
-
headers: { ...headers, ...mdEncoder.headers },
|
|
165
|
-
body: Readable.from(mdEncoder),
|
|
166
|
-
duplex: "half",
|
|
167
|
-
});
|
|
168
|
-
if (mdUploadRes.status === 401) {
|
|
169
|
-
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
170
|
-
}
|
|
171
|
-
const mdUploadData = await mdUploadRes.json();
|
|
172
|
-
if (!mdUploadRes.ok || !mdUploadData.success) {
|
|
173
|
-
throw new Error(`Upload Document Failed: ${mdUploadData.error || mdUploadData.desc || mdUploadRes.statusText}`);
|
|
174
|
-
}
|
|
175
|
-
const mdFileId = mdUploadData.data.fileId;
|
|
176
|
-
console.log(`[Client] Document uploaded, ID: ${mdFileId}`);
|
|
177
|
-
// ==========================================
|
|
178
|
-
// 5. 调用 /publish 接口,触发 Server 端发布
|
|
179
|
-
// ==========================================
|
|
180
|
-
console.log(`[Client] Requesting remote Server to publish ...`);
|
|
181
|
-
const publishRes = await fetch(`${serverUrl}/publish`, {
|
|
182
|
-
method: "POST",
|
|
183
|
-
headers: {
|
|
184
|
-
...headers,
|
|
185
|
-
"Content-Type": "application/json",
|
|
186
|
-
},
|
|
187
|
-
body: JSON.stringify({
|
|
188
|
-
fileId: mdFileId,
|
|
189
|
-
theme,
|
|
190
|
-
highlight,
|
|
191
|
-
customTheme,
|
|
192
|
-
macStyle,
|
|
193
|
-
footnote,
|
|
194
|
-
}),
|
|
195
|
-
});
|
|
196
|
-
if (publishRes.status === 401) {
|
|
197
|
-
throw new Error("鉴权失败 (401):Server 拒绝访问,请检查传入的 --api-key 是否正确。");
|
|
198
|
-
}
|
|
199
|
-
const publishData = await publishRes.json();
|
|
200
|
-
if (!publishRes.ok || publishData.code === -1) {
|
|
201
|
-
throw new Error(`Remote Publish Failed: ${publishData.desc || publishRes.statusText}`);
|
|
202
|
-
}
|
|
203
|
-
return publishData.media_id;
|
|
204
|
-
}
|
|
205
|
-
function needUpload(url) {
|
|
206
|
-
// 需要上传的图片链接通常是相对路径,且不以 http/https、data:、asset:// 等协议开头
|
|
207
|
-
return !/^(https?:\/\/|data:|asset:\/\/)/i.test(url);
|
|
208
|
-
}
|
package/dist/commands/publish.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { publishToWechatDraft } from "@wenyan-md/core/publish";
|
|
2
|
-
import { AppError } from "../types.js";
|
|
3
|
-
import { prepareRenderContext } from "./render.js";
|
|
4
|
-
export async function publishCommand(inputContent, options) {
|
|
5
|
-
const { gzhContent, absoluteDirPath } = await prepareRenderContext(inputContent, options);
|
|
6
|
-
if (!gzhContent.title)
|
|
7
|
-
throw new AppError("未能找到文章标题");
|
|
8
|
-
if (!gzhContent.cover)
|
|
9
|
-
throw new AppError("未能找到文章封面");
|
|
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
|
-
return data.media_id;
|
|
21
|
-
}
|
|
22
|
-
else {
|
|
23
|
-
throw new AppError(`上传失败,\n${data}`);
|
|
24
|
-
}
|
|
25
|
-
}
|
package/dist/commands/render.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { configStore, renderStyledContent } from "@wenyan-md/core/wrapper";
|
|
2
|
-
import { getInputContent, getNormalizeFilePath } from "../utils.js";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import { AppError } from "../types.js";
|
|
5
|
-
// --- 处理输入源、文件路径和主题 ---
|
|
6
|
-
export async function prepareRenderContext(inputContent, options) {
|
|
7
|
-
const { content, absoluteDirPath } = await getInputContent(inputContent, options);
|
|
8
|
-
const { theme, customTheme, highlight, macStyle, footnote } = options;
|
|
9
|
-
let handledCustomTheme = customTheme;
|
|
10
|
-
// 4. 当用户传入自定义主题路径时,优先级最高
|
|
11
|
-
if (customTheme) {
|
|
12
|
-
const normalizePath = getNormalizeFilePath(customTheme);
|
|
13
|
-
handledCustomTheme = await fs.readFile(normalizePath, "utf-8");
|
|
14
|
-
}
|
|
15
|
-
else if (theme) {
|
|
16
|
-
// 否则尝试读取配置中的自定义主题
|
|
17
|
-
handledCustomTheme = configStore.getThemeById(theme);
|
|
18
|
-
}
|
|
19
|
-
if (!handledCustomTheme && !theme) {
|
|
20
|
-
throw new AppError(`theme "${theme}" not found.`);
|
|
21
|
-
}
|
|
22
|
-
// 5. 执行核心渲染
|
|
23
|
-
const gzhContent = await renderStyledContent(content, {
|
|
24
|
-
themeId: theme,
|
|
25
|
-
hlThemeId: highlight,
|
|
26
|
-
isMacStyle: macStyle,
|
|
27
|
-
isAddFootnote: footnote,
|
|
28
|
-
themeCss: handledCustomTheme,
|
|
29
|
-
});
|
|
30
|
-
return { gzhContent, absoluteDirPath };
|
|
31
|
-
}
|
package/dist/commands/theme.js
DELETED
|
@@ -1,67 +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
|
-
import { AppError } from "../types.js";
|
|
6
|
-
export function listThemes() {
|
|
7
|
-
const themes = getAllGzhThemes();
|
|
8
|
-
const themeList = themes.map((theme) => {
|
|
9
|
-
return {
|
|
10
|
-
id: theme.meta.id,
|
|
11
|
-
name: theme.meta.name,
|
|
12
|
-
description: theme.meta.description,
|
|
13
|
-
isBuiltin: true,
|
|
14
|
-
};
|
|
15
|
-
});
|
|
16
|
-
const customThemes = configStore.getThemes();
|
|
17
|
-
if (customThemes.length > 0) {
|
|
18
|
-
customThemes.forEach((theme) => {
|
|
19
|
-
themeList.push({
|
|
20
|
-
id: theme.id,
|
|
21
|
-
name: theme.id,
|
|
22
|
-
description: theme.description,
|
|
23
|
-
isBuiltin: false,
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
return themeList;
|
|
28
|
-
}
|
|
29
|
-
export async function addTheme(name, path) {
|
|
30
|
-
if (!name || !path) {
|
|
31
|
-
throw new AppError("添加主题时必须提供名称(name)和路径(path)");
|
|
32
|
-
}
|
|
33
|
-
if (checkThemeExists(name) || checkCustomThemeExists(name)) {
|
|
34
|
-
throw new AppError(`主题 "${name}" 已存在`);
|
|
35
|
-
}
|
|
36
|
-
if (path.startsWith("http")) {
|
|
37
|
-
console.log(`正在从远程获取主题: ${path} ...`);
|
|
38
|
-
const response = await fetch(path);
|
|
39
|
-
if (!response.ok) {
|
|
40
|
-
throw new AppError(`无法从远程获取主题: ${response.statusText}`);
|
|
41
|
-
}
|
|
42
|
-
const content = await response.text();
|
|
43
|
-
configStore.addThemeToConfig(name, content);
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
const normalizePath = getNormalizeFilePath(path);
|
|
47
|
-
const content = await fs.readFile(normalizePath, "utf-8");
|
|
48
|
-
configStore.addThemeToConfig(name, content);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
export async function removeTheme(name) {
|
|
52
|
-
if (checkThemeExists(name)) {
|
|
53
|
-
throw new AppError(`默认主题 "${name}" 不能删除`);
|
|
54
|
-
}
|
|
55
|
-
if (!checkCustomThemeExists(name)) {
|
|
56
|
-
throw new AppError(`自定义主题 "${name}" 不存在`);
|
|
57
|
-
}
|
|
58
|
-
configStore.deleteThemeFromConfig(name);
|
|
59
|
-
}
|
|
60
|
-
function checkThemeExists(themeId) {
|
|
61
|
-
const themes = getAllGzhThemes();
|
|
62
|
-
return themes.some((theme) => theme.meta.id === themeId);
|
|
63
|
-
}
|
|
64
|
-
function checkCustomThemeExists(themeId) {
|
|
65
|
-
const customThemes = configStore.getThemes();
|
|
66
|
-
return customThemes.some((theme) => theme.id === themeId);
|
|
67
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { StyledContent } from "@wenyan-md/core/wrapper";
|
|
2
|
-
import { RenderOptions } from "../types.js";
|
|
3
|
-
interface RenderContext {
|
|
4
|
-
gzhContent: StyledContent;
|
|
5
|
-
absoluteDirPath: string | undefined;
|
|
6
|
-
}
|
|
7
|
-
export declare function prepareRenderContext(inputContent: string | undefined, options: RenderOptions): Promise<RenderContext>;
|
|
8
|
-
export {};
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export interface ThemeOptions {
|
|
2
|
-
list?: boolean;
|
|
3
|
-
add?: boolean;
|
|
4
|
-
name?: string;
|
|
5
|
-
path?: string;
|
|
6
|
-
rm?: string;
|
|
7
|
-
}
|
|
8
|
-
export interface ThemeInfo {
|
|
9
|
-
id: string;
|
|
10
|
-
name: string;
|
|
11
|
-
description?: string;
|
|
12
|
-
isBuiltin: boolean;
|
|
13
|
-
}
|
|
14
|
-
export declare function listThemes(): ThemeInfo[];
|
|
15
|
-
export declare function addTheme(name?: string, path?: string): Promise<void>;
|
|
16
|
-
export declare function removeTheme(name: string): Promise<void>;
|
package/dist/types/types.d.ts
DELETED
|
@@ -1,17 +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 PublishOptions extends RenderOptions {
|
|
10
|
-
server?: string;
|
|
11
|
-
apiKey?: string;
|
|
12
|
-
clientVersion?: string;
|
|
13
|
-
}
|
|
14
|
-
export declare class AppError extends Error {
|
|
15
|
-
message: string;
|
|
16
|
-
constructor(message: string);
|
|
17
|
-
}
|