juice-email-cli 2.1.8 → 2.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -105,8 +105,8 @@ juice
105
105
  | `--snippet <path>` | `-s` | 片段 HTML 文件路径:插入到模板 `<tbody id="content">` |
106
106
  | `--config <path>` | `-c` | 配置文件路径,不指定时自动查找 |
107
107
  | `--name <name>` | `-n` | 片段模式输出文件名(不含扩展名) |
108
- | `--install` | | 注册 Windows 右键菜单(需管理员权限) |
109
- | `--uninstall` | | 取消 Windows 右键菜单注册(需管理员权限) |
108
+ | `--install` | | 注册 Windows 右键菜单(当前用户,无需管理员) |
109
+ | `--uninstall` | | 取消 Windows 右键菜单注册 |
110
110
  | `--version` | `-v` | 查看版本号 |
111
111
  | `--help` | `-h` | 查看帮助 |
112
112
 
@@ -312,22 +312,28 @@ edm/
312
312
 
313
313
  ## Windows 右键菜单
314
314
 
315
- 注册后,在 `.html` / `.htm` 文件上右键可看到级联子菜单:
315
+ 注册后,在 `.html` / `.htm` / `.yaml` / `.yml` 文件上右键可看到统一子菜单(所有文件类型共享):
316
316
 
317
317
  ```
318
318
  📧 用 juice 生成邮件 HTML
319
- ├── 生成邮件 HTML(标准 + 压缩) → juice -f
320
- ├── 🧩 邮件片段组装(交互选择模板) juice -s
321
- └── 📂 在此目录打开 PowerShell 7 ← 仅在已安装 pwsh 时出现
319
+ ├── 📄 作为模板,生成邮件 HTML juice -f %1(后台执行)
320
+ ├── 🧩 作为片段,拼接邮件 HTML juice -s %1(交互选择模板)
321
+ ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c %1(交互选择品牌/模板/片段)
322
+ └── 📂 打开 PowerShell ← 仅已安装 pwsh 时出现
322
323
  ```
323
324
 
325
+ | 子命令 | 说明 |
326
+ |--------|------|
327
+ | 📄 作为模板,生成邮件 HTML | 将文件当模板处理,CSS 内联 + 变量替换 + 压缩 |
328
+ | 🧩 作为片段,拼接邮件 HTML | 将文件当片段,打开终端选择模板后拼接输出 |
329
+ | ⚙️ 作为配置,拼接邮件 HTML | 将文件当配置,打开终端选择品牌/模板/片段 |
330
+
324
331
  ```bash
325
- # 以管理员身份运行
326
- juice --install # 注册
332
+ juice --install # 注册(当前用户,无需管理员)
327
333
  juice --uninstall # 卸载
328
334
  ```
329
335
 
330
- > **注意**:注册/卸载需要**管理员权限**。注册成功后如菜单未立即出现,重启文件资源管理器(`explorer.exe`)即可。
336
+ > **注意**:注册到当前用户(HKCU),无需管理员权限。注册成功后如菜单未立即出现,重启文件资源管理器(`explorer.exe`)即可。
331
337
 
332
338
  ---
333
339
 
package/bin/juice.js CHANGED
@@ -14,8 +14,8 @@ program
14
14
  .option('-s, --snippet <path>', '片段 HTML 文件路径:将片段内容插入模板 <tbody id="content"> 中')
15
15
  .option('-c, --config <path>', '配置文件路径')
16
16
  .option('-n, --name <name>', '片段模式输出文件名(不含扩展名)')
17
- .option('--install', '注册 Windows 右键菜单(需管理员权限)')
18
- .option('--uninstall', '取消 Windows 右键菜单注册(需管理员权限)')
17
+ .option('--install', '注册 Windows 右键菜单(当前用户,无需管理员)')
18
+ .option('--uninstall', '取消 Windows 右键菜单注册')
19
19
  .addHelpText('before', `
20
20
  ╔════════════════════════════════════════════════════════════════╗
21
21
  ║ juice-email-cli v${pkg.version} ║
@@ -63,9 +63,16 @@ program
63
63
  右键菜单
64
64
  ════════════════════════════════════════════════════════════════
65
65
 
66
- .html/.htm 文件上右键即可使用:
67
- juice --install (管理员权限,注册右键菜单)
68
- juice --uninstall (管理员权限,卸载右键菜单)
66
+ 注册后,在 .html / .htm / .yaml / .yml 文件上右键即可看到:
67
+ juice --install (当前用户,无需管理员)
68
+ juice --uninstall (卸载右键菜单)
69
+
70
+ 菜单结构(所有文件类型统一):
71
+ 📧 用 juice 生成邮件 HTML
72
+ ├── 📄 作为模板,生成邮件 HTML → juice -f %1(后台执行)
73
+ ├── 🧩 作为片段,拼接邮件 HTML → juice -s %1(交互选择模板)
74
+ ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c %1(交互选择品牌/模板/片段)
75
+ └── 📂 打开 PowerShell (仅已安装 pwsh 时出现)
69
76
 
70
77
  更多信息:https://gitee.com/siriussupreme/juice-cli
71
78
  `)
