juice-email-cli 2.3.8 → 2.3.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/init.js +91 -99
  3. package/src/view.js +2 -1
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.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": {
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,105 @@ 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: confirm / shortcuts / customize, allow back 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
+ // Quick shortcut: snippet + config only (skip template)
211
+ if (variant && copyItems.some(c => c.value === 'snippet') && copyItems.some(c => c.value === 'config')) {
212
+ mainChoices.push({ name: '🧩 仅片段 + 配置', value: 'snippet-config' });
213
+ }
214
+ mainChoices.push({ name: `🔄 自定义选择(${summary})`, value: 'edit' });
223
215
 
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
- }
216
+ const action = await selectWithNav(
217
+ '拷贝操作:',
218
+ mainChoices,
219
+ true
220
+ );
221
+ if (action === 'back') { step = series ? 'variant' : 'series'; break; }
222
+ if (action === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
223
+ if (action === 'edit') {
224
+ const { checkbox } = await import('@inquirer/prompts');
225
+ selected = await checkbox({
226
+ message: '选择要拷贝的内容:',
227
+ choices: copyItems,
228
+ });
229
+ if (selected.length === 0) { console.log(chalk.dim(' (已清空选择)\n')); }
230
+ continue;
231
+ }
232
+ if (action === 'snippet-config') {
233
+ selected = ['snippet', 'config'];
234
+ continue;
235
+ }
230
236
 
231
- const realSelected = selected.filter(s => s !== 'back' && s !== 'exit');
232
- if (realSelected.length === 0) {
233
- console.log(chalk.gray('未选择任何内容,已跳过。\n'));
234
- return;
235
- }
237
+ if (selected.length === 0) {
238
+ console.log(chalk.gray('未选择任何内容。\n'));
239
+ step = series ? 'variant' : 'series';
240
+ break;
241
+ }
236
242
 
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);
243
+ const defaultBaseName = variant
244
+ ? `${brand.name}-${version.name}-${series.name}-${variant.name}`
245
+ : `${brand.name}-${version.name}`;
246
+ const outputBaseName = await promptOutputName(defaultBaseName, cwd);
242
247
 
243
- // Copy files
244
- console.log(chalk.green('\n✔ 已拷贝:'));
245
- const cwdRel = (p) => './' + path.relative(cwd, p);
248
+ console.log(chalk.green('\n✔ 已拷贝:'));
249
+ const cwdRel = (p) => './' + path.relative(cwd, p);
246
250
 
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
- }
251
+ if (selected.includes('template')) {
252
+ const destName = outputBaseName + path.extname(version.templatePath);
253
+ const dest = copyFileToCwd(version.templatePath, cwd, destName);
254
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
255
+ const icon = copyIcon(variant ? variant.path : null, version.path, brand.path, cwd);
256
+ if (icon) console.log(` ${chalk.cyan('·')} ${cwdRel(icon)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(icon).size) + ')')}`);
257
+ }
255
258
 
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
- }
259
+ if (selected.includes('snippet') && variant) {
260
+ const snipPath = path.join(variant.path, 'snippet.html');
261
+ const destName = outputBaseName + '-snippet' + path.extname(snipPath);
262
+ const dest = copyFileToCwd(snipPath, cwd, destName);
263
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
264
+ }
262
265
 
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
- }
266
+ if (selected.includes('config') && variant) {
267
+ const configs = findConfigs(variant.path);
268
+ const optimal = configs.find(c => c.isOptimal);
269
+ const cfgPath = optimal ? optimal.path : configs[0].path;
270
+ const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
271
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
272
+ }
270
273
 
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
- );
274
+ console.log();
275
+ if (selected.includes('snippet') && selected.includes('template')) {
276
+ const snipFile = outputBaseName + '-snippet' + path.extname(path.join(variant.path, 'snippet.html'));
277
+ const tplFile = outputBaseName + path.extname(version.templatePath);
278
+ console.log(
279
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
280
+ ' ' + chalk.cyan(`juice -s ${snipFile} -f ${tplFile}`) +
281
+ (selected.includes('config') ? chalk.cyan(' -c juice.yaml') : '') + '\n'
282
+ );
283
+ } else if (selected.includes('template')) {
284
+ const tplFile = outputBaseName + path.extname(version.templatePath);
285
+ console.log(
286
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
287
+ ' ' + chalk.cyan(`juice -f ${tplFile}`) + '\n'
288
+ );
289
+ }
290
+ break;
286
291
  }
287
- return;
292
+ if (step === 'copy') return; // confirmed and copied
293
+ continue; // went back
288
294
  }
289
295
  }
290
296
  }
@@ -329,15 +335,10 @@ function countFiles(dir) {
329
335
  return count;
330
336
  }
331
337
 
