juice-email-cli 2.0.5
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 +339 -0
- package/bin/juice.js +72 -0
- package/defaults/juice.yaml +149 -0
- package/package.json +64 -0
- package/src/context-menu.js +182 -0
- package/src/index.js +264 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,339 @@
|
|
|
1
|
+
# juice-email-cli
|
|
2
|
+
|
|
3
|
+
> 一个用于生成符合各大邮件平台标准的 HTML 邮件命令行工具,基于 [juice](https://github.com/Automattic/juice) 实现 CSS 内联,支持 Mustache 模板变量替换,同时输出标准版与压缩版两份文件。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 功能特性
|
|
8
|
+
|
|
9
|
+
- ✅ **CSS 内联** —— 将 `<style>` 中的样式全部内联为 `style=""` 属性,兼容 Gmail / Outlook / Apple Mail 等
|
|
10
|
+
- ✅ **Mustache 模板变量** —— 支持 `{{变量名}}` 语法,通过配置文件批量替换
|
|
11
|
+
- ✅ **Mustache 列表循环** —— 支持 `{{#items}}...{{/items}}` 遍历数组,支持嵌套循环
|
|
12
|
+
- ✅ **三层配置合并** —— CLI 默认 < 用户目录 < 优先配置,三者层层合并
|
|
13
|
+
- ✅ **双文件输出** —— 同时生成标准版 `.output.html` 和压缩版 `.minified.html`
|
|
14
|
+
- ✅ **响应式保留** —— `@media`、`@font-face`、`@keyframes` 均不丢失
|
|
15
|
+
- ✅ **Windows 右键菜单(自定义图标)** —— 子菜单层级,一键注册/卸载,支持自定义图标
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 安装
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 全局安装
|
|
23
|
+
npm install -g juice-email-cli
|
|
24
|
+
|
|
25
|
+
# 安装时自动注册 Windows 右键菜单(无管理员权限时静默跳过)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **提示**:安装时如未注册右键菜单,安装完成后以**管理员身份**运行:
|
|
29
|
+
> ```bash
|
|
30
|
+
> juice --install
|
|
31
|
+
> ```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 本地克隆安装
|
|
35
|
+
git clone https://github.com/GuoSirius/juice-cli.git
|
|
36
|
+
cd juice-cli
|
|
37
|
+
npm install # 自动注册右键菜单
|
|
38
|
+
npm link # 链接到全局
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 使用方法
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# 最简用法(自动查找配置文件)
|
|
47
|
+
juice -f my-email.html
|
|
48
|
+
|
|
49
|
+
# 指定配置文件
|
|
50
|
+
juice -c project.yaml -f my-email.html
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
生成两个文件(与输入文件同目录):
|
|
54
|
+
|
|
55
|
+
| 文件 | 说明 |
|
|
56
|
+
|------|------|
|
|
57
|
+
| `my-email.output.html` | CSS 内联 + 变量替换标准版 |
|
|
58
|
+
| `my-email.minified.html` | 在标准版基础上压缩的精简版 |
|
|
59
|
+
|
|
60
|
+
### 参数说明
|
|
61
|
+
|
|
62
|
+
| 参数 | 简写 | 说明 |
|
|
63
|
+
|------|------|------|
|
|
64
|
+
| `--file <path>` | `-f` | 输入 HTML 模板文件路径(必填) |
|
|
65
|
+
| `--config <path>` | `-c` | 配置文件路径,不指定时自动查找输入文件同级目录 |
|
|
66
|
+
| `--install` | | 注册 Windows 右键菜单(需管理员权限) |
|
|
67
|
+
| `--uninstall` | | 取消 Windows 右键菜单注册(需管理员权限) |
|
|
68
|
+
| `--version` | `-v` | 查看版本号 |
|
|
69
|
+
| `--help` | `-h` | 查看帮助 |
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 配置文件优先级
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
优先级 低 ──────────────────────────────────────────────── 高
|
|
77
|
+
|
|
78
|
+
CLI 内置默认值 < 用户主目录 ~/juice.yaml < 优先配置(互斥)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**优先配置(-c 和输入文件同级目录互斥,只生效一个):**
|
|
82
|
+
|
|
83
|
+
| 来源 | 说明 |
|
|
84
|
+
|------|------|
|
|
85
|
+
| `-c <path>` | 命令行指定,最优先 |
|
|
86
|
+
| 输入文件同目录 `juice.yaml` | 随模板文件走,适合项目级配置 |
|
|
87
|
+
|
|
88
|
+
**合并规则:**
|
|
89
|
+
- 用户主目录配置(如果存在)**始终参与合并**
|
|
90
|
+
- 优先配置覆盖用户主目录配置中的同名字段
|
|
91
|
+
- CLI 内置默认值兜底所有未配置字段
|
|
92
|
+
|
|
93
|
+
**示例:**
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# 指定配置文件(与用户目录合并)
|
|
97
|
+
juice -c project.yaml -f email.html
|
|
98
|
+
|
|
99
|
+
# 使用输入文件同级目录配置(与用户目录合并)
|
|
100
|
+
# → email.html 同目录下有 juice.yaml
|
|
101
|
+
juice -f email.html
|
|
102
|
+
|
|
103
|
+
# 无优先配置时,仅使用用户目录配置
|
|
104
|
+
juice -f email.html
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 配置文件示例
|
|
110
|
+
|
|
111
|
+
用户配置文件(`~/juice.yaml` 或项目目录 `juice.yaml`)只需填写需要覆盖的字段,其余自动继承:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
variables:
|
|
115
|
+
brandName: "我的品牌"
|
|
116
|
+
brandColor: "#ff6600"
|
|
117
|
+
logoUrl: "https://cdn.example.com/logo.png"
|
|
118
|
+
ctaText: "立即订购"
|
|
119
|
+
ctaUrl: "https://example.com/buy"
|
|
120
|
+
companyName: "My Company"
|
|
121
|
+
currentYear: "2026"
|
|
122
|
+
|
|
123
|
+
# 列表数据示例
|
|
124
|
+
products:
|
|
125
|
+
- name: "产品 A"
|
|
126
|
+
price: "¥99.00"
|
|
127
|
+
tag: "热销"
|
|
128
|
+
- name: "产品 B"
|
|
129
|
+
price: "¥199.00"
|
|
130
|
+
- name: "产品 C"
|
|
131
|
+
price: "¥299.00"
|
|
132
|
+
|
|
133
|
+
features:
|
|
134
|
+
- title: "特性一"
|
|
135
|
+
items:
|
|
136
|
+
- "优势 A"
|
|
137
|
+
- "优势 B"
|
|
138
|
+
- title: "特性二"
|
|
139
|
+
items:
|
|
140
|
+
- "优势 C"
|
|
141
|
+
- "优势 D"
|
|
142
|
+
|
|
143
|
+
# juice 选项(全部可选,均有内置默认值)
|
|
144
|
+
# juice:
|
|
145
|
+
# removeStyleTags: true
|
|
146
|
+
# preserveMediaQueries: true
|
|
147
|
+
# preservePseudos: true # 保留 hover 等伪类
|
|
148
|
+
# preservedSelectors: # 保留的选择器(不会被内联)
|
|
149
|
+
# - "a:hover"
|
|
150
|
+
# extraCssFiles:
|
|
151
|
+
# - email-reset.css
|
|
152
|
+
|
|
153
|
+
# 输出后缀(可选)
|
|
154
|
+
# output:
|
|
155
|
+
# normalSuffix: ".output.html"
|
|
156
|
+
# minifiedSuffix: ".minified.html"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
完整默认配置见 [`defaults/juice.yaml`](./defaults/juice.yaml)。
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 模板语法
|
|
164
|
+
|
|
165
|
+
使用 [Mustache](https://mustache.github.io/) 语法:
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<h1>你好,{{recipientName}}!</h1>
|
|
169
|
+
<a href="{{ctaUrl}}" style="background-color: {{brandColor}};">{{ctaText}}</a>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Mustache 列表循环
|
|
173
|
+
|
|
174
|
+
支持 `{{#items}}...{{/items}}` 语法遍历数组数据:
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
# juice.yaml - 配置列表数据
|
|
178
|
+
variables:
|
|
179
|
+
products:
|
|
180
|
+
- name: "产品 A"
|
|
181
|
+
price: "¥99.00"
|
|
182
|
+
tag: "热销"
|
|
183
|
+
- name: "产品 B"
|
|
184
|
+
price: "¥199.00"
|
|
185
|
+
- name: "产品 C"
|
|
186
|
+
price: "¥299.00"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```html
|
|
190
|
+
<!-- HTML 模板 -->
|
|
191
|
+
{{#products}}
|
|
192
|
+
<div class="product-item">
|
|
193
|
+
<h3>{{name}}</h3>
|
|
194
|
+
<p class="price">{{price}}</p>
|
|
195
|
+
{{#tag}}<span class="tag">{{tag}}</span>{{/tag}}
|
|
196
|
+
</div>
|
|
197
|
+
{{/products}}
|
|
198
|
+
|
|
199
|
+
<!-- 空列表备选内容 -->
|
|
200
|
+
{{^products}}
|
|
201
|
+
<p>暂无商品</p>
|
|
202
|
+
{{/products}}
|
|
203
|
+
|
|
204
|
+
<!-- 嵌套循环示例 -->
|
|
205
|
+
{{#features}}
|
|
206
|
+
<div class="feature-column">
|
|
207
|
+
<h4>{{title}}</h4>
|
|
208
|
+
<ul>
|
|
209
|
+
{{#items}}
|
|
210
|
+
<li>{{.}}</li>
|
|
211
|
+
{{/items}}
|
|
212
|
+
</ul>
|
|
213
|
+
</div>
|
|
214
|
+
{{/features}}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
| 语法 | 说明 | 示例 |
|
|
218
|
+
|------|------|------|
|
|
219
|
+
| `{{#list}}{{/list}}` | 循环遍历 | `{{#products}}{{name}}{{/products}}` |
|
|
220
|
+
| `{{^list}}{{/list}}` | 反向(空列表时显示) | `{{^products}}暂无{{/products}}` |
|
|
221
|
+
| `{{.}}` | 当前元素 | `{{#.}}{{.}}{{/}}` |
|
|
222
|
+
| `{{#var}}{{/var}}` | 条件渲染(仅当有值时显示) | `{{#tag}}{{tag}}{{/tag}}` |
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Windows 右键菜单(自定义图标)
|
|
227
|
+
|
|
228
|
+
注册后,在 `.html` / `.htm` 文件上右键可看到级联子菜单(带自定义图标):
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
📧 用 juice 生成邮件 HTML
|
|
232
|
+
├── ⚡ 生成邮件 HTML(标准 + 压缩)
|
|
233
|
+
└── 📂 在此目录打开 PowerShell 7 ← 仅在系统已安装 pwsh 时出现
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
# 以管理员身份运行 PowerShell,然后:
|
|
238
|
+
juice --install
|
|
239
|
+
|
|
240
|
+
# 卸载:
|
|
241
|
+
juice --uninstall
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
> **注意**:注册/卸载需要 **管理员权限**,请右键"以管理员身份运行"命令行。
|
|
245
|
+
> 注册成功后如菜单未立即出现,重启文件资源管理器(`explorer.exe`)即可。
|
|
246
|
+
|
|
247
|
+
> **图标说明**:自定义图标位于 `icons/juice-icon.ico`,支持 16x16 到 256x256 多种尺寸。
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 目录结构
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
juice-cli/
|
|
255
|
+
├── bin/
|
|
256
|
+
│ └── juice.js # CLI 入口
|
|
257
|
+
├── src/
|
|
258
|
+
│ ├── index.js # 核心逻辑(配置合并、模板处理、双输出)
|
|
259
|
+
│ └── context-menu.js # Windows 右键菜单注册(自定义图标)
|
|
260
|
+
├── defaults/
|
|
261
|
+
│ └── juice.yaml # CLI 内置默认配置(最低优先级基准)
|
|
262
|
+
├── examples/
|
|
263
|
+
│ ├── juice.yaml # 用户配置示例(含列表数据)
|
|
264
|
+
│ ├── template.html # HTML 邮件模板示例(含列表循环)
|
|
265
|
+
│ └── *.css # 附加样式文件
|
|
266
|
+
├── icons/
|
|
267
|
+
│ └── juice-icon.ico # 右键菜单自定义图标
|
|
268
|
+
├── scripts/
|
|
269
|
+
│ ├── generate-icon.js # 图标生成脚本
|
|
270
|
+
│ └── release.js # 发布脚本
|
|
271
|
+
├── .husky/
|
|
272
|
+
│ └── commit-msg # Git hooks - 提交信息校验
|
|
273
|
+
├── .commitlintrc.json # Commitlint 配置
|
|
274
|
+
├── .versionrc # Standard-version 配置
|
|
275
|
+
├── .npmrc # npm 发布配置
|
|
276
|
+
├── CHANGELOG.md # 变更日志(自动生成)
|
|
277
|
+
├── LICENSE # MIT
|
|
278
|
+
├── README.md
|
|
279
|
+
└── package.json
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 发布说明
|
|
285
|
+
|
|
286
|
+
本项目使用自动化发布流程,包含以下功能:
|
|
287
|
+
|
|
288
|
+
### 提交信息规范
|
|
289
|
+
|
|
290
|
+
使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
<type>(<scope>): <subject>
|
|
294
|
+
|
|
295
|
+
feat: 添加新功能
|
|
296
|
+
fix: 修复 bug
|
|
297
|
+
docs: 更新文档
|
|
298
|
+
style: 代码格式调整
|
|
299
|
+
refactor: 重构代码
|
|
300
|
+
perf: 性能优化
|
|
301
|
+
test: 添加测试
|
|
302
|
+
build: 构建系统变更
|
|
303
|
+
ci: CI/CD 配置变更
|
|
304
|
+
chore: 其他变更
|
|
305
|
+
revert: 回退提交
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### 自动化发布
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
# 执行一键发布(自动引导整个流程)
|
|
312
|
+
npm run release
|
|
313
|
+
|
|
314
|
+
# 预览发布(不实际执行)
|
|
315
|
+
npm run release:dry
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**发布流程:**
|
|
319
|
+
1. 检测未提交的文件,询问是否提交
|
|
320
|
+
2. 选择版本更新类型(Major / Minor / Patch)
|
|
321
|
+
3. 确认版本号
|
|
322
|
+
4. 自动更新版本号、生成 CHANGELOG、打 tag
|
|
323
|
+
5. 推送到远程仓库
|
|
324
|
+
6. 发布到 npm
|
|
325
|
+
|
|
326
|
+
### 配置文件
|
|
327
|
+
|
|
328
|
+
| 文件 | 说明 |
|
|
329
|
+
|------|------|
|
|
330
|
+
| `.commitlintrc.json` | 提交信息校验规则 |
|
|
331
|
+
| `.versionrc` | 版本更新和 CHANGELOG 生成规则 |
|
|
332
|
+
| `.npmrc` | npm 发布配置 |
|
|
333
|
+
| `.husky/commit-msg` | Git hooks 配置 |
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
MIT
|
package/bin/juice.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { program } = require('commander');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const pkg = require('../package.json');
|
|
7
|
+
const { run } = require('../src/index');
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('juice')
|
|
11
|
+
.description('生成符合各大邮件平台的内联 CSS HTML 邮件(同时输出标准版 + 压缩版)')
|
|
12
|
+
.version(pkg.version, '-v, --version')
|
|
13
|
+
.option('-f, --file <path>', '输入 HTML 模板文件路径')
|
|
14
|
+
.option('-c, --config <path>', '配置文件路径(不指定时自动查找输入文件同级目录)')
|
|
15
|
+
.option('--install', '注册 Windows 右键菜单(需管理员权限)')
|
|
16
|
+
.option('--uninstall', '取消 Windows 右键菜单注册(需管理员权限)')
|
|
17
|
+
.addHelpText('before', `
|
|
18
|
+
╔════════════════════════════════════════════════════════════════╗
|
|
19
|
+
║ juice-email-cli v${pkg.version} ║
|
|
20
|
+
║ HTML 邮件生成工具 - CSS 内联 + 模板变量 + 压缩 ║
|
|
21
|
+
╚════════════════════════════════════════════════════════════════╝
|
|
22
|
+
`)
|
|
23
|
+
.addHelpText('after', `
|
|
24
|
+
配置加载顺序(优先级从低到高):
|
|
25
|
+
1. CLI 内置默认值(defaults/juice.yaml)
|
|
26
|
+
2. 用户主目录 ~/juice.yaml(如果存在)
|
|
27
|
+
3. 优先配置(-c 指定 或 输入文件同级目录,二者互斥)
|
|
28
|
+
|
|
29
|
+
使用示例:
|
|
30
|
+
juice -f my-email.html
|
|
31
|
+
juice -c project.yaml -f emails/welcome.html
|
|
32
|
+
juice --install (管理员权限,注册右键菜单)
|
|
33
|
+
juice --uninstall (管理员权限,卸载右键菜单)
|
|
34
|
+
|
|
35
|
+
输出文件(与输入文件同目录):
|
|
36
|
+
<name>.output.html CSS 内联 + 变量替换后的标准版
|
|
37
|
+
<name>.minified.html 在标准版基础上压缩的精简版
|
|
38
|
+
|
|
39
|
+
更多信息:https://gitee.com/siriussupreme/juice-cli
|
|
40
|
+
`)
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
// 注册/卸载右键菜单
|
|
43
|
+
if (options.install) {
|
|
44
|
+
const { registerContextMenu } = require('../src/context-menu');
|
|
45
|
+
await registerContextMenu();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (options.uninstall) {
|
|
49
|
+
const { unregisterContextMenu } = require('../src/context-menu');
|
|
50
|
+
await unregisterContextMenu();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 支持右键菜单直接传文件路径(位置参数)
|
|
55
|
+
let inputFile = options.file;
|
|
56
|
+
if (!inputFile && program.args.length > 0) {
|
|
57
|
+
inputFile = program.args[0];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!inputFile) {
|
|
61
|
+
program.help();
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await run({
|
|
66
|
+
file: inputFile,
|
|
67
|
+
config: options.config,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program.allowUnknownOption(false);
|
|
72
|
+
program.parse(process.argv);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# juice-email-cli 内置默认配置
|
|
2
|
+
#
|
|
3
|
+
# 配置加载顺序(优先级从低到高):
|
|
4
|
+
# 1. CLI 内置默认值(defaults/juice.yaml)
|
|
5
|
+
# 2. 用户主目录 ~/juice.yaml(如果存在)
|
|
6
|
+
# 3. 优先配置文件(-c 指定 或 输入文件同级目录,二者互斥,只生效一个)
|
|
7
|
+
#
|
|
8
|
+
# 合并方式:深度合并,后面的覆盖前面的
|
|
9
|
+
#
|
|
10
|
+
# 用户配置文件(~/juice.yaml 或项目 juice.yaml)只需填写需要覆盖的字段即可。
|
|
11
|
+
|
|
12
|
+
# ─── juice CSS 内联选项 ───────────────────────────────────────────────────────
|
|
13
|
+
juice:
|
|
14
|
+
# 处理完成后移除 <style> 标签(推荐 true,提高 Gmail/Outlook 等邮件客户端兼容性)
|
|
15
|
+
# 设为 true 时,内联完成后会删除所有 <style> 标签(除非通过 preservedSelectors 保留特定选择器)
|
|
16
|
+
removeStyleTags: true
|
|
17
|
+
|
|
18
|
+
# 保留 @media 媒体查询到独立 <style> 块(响应式邮件必须开启)
|
|
19
|
+
preserveMediaQueries: true
|
|
20
|
+
|
|
21
|
+
# 保留 @font-face 声明(自定义字体支持)
|
|
22
|
+
preserveFontFaces: true
|
|
23
|
+
|
|
24
|
+
# 保留 @keyframes 动画(部分邮件客户端支持)
|
|
25
|
+
preserveKeyFrames: true
|
|
26
|
+
|
|
27
|
+
# 将 style 属性中的样式转换为 table 系列元素的 HTML 属性(align、bgcolor、valign 等)
|
|
28
|
+
# 开启后对 Outlook 2003-2019(使用 Word 渲染引擎)的兼容性更好
|
|
29
|
+
# 注意:仅对 <table>、<tr>、<td>、<th> 等表格元素生效
|
|
30
|
+
applyAttributesTableElements: true
|
|
31
|
+
|
|
32
|
+
# 是否处理 HTML 文档中的 <style> 标签(通常保持 true)
|
|
33
|
+
# 设为 true 时,<style> 标签内的 CSS 会被解析并内联到对应元素
|
|
34
|
+
# 设为 false 时,<style> 标签的 CSS 不会被处理(适用于已内联或使用外部 CSS 的场景)
|
|
35
|
+
applyStyleTags: true
|
|
36
|
+
|
|
37
|
+
# 保留 CSS 中的 !important 声明
|
|
38
|
+
preserveImportant: false
|
|
39
|
+
|
|
40
|
+
# 解析 CSS 变量(如 var(--brand-color))并替换为具体值
|
|
41
|
+
# 设为 true 时,CSS 变量会被解析并内联到对应的 style 属性中
|
|
42
|
+
resolveCSSVariables: true
|
|
43
|
+
|
|
44
|
+
# 为 <img> 元素应用 width 属性(基于 CSS 样式中的宽度值)
|
|
45
|
+
# 设为 true 时,图片的 CSS 宽度会同时添加为 HTML width 属性
|
|
46
|
+
applyWidthAttributes: true
|
|
47
|
+
|
|
48
|
+
# 为 <img> 元素应用 height 属性(基于 CSS 样式中的高度值)
|
|
49
|
+
# 设为 true 时,图片的 CSS 高度会同时添加为 HTML height 属性
|
|
50
|
+
applyHeightAttributes: true
|
|
51
|
+
|
|
52
|
+
# 将 ::before 和 ::after 伪元素转换为 <span> 标签插入 DOM
|
|
53
|
+
# 设为 true 时,伪元素内容将作为可见的 DOM 元素呈现,而非仅保留样式
|
|
54
|
+
inlinePseudoElements: true
|
|
55
|
+
|
|
56
|
+
# 移除 <style> 标签时,是否保留伪类选择器规则(:hover、:active、:focus 等)
|
|
57
|
+
# 设为 true 时,juice.ignoredPseudos 中的伪类规则会保留在 <style> 标签内,其他样式正常内联
|
|
58
|
+
# 设为 false 时,所有伪类规则都会被移除
|
|
59
|
+
preservePseudos: true
|
|
60
|
+
|
|
61
|
+
# 保留的 CSS 选择器列表(支持子字符串匹配)
|
|
62
|
+
# 当 removeStyleTags 为 true 时,这些选择器的规则会保留在 <style> 标签内,不会被内联
|
|
63
|
+
#
|
|
64
|
+
# 用途:保留邮件客户端特定的样式(如 Gmail/Outlook 兼容性修复)
|
|
65
|
+
# 匹配方式:子字符串包含即匹配
|
|
66
|
+
# 示例:
|
|
67
|
+
# - '.gmail-' → 匹配 '.gmail-desktop'、'.gmail-mobile' 等 Gmail 相关样式
|
|
68
|
+
# - '#outlook-' → 匹配 '#outlook-wrapper'、'#outlook-footer' 等 Outlook 相关样式
|
|
69
|
+
# - 'conditional' → 匹配包含 'conditional' 的选择器(如条件注释包裹的选择器)
|
|
70
|
+
#
|
|
71
|
+
# 注意:这是简化写法,实际由 juice 库的子字符串匹配逻辑处理
|
|
72
|
+
preservedSelectors:
|
|
73
|
+
- ".gmail-"
|
|
74
|
+
- "#outlook-"
|
|
75
|
+
- ":hover"
|
|
76
|
+
|
|
77
|
+
# 将保留的额外 CSS(媒体查询、@font-face、@keyframes)插入到文档的位置
|
|
78
|
+
# 仅当 preserveMediaQueries/preserveFontFaces/preserveKeyFrames 为 true 时生效
|
|
79
|
+
# - true: 自动选择位置(优先 <head>,其次 <body>,最后文档末尾)
|
|
80
|
+
# - CSS 选择器字符串: 插入到匹配元素的内部末尾,如 '#email-container'
|
|
81
|
+
insertPreservedExtraCss: true
|
|
82
|
+
|
|
83
|
+
# 额外附加的外部 CSS 文件(相对于 HTML 模板所在目录)
|
|
84
|
+
# extraCssFiles: []
|
|
85
|
+
|
|
86
|
+
# ─── 模板变量(Mustache 语法:{{变量名}})────────────────────────────────────
|
|
87
|
+
variables:
|
|
88
|
+
brandName: ""
|
|
89
|
+
brandColor: "#0066cc"
|
|
90
|
+
logoUrl: ""
|
|
91
|
+
websiteUrl: ""
|
|
92
|
+
recipientName: "用户"
|
|
93
|
+
subject: ""
|
|
94
|
+
previewText: ""
|
|
95
|
+
ctaText: "立即查看"
|
|
96
|
+
ctaUrl: ""
|
|
97
|
+
companyName: ""
|
|
98
|
+
companyAddress: ""
|
|
99
|
+
unsubscribeUrl: ""
|
|
100
|
+
currentYear: "2026"
|
|
101
|
+
|
|
102
|
+
# ─── 输出选项 ─────────────────────────────────────────────────────────────────
|
|
103
|
+
output:
|
|
104
|
+
# 普通输出文件后缀
|
|
105
|
+
normalSuffix: ".output.html"
|
|
106
|
+
# 压缩输出文件后缀
|
|
107
|
+
minifiedSuffix: ".minified.html"
|
|
108
|
+
# 文件编码
|
|
109
|
+
encoding: "utf8"
|
|
110
|
+
|
|
111
|
+
# ─── 压缩选项(html-minifier-terser)────────────────────────────────────────
|
|
112
|
+
# ⚠️ 邮件 HTML 压缩注意事项:
|
|
113
|
+
# 1. 必须保持 removeConditionalComments: false,保护 Outlook 条件注释
|
|
114
|
+
# 2. 不要移除属性引号,确保兼容性
|
|
115
|
+
# 3. 不要移除空属性,某些属性是必需的
|
|
116
|
+
# 4. 不要移除空元素,Outlook 对此支持不佳
|
|
117
|
+
minify:
|
|
118
|
+
# 折叠多余空白
|
|
119
|
+
collapseWhitespace: true
|
|
120
|
+
|
|
121
|
+
# 保守折叠:始终保留至少 1 个空格(避免单词连在一起)
|
|
122
|
+
conservativeCollapse: true
|
|
123
|
+
|
|
124
|
+
# 移除 HTML 注释(注意:不会移除条件注释)
|
|
125
|
+
removeComments: true
|
|
126
|
+
|
|
127
|
+
# 保留条件注释(不要改为 true,会删除 <!--[if mso]> 等 Outlook MSO 兼容代码)
|
|
128
|
+
removeConditionalComments: false
|
|
129
|
+
|
|
130
|
+
# 压缩内联 CSS(style 属性)
|
|
131
|
+
minifyCSS: true
|
|
132
|
+
|
|
133
|
+
# 压缩内联 JS(邮件中通常无 JS,保持 false)
|
|
134
|
+
minifyJS: false
|
|
135
|
+
|
|
136
|
+
# 移除空属性(设为 false 确保兼容性)
|
|
137
|
+
removeEmptyAttributes: false
|
|
138
|
+
|
|
139
|
+
# 规范化布尔属性(设为 false 确保兼容性)
|
|
140
|
+
collapseBooleanAttributes: false
|
|
141
|
+
|
|
142
|
+
# 移除冗余属性(当属性值与默认值相同时)
|
|
143
|
+
removeRedundantAttributes: true
|
|
144
|
+
|
|
145
|
+
# 使用短的 doctype
|
|
146
|
+
useShortDoctype: true
|
|
147
|
+
|
|
148
|
+
# 保留行尾换行(配合 collapseWhitespace 使用)
|
|
149
|
+
preserveLineBreaks: false
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "juice-email-cli",
|
|
3
|
+
"version": "2.0.5",
|
|
4
|
+
"description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"juice": "bin/juice.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/juice.js",
|
|
11
|
+
"test": "node bin/juice.js --help",
|
|
12
|
+
"postinstall": "node bin/juice.js --install",
|
|
13
|
+
"preuninstall": "node bin/juice.js --uninstall",
|
|
14
|
+
"prepare": "husky install",
|
|
15
|
+
"release": "node scripts/release.mjs",
|
|
16
|
+
"release:dry": "standard-version --dry-run"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"email",
|
|
20
|
+
"html",
|
|
21
|
+
"css-inline",
|
|
22
|
+
"juice",
|
|
23
|
+
"email-template",
|
|
24
|
+
"mustache",
|
|
25
|
+
"minify",
|
|
26
|
+
"html-email"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/GuoSirius/juice-cli",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/GuoSirius/juice-cli.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/GuoSirius/juice-cli/issues"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"commander": "^11.1.0",
|
|
40
|
+
"html-minifier-terser": "^7.2.0",
|
|
41
|
+
"js-yaml": "^4.1.0",
|
|
42
|
+
"juice": "^10.0.0",
|
|
43
|
+
"mustache": "^4.2.0",
|
|
44
|
+
"ora": "^5.4.1"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=14.0.0"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"bin/",
|
|
51
|
+
"src/",
|
|
52
|
+
"defaults/",
|
|
53
|
+
"README.md"
|
|
54
|
+
],
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@commitlint/cli": "^21.0.1",
|
|
57
|
+
"@commitlint/config-conventional": "^21.0.1",
|
|
58
|
+
"chalk": "^4.1.2",
|
|
59
|
+
"husky": "^9.1.7",
|
|
60
|
+
"@inquirer/prompts": "^5.0.2",
|
|
61
|
+
"inquirer": "^13.4.3",
|
|
62
|
+
"standard-version": "^9.5.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Windows 右键菜单注册 / 取消注册
|
|
5
|
+
*
|
|
6
|
+
* 风格参考 PowerShell 7 的右键菜单实现:
|
|
7
|
+
* - 使用 MUIVerb(菜单显示文字)+ Icon 键
|
|
8
|
+
* - 通过 ExtendedSubCommandsKey 实现子菜单("用 juice 处理" → 子项)
|
|
9
|
+
* - 注册到 SystemFileAssociations,兼容任意关联方式打开的 .html/.htm
|
|
10
|
+
*
|
|
11
|
+
* 菜单结构:
|
|
12
|
+
* 📧 用 juice 生成邮件 HTML
|
|
13
|
+
* ├── ⚡ 生成(标准 + 压缩)
|
|
14
|
+
* └── 📂 在此目录打开 PowerShell
|
|
15
|
+
*
|
|
16
|
+
* 需要以管理员权限运行。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { execSync } = require('child_process');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const chalk = require('chalk');
|
|
22
|
+
|
|
23
|
+
// ─── 路径工具 ─────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function getNodePath() {
|
|
26
|
+
return process.execPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getJuiceScript() {
|
|
30
|
+
return path.resolve(__dirname, '..', 'bin', 'juice.js');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getIconPath() {
|
|
34
|
+
return path.resolve(__dirname, '..', 'icons', 'juice-icon.ico');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── 注册表操作封装 ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 执行 reg add,失败时打印警告而不中断
|
|
41
|
+
*/
|
|
42
|
+
function regAdd(key, valueName, type, data) {
|
|
43
|
+
const vFlag = valueName === '' ? '/ve' : `/v "${valueName}"`;
|
|
44
|
+
const tFlag = type ? `/t ${type}` : '';
|
|
45
|
+
const dFlag = data !== undefined ? `/d "${escapeRegData(data)}"` : '';
|
|
46
|
+
const cmd = `reg add "${key}" ${vFlag} ${tFlag} ${dFlag} /f`.replace(/\s+/g, ' ').trim();
|
|
47
|
+
try {
|
|
48
|
+
execSync(cmd, { stdio: 'pipe' });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
const msg = e.stderr ? e.stderr.toString().trim() : e.message;
|
|
51
|
+
console.warn(chalk.yellow(` ⚠ reg add 失败(可能需要管理员权限)\n 键:${key}\n 原因:${msg}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 执行 reg delete,键不存在时静默忽略
|
|
57
|
+
*/
|
|
58
|
+
function regDelete(key) {
|
|
59
|
+
try {
|
|
60
|
+
execSync(`reg delete "${key}" /f`, { stdio: 'pipe' });
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 转义注册表数据中的特殊字符(反斜杠和双引号)
|
|
66
|
+
*/
|
|
67
|
+
function escapeRegData(str) {
|
|
68
|
+
// reg.exe 中反斜杠需要双写,双引号需要转义
|
|
69
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── 菜单结构常量 ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// 根键:SystemFileAssociations 对 .html/.htm 通用
|
|
75
|
+
const ROOTS = [
|
|
76
|
+
'HKEY_CLASSES_ROOT\\SystemFileAssociations\\.html\\shell',
|
|
77
|
+
'HKEY_CLASSES_ROOT\\SystemFileAssociations\\.htm\\shell',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// 父菜单键名(与 PowerShell 7 风格保持一致:不含特殊字符的英文键名)
|
|
81
|
+
const PARENT_KEY_NAME = 'JuiceEmail';
|
|
82
|
+
// 子命令注册表空间(SubCommands 引用的命令存放在 shell\JuiceEmail.SubCommands 下)
|
|
83
|
+
// PowerShell 7 的做法:在 shell 同级放一个专用 SubCommands 键
|
|
84
|
+
const SUBCMD_SPACE = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
|
|
85
|
+
|
|
86
|
+
// ─── 注册 ─────────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function registerContextMenu() {
|
|
89
|
+
console.log(chalk.cyan('\n 注册 juice 右键菜单(PowerShell 7 风格)...\n'));
|
|
90
|
+
|
|
91
|
+
const nodePath = getNodePath();
|
|
92
|
+
const scriptPath = getJuiceScript();
|
|
93
|
+
const iconPath = getIconPath();
|
|
94
|
+
|
|
95
|
+
// 子命令 1:生成(标准 + 压缩)
|
|
96
|
+
const subCmd1 = `JuiceEmail.Generate`;
|
|
97
|
+
// 子命令 2:在此打开 PowerShell(可选便利项)
|
|
98
|
+
const subCmd2 = `JuiceEmail.OpenPwsh`;
|
|
99
|
+
|
|
100
|
+
// ── 注册子命令到 CommandStore ─────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
// 子命令 1:juice 生成
|
|
103
|
+
const generateCmd = `"${nodePath}" "${scriptPath}" -f "%1"`;
|
|
104
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, '', 'REG_SZ', '⚡ 生成邮件 HTML(标准 + 压缩)');
|
|
105
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, 'Icon', 'REG_SZ', iconPath);
|
|
106
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd1}\\command`, '', 'REG_SZ', generateCmd);
|
|
107
|
+
|
|
108
|
+
// 子命令 2:在文件目录打开 pwsh(如果安装了 PowerShell 7)
|
|
109
|
+
const pwshPath = resolvePwsh();
|
|
110
|
+
if (pwshPath) {
|
|
111
|
+
const pwshCmd = `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%W'"`;
|
|
112
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, '', 'REG_SZ', '📂 在此目录打开 PowerShell 7');
|
|
113
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, 'Icon', 'REG_SZ', pwshPath);
|
|
114
|
+
regAdd(`${SUBCMD_SPACE}\\${subCmd2}\\command`, '', 'REG_SZ', pwshCmd);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 注册父菜单到 SystemFileAssociations ──────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const subCommands = pwshPath ? `${subCmd1};${subCmd2}` : subCmd1;
|
|
120
|
+
|
|
121
|
+
for (const root of ROOTS) {
|
|
122
|
+
const parentKey = `${root}\\${PARENT_KEY_NAME}`;
|
|
123
|
+
|
|
124
|
+
// MUIVerb:菜单显示文字
|
|
125
|
+
regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML');
|
|
126
|
+
// Icon:使用自定义图标
|
|
127
|
+
regAdd(parentKey, 'Icon', 'REG_SZ', iconPath);
|
|
128
|
+
// SubCommands:引用 CommandStore 中的子命令(分号分隔)
|
|
129
|
+
regAdd(parentKey, 'SubCommands', 'REG_SZ', subCommands);
|
|
130
|
+
// Extended:取消注释可让菜单仅在按住 Shift 时显示(默认不加)
|
|
131
|
+
// regAdd(parentKey, 'Extended', 'REG_SZ', '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(
|
|
135
|
+
'\n' +
|
|
136
|
+
chalk.green(' ✔ 右键菜单注册完成!') + '\n\n' +
|
|
137
|
+
` 在 ${chalk.cyan('.html')} / ${chalk.cyan('.htm')} 文件上右键即可看到:\n` +
|
|
138
|
+
` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
|
|
139
|
+
` ├── ⚡ 生成邮件 HTML(标准 + 压缩)\n` +
|
|
140
|
+
(pwshPath ? ` └── 📂 在此目录打开 PowerShell 7\n` : '') +
|
|
141
|
+
'\n' +
|
|
142
|
+
chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n') +
|
|
143
|
+
chalk.gray(' 安装时自动注册失败?请以管理员身份重新运行:\n') +
|
|
144
|
+
chalk.cyan(' juice --register\n')
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── 取消注册 ─────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
async function unregisterContextMenu() {
|
|
151
|
+
console.log(chalk.cyan('\n 取消注册 juice 右键菜单...\n'));
|
|
152
|
+
|
|
153
|
+
for (const root of ROOTS) {
|
|
154
|
+
regDelete(`${root}\\${PARENT_KEY_NAME}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
regDelete(`${SUBCMD_SPACE}\\JuiceEmail.Generate`);
|
|
158
|
+
regDelete(`${SUBCMD_SPACE}\\JuiceEmail.OpenPwsh`);
|
|
159
|
+
|
|
160
|
+
console.log(chalk.green(' ✔ 右键菜单已移除。\n'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── 工具:查找 PowerShell 7 ─────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function resolvePwsh() {
|
|
166
|
+
const candidates = [
|
|
167
|
+
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
|
168
|
+
'C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe',
|
|
169
|
+
path.join(process.env['LOCALAPPDATA'] || '', 'Microsoft', 'PowerShell', 'pwsh.exe'),
|
|
170
|
+
];
|
|
171
|
+
for (const p of candidates) {
|
|
172
|
+
if (require('fs').existsSync(p)) return p;
|
|
173
|
+
}
|
|
174
|
+
// 尝试 where.exe
|
|
175
|
+
try {
|
|
176
|
+
const result = execSync('where pwsh', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
|
|
177
|
+
if (result && require('fs').existsSync(result)) return result;
|
|
178
|
+
} catch (_) {}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { registerContextMenu, unregisterContextMenu };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const yaml = require('js-yaml');
|
|
7
|
+
const juice = require('juice');
|
|
8
|
+
const Mustache = require('mustache');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
const ora = require('ora');
|
|
11
|
+
const { minify: htmlMinify } = require('html-minifier-terser');
|
|
12
|
+
|
|
13
|
+
// ─── 从 defaults/juice.yaml 加载默认配置 ─────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', 'defaults', 'juice.yaml');
|
|
16
|
+
|
|
17
|
+
function loadDefaultConfig() {
|
|
18
|
+
try {
|
|
19
|
+
const content = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8');
|
|
20
|
+
return yaml.load(content) || {};
|
|
21
|
+
} catch (e) {
|
|
22
|
+
// 如果 defaults/juice.yaml 不存在,使用内置最小配置
|
|
23
|
+
console.warn(chalk.yellow(` ⚠ 无法加载默认配置 ${DEFAULT_CONFIG_PATH},使用内置默认值\n`));
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CODE_DEFAULTS = loadDefaultConfig();
|
|
29
|
+
|
|
30
|
+
// 用户主目录候选(支持 yaml/yml)
|
|
31
|
+
const HOME_CANDIDATES = [
|
|
32
|
+
path.join(os.homedir(), 'juice.yaml'),
|
|
33
|
+
path.join(os.homedir(), 'juice.yml'),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// ─── 配置文件查找 ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 查找用户配置文件
|
|
40
|
+
*
|
|
41
|
+
* 配置加载顺序(优先级从低到高):
|
|
42
|
+
* 1. CLI 内置默认值(defaults/juice.yaml)
|
|
43
|
+
* 2. 用户主目录 ~/juice.yaml(如果存在)
|
|
44
|
+
* 3. 优先配置文件(-c 指定 或 输入文件同级目录,二者互斥)
|
|
45
|
+
*
|
|
46
|
+
* 返回:
|
|
47
|
+
* - highPriorityPath: 高优先级配置路径(-c 指定 或 输入文件同级目录,二者互斥)
|
|
48
|
+
* - homePath: 用户主目录配置路径(可能不存在)
|
|
49
|
+
*/
|
|
50
|
+
function findConfigs(configPath, inputFile) {
|
|
51
|
+
let highPriorityPath = null;
|
|
52
|
+
|
|
53
|
+
// 1. 优先使用 -c 指定的配置文件
|
|
54
|
+
if (configPath) {
|
|
55
|
+
const resolved = path.resolve(configPath);
|
|
56
|
+
if (!fs.existsSync(resolved)) {
|
|
57
|
+
throw new Error(`指定的配置文件不存在:${resolved}`);
|
|
58
|
+
}
|
|
59
|
+
highPriorityPath = resolved;
|
|
60
|
+
}
|
|
61
|
+
// 2. 否则使用输入文件同级目录下的配置(互斥)
|
|
62
|
+
else if (inputFile) {
|
|
63
|
+
const inputDir = path.dirname(path.resolve(inputFile));
|
|
64
|
+
const fileCandidates = [
|
|
65
|
+
path.join(inputDir, 'juice.yaml'),
|
|
66
|
+
path.join(inputDir, 'juice.yml'),
|
|
67
|
+
];
|
|
68
|
+
for (const c of fileCandidates) {
|
|
69
|
+
if (fs.existsSync(c)) {
|
|
70
|
+
highPriorityPath = c;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { highPriorityPath, homePath: HOME_CANDIDATES[0] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── 配置文件加载 ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function loadYaml(filePath) {
|
|
82
|
+
if (!filePath || !fs.existsSync(filePath)) return {};
|
|
83
|
+
try {
|
|
84
|
+
return yaml.load(fs.readFileSync(filePath, 'utf8')) || {};
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new Error(`配置文件解析失败(${filePath}):${e.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 深度合并(后面的覆盖前面的),仅对普通对象递归,数组/基本类型直接覆盖
|
|
92
|
+
*/
|
|
93
|
+
function deepMerge(base, ...overrides) {
|
|
94
|
+
let result = Object.assign({}, base);
|
|
95
|
+
for (const src of overrides) {
|
|
96
|
+
if (!src || typeof src !== 'object' || Array.isArray(src)) continue;
|
|
97
|
+
for (const key of Object.keys(src)) {
|
|
98
|
+
const sv = src[key];
|
|
99
|
+
const rv = result[key];
|
|
100
|
+
if (
|
|
101
|
+
sv !== null &&
|
|
102
|
+
typeof sv === 'object' &&
|
|
103
|
+
!Array.isArray(sv) &&
|
|
104
|
+
rv !== null &&
|
|
105
|
+
typeof rv === 'object' &&
|
|
106
|
+
!Array.isArray(rv)
|
|
107
|
+
) {
|
|
108
|
+
result[key] = deepMerge(rv, sv);
|
|
109
|
+
} else {
|
|
110
|
+
result[key] = sv;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 配置合并,返回最终生效配置
|
|
119
|
+
*
|
|
120
|
+
* 合并顺序(优先级从低到高):
|
|
121
|
+
* CLI 内置默认值 < 用户目录 ~/juice.yaml < 优先配置(-c/输入目录)
|
|
122
|
+
*/
|
|
123
|
+
function buildConfig(highPriorityPath, homePath) {
|
|
124
|
+
const homeExists = fs.existsSync(homePath);
|
|
125
|
+
const layers = [];
|
|
126
|
+
|
|
127
|
+
// 1. CLI 内置默认值(最低优先级)
|
|
128
|
+
layers.push({ label: 'CLI 内置默认值', data: CODE_DEFAULTS });
|
|
129
|
+
|
|
130
|
+
// 2. 用户目录配置(如果有)
|
|
131
|
+
if (homeExists) {
|
|
132
|
+
layers.push({ label: `用户目录配置 (${homePath})`, data: loadYaml(homePath) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. 优先配置(最高优先级,-c 指定 或 输入文件同级目录,互斥)
|
|
136
|
+
if (highPriorityPath) {
|
|
137
|
+
layers.push({ label: `优先配置 (${highPriorityPath})`, data: loadYaml(highPriorityPath) });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const config = deepMerge(...layers.map((l) => l.data));
|
|
141
|
+
return { config, layers };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── HTML 模板处理 ────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function processTemplate(inputFile, config) {
|
|
147
|
+
const htmlRaw = fs.readFileSync(inputFile, 'utf8');
|
|
148
|
+
|
|
149
|
+
// 1. Mustache 变量替换
|
|
150
|
+
const htmlWithVars = Mustache.render(htmlRaw, config.variables || {});
|
|
151
|
+
|
|
152
|
+
// 2. 收集 extraCssFiles
|
|
153
|
+
const basePath = path.dirname(path.resolve(inputFile));
|
|
154
|
+
const extraCss = collectExtraCss(basePath, config);
|
|
155
|
+
|
|
156
|
+
// 3. juice CSS 内联
|
|
157
|
+
const juiceOpts = Object.assign({}, config.juice || {});
|
|
158
|
+
delete juiceOpts.extraCssFiles;
|
|
159
|
+
|
|
160
|
+
return juice(htmlWithVars, { ...juiceOpts, extraCss });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function collectExtraCss(basePath, config) {
|
|
164
|
+
const extraFiles = (config.juice && config.juice.extraCssFiles) || [];
|
|
165
|
+
if (!extraFiles.length) return '';
|
|
166
|
+
return extraFiles
|
|
167
|
+
.map((f) => {
|
|
168
|
+
const full = path.isAbsolute(f) ? f : path.join(basePath, f);
|
|
169
|
+
if (!fs.existsSync(full)) {
|
|
170
|
+
process.stderr.write(chalk.yellow(` ⚠ extraCssFiles 文件不存在,已跳过:${full}\n`));
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
return fs.readFileSync(full, 'utf8');
|
|
174
|
+
})
|
|
175
|
+
.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── 压缩 ─────────────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
async function minifyHtml(html, minifyConfig) {
|
|
181
|
+
return htmlMinify(html, {
|
|
182
|
+
removeConditionalComments: false,
|
|
183
|
+
...(minifyConfig || {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── 输出路径 ─────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function resolveOutputPaths(inputFile, config) {
|
|
190
|
+
const parsed = path.parse(path.resolve(inputFile));
|
|
191
|
+
const ns = (config.output && config.output.normalSuffix) || '.output.html';
|
|
192
|
+
const ms = (config.output && config.output.minifiedSuffix) || '.minified.html';
|
|
193
|
+
return {
|
|
194
|
+
normal: path.join(parsed.dir, parsed.name + ns),
|
|
195
|
+
minified: path.join(parsed.dir, parsed.name + ms),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── 主入口 ───────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async function run({ file, config: configPath }) {
|
|
202
|
+
const spinner = ora({ text: '正在处理...', color: 'cyan' }).start();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// 1. 输入文件
|
|
206
|
+
const inputFile = path.resolve(file);
|
|
207
|
+
if (!fs.existsSync(inputFile)) {
|
|
208
|
+
spinner.fail(chalk.red(`输入文件不存在:${inputFile}`));
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. 查找配置(-c 和输入文件同级目录互斥)+ 合并
|
|
213
|
+
const { highPriorityPath, homePath } = findConfigs(configPath, inputFile);
|
|
214
|
+
const { config, layers } = buildConfig(highPriorityPath, homePath);
|
|
215
|
+
const encoding = (config.output && config.output.encoding) || 'utf8';
|
|
216
|
+
|
|
217
|
+
// 3. CSS 内联
|
|
218
|
+
spinner.text = `CSS 内联处理:${path.basename(inputFile)}`;
|
|
219
|
+
const resultHtml = processTemplate(inputFile, config);
|
|
220
|
+
|
|
221
|
+
// 4. 输出路径
|
|
222
|
+
const outPaths = resolveOutputPaths(inputFile, config);
|
|
223
|
+
|
|
224
|
+
// 5. 写出标准版
|
|
225
|
+
spinner.text = '写出 .output.html ...';
|
|
226
|
+
fs.writeFileSync(outPaths.normal, resultHtml, encoding);
|
|
227
|
+
|
|
228
|
+
// 6. 压缩并写出
|
|
229
|
+
spinner.text = '写出 .minified.html ...';
|
|
230
|
+
const minified = await minifyHtml(resultHtml, config.minify);
|
|
231
|
+
fs.writeFileSync(outPaths.minified, minified, encoding);
|
|
232
|
+
|
|
233
|
+
// 7. 汇报
|
|
234
|
+
const layerLines = layers
|
|
235
|
+
.map((l) => ` ${chalk.gray('·')} ${l.label}`)
|
|
236
|
+
.join('\n');
|
|
237
|
+
|
|
238
|
+
spinner.succeed(
|
|
239
|
+
chalk.green('✔ 处理完成') + '\n' +
|
|
240
|
+
` ${chalk.bold('输入:')} ${chalk.cyan(inputFile)}\n` +
|
|
241
|
+
` ${chalk.bold('配置层(低→高):')}\n${layerLines}\n` +
|
|
242
|
+
` ${chalk.bold('输出:')}\n` +
|
|
243
|
+
` ${chalk.green('·')} 标准版 ${chalk.cyan(outPaths.normal)} ${chalk.gray('(' + fmtSize(resultHtml) + ')')}\n` +
|
|
244
|
+
` ${chalk.green('·')} 压缩版 ${chalk.cyan(outPaths.minified)} ${chalk.gray('(' + fmtSize(minified) + ',节省 ' + savings(resultHtml, minified) + ')')}`
|
|
245
|
+
);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
spinner.fail(chalk.red(`处理失败:${err.message}`));
|
|
248
|
+
if (process.env.DEBUG) console.error(err);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function fmtSize(str) {
|
|
254
|
+
const b = Buffer.byteLength(str, 'utf8');
|
|
255
|
+
return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function savings(original, minified) {
|
|
259
|
+
const orig = Buffer.byteLength(original, 'utf8');
|
|
260
|
+
const mini = Buffer.byteLength(minified, 'utf8');
|
|
261
|
+
return (((orig - mini) / orig) * 100).toFixed(1) + '%';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { run, findConfigs, buildConfig, processTemplate };
|