@peteryuan/wxformat 0.1.0
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/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/wxformat.mjs +335 -0
- package/lib/args.mjs +94 -0
- package/lib/clipboard.mjs +90 -0
- package/lib/core.mjs +70 -0
- package/lib/editor.mjs +125 -0
- package/lib/help.mjs +50 -0
- package/lib/live-server.mjs +46 -0
- package/lib/render.js +263 -0
- package/package.json +38 -0
- package/scripts/sync-render.mjs +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pengfei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# wxformat — 公众号文章排版 CLI
|
|
2
|
+
|
|
3
|
+
把 Markdown 草稿一键转为「可直接发布」的版本:**纯净 Markdown + 公众号 HTML(全内联样式,粘贴公众号后台即可发布)**。
|
|
4
|
+
|
|
5
|
+
设计目标:**CLI 优先**——可被脚本、CI 与大模型(LLM)直接调用;同时保留人用的爽点(浏览器预览、排版面板一键复制、边写边实时预览)。
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
wxformat drafts/文章.md # 产出 .clean.md + .html,macOS 自动复制发布 HTML
|
|
9
|
+
wxformat drafts/文章.md --preview # 浏览器预览
|
|
10
|
+
wxformat drafts/文章.md --editor # 排版面板:页面内微调 + 一键复制
|
|
11
|
+
wxformat drafts/文章.md --watch --preview # 边写边实时预览
|
|
12
|
+
cat 文章.md | wxformat - -f md -o - # 管道:纯净 Markdown → stdout
|
|
13
|
+
wxformat 文章.md --json --no-clipboard # LLM/脚本友好输出
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 快速开始
|
|
17
|
+
|
|
18
|
+
无需安装任何依赖(Node ≥ 18,ESM):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# 方式一:直接跑
|
|
22
|
+
node wxformat/bin/wxformat.mjs drafts/文章.md
|
|
23
|
+
|
|
24
|
+
# 方式二:工作区便捷入口
|
|
25
|
+
bin/wxformat drafts/文章.md
|
|
26
|
+
|
|
27
|
+
# 方式三:全局安装(可选)
|
|
28
|
+
cd wxformat && npm link
|
|
29
|
+
wxformat drafts/文章.md
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 完整选项
|
|
33
|
+
|
|
34
|
+
| 选项 | 说明 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `<file\|->` | 输入文件;`-` 表示从 stdin 读取(管道友好) |
|
|
37
|
+
| `-o, --output <路径\|->` | `.md`/`.html` 文件只写该格式;目录写入默认文件名;`-` 输出到 stdout;省略写到输入文件旁(`<名>.clean.md` / `<名>.html`) |
|
|
38
|
+
| `-f, --format <md\|html\|both>` | 输出格式(默认 `both`;stdin 且无 `-o` 时默认 html) |
|
|
39
|
+
| `-t, --theme <id>` | `bw` 黑白极简 | `paper` 纸墨书香 | `celadon` 晨雾青瓷(默认 `bw`) |
|
|
40
|
+
| `-p, --preview` | 生成预览页并自动打开浏览器 |
|
|
41
|
+
| `-e, --editor` | 打开浏览器排版面板:正文可微调,一键复制发布 HTML / 纯净 Markdown |
|
|
42
|
+
| `-c, --clipboard` | 复制到剪贴板(macOS 文件模式下默认自动复制发布 HTML——写入富文本 `text/html`,公众号后台**粘贴即渲染**;Windows/Linux 回退纯文本,请用浏览器打开 `.html` 全选复制) |
|
|
43
|
+
| `--clipboard=md\|html` | 指定复制哪种内容 |
|
|
44
|
+
| `--no-clipboard` | 禁止复制(脚本/CI 友好) |
|
|
45
|
+
| `-j, --json` | 机器可读 JSON 输出(含 md / section / 产物路径;自动跳过剪贴板) |
|
|
46
|
+
| `-w, --watch` | 监听输入文件,变更自动重排版(配 `--preview` 浏览器实时刷新) |
|
|
47
|
+
| `-q, --quiet` | 只输出产物路径(或 stdout 内容) |
|
|
48
|
+
| `--config <文件>` | 配置文件(默认探测 `.wxformatrc.json` / `wxformat.config.json` / `package.json#wxformat`) |
|
|
49
|
+
| `--no-meta-strip` | 不剥离元信息与发布包,整篇原文排版 |
|
|
50
|
+
| `--no-open` | 生成预览/面板文件但不自动打开浏览器(CI 友好) |
|
|
51
|
+
| `-V, --version` / `-h, --help` | 版本 / 帮助 |
|
|
52
|
+
|
|
53
|
+
退出码:`0` 成功 | `1` 参数/输入错误 | `2` 转换失败。
|
|
54
|
+
|
|
55
|
+
## 配置文件
|
|
56
|
+
|
|
57
|
+
`.wxformatrc.json`(放在 cwd 即可,CLI 参数优先):
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"theme": "paper",
|
|
62
|
+
"format": "both",
|
|
63
|
+
"clipboard": "html",
|
|
64
|
+
"stripMeta": true
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`clipboard` 支持 `true`(默认目标)或 `"md"` / `"html"`。
|
|
69
|
+
|
|
70
|
+
## LLM / 脚本调用
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# 一步拿到结构化结果(含正文 md、发布 html、产物路径、字数)
|
|
74
|
+
wxformat drafts/文章.md --json --no-clipboard
|
|
75
|
+
|
|
76
|
+
# 管道:把任意 Markdown 转成公众号 HTML 并输出到 stdout
|
|
77
|
+
cat note.md | wxformat - -f html -o -
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
JSON 输出结构:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"ok": true,
|
|
85
|
+
"title": "…",
|
|
86
|
+
"chars": 1234,
|
|
87
|
+
"theme": "bw",
|
|
88
|
+
"themeLabel": "黑白极简",
|
|
89
|
+
"tags": ["示例", "测试"],
|
|
90
|
+
"format": "html",
|
|
91
|
+
"md": "# 标题\n\n正文…",
|
|
92
|
+
"section": "<section style=\"…\">…</section>",
|
|
93
|
+
"files": { "md": "/abs/path.clean.md", "html": "/abs/path.html" },
|
|
94
|
+
"stdout": null,
|
|
95
|
+
"clipboard": "skip",
|
|
96
|
+
"preview": null,
|
|
97
|
+
"editor": null
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
失败时 JSON 模式仍输出可解析结果:`{ "ok": false, "error": "…", "code": 1|2 }`,同时 stderr 给人类可读信息。
|
|
102
|
+
|
|
103
|
+
## 架构
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
wxformat/
|
|
107
|
+
bin/wxformat.mjs CLI 入口(参数解析 / 输出调度 / 剪贴板 / 预览 / watch)
|
|
108
|
+
lib/core.mjs 排版核心(剥离 + 渲染编排)
|
|
109
|
+
lib/args.mjs 零依赖参数解析
|
|
110
|
+
lib/clipboard.mjs 跨平台剪贴板(pbcopy / clip / wl-copy…)
|
|
111
|
+
lib/editor.mjs 浏览器排版面板页(自包含单页)
|
|
112
|
+
lib/live-server.mjs --watch --preview 实时刷新用的极简本地服务
|
|
113
|
+
lib/help.mjs 帮助与版本
|
|
114
|
+
test/ node:test 测试
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**单一实现**:渲染器优先复用工作区 `../dsh-wechat-preview/lib/render.js`——CLI 与 DSH 里的「排版预览」GUI 输出同源,改主题、改剥离规则只动一处,两边同时生效。npm 独立安装时该路径不存在,自动回退到本包自带的 `lib/render.js` 副本(发布前由 `scripts/sync-render.mjs` 同步,`prepublishOnly` 钩子保证打包时副本永远最新)。
|
|
118
|
+
|
|
119
|
+
## 发布到 npm
|
|
120
|
+
|
|
121
|
+
包名 `@peteryuan/wxformat`(scoped;裸名 `wxformat` 因与已有包 `toformat` 过于相似被 npm 拒绝注册)。安装后命令仍为 `wxformat`。发布流程:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
cd ~/Desktop/DSH/wxformat
|
|
125
|
+
npm publish --dry-run --registry=https://registry.npmjs.org/ --access=public # 预演
|
|
126
|
+
npm publish --registry=https://registry.npmjs.org/ --access=public # 正式发布
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
(`--registry` 显式指定官方源,绕开本机 CNPM 镜像;scoped 包首次发布需 `--access=public`。)
|
|
130
|
+
|
|
131
|
+
发布后验证:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
mkdir /tmp/wx-demo && cd /tmp/wx-demo && npm init -y
|
|
135
|
+
npm i -g @peteryuan/wxformat # 或 npx @peteryuan/wxformat
|
|
136
|
+
wxformat --help
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
注意:npm 版携带的是发布时刻的渲染快照;工作区改过主题后,重新 `npm publish` 前 `prepublishOnly` 会自动同步最新 `render.js`。
|
|
140
|
+
|
|
141
|
+
## 主题(TODO)
|
|
142
|
+
|
|
143
|
+
当前三套:`bw`(黑白极简)、`paper`(纸墨书香)、`celadon`(晨雾青瓷),定义在 `dsh-wechat-preview/lib/render.js` 的 `THEMES` 里。后续优化主题(新增/调色)直接改那个文件即可,CLI 无需改动。
|
|
144
|
+
|
|
145
|
+
计划中的方向(待定稿):
|
|
146
|
+
- 增加 2-3 套新主题(如「科技蓝」「暖黄奶油」「暗夜」);
|
|
147
|
+
- 主题支持可配置项(字号、段距、配色)覆盖默认值;
|
|
148
|
+
- 允许用户自定义主题文件(`wxformat.theme.json`)。
|
|
149
|
+
|
|
150
|
+
## 后续路线
|
|
151
|
+
|
|
152
|
+
- [x] 独立发布:`render.js` 已打入包内(工作区优先 + 副本回退),可 `npm publish`
|
|
153
|
+
- [ ] 主题优化(见上)
|
|
154
|
+
- [ ] `--preview` 增加手机尺寸切换(375px 模拟)
|
|
155
|
+
- [ ] 批量排版:`wxformat "drafts/*.md"`(或显式多文件参数)
|
|
156
|
+
- [ ] 自动识别墨滴/135 等编辑器差异做兼容输出
|
package/bin/wxformat.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* wxformat — 公众号文章排版 CLI
|
|
4
|
+
*
|
|
5
|
+
* 把 Markdown 草稿(或 stdin 管道内容)一键转为"可直接发布"的版本:
|
|
6
|
+
* 纯净 Markdown + 公众号 HTML(全内联样式,粘贴公众号后台即可发布)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* wxformat <file|-> [选项]
|
|
10
|
+
*
|
|
11
|
+
* 示例:
|
|
12
|
+
* wxformat drafts/文章.md # 产出 .clean.md + .html,自动复制发布 HTML
|
|
13
|
+
* wxformat drafts/文章.md --theme paper # 换主题
|
|
14
|
+
* wxformat drafts/文章.md --preview # 浏览器预览
|
|
15
|
+
* wxformat drafts/文章.md --editor # 打开排版面板,页面内一键复制
|
|
16
|
+
* wxformat drafts/文章.md --watch --preview # 边写边实时预览
|
|
17
|
+
* cat 文章.md | wxformat - -f md -o - # 管道:纯净 Markdown 到 stdout
|
|
18
|
+
* wxformat 文章.md --json --no-clipboard # 机器可读输出(LLM/脚本友好)
|
|
19
|
+
*/
|
|
20
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
21
|
+
import { existsSync, statSync, watchFile, unwatchFile } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { formatArticle, THEMES } from "../lib/core.mjs";
|
|
26
|
+
import { copyText, copyHtml } from "../lib/clipboard.mjs";
|
|
27
|
+
import { editorPage } from "../lib/editor.mjs";
|
|
28
|
+
import { startLiveServer } from "../lib/live-server.mjs";
|
|
29
|
+
import { parseArgs } from "../lib/args.mjs";
|
|
30
|
+
import { HELP, VERSION } from "../lib/help.mjs";
|
|
31
|
+
|
|
32
|
+
const EXIT_OK = 0;
|
|
33
|
+
const EXIT_USAGE = 1;
|
|
34
|
+
const EXIT_CONVERT = 2;
|
|
35
|
+
|
|
36
|
+
function fail(code, msg, { json } = {}) {
|
|
37
|
+
if (json) console.log(JSON.stringify({ ok: false, error: msg, code }, null, 2));
|
|
38
|
+
console.error(`[wxformat] ${msg}`);
|
|
39
|
+
process.exit(code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readStdin() {
|
|
43
|
+
const chunks = [];
|
|
44
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
45
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadConfig(explicit) {
|
|
49
|
+
const list = explicit
|
|
50
|
+
? [path.resolve(explicit)]
|
|
51
|
+
: [".wxformatrc.json", "wxformat.config.json", "package.json"].map((p) => path.resolve(p));
|
|
52
|
+
for (const p of list) {
|
|
53
|
+
if (!existsSync(p)) continue;
|
|
54
|
+
const obj = JSON.parse(await readFile(p, "utf8"));
|
|
55
|
+
const src = path.basename(p) === "package.json" ? obj.wxformat : obj;
|
|
56
|
+
if (!src || typeof src !== "object") continue;
|
|
57
|
+
return {
|
|
58
|
+
theme: src.theme,
|
|
59
|
+
format: src.format,
|
|
60
|
+
clipboard: src.clipboard,
|
|
61
|
+
stripMeta: src.stripMeta,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 打开浏览器/文件(异步、不阻塞、不挂起父进程)。 */
|
|
68
|
+
function openPath(target) {
|
|
69
|
+
const cmd =
|
|
70
|
+
process.platform === "darwin"
|
|
71
|
+
? ["open", target]
|
|
72
|
+
: process.platform === "win32"
|
|
73
|
+
? ["cmd", "/c", "start", "", target]
|
|
74
|
+
: ["xdg-open", target];
|
|
75
|
+
try {
|
|
76
|
+
spawn(cmd[0], cmd.slice(1), { detached: true, stdio: "ignore" }).unref();
|
|
77
|
+
} catch {
|
|
78
|
+
// 打不开就算了,不影响主流程
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 解析输出目标,决定写哪些文件 / 是否走 stdout。
|
|
84
|
+
* @returns {{files: Record<string,string>, stdout: string|null, fmt: string}}
|
|
85
|
+
*/
|
|
86
|
+
function resolveOutput(out, format, fromStdin, inputLabel, result) {
|
|
87
|
+
const stem = fromStdin ? null : inputLabel.replace(/\.[^.]+$/, "");
|
|
88
|
+
const mdName = (b) => `${b}.clean.md`;
|
|
89
|
+
const htmlName = (b) => `${b}.html`;
|
|
90
|
+
|
|
91
|
+
if (out === undefined) {
|
|
92
|
+
if (fromStdin) {
|
|
93
|
+
// stdin 且未指定 -o:单一 stdout 流,默认给 html(发布物)
|
|
94
|
+
if (format === "both") return { stdout: result.section, files: {}, fmt: "html" };
|
|
95
|
+
return { stdout: format === "md" ? result.md : result.section, files: {}, fmt: format };
|
|
96
|
+
}
|
|
97
|
+
const files = {};
|
|
98
|
+
if (format !== "html") files.md = mdName(stem);
|
|
99
|
+
if (format !== "md") files.html = htmlName(stem);
|
|
100
|
+
return { files, stdout: null, fmt: format };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (out === "-") {
|
|
104
|
+
if (format === "both") {
|
|
105
|
+
throw new Error("「-o -」与「-f both」冲突:请指定 -f md 或 -f html(或加 --json 走 JSON 输出)");
|
|
106
|
+
}
|
|
107
|
+
return { stdout: format === "md" ? result.md : result.section, files: {}, fmt: format };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isDir = out.endsWith("/") || out.endsWith(path.sep) || (existsSync(out) && statSync(out).isDirectory());
|
|
111
|
+
if (isDir) {
|
|
112
|
+
const dir = path.resolve(out.replace(/[\\/]$/, ""));
|
|
113
|
+
const base = stem ? path.basename(stem) : "article";
|
|
114
|
+
const files = {};
|
|
115
|
+
if (format !== "html") files.md = path.join(dir, mdName(base));
|
|
116
|
+
if (format !== "md") files.html = path.join(dir, htmlName(base));
|
|
117
|
+
return { files, stdout: null, fmt: format };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ext = path.extname(out).toLowerCase();
|
|
121
|
+
const fromExt = ext === ".md" ? "md" : ext === ".html" ? "html" : null;
|
|
122
|
+
if (!fromExt) throw new Error(`-o 需以 .md / .html 结尾、为目录,或为 -(当前: ${out})`);
|
|
123
|
+
if (format === "both") throw new Error(`「-f both」需配合目录输出或省略 -o(当前 -o ${out})`);
|
|
124
|
+
if (format !== fromExt) throw new Error(`-o 扩展名(.${fromExt})与 -f ${format} 冲突`);
|
|
125
|
+
return { files: fromExt === "md" ? { md: path.resolve(out) } : { html: path.resolve(out) }, stdout: null, fmt: fromExt };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function main() {
|
|
129
|
+
const argv = process.argv.slice(2);
|
|
130
|
+
let opts;
|
|
131
|
+
try {
|
|
132
|
+
opts = parseArgs(argv);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
fail(EXIT_USAGE, e.message);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (opts.help) {
|
|
138
|
+
console.log(HELP);
|
|
139
|
+
process.exit(EXIT_OK);
|
|
140
|
+
}
|
|
141
|
+
if (opts.version) {
|
|
142
|
+
console.log(VERSION);
|
|
143
|
+
process.exit(EXIT_OK);
|
|
144
|
+
}
|
|
145
|
+
if (opts._.length !== 1) {
|
|
146
|
+
fail(EXIT_USAGE, "需要一个输入文件(或 - 表示从 stdin 读取)\n 示例: wxformat drafts/文章.md | cat a.md | wxformat -");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const json = !!opts.json;
|
|
150
|
+
const quiet = !!opts.quiet;
|
|
151
|
+
let watch = !!opts.watch;
|
|
152
|
+
|
|
153
|
+
let cfg = {};
|
|
154
|
+
try {
|
|
155
|
+
cfg = await loadConfig(opts.config);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
fail(EXIT_USAGE, `配置文件解析失败: ${e.message}`, { json });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const theme = opts.theme ?? cfg.theme ?? "bw";
|
|
161
|
+
if (!THEMES[theme]) fail(EXIT_USAGE, `未知主题「${theme}」,可用主题: ${Object.keys(THEMES).join(" / ")}`, { json });
|
|
162
|
+
const format = opts.format ?? cfg.format ?? "both";
|
|
163
|
+
if (!["md", "html", "both"].includes(format)) fail(EXIT_USAGE, `未知格式「${format}」,可用: md / html / both`, { json });
|
|
164
|
+
const strip = opts.noStrip ? false : cfg.stripMeta !== false;
|
|
165
|
+
|
|
166
|
+
const input = opts._[0];
|
|
167
|
+
const fromStdin = input === "-";
|
|
168
|
+
|
|
169
|
+
let raw;
|
|
170
|
+
let inputLabel;
|
|
171
|
+
if (fromStdin) {
|
|
172
|
+
raw = await readStdin();
|
|
173
|
+
inputLabel = "<stdin>";
|
|
174
|
+
if (!raw.trim()) fail(EXIT_USAGE, "stdin 为空", { json });
|
|
175
|
+
if (watch) {
|
|
176
|
+
console.warn("[wxformat] stdin 不支持 --watch,按单次处理");
|
|
177
|
+
watch = false;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
const src = path.resolve(input);
|
|
181
|
+
if (!existsSync(src) || statSync(src).isDirectory()) fail(EXIT_USAGE, `文件不存在: ${input}`, { json });
|
|
182
|
+
raw = await readFile(src, "utf8");
|
|
183
|
+
inputLabel = src;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let result;
|
|
187
|
+
try {
|
|
188
|
+
result = formatArticle(raw, { theme, stripMeta: strip });
|
|
189
|
+
} catch (e) {
|
|
190
|
+
fail(EXIT_CONVERT, e.message, { json });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let out;
|
|
194
|
+
try {
|
|
195
|
+
out = resolveOutput(opts.output, format, fromStdin, inputLabel, result);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
fail(EXIT_USAGE, e.message, { json });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* ── 写产物 ─────────────────────────────────────────────────── */
|
|
201
|
+
// .html 文件写"完整页面"(浏览器打开/全选复制);剪贴板与 JSON 里给的是 section 发布片段
|
|
202
|
+
const files = {};
|
|
203
|
+
for (const [k, p] of Object.entries(out.files)) {
|
|
204
|
+
try {
|
|
205
|
+
await mkdir(path.dirname(p), { recursive: true });
|
|
206
|
+
await writeFile(p, k === "md" ? result.md : result.document, "utf8");
|
|
207
|
+
files[k] = p;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
fail(EXIT_CONVERT, `写入失败 ${p}: ${e.message}`, { json });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ── 剪贴板 ─────────────────────────────────────────────────── */
|
|
214
|
+
// --json / --no-clipboard 一律不复制;stdin 与 stdout 模式不自动复制;
|
|
215
|
+
// macOS 文件输出模式下默认自动复制"发布 HTML"(直接去公众号后台粘贴发布)。
|
|
216
|
+
const defaultCopyTarget = () => (out.fmt === "md" ? "md" : "html");
|
|
217
|
+
let clipboard = "skip";
|
|
218
|
+
if (!json && !opts.noClipboard) {
|
|
219
|
+
let target = null;
|
|
220
|
+
if (opts.clipboard === true) target = defaultCopyTarget();
|
|
221
|
+
else if (typeof opts.clipboard === "string") target = opts.clipboard;
|
|
222
|
+
else if (cfg.clipboard) target = cfg.clipboard === true ? defaultCopyTarget() : cfg.clipboard;
|
|
223
|
+
else if (!fromStdin && Object.keys(out.files).length > 0 && process.platform === "darwin") {
|
|
224
|
+
target = defaultCopyTarget();
|
|
225
|
+
}
|
|
226
|
+
if (typeof target === "string" && !["html", "md"].includes(target)) {
|
|
227
|
+
fail(EXIT_USAGE, `--clipboard 取值需为 md 或 html(当前: ${target})`, { json });
|
|
228
|
+
}
|
|
229
|
+
if (target) {
|
|
230
|
+
const text = target === "html" ? result.section : result.md;
|
|
231
|
+
// 发布 HTML 走富文本剪贴板(text/html),公众号后台粘贴即渲染;Markdown 走纯文本
|
|
232
|
+
const ok = target === "html" ? copyHtml(text) : copyText(text);
|
|
233
|
+
clipboard = ok ? `已复制${target === "html" ? "发布 HTML" : "纯净 Markdown"}` : "复制失败(请手动复制产物文件)";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ── 预览 / 排版面板 / 实时服务 ─────────────────────────────── */
|
|
238
|
+
let preview = null;
|
|
239
|
+
let editor = null;
|
|
240
|
+
let live = null;
|
|
241
|
+
const tmp = path.join(os.tmpdir(), `wxformat-${process.pid}-${Date.now().toString(36)}`);
|
|
242
|
+
await mkdir(tmp, { recursive: true });
|
|
243
|
+
const tmpFile = (name, content) => writeFile(path.join(tmp, name), content, "utf8").then(() => path.join(tmp, name));
|
|
244
|
+
|
|
245
|
+
if (opts.preview) {
|
|
246
|
+
if (watch) {
|
|
247
|
+
live = await startLiveServer();
|
|
248
|
+
live.setDocument(result.document);
|
|
249
|
+
preview = live.url;
|
|
250
|
+
} else {
|
|
251
|
+
preview = await tmpFile("preview.html", result.document);
|
|
252
|
+
}
|
|
253
|
+
if (!opts.noOpen) openPath(live ? live.url : preview);
|
|
254
|
+
}
|
|
255
|
+
if (opts.editor) {
|
|
256
|
+
editor = await tmpFile(
|
|
257
|
+
"editor.html",
|
|
258
|
+
editorPage({ title: result.title, section: result.section, md: result.md, themeLabel: result.themeLabel }),
|
|
259
|
+
);
|
|
260
|
+
if (!opts.noOpen) openPath(editor);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ── 输出 ───────────────────────────────────────────────────── */
|
|
264
|
+
if (json) {
|
|
265
|
+
console.log(
|
|
266
|
+
JSON.stringify(
|
|
267
|
+
{
|
|
268
|
+
ok: true,
|
|
269
|
+
title: result.title,
|
|
270
|
+
chars: result.chars,
|
|
271
|
+
theme: result.theme,
|
|
272
|
+
themeLabel: result.themeLabel,
|
|
273
|
+
tags: result.tags,
|
|
274
|
+
format: out.fmt,
|
|
275
|
+
md: result.md,
|
|
276
|
+
section: result.section,
|
|
277
|
+
files,
|
|
278
|
+
stdout: out.stdout,
|
|
279
|
+
clipboard,
|
|
280
|
+
preview,
|
|
281
|
+
editor,
|
|
282
|
+
},
|
|
283
|
+
null,
|
|
284
|
+
2,
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
} else if (out.stdout !== null) {
|
|
288
|
+
// stdout 模式:只输出内容本身,杜绝报告杂音污染管道
|
|
289
|
+
process.stdout.write(out.stdout.endsWith("\n") ? out.stdout : `${out.stdout}\n`);
|
|
290
|
+
} else if (quiet) {
|
|
291
|
+
for (const p of Object.values(files)) console.log(p);
|
|
292
|
+
} else {
|
|
293
|
+
console.log(`✅ 排版完成:《${result.title}》`);
|
|
294
|
+
console.log(` 正文 ${result.chars} 字 | 主题:${result.themeLabel}(${result.theme})`);
|
|
295
|
+
for (const [k, p] of Object.entries(files)) console.log(` ${k === "md" ? "纯净 Markdown" : "公众号 HTML"}:${p}`);
|
|
296
|
+
console.log(` 剪贴板:${clipboard === "skip" ? "跳过" : clipboard}`);
|
|
297
|
+
if (preview) console.log(` 预览:${preview}`);
|
|
298
|
+
if (editor) console.log(` 排版面板:${editor}`);
|
|
299
|
+
if (watch) console.log(" 监听中…(Ctrl+C 退出)");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ── watch:监听输入文件,变更自动重排版 ─────────────────────── */
|
|
303
|
+
if (watch) {
|
|
304
|
+
let busy = false;
|
|
305
|
+
const rerun = async () => {
|
|
306
|
+
if (busy) return;
|
|
307
|
+
busy = true;
|
|
308
|
+
try {
|
|
309
|
+
const raw2 = await readFile(inputLabel, "utf8");
|
|
310
|
+
const r2 = formatArticle(raw2, { theme, stripMeta: strip });
|
|
311
|
+
for (const [k, p] of Object.entries(out.files)) {
|
|
312
|
+
await writeFile(p, k === "md" ? r2.md : r2.document, "utf8");
|
|
313
|
+
}
|
|
314
|
+
if (live) live.setDocument(r2.document);
|
|
315
|
+
// 状态日志走 stderr:不污染 --json / 管道模式的 stdout
|
|
316
|
+
if (!quiet) console.error(` [${new Date().toLocaleTimeString()}] 已重新排版(正文 ${r2.chars} 字)`);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
console.error(` [${new Date().toLocaleTimeString()}] 排版失败: ${e.message}`);
|
|
319
|
+
} finally {
|
|
320
|
+
busy = false;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
watchFile(inputLabel, { interval: 300 }, rerun);
|
|
324
|
+
process.on("SIGINT", () => {
|
|
325
|
+
unwatchFile(inputLabel, rerun);
|
|
326
|
+
if (live) live.close();
|
|
327
|
+
process.exit(EXIT_OK);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
main().catch((e) => {
|
|
333
|
+
console.error(`[wxformat] 未预期错误: ${e.stack || e.message}`);
|
|
334
|
+
process.exit(EXIT_CONVERT);
|
|
335
|
+
});
|
package/lib/args.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* args.mjs — 零依赖命令行参数解析
|
|
3
|
+
*
|
|
4
|
+
* 支持:
|
|
5
|
+
* --long 布尔开关
|
|
6
|
+
* --long=value 带值
|
|
7
|
+
* --long value 带值(空格分隔)
|
|
8
|
+
* -x 短开关
|
|
9
|
+
* --clipboard[=md|html] 可选值参数(裸写为 true,等号给值)
|
|
10
|
+
* -- 其后所有内容视为位置参数
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const VALUE_KEYS = new Set(["output", "format", "theme", "config"]);
|
|
14
|
+
|
|
15
|
+
const LONG = {
|
|
16
|
+
"--output": "output",
|
|
17
|
+
"--format": "format",
|
|
18
|
+
"--theme": "theme",
|
|
19
|
+
"--config": "config",
|
|
20
|
+
"--clipboard": "clipboard",
|
|
21
|
+
"--preview": "preview",
|
|
22
|
+
"--editor": "editor",
|
|
23
|
+
"--json": "json",
|
|
24
|
+
"--watch": "watch",
|
|
25
|
+
"--quiet": "quiet",
|
|
26
|
+
"--version": "version",
|
|
27
|
+
"--help": "help",
|
|
28
|
+
"--no-meta-strip": "noStrip",
|
|
29
|
+
"--no-clipboard": "noClipboard",
|
|
30
|
+
"--no-open": "noOpen",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const SHORT = {
|
|
34
|
+
"-o": "output",
|
|
35
|
+
"-f": "format",
|
|
36
|
+
"-t": "theme",
|
|
37
|
+
"-p": "preview",
|
|
38
|
+
"-e": "editor",
|
|
39
|
+
"-c": "clipboard",
|
|
40
|
+
"-j": "json",
|
|
41
|
+
"-w": "watch",
|
|
42
|
+
"-q": "quiet",
|
|
43
|
+
"-V": "version",
|
|
44
|
+
"-h": "help",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string[]} argv 不含 node 与脚本路径的参数数组
|
|
49
|
+
* @returns {object} 解析结果,位置参数收集在 `_` 数组
|
|
50
|
+
* @throws {Error} 未知参数 / 缺少值
|
|
51
|
+
*/
|
|
52
|
+
export function parseArgs(argv) {
|
|
53
|
+
const opts = { _: [] };
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const a = argv[i];
|
|
56
|
+
if (a === "--") {
|
|
57
|
+
opts._.push(...argv.slice(i + 1));
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
let key;
|
|
61
|
+
let inline;
|
|
62
|
+
if (a.startsWith("--")) {
|
|
63
|
+
const eq = a.indexOf("=");
|
|
64
|
+
if (eq > 0) {
|
|
65
|
+
key = LONG[a.slice(0, eq)];
|
|
66
|
+
inline = a.slice(eq + 1);
|
|
67
|
+
} else {
|
|
68
|
+
key = LONG[a];
|
|
69
|
+
}
|
|
70
|
+
if (!key) throw new Error(`未知参数: ${a}`);
|
|
71
|
+
} else if (a.startsWith("-") && a.length > 1) {
|
|
72
|
+
key = SHORT[a];
|
|
73
|
+
if (!key) throw new Error(`未知参数: ${a}`);
|
|
74
|
+
} else {
|
|
75
|
+
opts._.push(a);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key === "clipboard") {
|
|
80
|
+
// 可选值参数:`-c` / `--clipboard` → true;`--clipboard=html` → "html"
|
|
81
|
+
opts.clipboard = inline === undefined || inline === "" ? true : inline;
|
|
82
|
+
} else if (VALUE_KEYS.has(key)) {
|
|
83
|
+
let v = inline;
|
|
84
|
+
if (v === undefined) {
|
|
85
|
+
v = argv[++i];
|
|
86
|
+
if (v === undefined) throw new Error(`缺少「${a}」的值`);
|
|
87
|
+
}
|
|
88
|
+
opts[key] = v;
|
|
89
|
+
} else {
|
|
90
|
+
opts[key] = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return opts;
|
|
94
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clipboard.mjs — 跨平台剪贴板写入(零依赖)
|
|
3
|
+
*
|
|
4
|
+
* copyText — 纯文本(macOS pbcopy / Windows clip / Linux wl-copy、xclip、xsel)
|
|
5
|
+
* copyHtml — 富文本 HTML(仅 macOS):用 osascript + AppleScript-ObjC(AppKit)
|
|
6
|
+
* 写入 NSPasteboard 的 text/html 类型。微信公众号后台等富文本编辑器
|
|
7
|
+
* "粘贴即渲染";若只写纯文本,后台会把源码当文字贴出来(全是代码)。
|
|
8
|
+
* 其他平台回退为纯文本复制(请用浏览器打开 .html 全选复制)。
|
|
9
|
+
*
|
|
10
|
+
* 注意:不要用 JXA(-l JavaScript)——部分 macOS 上 ObjC 桥接对 NSPasteboard
|
|
11
|
+
* 解析异常;AppleScript-ObjC(use framework "AppKit")是稳定路径。
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export function copyText(text) {
|
|
19
|
+
const cmds =
|
|
20
|
+
process.platform === "darwin"
|
|
21
|
+
? [["pbcopy"]]
|
|
22
|
+
: process.platform === "win32"
|
|
23
|
+
? [["clip"]]
|
|
24
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
25
|
+
for (const [cmd, ...args] of cmds) {
|
|
26
|
+
try {
|
|
27
|
+
const r = spawnSync(cmd, args, {
|
|
28
|
+
input: text,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["pipe", "ignore", "pipe"],
|
|
31
|
+
});
|
|
32
|
+
if (r.status === 0) return true;
|
|
33
|
+
} catch {
|
|
34
|
+
// 尝试下一个可用命令
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 把 HTML 以富文本(text/html)写入剪贴板。
|
|
42
|
+
* @param {string} html 要粘贴到公众号后台的 HTML 片段
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function copyHtml(html) {
|
|
46
|
+
if (process.platform !== "darwin") return copyText(html);
|
|
47
|
+
const dir = mkdtempSync(path.join(tmpdir(), "wxformat-html-"));
|
|
48
|
+
try {
|
|
49
|
+
const htmlFile = path.join(dir, "clip.html");
|
|
50
|
+
const plainFile = path.join(dir, "clip.txt");
|
|
51
|
+
writeFileSync(htmlFile, html, "utf8");
|
|
52
|
+
// 纯文本兜底:普通编辑器粘贴也有内容(去标签,避免贴出源码)
|
|
53
|
+
const plain = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
54
|
+
writeFileSync(plainFile, plain, "utf8");
|
|
55
|
+
|
|
56
|
+
const scriptFile = path.join(dir, "clip.applescript");
|
|
57
|
+
writeFileSync(
|
|
58
|
+
scriptFile,
|
|
59
|
+
`use framework "AppKit"
|
|
60
|
+
use framework "Foundation"
|
|
61
|
+
use scripting additions
|
|
62
|
+
|
|
63
|
+
set thePath to (system attribute "WXFORMAT_CLIP_FILE")
|
|
64
|
+
set thePlainPath to (system attribute "WXFORMAT_CLIP_PLAIN_FILE")
|
|
65
|
+
|
|
66
|
+
set thePasteboard to current application's NSPasteboard's generalPasteboard()
|
|
67
|
+
set theHTML to current application's NSString's stringWithContentsOfFile_encoding_error_(thePath, current application's NSUTF8StringEncoding, missing value)
|
|
68
|
+
set theData to theHTML's dataUsingEncoding_(current application's NSUTF8StringEncoding)
|
|
69
|
+
set thePlain to current application's NSString's stringWithContentsOfFile_encoding_error_(thePlainPath, current application's NSUTF8StringEncoding, missing value)
|
|
70
|
+
|
|
71
|
+
thePasteboard's clearContents()
|
|
72
|
+
thePasteboard's setData_forType_(theData, current application's NSPasteboardTypeHTML)
|
|
73
|
+
thePasteboard's setString_forType_(thePlain, current application's NSPasteboardTypeString)
|
|
74
|
+
return "ok"`,
|
|
75
|
+
"utf8",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const r = spawnSync("osascript", [scriptFile], {
|
|
79
|
+
env: { ...process.env, WXFORMAT_CLIP_FILE: htmlFile, WXFORMAT_CLIP_PLAIN_FILE: plainFile },
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
timeout: 15000,
|
|
82
|
+
});
|
|
83
|
+
if (r.status === 0) return true;
|
|
84
|
+
return copyText(html);
|
|
85
|
+
} catch {
|
|
86
|
+
return copyText(html);
|
|
87
|
+
} finally {
|
|
88
|
+
rmSync(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core.mjs — wxformat 核心排版逻辑
|
|
3
|
+
*
|
|
4
|
+
* 渲染器优先复用工作区 dsh-wechat-preview 的 render.js(单一实现:CLI 与 GUI
|
|
5
|
+
* 预览输出同源,主题/剥离规则只维护一份);npm 独立安装时该路径不存在,
|
|
6
|
+
* 自动回退到本包自带的 lib/render.js 副本(发布前由 scripts/sync-render.mjs
|
|
7
|
+
* 同步,prepublishOnly 钩子保证打包时副本永远最新)。
|
|
8
|
+
*/
|
|
9
|
+
let render;
|
|
10
|
+
try {
|
|
11
|
+
render = await import("../../dsh-wechat-preview/lib/render.js");
|
|
12
|
+
} catch {
|
|
13
|
+
render = await import("./render.js");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { parseArticle, renderArticle, extractTags, THEMES, DEFAULT_THEME } = render;
|
|
17
|
+
|
|
18
|
+
export { THEMES, DEFAULT_THEME };
|
|
19
|
+
|
|
20
|
+
/** 校验主题 id:合法返回原 id,否则返回 null。 */
|
|
21
|
+
export function resolveTheme(id) {
|
|
22
|
+
return id && THEMES[id] ? id : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 全量排版(不剥离元信息/发布包):标题取首个 `# ` 行,正文为其余全部内容。 */
|
|
26
|
+
function parseWhole(raw) {
|
|
27
|
+
const m = raw.match(/^#\s+(.+)$/m);
|
|
28
|
+
const title = m ? m[1].trim() : "";
|
|
29
|
+
const body = raw.replace(/^#\s+.+$/m, "").trim();
|
|
30
|
+
return { title, body };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 统计 CJK 正文字符数。 */
|
|
34
|
+
export function countCjk(s) {
|
|
35
|
+
return (s.match(/[\u4e00-\u9fff]/g) || []).length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 排版一篇草稿。
|
|
40
|
+
*
|
|
41
|
+
* @param {string} raw 原始 Markdown 文本
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {string} [opts.theme] 主题 id(默认 bw;非法值回退默认)
|
|
44
|
+
* @param {boolean} [opts.stripMeta=true] 是否剥离元信息与发布包
|
|
45
|
+
* @param {boolean} [opts.tags=true] 是否解析文末话题标签(#标签# 效果)
|
|
46
|
+
* @returns {{title:string, body:string, md:string, section:string, document:string,
|
|
47
|
+
* tags:string[], theme:string, themeLabel:string, chars:number}}
|
|
48
|
+
*/
|
|
49
|
+
export function formatArticle(raw, opts = {}) {
|
|
50
|
+
const theme = resolveTheme(opts.theme) ?? DEFAULT_THEME;
|
|
51
|
+
const strip = opts.stripMeta !== false;
|
|
52
|
+
const { title, body } = strip ? parseArticle(raw) : parseWhole(raw);
|
|
53
|
+
if (!title || !body) {
|
|
54
|
+
throw new Error("无法解析:未找到标题或正文(需要至少一个 `# 标题` 与正文内容)");
|
|
55
|
+
}
|
|
56
|
+
const tags = opts.tags === false ? [] : extractTags(raw);
|
|
57
|
+
const { section, document } = renderArticle(title, body, theme, { tags });
|
|
58
|
+
const md = `# ${title}\n\n${body}\n`;
|
|
59
|
+
return {
|
|
60
|
+
title,
|
|
61
|
+
body,
|
|
62
|
+
md,
|
|
63
|
+
section,
|
|
64
|
+
document,
|
|
65
|
+
tags,
|
|
66
|
+
theme,
|
|
67
|
+
themeLabel: THEMES[theme].label,
|
|
68
|
+
chars: countCjk(body),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/lib/editor.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor.mjs — 浏览器排版面板页(--editor)
|
|
3
|
+
*
|
|
4
|
+
* 自包含单页:正文可直接在页面内微调,一键复制"发布 HTML"(粘贴公众号后台即可
|
|
5
|
+
* 发布)或"纯净 Markdown"(粘贴墨滴/135 等编辑器)。复制优先 navigator.clipboard,
|
|
6
|
+
* 失败回退 execCommand,file:// 下也可用。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function editorPage({ title, section, md, themeLabel }) {
|
|
10
|
+
const esc = (s) =>
|
|
11
|
+
String(s)
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """);
|
|
16
|
+
// 内嵌进 <script> 字符串:把 < 转成 \u003c,防止正文里的 </script> 截断脚本标签
|
|
17
|
+
const mdJson = JSON.stringify(md).replace(/</g, "\\u003c");
|
|
18
|
+
|
|
19
|
+
return `<!DOCTYPE html>
|
|
20
|
+
<html lang="zh-CN">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="UTF-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
24
|
+
<title>排版面板 · ${esc(title)}</title>
|
|
25
|
+
<style>
|
|
26
|
+
:root { --accent:#07c160; }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
html, body { margin:0; padding:0; background:#f2f2f2; font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif; }
|
|
29
|
+
header { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid #e5e5e5; padding:12px 20px; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
30
|
+
header h1 { font-size:15px; font-weight:600; margin:0; margin-right:auto; color:#333; max-width:50%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
31
|
+
header .badge { font-size:12px; color:#999; background:#f5f5f5; border-radius:10px; padding:2px 8px; }
|
|
32
|
+
button { border:none; border-radius:6px; padding:7px 14px; font-size:13px; cursor:pointer; background:#f0f0f0; color:#333; }
|
|
33
|
+
button:hover { background:#e3e3e3; }
|
|
34
|
+
button.primary { background:var(--accent); color:#fff; }
|
|
35
|
+
button.primary:hover { background:#06b056; }
|
|
36
|
+
button.primary.ok { background:#333; }
|
|
37
|
+
main { max-width:760px; margin:20px auto; background:#fff; border-radius:10px; box-shadow:0 1px 6px rgba(0,0,0,.08); padding:8px 0; }
|
|
38
|
+
#article { min-height:60vh; padding:18px 22px; outline:none; cursor:text; border-radius:6px; }
|
|
39
|
+
#article:focus { box-shadow:inset 0 0 0 1px rgba(7,193,96,.35); }
|
|
40
|
+
.hint { max-width:760px; margin:0 auto 40px; padding:0 12px; font-size:12.5px; color:#999; line-height:1.9; }
|
|
41
|
+
.hint b { color:#666; }
|
|
42
|
+
.toast { position:fixed; left:50%; bottom:40px; transform:translateX(-50%); background:rgba(0,0,0,.78); color:#fff; padding:9px 18px; border-radius:20px; font-size:13px; opacity:0; transition:opacity .25s; pointer-events:none; white-space:nowrap; }
|
|
43
|
+
.toast.show { opacity:1; }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body>
|
|
47
|
+
<header>
|
|
48
|
+
<h1 title="${esc(title)}">${esc(title)}</h1>
|
|
49
|
+
<span class="badge">主题:${esc(themeLabel)}</span>
|
|
50
|
+
<button class="primary" id="copyHtml">复制发布 HTML</button>
|
|
51
|
+
<button id="copyMd">复制纯净 Markdown</button>
|
|
52
|
+
<button id="reset">恢复原始</button>
|
|
53
|
+
</header>
|
|
54
|
+
<main>
|
|
55
|
+
<section id="article" contenteditable="true">${section}</section>
|
|
56
|
+
</main>
|
|
57
|
+
<div class="hint">
|
|
58
|
+
① 正文可直接在本面板内<b>微调</b>后再复制;② 点「复制发布 HTML」→ 到<b>公众号后台编辑器</b>直接粘贴(Cmd/Ctrl+V)即可发布(样式全部内联,后台能完整保留);
|
|
59
|
+
③ 标题在后台标题栏单独填写,不包含在正文里;④ 「复制纯净 Markdown」可粘贴到墨滴 / 135 等 Markdown 编辑器;⑤ 面板打开的是本地临时文件,可随时重新生成。
|
|
60
|
+
</div>
|
|
61
|
+
<div class="toast" id="toast"></div>
|
|
62
|
+
<script>
|
|
63
|
+
const article = document.getElementById('article');
|
|
64
|
+
const original = article.innerHTML;
|
|
65
|
+
const mdText = ${mdJson};
|
|
66
|
+
function fallbackCopy(text) {
|
|
67
|
+
const ta = document.createElement('textarea');
|
|
68
|
+
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
69
|
+
document.body.appendChild(ta); ta.focus(); ta.select();
|
|
70
|
+
let ok = false;
|
|
71
|
+
try { ok = document.execCommand('copy'); } catch (e) {}
|
|
72
|
+
ta.remove(); return ok;
|
|
73
|
+
}
|
|
74
|
+
async function copyPlain(text) {
|
|
75
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
76
|
+
try { await navigator.clipboard.writeText(text); return true; } catch (e) {}
|
|
77
|
+
}
|
|
78
|
+
return fallbackCopy(text);
|
|
79
|
+
}
|
|
80
|
+
// 富文本复制:带 text/html 类型,公众号后台"粘贴即渲染"(纯 writeText 只会贴出源码)
|
|
81
|
+
async function copyRich(html, plain) {
|
|
82
|
+
if (navigator.clipboard && window.ClipboardItem) {
|
|
83
|
+
try {
|
|
84
|
+
await navigator.clipboard.write([new ClipboardItem({
|
|
85
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
86
|
+
'text/plain': new Blob([plain], { type: 'text/plain' }),
|
|
87
|
+
})]);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (e) {}
|
|
90
|
+
}
|
|
91
|
+
// 回退:DOM 选区复制(浏览器会自动附带 text/html 与 text/plain)
|
|
92
|
+
const range = document.createRange();
|
|
93
|
+
range.selectNode(article);
|
|
94
|
+
const sel = window.getSelection();
|
|
95
|
+
sel.removeAllRanges();
|
|
96
|
+
sel.addRange(range);
|
|
97
|
+
let ok = false;
|
|
98
|
+
try { ok = document.execCommand('copy'); } catch (e) {}
|
|
99
|
+
sel.removeAllRanges();
|
|
100
|
+
return ok;
|
|
101
|
+
}
|
|
102
|
+
let toastTimer = null;
|
|
103
|
+
function toast(msg) {
|
|
104
|
+
const el = document.getElementById('toast');
|
|
105
|
+
el.textContent = msg; el.classList.add('show');
|
|
106
|
+
clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 1600);
|
|
107
|
+
}
|
|
108
|
+
document.getElementById('copyHtml').addEventListener('click', async () => {
|
|
109
|
+
const ok = await copyRich(article.outerHTML, article.innerText);
|
|
110
|
+
const btn = document.getElementById('copyHtml');
|
|
111
|
+
const old = btn.textContent;
|
|
112
|
+
btn.textContent = ok ? '✅ 已复制' : '复制失败';
|
|
113
|
+
btn.classList.add('ok');
|
|
114
|
+
setTimeout(() => { btn.textContent = old; btn.classList.remove('ok'); }, 1500);
|
|
115
|
+
toast(ok ? '发布 HTML 已复制,去公众号后台粘贴吧' : '复制失败,请手动 Cmd+A 全选后 Cmd+C');
|
|
116
|
+
});
|
|
117
|
+
document.getElementById('copyMd').addEventListener('click', async () => {
|
|
118
|
+
const ok = await copyPlain(mdText);
|
|
119
|
+
toast(ok ? '纯净 Markdown 已复制' : '复制失败,请手动复制');
|
|
120
|
+
});
|
|
121
|
+
document.getElementById('reset').addEventListener('click', () => { article.innerHTML = original; });
|
|
122
|
+
</script>
|
|
123
|
+
</body>
|
|
124
|
+
</html>`;
|
|
125
|
+
}
|
package/lib/help.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** help.mjs — 版本号与帮助文本 */
|
|
2
|
+
|
|
3
|
+
export const VERSION = "0.1.0";
|
|
4
|
+
|
|
5
|
+
export const HELP = `wxformat ${VERSION} — 公众号文章排版 CLI
|
|
6
|
+
|
|
7
|
+
把 Markdown 草稿一键转为"可直接发布"的版本:纯净 Markdown + 公众号 HTML
|
|
8
|
+
(全内联样式,粘贴公众号后台即可发布)。可被脚本、CI 与大模型直接调用。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
wxformat <file|-> [选项]
|
|
12
|
+
|
|
13
|
+
文件模式: wxformat drafts/文章.md
|
|
14
|
+
管道模式: cat 文章.md | wxformat - -f md -o -
|
|
15
|
+
浏览器预览: wxformat drafts/文章.md --preview
|
|
16
|
+
排版面板: wxformat drafts/文章.md --editor
|
|
17
|
+
边写边预览: wxformat drafts/文章.md --watch --preview
|
|
18
|
+
大模型/脚本: wxformat drafts/文章.md --json --no-clipboard
|
|
19
|
+
|
|
20
|
+
位置参数:
|
|
21
|
+
<file|-> 输入文件路径;或 - 表示从 stdin 读取
|
|
22
|
+
|
|
23
|
+
选项:
|
|
24
|
+
-o, --output <路径|-> 输出目标:
|
|
25
|
+
.md / .html 文件 → 只写该格式
|
|
26
|
+
目录(或结尾带 /)→ 写入默认文件名
|
|
27
|
+
- → 输出到 stdout(与 -f md|html 配合,内容纯净无杂音)
|
|
28
|
+
省略 → 写到输入文件旁(<名>.clean.md / <名>.html)
|
|
29
|
+
-f, --format <md|html|both> 输出格式(默认 both;stdin 且无 -o 时默认 html)
|
|
30
|
+
-t, --theme <id> 主题: bw 黑白极简 | paper 纸墨书香 | celadon 晨雾青瓷(默认 bw)
|
|
31
|
+
-p, --preview 生成预览页并自动打开浏览器
|
|
32
|
+
-e, --editor 打开浏览器排版面板:正文可微调,一键复制发布 HTML / 纯净 Markdown
|
|
33
|
+
-c, --clipboard 复制到剪贴板(macOS 文件模式下默认自动复制发布 HTML)
|
|
34
|
+
--clipboard=md|html 指定复制哪种内容
|
|
35
|
+
--no-clipboard 禁止复制(脚本/CI 友好)
|
|
36
|
+
-j, --json 机器可读 JSON 输出(含 md / section / 产物路径;自动跳过剪贴板)
|
|
37
|
+
-w, --watch 监听输入文件,变更自动重排版(配 --preview 浏览器实时刷新)
|
|
38
|
+
-q, --quiet 只输出产物路径(或 stdout 内容)
|
|
39
|
+
--config <文件> 配置文件(默认探测 .wxformatrc.json / wxformat.config.json / package.json#wxformat)
|
|
40
|
+
--no-meta-strip 不剥离元信息与发布包,整篇原文排版
|
|
41
|
+
--no-open 生成预览/面板文件但不自动打开浏览器(CI/脚本友好)
|
|
42
|
+
-V, --version 输出版本号
|
|
43
|
+
-h, --help 显示帮助
|
|
44
|
+
|
|
45
|
+
退出码:
|
|
46
|
+
0 成功 1 参数/输入错误 2 转换失败
|
|
47
|
+
|
|
48
|
+
配置示例(.wxformatrc.json):
|
|
49
|
+
{ "theme": "paper", "format": "both", "clipboard": "html" }
|
|
50
|
+
`;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live-server.mjs — --watch --preview 用的极简本地服务(零依赖)
|
|
3
|
+
*
|
|
4
|
+
* 浏览器页面轮询 /version,版本号变化即自动刷新 → 边写草稿边实时预览。
|
|
5
|
+
* 只监听 127.0.0.1 随机端口,服务随进程退出自动关闭。
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
|
|
9
|
+
export function startLiveServer() {
|
|
10
|
+
let doc = "<html><body></body></html>";
|
|
11
|
+
let version = 0;
|
|
12
|
+
|
|
13
|
+
const server = createServer((req, res) => {
|
|
14
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
15
|
+
if (u.pathname === "/version") {
|
|
16
|
+
res.setHeader("content-type", "application/json");
|
|
17
|
+
res.end(JSON.stringify({ v: version }));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
21
|
+
res.end(injectLive(doc, version));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
server.listen(0, "127.0.0.1", () => {
|
|
26
|
+
const { port } = server.address();
|
|
27
|
+
resolve({
|
|
28
|
+
url: `http://127.0.0.1:${port}/`,
|
|
29
|
+
setDocument(d) {
|
|
30
|
+
doc = d;
|
|
31
|
+
version += 1;
|
|
32
|
+
},
|
|
33
|
+
close() {
|
|
34
|
+
server.close();
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function injectLive(doc, v) {
|
|
42
|
+
// 渲染器产出的文档不含 <script>,直接注入轮询脚本;已有脚本则跳过(防重复注入)
|
|
43
|
+
if (/<script[\s>]/i.test(doc)) return doc;
|
|
44
|
+
const script = `<script>(function(){let v=${v};setTimeout(function poll(){fetch('/version').then(r=>r.json()).then(j=>{if(typeof j.v==='number'&&j.v!==v){v=j.v;location.reload();return;}}).catch(()=>{}).finally(()=>setTimeout(poll,600));},600);})();</script>`;
|
|
45
|
+
return doc.replace(/<\/body>/i, `${script}</body>`);
|
|
46
|
+
}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render.js — 公众号文章排版渲染器(多主题 · 全内联样式)
|
|
3
|
+
*
|
|
4
|
+
* 被 host 半(dsh-wechat-preview 的 /render RPC)、旧 bin/wechat_format.mjs 与 wxformat CLI 共用。
|
|
5
|
+
* 所有样式以内联 style 输出(公众号后台粘贴富文本时保留内联样式最稳)。
|
|
6
|
+
*
|
|
7
|
+
* 三套主题:
|
|
8
|
+
* bw 黑白极简 — 理性 · 通用
|
|
9
|
+
* paper 纸墨书香 — 情感 · 温情故事
|
|
10
|
+
* celadon 晨雾青瓷 — 商业 · 深度拆解
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const THEMES = {
|
|
14
|
+
bw: {
|
|
15
|
+
id: "bw",
|
|
16
|
+
label: "黑白极简",
|
|
17
|
+
hint: "理性 · 通用",
|
|
18
|
+
wrap: { background: "#ffffff", padding: "26px 22px", borderRadius: "0" },
|
|
19
|
+
h1: { fontSize: "23px", fontWeight: "800", color: "#111111", lineHeight: "1.5", letterSpacing: "1px", margin: "6px 0 26px", paddingBottom: "14px", borderBottom: "2px solid #111111" },
|
|
20
|
+
h2: { fontSize: "17.5px", fontWeight: "700", color: "#111111", lineHeight: "1.6", textAlign: "center", margin: "30px 0 16px", padding: "10px 14px", borderRadius: "6px", background: "linear-gradient(90deg,#e2e2e2,#ffffff)" },
|
|
21
|
+
h3: { fontSize: "17px", fontWeight: "700", color: "#222222", lineHeight: "1.5", margin: "24px 0 12px" },
|
|
22
|
+
p: { fontSize: "16px", lineHeight: "2", color: "#2b2b2b", letterSpacing: "0.3px", margin: "14px 0" },
|
|
23
|
+
strong: { color: "#000000", fontWeight: "700" },
|
|
24
|
+
em: {},
|
|
25
|
+
blockquote: { borderLeft: "3px solid #c9c9c9", padding: "2px 0 2px 14px", color: "#666666", margin: "16px 0", fontSize: "15.5px", lineHeight: "1.95" },
|
|
26
|
+
ul: { margin: "12px 0", paddingLeft: "22px" },
|
|
27
|
+
ol: { margin: "12px 0", paddingLeft: "22px" },
|
|
28
|
+
li: { margin: "6px 0", fontSize: "16px", lineHeight: "2", color: "#2b2b2b" },
|
|
29
|
+
code: { background: "#f2f2f2", color: "#c0392b", padding: "2px 5px", borderRadius: "3px", fontSize: "14px" },
|
|
30
|
+
hr: { border: "none", height: "4px", background: "radial-gradient(circle,#333333 1.2px,transparent 1.8px) repeat-x", backgroundSize: "8px 4px", margin: "28px 0" },
|
|
31
|
+
tag: { bg: "#f2f2f2", color: "#555555" },
|
|
32
|
+
a: { color: "#111111", textDecoration: "underline" },
|
|
33
|
+
img: { maxWidth: "100%", borderRadius: "6px", margin: "10px 0" }
|
|
34
|
+
},
|
|
35
|
+
paper: {
|
|
36
|
+
id: "paper",
|
|
37
|
+
label: "纸墨书香",
|
|
38
|
+
hint: "情感 · 温情故事",
|
|
39
|
+
wrap: { background: "#faf7f0", padding: "28px 24px", borderRadius: "10px" },
|
|
40
|
+
h1: { fontSize: "23px", fontWeight: "800", color: "#2f2a24", lineHeight: "1.5", letterSpacing: "2px", margin: "6px 0 26px", paddingBottom: "14px", borderBottom: "2px solid #d8cdb4", fontFamily: "'Songti SC','STSong','SimSun',serif" },
|
|
41
|
+
h2: { fontSize: "18px", fontWeight: "700", color: "#6b5736", lineHeight: "1.6", textAlign: "center", margin: "30px 0 16px", padding: "10px 14px", borderRadius: "6px", background: "linear-gradient(90deg,#e6d9bd,#faf7f0)" },
|
|
42
|
+
h3: { fontSize: "17px", fontWeight: "700", color: "#6b5736", lineHeight: "1.5", margin: "24px 0 12px" },
|
|
43
|
+
p: { fontSize: "16.5px", lineHeight: "2.05", color: "#4a4238", letterSpacing: "0.5px", margin: "15px 0" },
|
|
44
|
+
strong: { color: "#2f2a24", fontWeight: "700" },
|
|
45
|
+
em: {},
|
|
46
|
+
blockquote: { borderLeft: "3px solid #c9b896", padding: "2px 0 2px 14px", color: "#8a7a5c", margin: "16px 0", fontSize: "16px", lineHeight: "2", fontStyle: "italic" },
|
|
47
|
+
ul: { margin: "12px 0", paddingLeft: "22px" },
|
|
48
|
+
ol: { margin: "12px 0", paddingLeft: "22px" },
|
|
49
|
+
li: { margin: "6px 0", fontSize: "16.5px", lineHeight: "2.05", color: "#4a4238" },
|
|
50
|
+
code: { background: "#efe8d9", color: "#8a5a3b", padding: "2px 5px", borderRadius: "3px", fontSize: "14px" },
|
|
51
|
+
hr: { border: "none", height: "4px", background: "radial-gradient(circle,#333333 1.2px,transparent 1.8px) repeat-x", backgroundSize: "8px 4px", margin: "28px 0" },
|
|
52
|
+
tag: { bg: "#efe8d9", color: "#8a6d3b" },
|
|
53
|
+
a: { color: "#8a6d3b", textDecoration: "underline" },
|
|
54
|
+
img: { maxWidth: "100%", borderRadius: "6px", margin: "10px 0" }
|
|
55
|
+
},
|
|
56
|
+
celadon: {
|
|
57
|
+
id: "celadon",
|
|
58
|
+
label: "晨雾青瓷",
|
|
59
|
+
hint: "商业 · 深度拆解",
|
|
60
|
+
wrap: { background: "#f2f7f6", padding: "28px 24px", borderRadius: "10px" },
|
|
61
|
+
h1: { fontSize: "23px", fontWeight: "800", color: "#1f3d3a", lineHeight: "1.5", letterSpacing: "1px", margin: "6px 0 26px", paddingBottom: "14px", borderBottom: "2px solid #9cc4bd" },
|
|
62
|
+
h2: { fontSize: "18px", fontWeight: "700", color: "#1f3d3a", lineHeight: "1.6", textAlign: "center", margin: "30px 0 16px", padding: "10px 14px", borderRadius: "6px", background: "linear-gradient(90deg,#cfe5df,#f2f7f6)" },
|
|
63
|
+
h3: { fontSize: "17px", fontWeight: "700", color: "#2e6e63", lineHeight: "1.5", margin: "24px 0 12px" },
|
|
64
|
+
p: { fontSize: "16.5px", lineHeight: "2", color: "#33433f", letterSpacing: "0.3px", margin: "15px 0" },
|
|
65
|
+
strong: { color: "#1f3d3a", fontWeight: "700" },
|
|
66
|
+
em: {},
|
|
67
|
+
blockquote: { borderLeft: "3px solid #9cc4bd", padding: "2px 0 2px 14px", color: "#5d7a74", margin: "16px 0", fontSize: "16px", lineHeight: "1.95" },
|
|
68
|
+
ul: { margin: "12px 0", paddingLeft: "22px" },
|
|
69
|
+
ol: { margin: "12px 0", paddingLeft: "22px" },
|
|
70
|
+
li: { margin: "6px 0", fontSize: "16.5px", lineHeight: "2", color: "#33433f" },
|
|
71
|
+
code: { background: "#e3efec", color: "#1f5c52", padding: "2px 5px", borderRadius: "3px", fontSize: "14px" },
|
|
72
|
+
hr: { border: "none", height: "4px", background: "radial-gradient(circle,#333333 1.2px,transparent 1.8px) repeat-x", backgroundSize: "8px 4px", margin: "28px 0" },
|
|
73
|
+
tag: { bg: "#e3efec", color: "#2e6e63" },
|
|
74
|
+
a: { color: "#2e6e63", textDecoration: "underline" },
|
|
75
|
+
img: { maxWidth: "100%", borderRadius: "6px", margin: "10px 0" }
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_THEME = "bw";
|
|
80
|
+
|
|
81
|
+
/* ── Markdown 解析(轻量,覆盖本账号文章语法) ─────────────────────── */
|
|
82
|
+
|
|
83
|
+
function esc(s) {
|
|
84
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function inline(md, t) {
|
|
88
|
+
return md
|
|
89
|
+
.replace(/\*\*([^*]+)\*\*/g, `<strong style="${css(t.strong)}">$1</strong>`)
|
|
90
|
+
.replace(/\*([^*]+)\*/g, `<em style="${css(t.em)}">$1</em>`)
|
|
91
|
+
.replace(/`([^`]+)`/g, `<code style="${css(t.code)}">$1</code>`)
|
|
92
|
+
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, `<img src="${esc("$2")}" alt="${esc("$1")}" style="${css(t.img)}" />`)
|
|
93
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, txt, href) => `<a href="${esc(href)}" style="${css(t.a)}">${esc(txt)}</a>`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function css(rule) {
|
|
97
|
+
return Object.entries(rule ?? {})
|
|
98
|
+
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${v}`)
|
|
99
|
+
.join(";");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseBlocks(bodyMd) {
|
|
103
|
+
const blocks = bodyMd.split(/\n{2,}/);
|
|
104
|
+
const out = [];
|
|
105
|
+
for (let block of blocks) {
|
|
106
|
+
const t = block.trim();
|
|
107
|
+
if (!t) continue;
|
|
108
|
+
if (/^#{1,6}\s+/.test(t)) {
|
|
109
|
+
const level = Math.min(t.match(/^(#+)/)[1].length, 3);
|
|
110
|
+
const text = t.replace(/^#+\s+/, "");
|
|
111
|
+
out.push({ type: `h${level}`, text });
|
|
112
|
+
} else if (/^-{3,}$/.test(t)) {
|
|
113
|
+
out.push({ type: "hr" });
|
|
114
|
+
} else if (t.startsWith("> ")) {
|
|
115
|
+
out.push({ type: "blockquote", text: t.replace(/^>\s?/gm, "") });
|
|
116
|
+
} else if (/^[-*]\s+/.test(t)) {
|
|
117
|
+
out.push({ type: "ul", items: t.split(/\n/).filter((l) => /^[-*]\s+/.test(l)).map((l) => l.replace(/^[-*]\s+/, "")) });
|
|
118
|
+
} else if (/^\d+\.\s+/.test(t)) {
|
|
119
|
+
out.push({ type: "ol", items: t.split(/\n/).filter((l) => /^\d+\.\s+/.test(l)).map((l) => l.replace(/^\d+\.\s+/, "")) });
|
|
120
|
+
} else if (/^【插图[::】]/.test(t)) {
|
|
121
|
+
// 文内插图占位(草稿正文中的 `【插图:画面描述|提示词】` 行)→ 渲染为可见占位块
|
|
122
|
+
out.push({ type: "imgph", text: t });
|
|
123
|
+
} else {
|
|
124
|
+
out.push({ type: "p", text: t.replace(/\n/g, "<br />") });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* ── 渲染 ──────────────────────────────────────────────────────────── */
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 渲染完整文章。
|
|
134
|
+
* @param {string} title 文章标题
|
|
135
|
+
* @param {string} bodyMd 正文 Markdown(不含元信息/发布包)
|
|
136
|
+
* @param {string} themeId 主题 id
|
|
137
|
+
* @returns {{ section: string, document: string }}
|
|
138
|
+
* section — 内联样式正文(复制给公众号后台用)
|
|
139
|
+
* document — 完整 HTML 文档(浏览器预览用)
|
|
140
|
+
*/
|
|
141
|
+
export function renderArticle(title, bodyMd, themeId = DEFAULT_THEME, opts = {}) {
|
|
142
|
+
const t = THEMES[themeId] ?? THEMES[DEFAULT_THEME];
|
|
143
|
+
const blocks = parseBlocks(bodyMd);
|
|
144
|
+
|
|
145
|
+
const h1 = `<h1 style="${css(t.h1)}">${inline(title, t)}</h1>`;
|
|
146
|
+
const parts = blocks.map((b) => {
|
|
147
|
+
switch (b.type) {
|
|
148
|
+
case "h2":
|
|
149
|
+
case "h3":
|
|
150
|
+
return `<${b.type} style="${css(t[b.type])}">${inline(b.text, t)}</${b.type}>`;
|
|
151
|
+
case "p":
|
|
152
|
+
return `<p style="${css(t.p)}">${inline(b.text, t)}</p>`;
|
|
153
|
+
case "imgph":
|
|
154
|
+
return renderImgPlaceholder(b.text);
|
|
155
|
+
case "blockquote":
|
|
156
|
+
return `<blockquote style="${css(t.blockquote)}">${inline(b.text, t)}</blockquote>`;
|
|
157
|
+
case "ul":
|
|
158
|
+
return `<ul style="${css(t.ul)}">${b.items.map((i) => `<li style="${css(t.li)}">${inline(i, t)}</li>`).join("")}</ul>`;
|
|
159
|
+
case "ol":
|
|
160
|
+
return `<ol style="${css(t.ol)}">${b.items.map((i) => `<li style="${css(t.li)}">${inline(i, t)}</li>`).join("")}</ol>`;
|
|
161
|
+
case "hr":
|
|
162
|
+
return `<hr style="${css(t.hr)}" />`;
|
|
163
|
+
default:
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
}).join("\n");
|
|
167
|
+
|
|
168
|
+
// 公众号后台有独立的标题栏:默认不渲染标题与标题分割线,只要正文。
|
|
169
|
+
const head = opts.withTitle ? `\n${h1}\n` : "\n";
|
|
170
|
+
// 文末 #标签# 打标签效果(胶囊样式,主题化配色)。
|
|
171
|
+
const tagsHtml = renderTags(opts.tags ?? [], t);
|
|
172
|
+
|
|
173
|
+
const section = `<section style="${css(t.wrap)}">${head}${parts}\n${tagsHtml}</section>`;
|
|
174
|
+
|
|
175
|
+
const document = `<!DOCTYPE html>
|
|
176
|
+
<html lang="zh-CN">
|
|
177
|
+
<head>
|
|
178
|
+
<meta charset="UTF-8" />
|
|
179
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
180
|
+
<title>${esc(title)}</title>
|
|
181
|
+
<style>
|
|
182
|
+
html, body { margin: 0; padding: 0; }
|
|
183
|
+
body { background: #e9e9e9; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; padding: 24px 0 60px; }
|
|
184
|
+
body > section { max-width: 680px; margin: 0 auto; box-sizing: border-box; }
|
|
185
|
+
@media (max-width: 700px) { body { padding: 0; } body > section { max-width: 100%; } }
|
|
186
|
+
</style>
|
|
187
|
+
</head>
|
|
188
|
+
<body>
|
|
189
|
+
${section}
|
|
190
|
+
</body>
|
|
191
|
+
</html>`;
|
|
192
|
+
|
|
193
|
+
return { section, document };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 文末话题标签:纯 `#文字`(无闭合井号、无任何样式)。
|
|
198
|
+
* 微信公众后台会自行识别 `#话题` 并渲染成话题卡片,任何样式反而会残留为空壳,
|
|
199
|
+
* 所以这里只输出纯文本,每个标签独占一行(识别边界最清晰)。
|
|
200
|
+
*/
|
|
201
|
+
function renderTags(tags) {
|
|
202
|
+
if (!tags || tags.length === 0) return "";
|
|
203
|
+
const text = tags.map((tag) => `#${esc(tag)}`).join("<br />");
|
|
204
|
+
return `<p style="margin:26px 0 0;">${text}</p>`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 文内插图占位块:把正文中的 `【插图:画面描述|文生图提示词】` 渲染成醒目的虚线占位,
|
|
209
|
+
* 发布前作者直接选中整块删除、替换为真实图片(公众号后台编辑器可正常粘贴/删除该块)。
|
|
210
|
+
* 占位块内容同时给出画面描述与提示词,生成图片时无需再翻发布包。
|
|
211
|
+
*/
|
|
212
|
+
function renderImgPlaceholder(text) {
|
|
213
|
+
const rest = text.replace(/^【插图[::】]\s*/, "").trim();
|
|
214
|
+
const [desc, ...promptParts] = rest.split(/||\|/).map((s) => s.trim()).filter(Boolean);
|
|
215
|
+
const descHtml = esc(desc || "此处插入图片");
|
|
216
|
+
const promptHtml = promptParts.length
|
|
217
|
+
? `<span style="display:block;margin-top:6px;font-size:13px;color:#a3a3a3;">提示词:${esc(promptParts.join("|"))}</span>`
|
|
218
|
+
: "";
|
|
219
|
+
return `<p style="margin:18px 0;padding:14px 16px;border:1px dashed #c9c9c9;background:#f8f8f8;color:#8c8c8c;font-size:14px;line-height:1.7;letter-spacing:0.3px;text-align:center;">📷 插图位:${descHtml}${promptHtml}</p>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 从草稿原始文本提取发布包中的标签(发布包标签行,兼容两种格式:`- **标签**:a | b | c` 与 `- 标签(3–5 个,用 | 分隔):a | b | c`)。 */
|
|
223
|
+
export function extractTags(rawMd) {
|
|
224
|
+
const m = rawMd.match(/^\s*[-*]\s*\*{0,2}标签\*{0,2}(?:([^)]*))?[::]\s*(.+)$/m);
|
|
225
|
+
if (!m) return [];
|
|
226
|
+
return m[1]
|
|
227
|
+
.split(/[||,,、\s]+/)
|
|
228
|
+
.map((s) => s.trim())
|
|
229
|
+
.filter((s) => s.length > 0 && s.length <= 12)
|
|
230
|
+
.slice(0, 6);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 从草稿原始文本提取标题与纯净正文(与 wxformat CLI 相同的剥离规则)。 */
|
|
234
|
+
export function parseArticle(rawMd) {
|
|
235
|
+
const m = rawMd.match(/^#\s+(.+)$/m);
|
|
236
|
+
const title = m ? m[1].trim() : "";
|
|
237
|
+
const lines = rawMd.split(/\r?\n/);
|
|
238
|
+
let start = 0;
|
|
239
|
+
let seenTitle = false;
|
|
240
|
+
for (let i = 0; i < lines.length; i++) {
|
|
241
|
+
const line = lines[i].trim();
|
|
242
|
+
if (!seenTitle && line.startsWith("# ")) {
|
|
243
|
+
seenTitle = true;
|
|
244
|
+
start = i + 1;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!line || line.startsWith(">") || /^-{3,}$/.test(line)) {
|
|
248
|
+
if (line) start = i + 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
start = i;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
let end = lines.length;
|
|
255
|
+
for (let i = start; i < lines.length; i++) {
|
|
256
|
+
if (/^#+\s*发布包/.test(lines[i].trim())) {
|
|
257
|
+
end = i;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const body = lines.slice(start, end).join("\n").trim();
|
|
262
|
+
return { title, body };
|
|
263
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@peteryuan/wxformat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "公众号文章排版 CLI:草稿一键转纯净 Markdown + 公众号 HTML(全内联样式),支持浏览器预览、排版面板一键复制、剪贴板自动复制、JSON 输出,可被脚本、CI 与大模型直接调用。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/core.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/core.mjs"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"wxformat": "bin/wxformat.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"lib",
|
|
16
|
+
"scripts",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"sync-render": "node scripts/sync-render.mjs",
|
|
26
|
+
"prepublishOnly": "node scripts/sync-render.mjs && npm test"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"wechat",
|
|
30
|
+
"公众号",
|
|
31
|
+
"markdown",
|
|
32
|
+
"cli",
|
|
33
|
+
"publish",
|
|
34
|
+
"formatter"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"author": "pengfei"
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sync-render.mjs — 把工作区 dsh-wechat-preview/lib/render.js 同步为本包副本
|
|
3
|
+
*
|
|
4
|
+
* npm 发布版自带 lib/render.js,依赖本脚本保证与工作区单一实现一致。
|
|
5
|
+
* 已由 package.json 的 prepublishOnly 钩子在每次 publish/pack 前自动执行。
|
|
6
|
+
* 用法: node scripts/sync-render.mjs
|
|
7
|
+
*/
|
|
8
|
+
import { copyFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const src = path.join(here, "..", "..", "dsh-wechat-preview", "lib", "render.js");
|
|
14
|
+
const dst = path.join(here, "..", "lib", "render.js");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
copyFileSync(src, dst);
|
|
18
|
+
console.log(`✅ 已同步渲染器 → ${dst}`);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
// 在非工作区(如已安装的 npm 包内)运行时报错是预期行为,可忽略
|
|
21
|
+
console.warn(`[wxformat] 同步渲染器失败(非工作区环境?): ${e.message}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|