@@ -1524,7 +1524,7 @@
1524
1524
 
1525
1525
  <tr>
1526
1526
  <td>Quality Control</td>
1527
- <td colspan="2">Sterilized by 0.1 μm filtration, Endotoxin Content < 3 EU/mL.</td>
1527
+ <td colspan="2">Sterilized by 0.1 μm filtration, Endotoxin Content &lt; 3 EU/mL.</td>
1528
1528
  </tr>
1529
1529
  </tbody>
1530
1530
  </table>
@@ -65,7 +65,7 @@
65
65
  <td rowspan="2" style="padding-right: 12px;" class="image-box align-middle">
66
66
  <img src="{{information.image}}" />
67
67
  </td>
68
- <td style="padding-bottoms: 24px;" class="h-0">
68
+ <td class="h-0 {{#information.link}}pb-24{{/information.link}}">
69
69
  <p>{{information.content}}</p>
70
70
  </td>
71
71
  </tr>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.1.8",
3
+ "version": "2.1.10",
4
4
  "description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/juice.js",
11
- "test": "node bin/juice.js --help",
11
+ "test": "node test/smoke.js",
12
12
  "postinstall": "node bin/juice.js --install",
13
13
  "preuninstall": "node bin/juice.js --uninstall",
14
14
  "prepare": "husky install",
@@ -46,7 +46,7 @@
46
46
  "ora": "^5.4.1"
47
47
  },
48
48
  "engines": {
49
- "node": ">=14.0.0"
49
+ "node": ">=16.0.0"
50
50
  },
51
51
  "files": [
52
52
  "bin/",
@@ -60,7 +60,6 @@
60
60
  "@commitlint/cli": "^21.0.1",
61
61
  "@commitlint/config-conventional": "^21.0.1",
62
62
  "husky": "^9.1.7",
63
- "inquirer": "^13.4.3",
64
63
  "standard-version": "^9.5.0"
65
64
  }
66
65
  }
@@ -3,18 +3,15 @@
3
3
  /**
4
4
  * Windows 右键菜单注册 / 取消注册
5
5
  *
6
- * 风格参考 PowerShell 7 的右键菜单实现:
7
- * - 使用 MUIVerb(菜单显示文字)+ Icon 键
8
- * - 通过 ExtendedSubCommandsKey 实现子菜单("用 juice 处理" → 子项)
9
- * - 注册到 SystemFileAssociations,兼容任意关联方式打开的 .html/.htm
6
+ * 使用 HKEY_CURRENT_USER,无需管理员权限,仅对当前用户生效。
10
7
  *
11
- * 菜单结构:
12
- * 📧 juice 生成邮件 HTML
13
- * ├── 生成邮件 HTML(标准 + 压缩)
14
- * ├── 🧩 邮件片段组装(交互选择模板)
15
- * └── 📂 在此目录打开 PowerShell
16
- *
17
- * 需要以管理员权限运行。
8
+ * 菜单结构(所有文件类型统一):
9
+ * .html / .htm / .yaml / .yml:
10
+ * 📧 juice 生成邮件 HTML
11
+ * ├── 📄 作为模板,生成邮件 HTML → juice -f(后台执行)
12
+ * ├── 🧩 作为片段,拼接邮件 HTML → juice -s(交互选择模板)
13
+ * ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)
14
+ * └── 📂 打开 PowerShell (可选)
18
15
  */
19
16
 
20
17
  const { execSync } = require('child_process');