332
- /**
333
- * Find and copy favicon.ico alongside the template.
334
- * Lookup: variant dir → template version dir → brand's first template → skip
335
- */
336
338
  function copyIcon(variantDir, versionDir, brandDir, cwd) {
337
339
  const candidates = [];
338
340
  if (variantDir) candidates.push(path.join(variantDir, 'favicon.ico'));
339
341
  if (versionDir) candidates.push(path.join(versionDir, 'favicon.ico'));
340
- // First template version in brand
341
342
  try {
342
343
  const versions = findTemplateVersions(brandDir);
343
344
  if (versions.length > 0) {
@@ -346,7 +347,8 @@ function copyIcon(variantDir, versionDir, brandDir, cwd) {
346
347
  } catch (_) {}
347
348
  for (const src of candidates) {
348
349
  if (fs.existsSync(src)) {
349
- const dest = copyFileToCwd(src, cwd, 'favicon.ico');
350
+ const dest = path.join(cwd, 'favicon.ico');
351
+ fs.copyFileSync(src, dest);
350
352
  return dest;
351
353
  }
352
354
  }
@@ -365,9 +367,7 @@ function findNextEdmVersion(targetDir) {
365
367
  async function runInitMode({ initPath, template, snippet, config, all }) {
366
368
  const cwd = process.cwd();
367
369
 
368
- // --all: copy entire EDM directory
369
370
  if (all) {
370
- // Always use package built-in EDM for --all (not CWD's local copy)
371
371
  const pkgEdm = path.resolve(__dirname, '..', 'edm');
372
372
  const edmDir = fs.existsSync(pkgEdm) ? pkgEdm : (() => {
373
373
  try { return resolveEdmDir(); } catch (_) { return null; }
@@ -379,20 +379,17 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
379
379
  const target = all === true ? cwd : path.resolve(all);
380
380
  const destDir = path.join(target, 'edm');
381
381
 
382
- // Prevent deleting the source when target overlaps
383
382
  if (path.resolve(edmDir) === path.resolve(destDir)) {
384
383
  console.log(chalk.yellow('目标目录与 EDM 资源库相同,无需拷贝。\n'));
385
384
  return;
386
385
  }
387
386
  console.log(chalk.cyan(`\n 拷贝 EDM 资源库...`));
388
- console.log(chalk.gray(` 源:${edmDir}`));
389
- console.log(chalk.gray(` 目标:${destDir}`));
390
387
 
391
388
  if (fs.existsSync(destDir)) {
392
389
  const { select } = await import('@inquirer/prompts');
393
390
  const vName = findNextEdmVersion(target);
394
391
  const action = await select({
395
- message: `目录 ${destDir} 已存在:`,
392
+ message: `目录 edm/ 已存在:`,
396
393
  choices: [
397
394
  { name: '覆盖', value: 'overwrite' },
398
395
  { name: `版本(${path.basename(vName)})`, value: 'version' },
@@ -410,7 +407,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
410
407
  console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库 → ${path.basename(vName)}(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
411
408
  return;
412
409
  }
413
- // overwrite: remove and replace
414
410
  fs.rmSync(destDir, { recursive: true, force: true });
415
411
  }
416
412
 
@@ -421,7 +417,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
421
417
  return;
422
418
  }
423
419
 
424
- // Direct copy modes
425
420
  if (template) {
426
421
  await directCopy(template, cwd);
427
422
  return;
@@ -435,7 +430,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
435
430
  return;
436
431
  }
437
432
 
438
- // Path-based from EDM
439
433
  if (initPath) {
440
434
  const { parseViewPath } = require('./view');
441
435
  let edmDir;
@@ -457,12 +451,11 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
457
451
  if (parsed.type === 'template') {
458
452
  await directCopy(parsed.versionData.templatePath, cwd);
459
453
  } else if (parsed.type === 'variant') {
460
- // Multi-select copy
461
454
  const snipPath = path.join(parsed.variantData.path, 'snippet.html');
462
455
  const configs = findConfigs(parsed.variantData.path);
463
456
  const brandDir = path.join(edmDir, parsed.brand);
464
457
  const versions = findTemplateVersions(brandDir);
465
- const version = versions[0]; // default to first version
458
+ const version = versions[0];
466
459
  const tplPath = version.templatePath;
467
460
 
468
461
  const copyItems = [
@@ -521,7 +514,6 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
521
514
  return;
522
515
  }
523
516
 
524
- // No arguments: interactive mode
525
517
  let edmDir;
526
518
  try {
527
519
  edmDir = resolveEdmDir();
package/src/view.js CHANGED
@@ -634,7 +634,8 @@ async function interactiveBrowse(edmDir, startParsed) {
634
634
  ];
635
635
  for (const isrc of iconSrcs) {
636
636
  if (fs.existsSync(isrc)) {
637
- const idest = copyFileSafe(isrc, path.join(cwd, 'favicon.ico'));
637
+ const idest = path.join(cwd, 'favicon.ico');
638
+ fs.copyFileSync(isrc, idest);
638
639
  console.log(` ${chalk.cyan('·')} ${cwdRel(idest)} ${chalk.gray('(图标, ' + fmtBytes(fs.statSync(idest).size) + ')')}`);
639
640
  break;
640
641
  }