juice-email-cli 2.3.5 → 2.3.7

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/bin/juice.js CHANGED
@@ -5,6 +5,29 @@ const { program } = require('commander');
5
5
  const path = require('path');
6
6
  const pkg = require('../package.json');
7
7
  const { run } = require('../src/index');
8
+ let ExitPromptError;
9
+ try {
10
+ ({ ExitPromptError } = require('@inquirer/core'));
11
+ } catch (_) {}
12
+
13
+ /**
14
+ * Wrap an async action handler to catch Ctrl+C and other errors gracefully.
15
+ */
16
+ function safeAction(fn) {
17
+ return async (...args) => {
18
+ try {
19
+ await fn(...args);
20
+ } catch (err) {
21
+ if (ExitPromptError && err instanceof ExitPromptError) {
22
+ // Ctrl+C — exit silently
23
+ process.exit(0);
24
+ }
25
+ console.error('\n' + require('chalk').red(` ✘ ${err.message}`));
26
+ if (process.env.DEBUG) console.error(err);
27
+ process.exit(1);
28
+ }
29
+ };
30
+ }
8
31
 
9
32
  program
10
33
  .name('juice')
@@ -87,7 +110,7 @@ program
87
110
  .option('--templates', '列出所有模板')
88
111
  .option('--series', '列出所有系列')
89
112
  .option('--snippets', '列出所有片段')
90
- .action(async (viewPath, options) => {
113
+ .action(safeAction(async (viewPath, options) => {
91
114
  const { runViewMode } = require('../src/view');
92
115
  await runViewMode({
93
116
  viewPath: viewPath || null,
@@ -96,7 +119,7 @@ program
96
119
  : (options.series ? 'series'
97
120
  : (options.snippets ? 'snippets' : null)),
98
121
  });
99
- });
122
+ }));
100
123
 
101
124
  // ─── Subcommand: juice init ─────────────────────────────────────────────
102
125
  program
@@ -106,7 +129,7 @@ program
106
129
  .option('-s, --snippet <file-path>', '仅拷贝片段 HTML 文件')
107
130
  .option('-c, --config <file-path>', '仅拷贝配置 YAML 文件')
108
131
  .option('--all [target]', '拷贝整个 EDM 资源目录到当前或指定目录')
109
- .action(async (initPath, options) => {
132
+ .action(safeAction(async (initPath, options) => {
110
133
  const { runInitMode } = require('../src/init');
111
134
  await runInitMode({
112
135
  initPath: initPath || null,
@@ -115,11 +138,11 @@ program
115
138
  config: options.config || null,
116
139
  all: options.all !== undefined ? (options.all || true) : null,
117
140
  });
118
- });
141
+ }));
119
142
 
120
143
  // ─── Default action (backward compatible) ───────────────────────────────
121
144
  program
