juice-email-cli 2.3.7 → 2.3.9

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/init.js +93 -108
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
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
@@ -29,7 +29,6 @@ function formatName(meta, dirName) {
29
29
  function copyFileToCwd(srcPath, cwd, destName) {
30
30
  const dest = path.join(cwd, destName || path.basename(srcPath));
31
31
  if (fs.existsSync(dest)) {
32
- // Find a non-conflicting name
33
32
  const parsed = path.parse(dest);
34
33
  let v = 1;
35
34
  while (true) {
@@ -50,20 +49,12 @@ function copyFileToCwd(srcPath, cwd, destName) {
50
49
 
51
50
  // ─── Direct Copy (--template / --snippet / --config) ─────────────────────────
52
51
 
53
- /**
54
- * Derive a human-friendly default name from an EDM file path.
55
- * e.g. edm/elabscience/templates/standard/template.html → elabscience-standard
56
- * edm/elabscience/series/literature/default/snippet.html → elabscience-literature-default-snippet
57
- */
58
52
  function deriveDefaultName(filePath) {
59
53
  const normalized = filePath.replace(/\\/g, '/');
60
- // Try to match edm/<brand>/templates/<version>/...
61
54
  const tplMatch = normalized.match(/edm\/([^/]+)\/templates\/([^/]+)/);
62
55
  if (tplMatch) return `${tplMatch[1]}-${tplMatch[2]}`;
63
- // Try to match edm/<brand>/series/<series>/<variant>/...
64
56
  const snipMatch = normalized.match(/edm\/([^/]+)\/series\/([^/]+)\/([^/]+)/);
65
57
  if (snipMatch) return `${snipMatch[1]}-${snipMatch[2]}-${snipMatch[3]}`;
66
- // Fallback: source file basename
67
58
  return path.parse(filePath).name;
68
59
  }
69
60
 
@@ -89,17 +80,13 @@ async function directCopy(srcPath, cwd) {
89
80
 
90
81
  // ─── Interactive Init ────────────────────────────────────────────────────────
91
82
 
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) {
83
+ async function selectWithNav(message, choices, showBack) {
97
84
  const { select } = await import('@inquirer/prompts');
98
85
  const all = [];
99
86
  if (showBack) all.push({ name: '.. 返回上级', value: 'back' });
100
87
  all.push(...choices);
101
88
  all.push({ name: '✕ 退出', value: 'exit' });
102
- return select({ message: choices[0] ? undefined : '', choices: all, loop: false });
89
+ return select({ message, choices: all, loop: false });
103
90
  }
104
91
 
105
92
  async function interactiveInit(edmDir, cwd) {
@@ -113,7 +100,7 @@ async function interactiveInit(edmDir, cwd) {
113
100
  name: formatName(b.meta, b.name) + (b.meta.description ? ' — ' + chalk.dim(b.meta.description) : ''),
114
101
  value: b,
115
102
  }));
116
- const result = await selectWithNav(choices, false);
103
+ const result = await selectWithNav('选择品牌:', choices, false);
117
104
  if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
118
105
  brand = result;
119
106
  step = 'version';
@@ -126,7 +113,7 @@ async function interactiveInit(edmDir, cwd) {
126
113
  name: formatName(v.meta, v.name) + (v.meta.description ? ' — ' + chalk.dim(v.meta.description) : ''),
127
114
  value: v,
128
115
  }));
129
- const result = await selectWithNav(choices, true);
116
+ const result = await selectWithNav('选择模板版本:', choices, true);
130
117
  if (result === 'back') { step = 'brand'; continue; }
131
118
  if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
132
119
  version = result;
@@ -148,7 +135,7 @@ async function interactiveInit(edmDir, cwd) {
148
135
  value: s,
149
136
  })),
150
137
  ];
151
- const result = await selectWithNav(choices, true);
138
+ const result = await selectWithNav('选择片段系列:', choices, true);
152
139
  if (result === 'back') { step = 'version'; continue; }
153
140
  if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
154
141
  series = result;
@@ -167,7 +154,7 @@ async function interactiveInit(edmDir, cwd) {
167
154
  name: formatName(v.meta, v.name) + (v.meta.description ? ' — ' + chalk.dim(v.meta.description) : ''),
168
155
  value: v,
169
156
  }));
170
- const result = await selectWithNav(choices, true);
157
+ const result = await selectWithNav('选择片段变体:', choices, true);
171
158
  if (result === 'back') { step = 'series'; continue; }
