juice-email-cli 2.3.8 → 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 +81 -98
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.3.8",
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,10 +80,6 @@ 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
83
  async function selectWithNav(message, choices, showBack) {
97
84
  const { select } = await import('@inquirer/prompts');
98
85
  const all = [];
@@ -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,9 +358,7 @@ 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
- // Always use package built-in EDM for --all (not CWD's local copy)
371
362
  const pkgEdm = path.resolve(__dirname, '..', 'edm');
372
363
  const edmDir = fs.existsSync(pkgEdm) ? pkgEdm : (() => {
373
364
  try { return resolveEdmDir(); } catch (_) { return null; }
@@ -379,20 +370,17 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
379
370
  const target = all === true ? cwd : path.resolve(all);
380
371
  const destDir = path.join(target, 'edm');
381
372
 
382
- // Prevent deleting the source when target overlaps
383
373
  if (path.resolve(edmDir) === path.resolve(destDir)) {
384
374
  console.log(chalk.yellow('目标目录与 EDM 资源库相同,无需拷贝。\n'));
385
375
  return;
386
376
  }
387
377
  console.log(chalk.cyan(`\n 拷贝 EDM 资源库...`));
388
- console.log(chalk.gray(` 源:${edmDir}`));
389
- console.log(chalk.gray(` 目标:${destDir}`));
390
378
 
391
379
  if (fs.existsSync(destDir)) {
392
380
  const { select } = await import('@inquirer/prompts');
393
381
  const vName = findNextEdmVersion(target);
394
382
  const action = await select({
395
- message: `目录 ${destDir} 已存在:`,
383
+ message: `目录 edm/ 已存在:`,
396
384
  choices: [
397
385
  { name: '覆盖', value: 'overwrite' },
398
386
  { name: `版本(${path.basename(vName)})`, value: 'version' },
@@ -410,7 +398,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
410
398
  console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库 → ${path.basename(vName)}(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
411
399
  return;
412
400
  }
413
- // overwrite: remove and replace
414
401
  fs.rmSync(destDir, { recursive: true, force: true });
415
402
  }
416
403
 
@@ -421,7 +408,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
421
408
  return;
422
409
  }
423
410
 
424
- // Direct copy modes
425
411
  if (template) {
426
412
  await directCopy(template, cwd);
427
413
  return;
@@ -435,7 +421,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
435
421
  return;
436
422
  }
437
423
 
438
- // Path-based from EDM
439
424
  if (initPath) {
440
425
  const { parseViewPath } = require('./view');
441
426
  let edmDir;
@@ -457,12 +442,11 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
457
442
  if (parsed.type === 'template') {
458
443
  await directCopy(parsed.versionData.templatePath, cwd);
459
444
  } else if (parsed.type === 'variant') {
460
- // Multi-select copy
461
445
  const snipPath = path.join(parsed.variantData.path, 'snippet.html');
462
446
  const configs = findConfigs(parsed.variantData.path);
463
447
  const brandDir = path.join(edmDir, parsed.brand);
464
448
  const versions = findTemplateVersions(brandDir);
465
- const version = versions[0]; // default to first version
449
+ const version = versions[0];
466
450
  const tplPath = version.templatePath;
467
451
 
468
452
  const copyItems = [
@@ -521,7 +505,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
521
505
  return;
522
506
  }
523
507
 
524
- // No arguments: interactive mode
525
508
  let edmDir;
526
509
  try {
527
510
  edmDir = resolveEdmDir();