122
- .action(async (options) => {
145
+ .action(safeAction(async (options) => {
123
146
  // 注册/卸载右键菜单
124
147
  if (options.install) {
125
148
  const { registerContextMenu } = require('../src/context-menu');
@@ -161,7 +184,7 @@ program
161
184
  // 全交互模式:无 --snippet,无 -f
162
185
  const { runInteractiveMode } = require('../src/snippet');
163
186
  await runInteractiveMode({ config: options.config });
164
- });
187
+ }));
165
188
 
166
189
  program.allowUnknownOption(false);
167
190
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.3.5",
3
+ "version": "2.3.7",
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": {
package/src/init.js CHANGED
@@ -6,8 +6,7 @@ const chalk = require('chalk');
6
6
  const {
7
7
  resolveEdmDir, loadMeta, findBrands, findTemplateVersions,
8
8
  findSeriesDirs, findSnippetVariants, findConfigs,
9
- promptBrand, promptTemplateVersion, promptSeries, promptSnippetVariant,
10
- promptOutputName, checkOutputConflicts, findNextVersion,
9
+ promptOutputName,
11
10
  } = require('./snippet');
12
11
 
13
12
  function fmtBytes(b) {
@@ -90,141 +89,203 @@ async function directCopy(srcPath, cwd) {
90
89
 
91
90
  // ─── Interactive Init ────────────────────────────────────────────────────────
92
91
 
92
+ /**
93
+ * Wrap @inquirer/prompts select with back/exit navigation.
94
+ * Returns the chosen value, 'back', or 'exit'.
95
+ */
96
+ async function selectWithNav(choices, showBack) {
97
+ const { select } = await import('@inquirer/prompts');
98
+ const all = [];
99
+ if (showBack) all.push({ name: '.. 返回上级', value: 'back' });
100
+ all.push(...choices);
101
+ all.push({ name: '✕ 退出', value: 'exit' });
102
+ return select({ message: choices[0] ? undefined : '', choices: all, loop: false });
103
+ }
104
+
93
105
  async function interactiveInit(edmDir, cwd) {
94
- const brands = findBrands(edmDir);
95
- const brand = await promptBrand(brands);
96
- const versions = findTemplateVersions(brand.path);
97
- const version = await promptTemplateVersion(versions);
98
-
99
- // Series selection
100
- const allSeries = findSeriesDirs(brand.path);
101
- let series = null;
102
- let variant = null;
103
-
104
- if (allSeries.length > 0) {
105
- const { select } = await import('@inquirer/prompts');
106
- const seriesChoices = [
107
- { name: '[跳过] 仅使用模板', value: null },
108
- ...allSeries.map(s => {
109
- const meta = s.meta;
110
- const display = meta.name
111
- ? formatName(meta, s.name)
112
- : chalk.bold.cyan(s.name);
113
- return {
114
- name: display,
106
+ let step = 'brand';
107
+ let brand = null, version = null, series = null, variant = null;
108
+
109
+ while (true) {
110
+ if (step === 'brand') {
111
+ const brands = findBrands(edmDir);
112
+ const choices = brands.map(b => ({
113
+ name: formatName(b.meta, b.name) + (b.meta.description ? ' — ' + chalk.dim(b.meta.description) : ''),
114
+ value: b,
115
+ }));
116
+ const result = await selectWithNav(choices, false);
117
+ if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
118
+ brand = result;
119
+ step = 'version';
120
+ continue;
121
+ }
122
+
123
+ if (step === 'version') {
124
+ const versions = findTemplateVersions(brand.path);
125
+ const choices = versions.map(v => ({
126
+ name: formatName(v.meta, v.name) + (v.meta.description ? ' — ' + chalk.dim(v.meta.description) : ''),
127
+ value: v,
128
+ }));
129
+ const result = await selectWithNav(choices, true);
130
+ if (result === 'back') { step = 'brand'; continue; }
131
+ if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
132
+ version = result;
133
+ step = 'series';
134
+ continue;
135
+ }
136
+
137
+ if (step === 'series') {
138
+ const allSeries = findSeriesDirs(brand.path);
139
+ if (allSeries.length === 0) {
140
+ series = null;
141
+ step = 'copy';
142
+ continue;
143
+ }
144
+ const choices = [
145
+ { name: '[跳过] 仅使用模板', value: null },
146
+ ...allSeries.map(s => ({
147
+ name: formatName(s.meta, s.name) + (s.meta.description ? ' — ' + chalk.dim(s.meta.description) : ''),
115
148
  value: s,
116
- description: meta.description || undefined,
117
- };
118
- }),
119
- ];
120
- series = await select({
121
- message: '选择片段系列(可选):',
122
- choices: seriesChoices,
123
- });
124
-
125
- if (series) {
149
+ })),
150
+ ];
151
+ const result = await selectWithNav(choices, true);
152
+ if (result === 'back') { step = 'version'; continue; }
153
+ if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
154
+ series = result;
155
+ step = series ? 'variant' : 'copy';
156
+ continue;
157
+ }
158
+
159
+ if (step === 'variant') {
126
160
  const variants = findSnippetVariants(series.path);
127
- if (variants.length > 0) {
128
- variant = await promptSnippetVariant(variants);
161
+ if (variants.length === 0) {
162
+ variant = null;
163
+ step = 'copy';
164
+ continue;
129
165
  }
166
+ const choices = variants.map(v => ({
167
+ name: formatName(v.meta, v.name) + (v.meta.description ? ' — ' + chalk.dim(v.meta.description) : ''),
168
+ value: v,
169
+ }));
170
+ const result = await selectWithNav(choices, true);
171
+ if (result === 'back') { step = 'series'; continue; }
172
+ if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
173
+ variant = result;
174
+ step = 'copy';
175
+ continue;
130
176
  }
131
- }
132
177
 
133
- // Multi-select what to copy
134
- const copyItems = [];
135
- copyItems.push({
136
- name: '📋 模板 HTML',
137
- value: 'template',
138
- description: path.basename(version.templatePath),
139
- checked: true,
140
- });
141
-
142
- if (variant) {
143
- const snipPath = path.join(variant.path, 'snippet.html');
144
- if (fs.existsSync(snipPath)) {
178
+ if (step === 'copy') {
179
+ // Multi-select what to copy
180
+ const copyItems = [];
145
181
  copyItems.push({
146
- name: '🧩 片段 HTML',
147
- value: 'snippet',
148
- description: path.basename(snipPath),
182
+ name: `📋 模板 HTML(${version.meta.name || version.name})`,
183
+ value: 'template',
184
+ description: path.basename(version.templatePath),
149
185
  checked: true,
150
186
  });
151
- }
152
187
 
153
- const configs = findConfigs(variant.path);
154
- if (configs.length > 0) {
155
- copyItems.push({
156
- name: '⚙️ 配置文件',
157
- value: 'config',
158
- description: configs.map(c => c.name).join(', '),
159
- checked: true,
188
+ if (variant) {
189
+ const snipPath = path.join(variant.path, 'snippet.html');
190
+ if (fs.existsSync(snipPath)) {
191
+ copyItems.push({
192
+ name: '🧩 片段 HTML',
193
+ value: 'snippet',
194
+ description: path.basename(snipPath),
195
+ checked: true,
196
+ });
197
+ }
198
+ const configs = findConfigs(variant.path);
199
+ if (configs.length > 0) {
200
+ copyItems.push({
201
+ name: '⚙️ 配置文件',
202
+ value: 'config',
203
+ description: configs.map(c => c.name).join(', '),
204
+ checked: true,
205
+ });
206
+ }
207
+ }
208
+
209
+ // Show summary before multi-select
210
+ console.log(chalk.dim(`\n 品牌:${brand.meta.name || brand.name} → 模板:${version.meta.name || version.name}`));
211
+ if (series) console.log(chalk.dim(` 系列:${series.meta.name || series.name}${variant ? ' → 变体:' + (variant.meta.name || variant.name) : ''}`));
212
+
213
+ const { checkbox } = await import('@inquirer/prompts');
214
+ const navCopyItems = [
215
+ ...copyItems,
216
+ { name: '.. 返回上级', value: 'back' },
217
+ { name: '✕ 退出', value: 'exit' },
218
+ ];
219
+ const selected = await checkbox({
220
+ message: '选择要拷贝的内容:',
221
+ choices: navCopyItems,
160
222
  });
161
- }
162
- }
163
223
 
164
- const { checkbox } = await import('@inquirer/prompts');
165
- const selected = await checkbox({
166
- message: '选择要拷贝的内容:',
167
- choices: copyItems,
168
- });
224
+ // Filter out nav values
225
+ if (selected.includes('exit')) { console.log(chalk.gray('已退出。\n')); return; }
226
+ if (selected.includes('back')) {
227
+ step = series ? 'variant' : 'series';
228
+ continue;
229
+ }
169
230
 
170
- if (selected.length === 0) {
171
- console.log(chalk.gray('未选择任何内容,已取消。\n'));
172
- return;
173
- }
231
+ const realSelected = selected.filter(s => s !== 'back' && s !== 'exit');
232
+ if (realSelected.length === 0) {
233
+ console.log(chalk.gray('未选择任何内容,已跳过。\n'));
234
+ return;
235
+ }
174
236
 
175
- // Output name
176
- const defaultBaseName = variant
177
- ? `${brand.name}-${version.name}-${series.name}-${variant.name}`
178
- : `${brand.name}-${version.name}`;
179
- const outputBaseName = await promptOutputName(defaultBaseName, cwd);
237
+ // Output name
238
+ const defaultBaseName = variant
239
+ ? `${brand.name}-${version.name}-${series.name}-${variant.name}`
240
+ : `${brand.name}-${version.name}`;
241
+ const outputBaseName = await promptOutputName(defaultBaseName, cwd);
180
242
 
181
- // Copy files
182
- console.log(chalk.green('\n✔ 已拷贝:'));
183
- const cwdRel = (p) => './' + path.relative(cwd, p);
243
+ // Copy files
244
+ console.log(chalk.green('\n✔ 已拷贝:'));
245
+ const cwdRel = (p) => './' + path.relative(cwd, p);
184
246
 
185
- if (selected.includes('template')) {
186
- const destName = outputBaseName + path.extname(version.templatePath);
187
- const dest = copyFileToCwd(version.templatePath, cwd, destName);
188
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
189
- }
247
+ if (realSelected.includes('template')) {
248
+ const destName = outputBaseName + path.extname(version.templatePath);
249
+ const dest = copyFileToCwd(version.templatePath, cwd, destName);
250
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
251
+ // Auto-copy icon
252
+ const icon = copyIcon(variant ? variant.path : null, version.path, brand.path, cwd);
253
+ if (icon) console.log(` ${chalk.cyan('·')} ${cwdRel(icon)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(icon).size) + ')')}`);
254
+ }
190
255
 
191
- if (selected.includes('snippet') && variant) {
192
- const snipPath = path.join(variant.path, 'snippet.html');
193
- const destName = outputBaseName + '-snippet' + path.extname(snipPath);
194
- const dest = copyFileToCwd(snipPath, cwd, destName);
195
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
196
- }
256
+ if (realSelected.includes('snippet') && variant) {
257
+ const snipPath = path.join(variant.path, 'snippet.html');
258
+ const destName = outputBaseName + '-snippet' + path.extname(snipPath);
259
+ const dest = copyFileToCwd(snipPath, cwd, destName);
260
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
261
+ }
197
262
 
198
- if (selected.includes('config') && variant) {
199
- const configs = findConfigs(variant.path);
200
- let cfgPath;
201
- if (configs.length === 1) {
202
- cfgPath = configs[0].path;
203
- } else {
204
- // Use optimal config
205
- const optimal = configs.find(c => c.isOptimal);
206
- cfgPath = optimal ? optimal.path : configs[0].path;
207
- }
208
- const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
209
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
210
- }
263
+ if (realSelected.includes('config') && variant) {
264
+ const configs = findConfigs(variant.path);
265
+ const optimal = configs.find(c => c.isOptimal);
266
+ const cfgPath = optimal ? optimal.path : configs[0].path;
267
+ const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
268
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
269
+ }
211
270
 
212
- // Summary
213
- console.log();
214
- if (selected.includes('snippet') && selected.includes('template')) {
215
- const snipFile = outputBaseName + '-snippet' + path.extname(path.join(variant.path, 'snippet.html'));
216
- const tplFile = outputBaseName + path.extname(version.templatePath);
217
- console.log(
218
- ' ' + chalk.dim('💡 下一步:') + '\n' +
219
- ' ' + chalk.cyan(`juice -s ${snipFile} -f ${tplFile}`) +
220
- (selected.includes('config') ? chalk.cyan(' -c juice.yaml') : '') + '\n'
221
- );
222
- } else if (selected.includes('template')) {
223
- const tplFile = outputBaseName + path.extname(version.templatePath);
224
- console.log(
225
- ' ' + chalk.dim('💡 下一步:') + '\n' +
226
- ' ' + chalk.cyan(`juice -f ${tplFile}`) + '\n'
227
- );
271
+ console.log();
272
+ if (realSelected.includes('snippet') && realSelected.includes('template')) {
273
+ const snipFile = outputBaseName + '-snippet' + path.extname(path.join(variant.path, 'snippet.html'));
274
+ const tplFile = outputBaseName + path.extname(version.templatePath);
275
+ console.log(
276
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
277
+ ' ' + chalk.cyan(`juice -s ${snipFile} -f ${tplFile}`) +
278
+ (realSelected.includes('config') ? chalk.cyan(' -c juice.yaml') : '') + '\n'
279
+ );
280
+ } else if (realSelected.includes('template')) {
281
+ const tplFile = outputBaseName + path.extname(version.templatePath);
282
+ console.log(
283
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
284
+ ' ' + chalk.cyan(`juice -f ${tplFile}`) + '\n'
285
+ );
286
+ }
287
+ return;
288
+ }
228
289
  }
229
290
  }
230
291
 
@@ -268,6 +329,30 @@ function countFiles(dir) {
268
329
  return count;
269
330
  }
270
331
 
332
+ /**
333
+ * Find and copy favicon.ico alongside the template.
334
+ * Lookup: variant dir → template version dir → brand's first template → skip
335
+ */
336
+ function copyIcon(variantDir, versionDir, brandDir, cwd) {
337
+ const candidates = [];
338
+ if (variantDir) candidates.push(path.join(variantDir, 'favicon.ico'));
339
+ if (versionDir) candidates.push(path.join(versionDir, 'favicon.ico'));
340
+ // First template version in brand
341
+ try {
342
+ const versions = findTemplateVersions(brandDir);
343
+ if (versions.length > 0) {
344
+ candidates.push(path.join(versions[0].path, 'favicon.ico'));
345
+ }
346
+ } catch (_) {}
347
+ for (const src of candidates) {
348
+ if (fs.existsSync(src)) {
349
+ const dest = copyFileToCwd(src, cwd, 'favicon.ico');
350
+ return dest;
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+
271
356
  function findNextEdmVersion(targetDir) {
272
357
  let v = 1;
273
358
  while (true) {
@@ -308,12 +393,12 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
308
393
  message: `目录 ${destDir} 已存在:`,
309
394
  choices: [
310
395
  { name: '覆盖', value: 'overwrite' },
311
- { name: `自动版本号(${path.basename(vName)})`, value: 'version' },
312
- { name: '取消', value: 'cancel' },
396
+ { name: `版本(${path.basename(vName)})`, value: 'version' },
397
+ { name: '跳过', value: 'skip' },
313
398
  ],
314
399
  });
315
- if (action === 'cancel') {
316
- console.log(chalk.gray('已取消。\n'));
400
+ if (action === 'skip') {
401
+ console.log(chalk.gray('已跳过。\n'));
317
402
  return;
318
403
  }
319
404
  if (action === 'version') {
@@ -409,6 +494,8 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
409
494
  const destName = outputBaseName + path.extname(tplPath);
410
495
  const dest = copyFileToCwd(tplPath, cwd, destName);
411
496
  console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
497
+ const icon = copyIcon(parsed.variantData.path, version.path, brandDir, cwd);
498
+ if (icon) console.log(` ${chalk.cyan('·')} ${cwdRel(icon)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(icon).size) + ')')}`);
412
499
  }
413
500
  if (selected.includes('snippet') && fs.existsSync(snipPath)) {
414
501
  const destName = outputBaseName + '-snippet' + path.extname(snipPath);
package/src/view.js CHANGED
@@ -627,6 +627,18 @@ async function interactiveBrowse(edmDir, startParsed) {
627
627
  const dest = path.join(cwd, outputBaseName + path.extname(src));
628
628
  const actual = copyFileSafe(src, dest);
629
629
  console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
630
+ // Auto-copy icon: variant dir → template version dir → first version in brand
631
+ const iconSrcs = [
632
+ path.join(current.variantData.path, 'favicon.ico'),
633
+ path.join(versions[0].path, 'favicon.ico'),
634
+ ];
635
+ for (const isrc of iconSrcs) {
636
+ if (fs.existsSync(isrc)) {
637
+ const idest = copyFileSafe(isrc, path.join(cwd, 'favicon.ico'));
638
+ console.log(` ${chalk.cyan('·')} ${cwdRel(idest)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(idest).size) + ')')}`);
639
+ break;
640
+ }
641
+ }
630
642
  }
631
643
  if (selected.includes('snippet') && fs.existsSync(snipPath)) {
632
644
  const dest = path.join(cwd, outputBaseName + '-snippet' + path.extname(snipPath));