172
159
  if (result === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
173
160
  variant = result;
@@ -176,7 +163,6 @@ async function interactiveInit(edmDir, cwd) {
176
163
  }
177
164
 
178
165
  if (step === 'copy') {
179
- // Multi-select what to copy
180
166
  const copyItems = [];
181
167
  copyItems.push({
182
168
  name: `📋 模板 HTML(${version.meta.name || version.name})`,
@@ -206,85 +192,97 @@ async function interactiveInit(edmDir, cwd) {
206
192
  }
207
193
  }
208
194
 
209
- // Show summary before multi-select
210
195
  console.log(chalk.dim(`\n 品牌:${brand.meta.name || brand.name} → 模板:${version.meta.name || version.name}`));
211
196
  if (series) console.log(chalk.dim(` 系列:${series.meta.name || series.name}${variant ? ' → 变体:' + (variant.meta.name || variant.name) : ''}`));
212
197
 
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,
222
- });
198
+ // Loop: select → checkbox confirm, allow back/modify at any point
199
+ let selected = copyItems.filter(c => c.checked).map(c => c.value);
200
+ while (true) {
201
+ const picked = selected
202
+ .map(v => copyItems.find(c => c.value === v))
203
+ .filter(Boolean)
204
+ .map(c => c.name.replace(/^[^\s]+\s/, ''));
205
+ const summary = picked.length > 0 ? picked.join(' + ') : '(未选择)';
206
+ const mainChoices = [];
207
+ if (picked.length > 0) {
208
+ mainChoices.push({ name: `✅ 确认拷贝(${summary})`, value: 'confirm' });
209
+ }
210
+ mainChoices.push({ name: `🔄 修改选择(${summary})`, value: 'edit' });
223
211
 
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
- }
212
+ const action = await selectWithNav(
213
+ '拷贝操作:',
214
+ mainChoices,
215
+ true
216
+ );
217
+ if (action === 'back') { step = series ? 'variant' : 'series'; break; }
218
+ if (action === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
219
+ if (action === 'edit') {
220
+ const { checkbox } = await import('@inquirer/prompts');
221
+ selected = await checkbox({
222
+ message: '选择要拷贝的内容:',
223
+ choices: copyItems,
224
+ });
225
+ if (selected.length === 0) { console.log(chalk.dim(' (已清空选择)\n')); }
226
+ continue;
227
+ }
230
228
 
231
- const realSelected = selected.filter(s => s !== 'back' && s !== 'exit');
232
- if (realSelected.length === 0) {
233
- console.log(chalk.gray('未选择任何内容,已跳过。\n'));
234
- return;
235
- }
229
+ if (selected.length === 0) {
230
+ console.log(chalk.gray('未选择任何内容。\n'));
231
+ step = series ? 'variant' : 'series';
232
+ break;
233
+ }
236
234
 
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);
235
+ const defaultBaseName = variant
236
+ ? `${brand.name}-${version.name}-${series.name}-${variant.name}`
237
+ : `${brand.name}-${version.name}`;
238
+ const outputBaseName = await promptOutputName(defaultBaseName, cwd);
242
239
 
243
- // Copy files
244
- console.log(chalk.green('\n✔ 已拷贝:'));
245
- const cwdRel = (p) => './' + path.relative(cwd, p);
240
+ console.log(chalk.green('\n✔ 已拷贝:'));
241
+ const cwdRel = (p) => './' + path.relative(cwd, p);
246
242
 
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
- }
243
+ if (selected.includes('template')) {
244
+ const destName = outputBaseName + path.extname(version.templatePath);
245
+ const dest = copyFileToCwd(version.templatePath, cwd, destName);
246
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
247
+ const icon = copyIcon(variant ? variant.path : null, version.path, brand.path, cwd);
248
+ if (icon) console.log(` ${chalk.cyan('·')} ${cwdRel(icon)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(icon).size) + ')')}`);
249
+ }
255
250
 
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
- }
251
+ if (selected.includes('snippet') && variant) {
252
+ const snipPath = path.join(variant.path, 'snippet.html');
253
+ const destName = outputBaseName + '-snippet' + path.extname(snipPath);
254
+ const dest = copyFileToCwd(snipPath, cwd, destName);
255
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
256
+ }
262
257
 
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
- }
258
+ if (selected.includes('config') && variant) {
259
+ const configs = findConfigs(variant.path);
260
+ const optimal = configs.find(c => c.isOptimal);
261
+ const cfgPath = optimal ? optimal.path : configs[0].path;
262
+ const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
263
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
264
+ }
270
265
 
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
- );
266
+ console.log();
267
+ if (selected.includes('snippet') && selected.includes('template')) {
268
+ const snipFile = outputBaseName + '-snippet' + path.extname(path.join(variant.path, 'snippet.html'));
269
+ const tplFile = outputBaseName + path.extname(version.templatePath);
270
+ console.log(
271
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
272
+ ' ' + chalk.cyan(`juice -s ${snipFile} -f ${tplFile}`) +
273
+ (selected.includes('config') ? chalk.cyan(' -c juice.yaml') : '') + '\n'
274
+ );
275
+ } else if (selected.includes('template')) {
276
+ const tplFile = outputBaseName + path.extname(version.templatePath);
277
+ console.log(
278
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
279
+ ' ' + chalk.cyan(`juice -f ${tplFile}`) + '\n'
280
+ );
281
+ }
282
+ break;
286
283
  }