@@ -47,108 +44,158 @@ function regAdd(key, valueName, type, data) {
47
44
  const cmd = `reg add "${key}" ${vFlag} ${tFlag} ${dFlag} /f`.replace(/\s+/g, ' ').trim();
48
45
  try {
49
46
  execSync(cmd, { stdio: 'pipe' });
47
+ return true;
50
48
  } catch (e) {
51
49
  const msg = e.stderr ? e.stderr.toString().trim() : e.message;
52
- console.warn(chalk.yellow(` ⚠ reg add 失败(可能需要管理员权限)\n 键:${key}\n 原因:${msg}`));
50
+ console.warn(chalk.yellow(` ⚠ reg add 失败\n 键:${key}\n 原因:${msg}`));
51
+ return false;
53
52
  }
54
53
  }
55
54
 
56
55
  /**
57
- * 执行 reg delete,键不存在时静默忽略
56
+ * 执行 reg delete,键不存在时静默忽略,返回是否实际删除了
58
57
  */
59
58
  function regDelete(key) {
60
59
  try {
61
60
  execSync(`reg delete "${key}" /f`, { stdio: 'pipe' });
62
- } catch (_) {}
61
+ return true;
62
+ } catch (_) {
63
+ return false;
64
+ }
63
65
  }
64
66
 
65
67
  /**
66
- * 转义注册表数据中的特殊字符(反斜杠和双引号)
68
+ * 转义注册表数据中的特殊字符
67
69
  */
68
70
  function escapeRegData(str) {
69
- // reg.exe 中反斜杠需要双写,双引号需要转义
70
- return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
71
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
71
72
  }
72
73
 
73
74
  // ─── 菜单结构常量 ─────────────────────────────────────────────────────────────
74
75
 
