juice-email-cli 2.0.6 → 2.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.0.6",
3
+ "version": "2.1.2",
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": {
@@ -36,6 +36,8 @@
36
36
  "url": "https://github.com/GuoSirius/juice-cli/issues"
37
37
  },
38
38
  "dependencies": {
39
+ "@inquirer/prompts": "^5.0.2",
40
+ "chalk": "^4.1.2",
39
41
  "commander": "^11.1.0",
40
42
  "html-minifier-terser": "^7.2.0",
41
43
  "js-yaml": "^4.1.0",
@@ -50,14 +52,13 @@
50
52
  "bin/",
51
53
  "src/",
52
54
  "defaults/",
55
+ "edm/",
53
56
  "README.md"
54
57
  ],
55
58
  "devDependencies": {
56
59
  "@commitlint/cli": "^21.0.1",
57
60
  "@commitlint/config-conventional": "^21.0.1",
58
- "chalk": "^4.1.2",
59
61
  "husky": "^9.1.7",
60
- "@inquirer/prompts": "^5.0.2",
61
62
  "inquirer": "^13.4.3",
62
63
  "standard-version": "^9.5.0"
63
64
  }
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * 菜单结构:
12
12
  * 📧 用 juice 生成邮件 HTML
13
- * ├── ⚡ 生成(标准 + 压缩)
13
+ * ├── ⚡ 生成邮件 HTML(标准 + 压缩)
14
+ * ├── 🧩 邮件片段组装(交互选择模板)
14
15
  * └── 📂 在此目录打开 PowerShell
15
16
  *
16
17
  * 需要以管理员权限运行。