287
- return;
284
+ if (step === 'copy') return; // confirmed and copied
285
+ continue; // went back
288
286
  }
289
287
  }
290
288
  }
@@ -329,15 +327,10 @@ function countFiles(dir) {
329
327
  return count;
330
328
  }
331
329
 
332
- /**
333
- * Find and copy favicon.ico alongside the template.
334
- * Lookup: variant dir → template version dir → brand's first template → skip
335
- */
336
330
  function copyIcon(variantDir, versionDir, brandDir, cwd) {
337
331
  const candidates = [];
338
332
  if (variantDir) candidates.push(path.join(variantDir, 'favicon.ico'));
339
333
  if (versionDir) candidates.push(path.join(versionDir, 'favicon.ico'));
340
- // First template version in brand
341
334
  try {
342
335
  const versions = findTemplateVersions(brandDir);
343
336
  if (versions.length > 0) {
@@ -365,32 +358,29 @@ function findNextEdmVersion(targetDir) {
365
358
  async function runInitMode({ initPath, template, snippet, config, all }) {
366
359
  const cwd = process.cwd();
367
360
 
368
- // --all: copy entire EDM directory
369
361
  if (all) {
370
- let edmDir;
371
- try {
372
- edmDir = resolveEdmDir();
373
- } catch (err) {
374
- console.error(chalk.red(`\n ✘ ${err.message}\n`));
362
+ const pkgEdm = path.resolve(__dirname, '..', 'edm');
363
+ const edmDir = fs.existsSync(pkgEdm) ? pkgEdm : (() => {
364
+ try { return resolveEdmDir(); } catch (_) { return null; }
365
+ })();
366
+ if (!edmDir) {
367
+ console.error(chalk.red('\n ✘ EDM 资源库不存在,无法拷贝。\n'));
375
368
  process.exit(1);
376
369
  }
377
370
  const target = all === true ? cwd : path.resolve(all);
378
371
  const destDir = path.join(target, 'edm');
379
372
 
380
- // Prevent deleting the source when target overlaps
381
373
  if (path.resolve(edmDir) === path.resolve(destDir)) {
382
374
  console.log(chalk.yellow('目标目录与 EDM 资源库相同,无需拷贝。\n'));
383
375
  return;
384
376
  }
385
377
  console.log(chalk.cyan(`\n 拷贝 EDM 资源库...`));
386
- console.log(chalk.gray(` 源:${edmDir}`));
387
- console.log(chalk.gray(` 目标:${destDir}`));
388
378
 
389
379
  if (fs.existsSync(destDir)) {
390
380
  const { select } = await import('@inquirer/prompts');
391
381
  const vName = findNextEdmVersion(target);
392
382
  const action = await select({
393
- message: `目录 ${destDir} 已存在:`,
383
+ message: `目录 edm/ 已存在:`,
394
384
  choices: [
395
385
  { name: '覆盖', value: 'overwrite' },
396
386
  { name: `版本(${path.basename(vName)})`, value: 'version' },
@@ -408,7 +398,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
408
398
  console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库 → ${path.basename(vName)}(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
409
399
  return;
410
400
  }
411
- // overwrite: remove and replace
412
401
  fs.rmSync(destDir, { recursive: true, force: true });
413
402
  }
414
403
 
@@ -419,7 +408,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
419
408
  return;
420
409
  }
421
410
 
422
- // Direct copy modes
423
411
  if (template) {
424
412
  await directCopy(template, cwd);
425
413
  return;
@@ -433,7 +421,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
433
421
  return;
434
422
  }
435
423
 
436
- // Path-based from EDM
437
424
  if (initPath) {
438
425
  const { parseViewPath } = require('./view');
439
426
  let edmDir;
@@ -455,12 +442,11 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
455
442
  if (parsed.type === 'template') {
456
443
  await directCopy(parsed.versionData.templatePath, cwd);
457
444
  } else if (parsed.type === 'variant') {
458
- // Multi-select copy
459
445
  const snipPath = path.join(parsed.variantData.path, 'snippet.html');
460
446
  const configs = findConfigs(parsed.variantData.path);
461
447
  const brandDir = path.join(edmDir, parsed.brand);
462
448
  const versions = findTemplateVersions(brandDir);
463
- const version = versions[0]; // default to first version
449
+ const version = versions[0];
464
450
  const tplPath = version.templatePath;
465
451
 
466
452
  const copyItems = [
@@ -519,7 +505,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
519
505
  return;
520
506
  }
521
507
 
522
- // No arguments: interactive mode
523
508
  let edmDir;
524
509
  try {
525
510
  edmDir = resolveEdmDir();