75
- // 根键:SystemFileAssociations 对 .html/.htm 通用
76
- const ROOTS = [
77
- 'HKEY_CLASSES_ROOT\\SystemFileAssociations\\.html\\shell',
78
- 'HKEY_CLASSES_ROOT\\SystemFileAssociations\\.htm\\shell',
76
+ // 使用 HKCU,无需管理员权限
77
+ const HKCU_SHELL = 'HKEY_CURRENT_USER\\Software\\Classes';
78
+
79
+ // HTML 文件右键
80
+ const HTML_ROOTS = [
81
+ `${HKCU_SHELL}\\SystemFileAssociations\\.html\\shell`,
82
+ `${HKCU_SHELL}\\SystemFileAssociations\\.htm\\shell`,
83
+ ];
84
+
85
+ // YAML 文件右键
86
+ const YAML_ROOTS = [
87
+ `${HKCU_SHELL}\\SystemFileAssociations\\.yaml\\shell`,
88
+ `${HKCU_SHELL}\\SystemFileAssociations\\.yml\\shell`,
79
89
  ];
80
90
 
81
- // 父菜单键名(与 PowerShell 7 风格保持一致:不含特殊字符的英文键名)
91
+ // CommandStore 子命令注册空间
92
+ const SUBCMD_SPACE = 'HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
93
+
94
+ // 子命令名称常量(register / unregister 共用,避免硬编码不一致)
95
+ const SUBCMDS = {
96
+ generate: 'JuiceEmail.Generate',
97
+ snippet: 'JuiceEmail.Snippet',
98
+ config: 'JuiceEmail.WithConfig',
99
+ pwsh: 'JuiceEmail.OpenPwsh',
100
+ };
101
+
82
102
  const PARENT_KEY_NAME = 'JuiceEmail';
83
- // 子命令注册表空间(SubCommands 引用的命令存放在 shell\JuiceEmail.SubCommands 下)
84
- // PowerShell 7 的做法:在 shell 同级放一个专用 SubCommands 键
85
- const SUBCMD_SPACE = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\CommandStore\\shell';
103
+
104
+ /**
105
+ * 为交互式命令包一层 PowerShell:
106
+ * - 成功:窗口自动关闭
107
+ * - 失败:窗口保持开启,显示重新执行的命令
108
+ */
109
+ function wrapInteractive(nodePath, scriptPath, cliArgs) {
110
+ const node = nodePath.replace(/'/g, "''");
111
+ const script = scriptPath.replace(/'/g, "''");
112
+ // %1 由 Windows 在 CreateProcess 前展开为实际文件路径
113
+ const ps = [
114
+ `& '${node}' '${script}' ${cliArgs}`,
115
+ `if ($LASTEXITCODE) {`,
116
+ ` Write-Host ''`,
117
+ ` Write-Host '[Failed] Exit code:' $LASTEXITCODE`,
118
+ ` Write-Host '[Re-run] juice ${cliArgs}'`,
119
+ ` Write-Host ''`,
120
+ ` Read-Host 'Press Enter to close'`,
121
+ `}`,
122
+ ].join('; ');
123
+ return `powershell.exe -Command "${ps}"`;
124
+ }
86
125
 
87
126
  // ─── 注册 ─────────────────────────────────────────────────────────────────────
88
127
 
89
128
  async function registerContextMenu() {
90
- console.log(chalk.cyan('\n 注册 juice 右键菜单(PowerShell 7 风格)...\n'));
129
+ console.log(chalk.cyan('\n 注册 juice 右键菜单(当前用户,无需管理员权限)...\n'));
91
130
 
92
131
  const nodePath = getNodePath();
93
132
  const scriptPath = getJuiceScript();
94
133
  const iconPath = getIconPath();
95
134
 
96
- // 子命令 1:生成(标准 + 压缩)
97
- const subCmd1 = `JuiceEmail.Generate`;
98
- // 子命令 2:片段组装
99
- const subCmd2 = `JuiceEmail.Snippet`;
100
- // 子命令 3:在此打开 PowerShell(可选便利项)
101
- const subCmd3 = `JuiceEmail.OpenPwsh`;
135
+ let ok = true;
102
136
 
103
- // ── 注册子命令到 CommandStore ─────────────────────────────────────────────
137
+ // ── 子命令 1:普通模式 - juice 生成(非交互,不需终端)─────────────────
138
+ const generateCmd = `"${nodePath}" "${scriptPath}" -f %1`;
139
+ ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}`, '', 'REG_SZ', '📄 作为模板,生成邮件 HTML') && ok;
140
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}`, 'Icon', 'REG_SZ', iconPath);
141
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.generate}\\command`, '', 'REG_SZ', generateCmd);
104
142
 
105
- // 子命令 1:普通模式 - juice 生成
106
- const generateCmd = `"${nodePath}" "${scriptPath}" -f "%1"`;
107
- regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, '', 'REG_SZ', ' 生成邮件 HTML(标准 + 压缩)');
108
- regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, 'Icon', 'REG_SZ', iconPath);
109
- regAdd(`${SUBCMD_SPACE}\\${subCmd1}\\command`, '', 'REG_SZ', generateCmd);
143
+ // ── 子命令 2:片段模式 - 片段组装(交互,需要终端)────────────────────────
144
+ const snippetCmd = wrapInteractive(nodePath, scriptPath, '-s %1');
145
+ ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}`, '', 'REG_SZ', '🧩 作为片段,拼接邮件 HTML') && ok;
146
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}`, 'Icon', 'REG_SZ', iconPath);
147
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.snippet}\\command`, '', 'REG_SZ', snippetCmd);
110
148
 
111
- // 子命令 2:片段模式 - 片段组装(交互选择模板)
112
- const snippetCmd = `"${nodePath}" "${scriptPath}" -s "%1"`;
113
- regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, '', 'REG_SZ', '🧩 邮件片段组装(交互选择模板)');
114
- regAdd(`${SUBCMD_SPACE}\\${subCmd2}`, 'Icon', 'REG_SZ', iconPath);
115
- regAdd(`${SUBCMD_SPACE}\\${subCmd2}\\command`, '', 'REG_SZ', snippetCmd);
149
+ // ── 子命令 3:配置文件模式 - 交互式生成(交互,需要终端)──────────────────
150
+ const configCmd = wrapInteractive(nodePath, scriptPath, '-c %1');
151
+ ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}`, '', 'REG_SZ', '⚙️ 作为配置,拼接邮件 HTML') && ok;
152
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}`, 'Icon', 'REG_SZ', iconPath);
153
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.config}\\command`, '', 'REG_SZ', configCmd);
116
154
 
117
- // 子命令 3:在文件目录打开 pwsh(如果安装了 PowerShell 7)
155
+ // ── 子命令 4:在文件目录打开 pwsh(可选)──────────────────────────────────
118
156
  const pwshPath = resolvePwsh();
119
157
  if (pwshPath) {
120
- const pwshCmd = `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%W'"`;
121
- regAdd(`${SUBCMD_SPACE}\\${subCmd3}`, '', 'REG_SZ', '📂 在此目录打开 PowerShell 7');
122
- regAdd(`${SUBCMD_SPACE}\\${subCmd3}`, 'Icon', 'REG_SZ', pwshPath);
123
- regAdd(`${SUBCMD_SPACE}\\${subCmd3}\\command`, '', 'REG_SZ', pwshCmd);
158
+ const pwshCmd = `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath (Split-Path '%1')"`;
159
+ ok = regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}`, '', 'REG_SZ', '📂 打开 PowerShell') && ok;
160
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}`, 'Icon', 'REG_SZ', pwshPath);
161
+ regAdd(`${SUBCMD_SPACE}\\${SUBCMDS.pwsh}\\command`, '', 'REG_SZ', pwshCmd);
124
162
  }