@@ -94,8 +95,10 @@ async function registerContextMenu() {
94
95
 
95
96
  // 子命令 1:生成(标准 + 压缩)
96
97
  const subCmd1 = `JuiceEmail.Generate`;
97
- // 子命令 2:在此打开 PowerShell(可选便利项)
98
- const subCmd2 = `JuiceEmail.OpenPwsh`;
98
+ // 子命令 2:片段组装
99
+ const subCmd2 = `JuiceEmail.Snippet`;
100
+ // 子命令 3:在此打开 PowerShell(可选便利项)
101
+ const subCmd3 = `JuiceEmail.OpenPwsh`;
99
102
 
100
103
  // ── 注册子命令到 CommandStore ─────────────────────────────────────────────
101
104
 
@@ -105,18 +108,25 @@ async function registerContextMenu() {
105
108
  regAdd(`${SUBCMD_SPACE}\\${subCmd1}`, 'Icon', 'REG_SZ', iconPath);
106
109
  regAdd(`${SUBCMD_SPACE}\\${subCmd1}\\command`, '', 'REG_SZ', generateCmd);
107
110
 
108
- // 子命令 2:在文件目录打开 pwsh(如果安装了 PowerShell 7)
111
+ // 子命令 2:片段组装
112
+ const snippetCmd = `"${nodePath}" "${scriptPath}" --snippet "%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);
116
+
117
+ // 子命令 3:在文件目录打开 pwsh(如果安装了 PowerShell 7)
109
118
  const pwshPath = resolvePwsh();
110
119
  if (pwshPath) {
111
120
  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);
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);
115
124
  }
116
125
 
117
126
  // ── 注册父菜单到 SystemFileAssociations ──────────────────────────────────
118
127
 
119
- const subCommands = pwshPath ? `${subCmd1};${subCmd2}` : subCmd1;
128
+ let subCommands = `${subCmd1};${subCmd2}`;
129
+ if (pwshPath) subCommands += `;${subCmd3}`;
120
130
 
121
131
  for (const root of ROOTS) {
122
132
  const parentKey = `${root}\\${PARENT_KEY_NAME}`;
@@ -137,6 +147,7 @@ async function registerContextMenu() {
137
147
  ` 在 ${chalk.cyan('.html')} / ${chalk.cyan('.htm')} 文件上右键即可看到:\n` +
138
148
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
139
149
  ` ├── ⚡ 生成邮件 HTML(标准 + 压缩)\n` +
150
+ ` ├── 🧩 邮件片段组装(交互选择模板)\n` +
140
151
  (pwshPath ? ` └── 📂 在此目录打开 PowerShell 7\n` : '') +
141
152
  '\n' +
142
153
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n') +
@@ -155,6 +166,7 @@ async function unregisterContextMenu() {
155
166
  }
156
167
 
157
168
  regDelete(`${SUBCMD_SPACE}\\JuiceEmail.Generate`);
169
+ regDelete(`${SUBCMD_SPACE}\\JuiceEmail.Snippet`);
158
170
  regDelete(`${SUBCMD_SPACE}\\JuiceEmail.OpenPwsh`);
159
171
 
160
172
  console.log(chalk.green(' ✔ 右键菜单已移除。\n'));
package/src/index.js CHANGED
@@ -261,4 +261,4 @@ function savings(original, minified) {
261
261
  return (((orig - mini) / orig) * 100).toFixed(1) + '%';
262
262
  }
263
263
 
264
- module.exports = { run, findConfigs, buildConfig, processTemplate };
264
+ module.exports = { run, findConfigs, buildConfig, processTemplate, minifyHtml, deepMerge, collectExtraCss, loadYaml, fmtSize, savings };
package/src/snippet.js ADDED
@@ -0,0 +1,524 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const juice = require('juice');
7
+ const Mustache = require('mustache');
8
+ const chalk = require('chalk');
9
+ const {
10
+ loadYaml,
11
+ deepMerge,
12
+ collectExtraCss,
13
+ minifyHtml,
14
+ fmtSize,
15
+ savings,
16
+ } = require('./index');
17
+
18
+ // ─── EDM 目录解析 ──────────────────────────────────────────────────────────────
19
+
20
+ function resolveEdmDir() {
21
+ // 优先:当前工作目录下的 edm/(用户自己的 EDM 资源)
22
+ const cwdEdm = path.join(process.cwd(), 'edm');
23
+ if (fs.existsSync(cwdEdm)) return cwdEdm;
24
+
25
+ // 回退:npm 全局安装时,包内置的 edm/
26
+ const pkgEdm = path.resolve(__dirname, '..', 'edm');
27
+ if (fs.existsSync(pkgEdm)) return pkgEdm;
28
+
29
+ throw new Error(
30
+ `EDM 目录不存在。\n` +
31
+ ` 已检查:${cwdEdm}\n` +
32
+ ` 已检查:${pkgEdm}\n` +
33
+ ` 请在项目根目录创建 edm/ 目录结构,或重新安装 juice-email-cli。`
34
+ );
35
+ }
36
+
37
+ function findBrands(edmDir) {
38
+ const entries = fs.readdirSync(edmDir, { withFileTypes: true });
39
+ const brands = entries
40
+ .filter((e) => e.isDirectory())
41
+ .map((e) => ({ name: e.name, path: path.join(edmDir, e.name) }));
42
+ if (brands.length === 0) {
43
+ throw new Error(`EDM 目录下未找到任何品牌子目录:${edmDir}`);
44
+ }
45
+ return brands;
46
+ }
47
+
48
+ function findSnippetFolders(brandDir) {
49
+ const entries = fs.readdirSync(brandDir, { withFileTypes: true });
50
+ return entries
51
+ .filter((e) => e.isDirectory())
52
+ .map((e) => ({ name: e.name, path: path.join(brandDir, e.name) }));
53
+ }
54
+
55
+ function findHtmlFiles(dir) {
56
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
57
+ return entries
58
+ .filter((e) => e.isFile() && /\.html?$/i.test(e.name))
59
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }));
60
+ }
61
+
62
+ function findYamlFiles(dir) {
63
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
64
+ return entries
65
+ .filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
66
+ .map((e) => ({ name: e.name, path: path.join(dir, e.name) }));
67
+ }
68
+
69
+ /**
70
+ * 在指定目录下查找本地配置文件
71
+ * 优先 juice.yaml,其次 juice.yml,都不存在返回 null
72
+ */
73
+ function findLocalConfig(dir) {
74
+ const candidates = [
75
+ path.join(dir, 'juice.yaml'),
76
+ path.join(dir, 'juice.yml'),
77
+ ];
78
+ for (const c of candidates) {
79
+ if (fs.existsSync(c)) return c;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * 从 edm 子路径中提取品牌名
86
+ * 例如:edm/elabscience/literature/snippet.html → elabscience
87
+ * 如果文件不在 edmDir 下,返回 null
88
+ */
89
+ function getBrand(filePath, edmDir) {
90
+ const rel = path.relative(edmDir, filePath);
91
+ if (rel.startsWith('..')) return null;
92
+ const parts = rel.split(path.sep);
93
+ return parts[0] || null;
94
+ }
95
+
96
+ // ─── 配置合并 ─────────────────────────────────────────────────────────────────
97
+
98
+ const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', 'defaults', 'juice.yaml');
99
+
100
+ /**
101
+ * 片段模式配置合并
102
+ *
103
+ * 优先级(低 → 高):
104
+ * 1. CLI 内置默认值(defaults/juice.yaml)
105
+ * 2. 用户主目录 ~/juice.yaml(如果存在)
106
+ * 3. 项目配置(片段目录 / 当前目录 / 手动输入 —— 由调用方传入)
107
+ * 4. -c 命令行指定(最高优先级)
108
+ */
109
+ function buildSnippetConfig({ priorityConfigPath, cliConfigPath }) {
110
+ const layers = [];
111
+
112
+ // 1. CLI 内置默认(最低优先级)
113
+ const defaults = loadYaml(DEFAULT_CONFIG_PATH);
114
+ layers.push({ label: 'CLI 内置默认值', data: defaults });
115
+
116
+ // 2. 用户主目录
117
+ const homePath = path.join(os.homedir(), 'juice.yaml');
118
+ if (fs.existsSync(homePath)) {
119
+ layers.push({ label: `用户目录配置 (${homePath})`, data: loadYaml(homePath) });
120
+ }
121
+
122
+ // 3. 项目配置(片段目录 / 当前目录 / 手动输入 —— 三选一)
123
+ if (priorityConfigPath && fs.existsSync(priorityConfigPath)) {
124
+ layers.push({ label: `项目配置 (${priorityConfigPath})`, data: loadYaml(priorityConfigPath) });
125
+ }
126
+
127
+ // 4. -c 指定(最高优先级)
128
+ if (cliConfigPath) {
129
+ const resolved = path.resolve(cliConfigPath);
130
+ if (!fs.existsSync(resolved)) {
131
+ throw new Error(`指定的配置文件不存在:${resolved}`);
132
+ }
133
+ layers.push({ label: `CLI 指定配置 (${resolved})`, data: loadYaml(resolved) });
134
+ }
135
+
136
+ const config = deepMerge(...layers.map((l) => l.data));
137
+ return { config, layers };
138
+ }
139
+
140
+ // ─── 内容插入 ─────────────────────────────────────────────────────────────────
141
+
142
+ /**
143
+ * 将 snippet HTML 插入到模板的 <tbody id="content"> 中。
144
+ * 使用深度计数算法处理嵌套 <tbody>。
145
+ */
146
+ function insertIntoContent(templateHtml, snippetHtml) {
147
+ const openRe = /<tbody[\s>][^>]*\bid\s*=\s*["']content["'][^>]*>/i;
148
+ const match = openRe.exec(templateHtml);
149
+ if (!match) {
150
+ throw new Error('模板中未找到 <tbody id="content"> 元素。模板必须包含一个带 id="content" 的 <tbody> 用于插入片段内容。');
151
+ }
152
+
153
+ const openTagEnd = match.index + match[0].length;
154
+ let depth = 1;
155
+ let searchPos = openTagEnd;
156
+
157
+ const tbodyOpenRe = /<tbody[\s>]/gi;
158
+ const tbodyCloseRe = /<\/tbody>/gi;
159
+
160
+ while (depth > 0 && searchPos < templateHtml.length) {
161
+ tbodyOpenRe.lastIndex = searchPos;
162
+ tbodyCloseRe.lastIndex = searchPos;
163
+
164
+ const nextOpen = tbodyOpenRe.exec(templateHtml);
165
+ const nextClose = tbodyCloseRe.exec(templateHtml);
166
+
167
+ if (!nextClose) break;
168
+
169
+ if (nextOpen && nextOpen.index < nextClose.index) {
170
+ depth++;
171
+ searchPos = nextOpen.index + 1;
172
+ } else {
173
+ depth--;
174
+ if (depth === 0) {
175
+ return templateHtml.substring(0, openTagEnd) + '\n' + snippetHtml + '\n' + templateHtml.substring(nextClose.index);
176
+ }
177
+ searchPos = nextClose.index + 1;
178
+ }
179
+ }
180
+
181
+ throw new Error('无法找到 id="content" 对应的闭合 </tbody> 标签,模板 HTML 可能存在标签不匹配问题。');
182
+ }
183
+
184
+ // ─── 输出路径 ─────────────────────────────────────────────────────────────────
185
+
186
+ function resolveSnippetOutputPaths(snippetPath, cwd) {
187
+ const name = path.parse(snippetPath).name;
188
+ return {
189
+ raw: path.join(cwd, name + '.raw.html'),
190
+ normal: path.join(cwd, name + '.html'),
191
+ output: path.join(cwd, name + '.output.html'),
192
+ minified: path.join(cwd, name + '.minified.html'),
193
+ };
194
+ }
195
+
196
+ // ─── 组装流水线 ───────────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * 片段组装流水线:
200
+ * 1. 未渲染的片段 + 模板 → .raw.html(Mustache 标签保留,无 juice 内联)
201
+ * 2. Mustache 渲染合并 HTML → .html(已渲染,无 juice 内联)
202
+ * 3. Juice CSS 内联 → .output.html
203
+ * 4. 压缩 → .minified.html
204
+ */
205
+ async function assembleSnippet({ snippetPath, templatePath, config, layers, cwd }) {
206
+ const templateHtml = fs.readFileSync(templatePath, 'utf8');
207
+ const snippetRaw = fs.readFileSync(snippetPath, 'utf8');
208
+ const outPaths = resolveSnippetOutputPaths(snippetPath, cwd);
209
+ const variables = config.variables || {};
210
+
211
+ // 1. 未渲染的片段插入模板 → .raw.html(Mustache 标签保留,无 juice)
212
+ const rawHtml = insertIntoContent(templateHtml, snippetRaw);
213
+ fs.writeFileSync(outPaths.raw, rawHtml, 'utf8');
214
+
215
+ // 2. Mustache 渲染合并 HTML → .html(已渲染,无 juice 内联)
216
+ const renderedHtml = Mustache.render(rawHtml, variables);
217
+ fs.writeFileSync(outPaths.normal, renderedHtml, 'utf8');
218
+
219
+ // 3. 收集模板目录的额外 CSS + Juice CSS 内联 → .output.html
220
+ const templateDir = path.dirname(templatePath);
221
+ const extraCss = collectExtraCss(templateDir, config);
222
+ const juiceOpts = Object.assign({}, config.juice || {});
223
+ delete juiceOpts.extraCssFiles;
224
+ const processed = juice(renderedHtml, { ...juiceOpts, extraCss });
225
+ fs.writeFileSync(outPaths.output, processed, 'utf8');
226
+
227
+ // 4. 压缩 → .minified.html
228
+ const minified = await minifyHtml(processed, config.minify);
229
+ fs.writeFileSync(outPaths.minified, minified, 'utf8');
230
+
231
+ // 5. 报告
232
+ const layerLines = layers
233
+ .map((l) => ` ${chalk.gray('·')} ${l.label}`)
234
+ .join('\n');
235
+
236
+ console.log(
237
+ chalk.green('\n✔ 片段组装完成') + '\n' +
238
+ ` ${chalk.bold('片段:')} ${chalk.cyan(snippetPath)}\n` +
239
+ ` ${chalk.bold('模板:')} ${chalk.cyan(templatePath)}\n` +
240
+ ` ${chalk.bold('配置层(低→高):')}\n${layerLines}\n` +
241
+ ` ${chalk.bold('输出:')}\n` +
242
+ ` ${chalk.green('·')} 原始组装 ${chalk.cyan(outPaths.raw)} ${chalk.gray('(' + fmtSize(rawHtml) + ')')}\n` +
243
+ ` ${chalk.green('·')} 已渲染 ${chalk.cyan(outPaths.normal)} ${chalk.gray('(' + fmtSize(renderedHtml) + ')')}\n` +
244
+ ` ${chalk.green('·')} 内联后 ${chalk.cyan(outPaths.output)} ${chalk.gray('(' + fmtSize(processed) + ')')}\n` +
245
+ ` ${chalk.green('·')} 压缩版 ${chalk.cyan(outPaths.minified)} ${chalk.gray('(' + fmtSize(minified) + ',节省 ' + savings(processed, minified) + ')')}`
246
+ );
247
+
248
+ return outPaths;
249
+ }
250
+
251
+ // ─── 交互式提示 ───────────────────────────────────────────────────────────────
252
+
253
+ async function promptBrand(brands) {
254
+ const { select } = await import('@inquirer/prompts');
255
+ return select({
256
+ message: '请选择品牌/系列:',
257
+ choices: brands.map((b) => ({ name: b.name, value: b })),
258
+ });
259
+ }
260
+
261
+ async function promptSnippetFolder(folders) {
262
+ const { select } = await import('@inquirer/prompts');
263
+ if (folders.length === 0) return null;
264
+ return select({
265
+ message: '请选择片段文件夹:',
266
+ choices: folders.map((f) => ({ name: f.name, value: f })),
267
+ });
268
+ }
269
+
270
+ async function promptSnippetFile(htmlFiles) {
271
+ const { select } = await import('@inquirer/prompts');
272
+ if (htmlFiles.length === 0) return null;
273
+ const defaultName = 'snippet.html';
274
+ const defaultIdx = htmlFiles.findIndex((f) => f.name === defaultName);
275
+
276
+ return select({
277
+ message: '请选择片段 HTML 文件:',
278
+ choices: htmlFiles.map((f, i) => ({
279
+ name: f.name + (i === defaultIdx ? ' (默认)' : ''),
280
+ value: f,
281
+ })),
282
+ default: defaultIdx >= 0 ? defaultIdx : 0,
283
+ });
284
+ }
285
+
286
+ /**
287
+ * 交互模式的配置选择提示
288
+ * 优先 CWD 下的配置文件,不存在则回退到片段目录下的,也可手动输入或跳过
289
+ */
290
+ async function promptConfigForInteractive(snippetDirYamlFiles) {
291
+ const { select, input } = await import('@inquirer/prompts');
292
+
293
+ // 检查当前工作目录是否有配置文件
294
+ const cwdCandidates = [
295
+ { path: path.join(process.cwd(), 'juice.yaml'), name: 'juice.yaml' },
296
+ { path: path.join(process.cwd(), 'juice.yml'), name: 'juice.yml' },
297
+ ];
298
+ const cwdConfig = cwdCandidates.find((c) => fs.existsSync(c.path));
299
+
300
+ const choices = [];
301
+ let defaultIdx = 0;
302
+
303
+ if (cwdConfig) {
304
+ choices.push({
305
+ name: `📂 当前目录配置 → ${cwdConfig.name}(优先)`,
306
+ value: { type: 'file', path: cwdConfig.path, name: cwdConfig.name },
307
+ });
308
+ }
309
+
310
+ // 片段目录下的配置文件
311
+ const defaultName = 'juice.yaml';
312
+ for (const f of snippetDirYamlFiles) {
313
+ const isDefault = f.name === defaultName;
314
+ choices.push({
315
+ name: `📄 片段目录配置 → ${f.name}${isDefault ? '(默认)' : ''}`,
316
+ value: { type: 'file', path: f.path, name: f.name },
317
+ });
318
+ if (isDefault && !cwdConfig) {
319
+ defaultIdx = choices.length - 1;
320
+ }
321
+ }
322
+
323
+ choices.push(
324
+ { name: '📁 输入自定义路径...', value: { type: 'custom' } },
325
+ { name: '⊘ 跳过(不使用项目配置)', value: { type: 'skip' } },
326
+ );
327
+
328
+ const result = await select({
329
+ message: '请选择配置文件:',
330
+ choices,
331
+ default: defaultIdx,
332
+ });
333
+
334
+ if (result.type === 'custom') {
335
+ const customPath = await input({ message: '请输入配置文件路径:' });
336
+ return { type: 'file', path: path.resolve(customPath), name: path.basename(customPath) };
337
+ }
338
+
339
+ return result;
340
+ }
341
+
342
+ async function promptTemplate(templateFiles) {
343
+ const { select } = await import('@inquirer/prompts');
344
+ if (templateFiles.length === 0) {
345
+ throw new Error('品牌目录下未找到模板 HTML 文件。');
346
+ }
347
+ const templateRe = /-template\.html?$/i;
348
+ const defaultIdx = templateFiles.findIndex((f) => templateRe.test(f.name));
349
+
350
+ return select({
351
+ message: '请选择模板 HTML 文件:',
352
+ choices: templateFiles.map((f, i) => ({
353
+ name: f.name + (i === defaultIdx ? ' (默认)' : ''),
354
+ value: f,
355
+ })),
356
+ default: defaultIdx >= 0 ? defaultIdx : 0,
357
+ });
358
+ }
359
+
360
+ async function promptConfirm(summary) {
361
+ const { confirm } = await import('@inquirer/prompts');
362
+ console.log('\n' + chalk.cyan('═══════════════════════════════════════════'));
363
+ console.log(chalk.bold(' 片段组装汇总'));
364
+ console.log(chalk.cyan('═══════════════════════════════════════════'));
365
+ console.log(` 品牌: ${chalk.green(summary.brand)}`);
366
+ console.log(` 模板 HTML: ${chalk.green(summary.templateFile)}`);
367
+ console.log(` 片段文件夹: ${chalk.green(summary.snippetFolder)}`);
368
+ console.log(` 片段 HTML: ${chalk.green(summary.snippetFile)}`);
369
+ console.log(` 配置 YAML: ${chalk.green(summary.configFile)}`);
370
+ console.log(chalk.gray('───────────────────────────────────────────'));
371
+ console.log(` 输出目录: ${chalk.cyan(summary.outputDir)}`);
372
+ console.log(` 输出文件:`);
373
+ const outPaths = resolveSnippetOutputPaths(summary.snippetPath, summary.outputDir);
374
+ console.log(` ${chalk.green('·')} ${path.basename(outPaths.raw)} ${chalk.gray('(未渲染,无 CSS 内联)')}`);
375
+ console.log(` ${chalk.green('·')} ${path.basename(outPaths.normal)} ${chalk.gray('(已渲染,无 CSS 内联)')}`);
376
+ console.log(` ${chalk.green('·')} ${path.basename(outPaths.output)} ${chalk.gray('(Juice CSS 内联)')}`);
377
+ console.log(` ${chalk.green('·')} ${path.basename(outPaths.minified)} ${chalk.gray('(压缩版)')}`);
378
+ console.log(chalk.cyan('═══════════════════════════════════════════\n'));
379
+
380
+ return confirm({
381
+ message: '确认执行片段组装?',
382
+ default: true,
383
+ });
384
+ }
385
+
386
+ // ─── 模式入口 ─────────────────────────────────────────────────────────────────
387
+
388
+ /**
389
+ * --snippet 模式:指定了片段 HTML
390
+ * - 如果同时指定了 -f(模板),直接使用
391
+ * - 如果未指定 -f,交互式选择:品牌 → 模板
392
+ * - 配置文件:自动检测片段目录下的 juice.yaml / juice.yml,-c 可覆盖
393
+ * - 合并顺序:项目默认 → 用户目录 → 片段目录配置 → CLI -c
394
+ */
395
+ async function runSnippetMode({ snippet, template, config: cliConfigPath }) {
396
+ const snippetPath = path.resolve(snippet);
397
+ if (!fs.existsSync(snippetPath)) {
398
+ console.error(chalk.red(`片段文件不存在:${snippetPath}`));
399
+ process.exit(1);
400
+ }
401
+
402
+ let templatePath;
403
+
404
+ if (template) {
405
+ templatePath = path.resolve(template);
406
+ if (!fs.existsSync(templatePath)) {
407
+ console.error(chalk.red(`模板文件不存在:${templatePath}`));
408
+ process.exit(1);
409
+ }
410
+ } else {
411
+ // 交互式选择:品牌 → 模板
412
+ const edmDir = resolveEdmDir();
413
+ const brands = findBrands(edmDir);
414
+ const brand = await promptBrand(brands);
415
+ const templateFiles = findHtmlFiles(brand.path);
416
+ const chosen = await promptTemplate(templateFiles);
417
+ templatePath = chosen.path;
418
+ }
419
+
420
+ // 自动检测片段目录下的配置文件(juice.yaml 优先,juice.yml 次之)
421
+ const snippetDir = path.dirname(snippetPath);
422
+ const priorityConfigPath = findLocalConfig(snippetDir);
423
+
424
+ // 跨品牌检查:片段和模板品牌不一致时给出警告
425
+ try {
426
+ const edmDir = resolveEdmDir();
427
+ const snippetBrand = getBrand(snippetPath, edmDir);
428
+ const templateBrand = getBrand(templatePath, edmDir);
429
+ if (snippetBrand && templateBrand && snippetBrand !== templateBrand) {
430
+ console.warn(chalk.yellow(
431
+ `\n⚠ 片段品牌「${snippetBrand}」与模板品牌「${templateBrand}」不一致,可能导致样式错乱。`
432
+ ));
433
+ }
434
+ } catch (_) {
435
+ // edm/ 不存在时跳过品牌检查(用户可能使用自定义路径)
436
+ }
437
+
438
+ const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
439
+
440
+ await assembleSnippet({
441
+ snippetPath,
442
+ templatePath,
443
+ config,
444
+ layers,
445
+ cwd: process.cwd(),
446
+ });
447
+ }
448
+
449
+ /**
450
+ * 交互模式:未指定 --snippet,未指定 -f
451
+ * 流程:品牌 → 模板 → 片段文件夹 → 片段 HTML → 配置文件 → 汇总确认 → 执行
452
+ * 配置文件:优先 CWD,不存在则用片段目录下 YAML,也可手动输入
453
+ * 合并顺序:项目默认 → 用户目录 → 项目配置(CWD/片段/手动三选一)→ CLI -c
454
+ */
455
+ async function runInteractiveMode({ config: cliConfigPath }) {
456
+ const edmDir = resolveEdmDir();
457
+
458
+ // 1. 选择品牌
459
+ const brands = findBrands(edmDir);
460
+ const brand = await promptBrand(brands);
461
+
462
+ // 2. 选择模板
463
+ const templateFiles = findHtmlFiles(brand.path);
464
+ const templateChoice = await promptTemplate(templateFiles);
465
+
466
+ // 3. 选择片段文件夹
467
+ const snippetFolders = findSnippetFolders(brand.path);
468
+ if (snippetFolders.length === 0) {
469
+ console.log(chalk.yellow(`\n ⚠ 品牌「${brand.name}」下暂无片段系列,无法继续。`));
470
+ console.log(chalk.gray(` 请在 edm/${brand.name}/ 下创建片段文件夹(含 HTML 和 YAML 文件)。`));
471
+ return;
472
+ }
473
+ const folder = await promptSnippetFolder(snippetFolders);
474
+ if (!folder) return;
475
+
476
+ // 4. 选择片段 HTML
477
+ const htmlFiles = findHtmlFiles(folder.path);
478
+ if (htmlFiles.length === 0) {
479
+ console.log(chalk.yellow(`\n ⚠ 片段系列「${folder.name}」下暂无 HTML 文件。`));
480
+ return;
481
+ }
482
+ const snippetFile = await promptSnippetFile(htmlFiles);
483
+ if (!snippetFile) return;
484
+
485
+ // 5. 选择配置文件(优先 CWD,不存在则用片段目录)
486
+ const yamlFiles = findYamlFiles(folder.path);
487
+ const configChoice = await promptConfigForInteractive(yamlFiles);
488
+
489
+ let priorityConfigPath = null;
490
+ let configFileName = '(跳过)';
491
+ if (configChoice.type === 'file') {
492
+ priorityConfigPath = configChoice.path;
493
+ configFileName = configChoice.name;
494
+ }
495
+
496
+ // 6. 汇总确认
497
+ const confirmed = await promptConfirm({
498
+ brand: brand.name,
499
+ templateFile: templateChoice.name,
500
+ snippetFolder: folder.name,
501
+ snippetFile: snippetFile.name,
502
+ configFile: configFileName,
503
+ snippetPath: snippetFile.path,
504
+ outputDir: process.cwd(),
505
+ });
506
+
507
+ if (!confirmed) {
508
+ console.log(chalk.gray('已取消。'));
509
+ return;
510
+ }
511
+
512
+ // 7. 构建配置并执行
513
+ const { config, layers } = buildSnippetConfig({ priorityConfigPath, cliConfigPath });
514
+
515
+ await assembleSnippet({
516
+ snippetPath: snippetFile.path,
517
+ templatePath: templateChoice.path,
518
+ config,
519
+ layers,
520
+ cwd: process.cwd(),
521
+ });
522
+ }
523
+
524
+ module.exports = { runSnippetMode, runInteractiveMode };