125
163
 
126
- // ── 注册父菜单到 SystemFileAssociations ──────────────────────────────────
164
+ // ── 所有文件类型共用同一套子命令 ────────────────────────────────────────
165
+ const allSubs = pwshPath
166
+ ? `${SUBCMDS.generate};${SUBCMDS.snippet};${SUBCMDS.config};${SUBCMDS.pwsh}`
167
+ : `${SUBCMDS.generate};${SUBCMDS.snippet};${SUBCMDS.config}`;
127
168
 
128
- let subCommands = `${subCmd1};${subCmd2}`;
129
- if (pwshPath) subCommands += `;${subCmd3}`;
130
-
131
- for (const root of ROOTS) {
169
+ // .html / .htm
170
+ for (const root of HTML_ROOTS) {
132
171
  const parentKey = `${root}\\${PARENT_KEY_NAME}`;
172
+ ok = regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML') && ok;
173
+ regAdd(parentKey, 'Icon', 'REG_SZ', iconPath);
174
+ regAdd(parentKey, 'SubCommands', 'REG_SZ', allSubs);
175
+ }
133
176
 
134
- // MUIVerb:菜单显示文字
135
- regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML');
136
- // Icon:使用自定义图标
177
+ // .yaml / .yml(与 .html 共享同一套子命令)
178
+ for (const root of YAML_ROOTS) {
179
+ const parentKey = `${root}\\${PARENT_KEY_NAME}`;
180
+ ok = regAdd(parentKey, 'MUIVerb', 'REG_SZ', '📧 用 juice 生成邮件 HTML') && ok;
137
181
  regAdd(parentKey, 'Icon', 'REG_SZ', iconPath);
138
- // SubCommands:引用 CommandStore 中的子命令(分号分隔)
139
- regAdd(parentKey, 'SubCommands', 'REG_SZ', subCommands);
140
- // Extended:取消注释可让菜单仅在按住 Shift 时显示(默认不加)
141
- // regAdd(parentKey, 'Extended', 'REG_SZ', '');
182
+ regAdd(parentKey, 'SubCommands', 'REG_SZ', allSubs);
183
+ }
184
+
185
+ // ── 输出 ──────────────────────────────────────────────────────────────────
186
+ if (!ok) {
187
+ console.log(chalk.yellow(' ⚠ 部分注册表项写入失败,右键菜单可能不完整。\n'));
142
188
  }
143
189
 
144
190
  console.log(
145
191
  '\n' +
146
192
  chalk.green(' ✔ 右键菜单注册完成!') + '\n\n' +
147
- ` ${chalk.cyan('.html')} / ${chalk.cyan('.htm')} 文件上右键即可看到:\n` +
148
- ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
149
- ` ├── 生成邮件 HTML(标准 + 压缩) → juice -f\n` +
150
- ` ├── 🧩 邮件片段组装(交互选择模板) → juice -s\n` +
151
- (pwshPath ? ` └── 📂 在此目录打开 PowerShell 7\n` : '') +
193
+ ` ${chalk.bold('.html / .htm / .yaml / .yml')} 文件右键:\n` +
194
+ ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
195
+ ` ├── 📄 作为模板,生成邮件 HTML → juice -f(后台执行)\n` +
196
+ ` ├── 🧩 作为片段,拼接邮件 HTML → juice -s(交互选择模板)\n` +
197
+ ` ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n` +
198
+ (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
152
199
  '\n' +
153
200
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
154
201
  );
@@ -159,15 +206,28 @@ async function registerContextMenu() {
159
206
  async function unregisterContextMenu() {
160
207
  console.log(chalk.cyan('\n 取消注册 juice 右键菜单...\n'));
161
208
 
162
- for (const root of ROOTS) {
163
- regDelete(`${root}\\${PARENT_KEY_NAME}`);
209
+ let removed = 0;
210
+
211
+ // 清理 HTML 父菜单
212
+ for (const root of HTML_ROOTS) {
213
+ if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
164
214
  }
165
215
 
166
- regDelete(`${SUBCMD_SPACE}\\JuiceEmail.Generate`);
167
- regDelete(`${SUBCMD_SPACE}\\JuiceEmail.Snippet`);
168
- regDelete(`${SUBCMD_SPACE}\\JuiceEmail.OpenPwsh`);
216
+ // 清理 YAML 父菜单
217
+ for (const root of YAML_ROOTS) {
218
+ if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
219
+ }
169
220
 
170
- console.log(chalk.green(' ✔ 右键菜单已移除。\n'));
221
+ // 清理子命令
222
+ for (const name of Object.values(SUBCMDS)) {
223
+ regDelete(`${SUBCMD_SPACE}\\${name}`);
224
+ }
225
+
226
+ if (removed > 0) {
227
+ console.log(chalk.green(` ✔ 已移除 ${removed} 个右键菜单项。\n`));
228
+ } else {
229
+ console.log(chalk.gray(' ℹ 未找到已注册的右键菜单,无需卸载。\n'));
230
+ }
171
231
  }
172
232
 
173
233
  // ─── 工具:查找 PowerShell 7 ─────────────────────────────────────────────────
@@ -181,7 +241,6 @@ function resolvePwsh() {
181
241
  for (const p of candidates) {
182
242
  if (require('fs').existsSync(p)) return p;
183
243
  }
184
- // 尝试 where.exe
185
244
  try {
186
245
  const result = execSync('where pwsh', { stdio: 'pipe' }).toString().trim().split('\n')[0].trim();
187
246
  if (result && require('fs').existsSync(result)) return result;
package/src/index.js CHANGED
@@ -33,6 +33,10 @@ const HOME_CANDIDATES = [
33
33
  path.join(os.homedir(), 'juice.yml'),
34
34
  ];
35
35
 
36
+ function resolveHomeConfig() {
37
+ return HOME_CANDIDATES.find((c) => fs.existsSync(c)) || null;
38
+ }
39
+
36
40
  // ─── 配置文件查找 ─────────────────────────────────────────────────────────────
37
41
 
38
42
  /**
@@ -73,7 +77,7 @@ function findConfigs(configPath, inputFile) {
73
77
  }
74
78
  }
75
79
 
76
- return { highPriorityPath, homePath: HOME_CANDIDATES[0] };
80
+ return { highPriorityPath, homePath: resolveHomeConfig() };
77
81
  }
78
82
 
79
83
  // ─── 配置文件加载 ─────────────────────────────────────────────────────────────
@@ -121,14 +125,13 @@ function deepMerge(base, ...overrides) {
121
125
  * CLI 内置默认值 < 用户目录 ~/juice.yaml < 优先配置(-c/输入目录)
122
126
  */
123
127
  function buildConfig(highPriorityPath, homePath) {
124
- const homeExists = fs.existsSync(homePath);
125
128
  const layers = [];
126
129
 
127
130
  // 1. CLI 内置默认值(最低优先级)
128
131
  layers.push({ label: 'CLI 内置默认值', data: CODE_DEFAULTS });
129
132
 
130
133
  // 2. 用户目录配置(如果有)
131
- if (homeExists) {
134
+ if (homePath) {
132
135
  layers.push({ label: `用户目录配置 (${homePath})`, data: loadYaml(homePath) });
133
136
  }
134
137
 
@@ -148,11 +151,15 @@ function processTemplate(inputFile, config) {
148
151
 
149
152
  // 1. Mustache 变量替换(根据 rawHtml 配置决定是否转义 HTML)
150
153
  const originalEscape = Mustache.escape;
151
- if (config.rawHtml) {
152
- Mustache.escape = (text) => text;
154
+ let htmlWithVars;
155
+ try {
156
+ if (config.rawHtml) {
157
+ Mustache.escape = (text) => text;
158
+ }
159
+ htmlWithVars = Mustache.render(htmlRaw, config.variables || {});
160
+ } finally {
161
+ Mustache.escape = originalEscape;
153
162
  }
154
- const htmlWithVars = Mustache.render(htmlRaw, config.variables || {});
155
- Mustache.escape = originalEscape;
156
163
 
157
164
  // 2. 收集 extraCssFiles
158
165
  const basePath = path.dirname(path.resolve(inputFile));
@@ -228,10 +235,16 @@ async function run({ file, config: configPath }) {
228
235
 
229
236
  // 5. 写出标准版
230
237
  spinner.text = '写出 .output.html ...';
238
+ if (fs.existsSync(outPaths.normal)) {
239
+ spinner.warn(chalk.yellow(`目标文件已存在,将覆盖:${outPaths.normal}`));
240
+ }
231
241
  fs.writeFileSync(outPaths.normal, resultHtml, encoding);
232
242
 
233
243
  // 6. 压缩并写出
234
244
  spinner.text = '写出 .minified.html ...';
245
+ if (fs.existsSync(outPaths.minified)) {
246
+ spinner.warn(chalk.yellow(`目标文件已存在,将覆盖:${outPaths.minified}`));
247
+ }
235
248
  const minified = await minifyHtml(resultHtml, config.minify);
236
249
  fs.writeFileSync(outPaths.minified, minified, encoding);
237
250
 
@@ -266,4 +279,4 @@ function savings(original, minified) {
266
279
  return (((orig - mini) / orig) * 100).toFixed(1) + '%';
267
280
  }
268
281
 
269
- module.exports = { run, findConfigs, buildConfig, processTemplate, minifyHtml, deepMerge, collectExtraCss, loadYaml, fmtSize, savings };
282
+ module.exports = { run, findConfigs, buildConfig, processTemplate, minifyHtml, deepMerge, collectExtraCss, loadYaml, fmtSize, savings, DEFAULT_CONFIG_PATH, resolveHomeConfig };
package/src/snippet.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  minifyHtml,
14
14
  fmtSize,
15
15
  savings,
16
+ DEFAULT_CONFIG_PATH,
16
17
  } = require('./index');
17
18
 
18
19
  // ─── EDM 目录解析 ──────────────────────────────────────────────────────────────
@@ -95,8 +96,6 @@ function getBrand(filePath, edmDir) {
95
96
 
96
97
  // ─── 配置合并 ─────────────────────────────────────────────────────────────────
97
98
 
98
- const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', 'defaults', 'juice.yaml');
99
-
100
99
  /**
101
100
  * 片段模式配置合并
102
101
  *
@@ -114,8 +113,12 @@ function buildSnippetConfig({ priorityConfigPath, cliConfigPath }) {
114
113
  layers.push({ label: 'CLI 内置默认值', data: defaults });
115
114
 
116
115
  // 2. 用户主目录
117
- const homePath = path.join(os.homedir(), 'juice.yaml');
118
- if (fs.existsSync(homePath)) {
116
+ const homeCandidates = [
117
+ path.join(os.homedir(), 'juice.yaml'),
118
+ path.join(os.homedir(), 'juice.yml'),
119
+ ];
120
+ const homePath = homeCandidates.find((c) => fs.existsSync(c));
121
+ if (homePath) {
119
122
  layers.push({ label: `用户目录配置 (${homePath})`, data: loadYaml(homePath) });
120
123
  }
121
124
 
@@ -246,7 +249,7 @@ function resolveSnippetOutputPaths(outputBaseName, cwd) {
246
249
  * 3. Juice CSS 内联 → .output.html
247
250
  * 4. 压缩 → .minified.html
248
251
  */
249
- async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd, outputBaseName }) {
252
+ async function assembleSnippet({ snippetPath, templatePath, config, cwd, outputBaseName }) {
250
253
  const templateHtml = fs.readFileSync(templatePath, 'utf8');
251
254
  const snippetRaw = fs.readFileSync(snippetPath, 'utf8');
252
255
  const outPaths = resolveSnippetOutputPaths(outputBaseName, cwd);
@@ -258,11 +261,15 @@ async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd,
258
261
 
259
262
  // 2. Mustache 渲染合并 HTML → .html(已渲染,无 juice 内联)
260
263
  const originalEscape = Mustache.escape;
261
- if (config.rawHtml) {
262
- Mustache.escape = (text) => text;
264
+ let renderedHtml;
265
+ try {
266
+ if (config.rawHtml) {
267
+ Mustache.escape = (text) => text;
268
+ }
269
+ renderedHtml = Mustache.render(rawMarkup, variables);
270
+ } finally {
271
+ Mustache.escape = originalEscape;
263
272
  }
264
- const renderedHtml = Mustache.render(rawMarkup, variables);
265
- Mustache.escape = originalEscape;
266
273
  fs.writeFileSync(outPaths.normal, renderedHtml, 'utf8');
267
274
 
268
275
  // 3. 收集模板目录的额外 CSS + Juice CSS 内联 → .output.html
@@ -278,6 +285,7 @@ async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd,
278
285
  fs.writeFileSync(outPaths.minified, minified, 'utf8');
279
286
 
280
287
  // 5. 报告
288
+ const layers = config._layers || [];
281
289
  const layerLines = layers
282
290
  .map((l) => ` ${chalk.gray('·')} ${l.label}`)
283
291
  .join('\n');
@@ -479,6 +487,32 @@ async function promptOutputName(defaultBaseName, cwd) {
479
487
  return baseName;
480
488
  }
481
489
 
490
+ /**
491
+ * 无片段可用时,将模板拷贝到当前目录作为快捷副本
492
+ */
493
+ async function copyTemplateToCwd(templatePath) {
494
+ const { confirm } = await import('@inquirer/prompts');
495
+
496
+ const templateName = path.parse(templatePath).name;
497
+ console.log(chalk.cyan(`\n 可将模板「${path.basename(templatePath)}」拷贝到当前目录作为快捷副本。`));
498
+
499
+ const proceed = await confirm({
500
+ message: '是否拷贝模板到当前目录?',
501
+ default: true,
502
+ });
503
+
504
+ if (!proceed) {
505
+ console.log(chalk.gray('已取消。'));
506
+ return;
507
+ }
508
+
509
+ const baseName = await promptOutputName(templateName, process.cwd());
510
+ const destPath = path.join(process.cwd(), baseName + '.html');
511
+
512
+ fs.copyFileSync(templatePath, destPath);
513
+ console.log(chalk.green(`\n✔ 模板已拷贝到:${destPath}`));
514
+ }
515
+
482
516
  async function promptConfirm(summary) {
483
517
  const { confirm } = await import('@inquirer/prompts');
484
518
  const outPaths = resolveSnippetOutputPaths(summary.outputBaseName, summary.outputDir);
@@ -559,6 +593,7 @@ async function runSnippetMode({ snippet, template, config: cliConfigPath, output
559
593
  }
560
594
 
561
595
  const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
596
+ config._layers = layers;
562
597
 
563
598
  // 确定输出文件名
564
599
  const defaultBaseName = outputName || path.parse(templatePath).name;
@@ -595,7 +630,6 @@ async function runSnippetMode({ snippet, template, config: cliConfigPath, output
595
630
  snippetPath,
596
631
  templatePath,
597
632
  config,
598
- layers,
599
633
  cwd: process.cwd(),
600
634
  outputBaseName,
601
635
  });
@@ -621,8 +655,8 @@ async function runInteractiveMode({ config: cliConfigPath }) {
621
655
  // 3. 选择片段文件夹
622
656
  const snippetFolders = findSnippetFolders(brand.path);
623
657
  if (snippetFolders.length === 0) {
624
- console.log(chalk.yellow(`\n ⚠ 品牌「${brand.name}」下暂无片段系列,无法继续。`));
625
- console.log(chalk.gray(` 请在 edm/${brand.name}/ 下创建片段文件夹(含 HTML 和 YAML 文件)。`));
658
+ console.log(chalk.yellow(`\n ⚠ 品牌「${brand.name}」下暂无片段系列。`));
659
+ await copyTemplateToCwd(templateChoice.path);
626
660
  return;
627
661
  }
628
662
  const folder = await promptSnippetFolder(snippetFolders);
@@ -671,12 +705,12 @@ async function runInteractiveMode({ config: cliConfigPath }) {
671
705
 
672
706
  // 8. 构建配置并执行
673
707
  const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
708
+ config._layers = layers;
674
709
 
675
710
  await assembleSnippet({
676
711
  snippetPath: snippetFile.path,
677
712
  templatePath: templateChoice.path,
678
713
  config,
679
- layers,
680
714
  cwd: process.cwd(),
681
715
  outputBaseName,
682
716
  });