juice-email-cli 2.3.3 → 2.3.4

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
@@ -103,6 +103,7 @@ program
103
103
  .option('-t, --template <file-path>', '仅拷贝模板 HTML 文件')
104
104
  .option('-s, --snippet <file-path>', '仅拷贝片段 HTML 文件')
105
105
  .option('-c, --config <file-path>', '仅拷贝配置 YAML 文件')
106
+ .option('--all [target]', '拷贝整个 EDM 资源目录到当前或指定目录')
106
107
  .action(async (initPath, options) => {
107
108
  const { runInitMode } = require('../src/init');
108
109
  await runInitMode({
@@ -110,6 +111,7 @@ program
110
111
  template: options.template || null,
111
112
  snippet: options.snippet || null,
112
113
  config: options.config || null,
114
+ all: options.all !== undefined ? (options.all || true) : null,
113
115
  });
114
116
  });
115
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.3.3",
3
+ "version": "2.3.4",
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": {
@@ -262,54 +262,60 @@ function wrapWithPause(nodePath, scriptPath, cliArgs) {
262
262
  // ─── Directory / Background 菜单注册 ──────────────────────────────────────────
263
263
 
264
264
  /**
265
- * Register individual shell commands for Directory and Directory\Background.
266
- * ExtendedSubCommandsKey doesn't work reliably for non-file-type associations,
267
- * so we register each item as a standalone shell verb.
265
+ * Register Directory and Background right-click menus using SubCommands
266
+ * + CommandStore (same format as file-type menus for consistent UX).
268
267
  */
269
268
  function registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath) {
269
+ const node = nodePath.replace(/'/g, "''");
270
+ const script = scriptPath.replace(/'/g, "''");
271
+
272
+ // CommandStore subcommand names
273
+ const DIR_VIEW = 'JuiceEmailDir.View';
274
+ const DIR_COPYALL = 'JuiceEmailDir.CopyAll';
275
+ const DIR_COPYPICK= 'JuiceEmailDir.CopyPick';
276
+ const DIR_PWSH = 'JuiceEmailDir.Pwsh';
277
+
278
+ // Helper: register a CommandStore entry
279
+ function regCmd(name, label, psCmd) {
280
+ const cmdKey = `${HKCU_SHELL}\\${name}`;
281
+ regAdd(cmdKey, 'MUIVerb', 'REG_SZ', label);
282
+ regAdd(cmdKey, 'Icon', 'REG_SZ', iconPath);
283
+ // Always pause so user can see output
284
+ const fullPs = `${psCmd}; Write-Host ''; Read-Host 'Press Enter to close'`;
285
+ regAdd(`${cmdKey}\\command`, '', 'REG_SZ', `powershell.exe -Command "${fullPs}"`);
286
+ }
287
+
288
+ // View: non-interactive, no directory dependency
289
+ regCmd(DIR_VIEW, '📋 查看可用资源', `& '${node}' '${script}' view`);
290
+
291
+ // CopyAll: copy entire EDM to clicked directory
292
+ regCmd(DIR_COPYALL, '📦 拷贝全部资源', `& '${node}' '${script}' init --all '%V'`);
293
+
294
+ // CopyPick: interactive, cd to directory first so output lands there
295
+ regCmd(DIR_COPYPICK, '📥 选择资源拷贝', `Set-Location '%V'; & '${node}' '${script}' init`);
296
+
297
+ // Pwsh
298
+ if (pwshPath) {
299
+ const pwshKey = `${HKCU_SHELL}\\${DIR_PWSH}`;
300
+ regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
301
+ regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
302
+ regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
303
+ `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%V'"`);
304
+ }
305
+
306
+ // Build SubCommands string
307
+ const subCmds = [DIR_VIEW, DIR_COPYALL, DIR_COPYPICK];
308
+ if (pwshPath) subCmds.push(DIR_PWSH);
309
+
310
+ // Parent keys: Directory + Directory\Background
270
311
  const roots = [
271
312
  `${HKCU_SHELL}\\Directory\\shell`,
272
313
  `${HKCU_SHELL}\\Directory\\Background\\shell`,
273
314
  ];
274
-
275
- // First, clean up old keys from all previous versions to prevent stale entries
276
315
  for (const root of roots) {
277
- // Old parent key (ExtendedSubCommandsKey approach, v2.x commit 3fc761e)
278
- regDelete(`${root}\\${PARENT_KEY_NAME}`);
279
- // Old individual entries from any prior version
280
- regDelete(`${root}\\JuiceEmail.Init`);
281
- regDelete(`${root}\\JuiceEmail.View`);
282
- regDelete(`${root}\\JuiceEmail.Browse`);
283
- regDelete(`${root}\\JuiceEmail.Pwsh`);
284
- }
285
-
286
- for (const root of roots) {
287
- // 📥 从资源库拷贝到此处
288
- const initKey = `${root}\\JuiceEmail.Init`;
289
- regAdd(initKey, 'MUIVerb', 'REG_SZ', '📥 从资源库拷贝到此处');
290
- regAdd(initKey, 'Icon', 'REG_SZ', iconPath);
291
- regAdd(`${initKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'init'));
292
-
293
- // 📦 查看资源列表
294
- const viewKey = `${root}\\JuiceEmail.View`;
295
- regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
296
- regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
297
- regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapWithPause(nodePath, scriptPath, 'view'));
298
-
299
- // 📋 浏览资源库
300
- const browseKey = `${root}\\JuiceEmail.Browse`;
301
- regAdd(browseKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
302
- regAdd(browseKey, 'Icon', 'REG_SZ', iconPath);
303
- regAdd(`${browseKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
304
-
305
- // 📂 在此打开终端
306
- if (pwshPath) {
307
- const pwshKey = `${root}\\JuiceEmail.Pwsh`;
308
- regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
309
- regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
310
- regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
311
- `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%V'"`);
312
- }
316
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'MUIVerb', 'REG_SZ', '📧 juice 生成邮件 HTML');
317
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'Icon', 'REG_SZ', iconPath);
318
+ regAdd(`${root}\\${PARENT_KEY_NAME}`, 'SubCommands', 'REG_SZ', subCmds.join(';'));
313
319
  }
314
320
  }
315
321
 
@@ -367,8 +373,20 @@ async function registerContextMenu() {
367
373
  regAdd(parentKey, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_YAML);
368
374
  }
369
375
 
370
- // Directory / Background → 独立容器
376
+ // Directory / Background → 子菜单(SubCommands + CommandStore)
371
377
  console.log(chalk.gray(' 注册文件夹/空白处菜单...'));
378
+ // Clean up old individual entries from v2.3.x before registering new format
379
+ for (const root of [`${HKCU_SHELL}\\Directory\\shell`, `${HKCU_SHELL}\\Directory\\Background\\shell`]) {
380
+ regDelete(`${root}\\${PARENT_KEY_NAME}`);
381
+ regDelete(`${root}\\JuiceEmail.Init`);
382
+ regDelete(`${root}\\JuiceEmail.View`);
383
+ regDelete(`${root}\\JuiceEmail.Browse`);
384
+ regDelete(`${root}\\JuiceEmail.Pwsh`);
385
+ }
386
+ // Clean up old CommandStore entries for Dir menu (from any prior version)
387
+ for (const name of ['JuiceEmailDir.View', 'JuiceEmailDir.CopyAll', 'JuiceEmailDir.CopyPick', 'JuiceEmailDir.Pwsh']) {
388
+ regDelete(`${HKCU_SHELL}\\${name}`);
389
+ }
372
390
  registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath);
373
391
 
374
392
  if (!ok) {
@@ -392,10 +410,11 @@ async function registerContextMenu() {
392
410
  ` ├── 📋 浏览资源库 → juice view -i\n` +
393
411
  (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
394
412
  `\n ${chalk.bold('文件夹 / 空白处')} 右键:\n` +
395
- ` 📥 从资源库拷贝到此处 → juice init\n` +
396
- ` 📦 查看资源列表 → juice view\n` +
397
- ` 📋 浏览资源库 → juice view -i\n` +
398
- (pwshPath ? ` 📂 在此打开终端\n` : '') +
413
+ ` ${chalk.bold('📧 juice 生成邮件 HTML')}\n` +
414
+ ` ├── 📋 查看可用资源 → juice view\n` +
415
+ ` ├── 📦 拷贝全部资源 → juice init --all\n` +
416
+ ` ├── 📥 选择资源拷贝 → juice init\n` +
417
+ (pwshPath ? ` └── 📂 在此打开终端\n` : '') +
399
418
  '\n' +
400
419
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
401
420
  );
@@ -422,18 +441,22 @@ async function unregisterContextMenu() {
422
441
  if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
423
442
  }
424
443
 
425
- // 清理 Directory / Background 独立菜单项 + 旧版父键
444
+ // 清理 Directory / Background 父菜单 + 旧版独立条目
426
445
  const dirBgRoots = [
427
446
  `${HKCU_SHELL}\\Directory\\shell`,
428
447
  `${HKCU_SHELL}\\Directory\\Background\\shell`,
429
448
  ];
430
- const dirBgKeys = ['JuiceEmail.Init', 'JuiceEmail.View', 'JuiceEmail.Browse', 'JuiceEmail.Pwsh',
431
- PARENT_KEY_NAME]; // PARENT_KEY_NAME = 'JuiceEmail' (old ExtendedSubCommandsKey parent)
449
+ // New parent key + old individual entries from v2.3.x
450
+ const dirBgKeys = [PARENT_KEY_NAME, 'JuiceEmail.Init', 'JuiceEmail.View', 'JuiceEmail.Browse', 'JuiceEmail.Pwsh'];
432
451
  for (const root of dirBgRoots) {
433
452
  for (const key of dirBgKeys) {
434
453
  if (regDelete(`${root}\\${key}`)) removed++;
435
454
  }
436
455
  }
456
+ // CommandStore entries for Dir menus
457
+ for (const name of ['JuiceEmailDir.View', 'JuiceEmailDir.CopyAll', 'JuiceEmailDir.CopyPick', 'JuiceEmailDir.Pwsh']) {
458
+ if (regDelete(`${HKCU_SHELL}\\${name}`)) removed++;
459
+ }
437
460
 
438
461
  // 清理所有子命令容器(含旧版 Dir/Bg 容器)
439
462
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`)) removed++;
package/src/init.js CHANGED
@@ -71,8 +71,7 @@ function deriveDefaultName(filePath) {
71
71
  async function directCopy(srcPath, cwd) {
72
72
  const resolved = path.resolve(srcPath);
73
73
  if (!fs.existsSync(resolved)) {
74
- console.error(chalk.red(`\n ✘ 文件不存在:${resolved}\n`));
75
- process.exit(1);
74
+ throw new Error(`文件不存在:${resolved}`);
76
75
  }
77
76
 
78
77
  const destName = await promptOutputName(
@@ -231,9 +230,110 @@ async function interactiveInit(edmDir, cwd) {
231
230
 
232
231
  // ─── Main Entry ──────────────────────────────────────────────────────────────
233
232
 
234
- async function runInitMode({ initPath, template, snippet, config }) {
233
+ function copyDir(src, dest) {
234
+ fs.mkdirSync(dest, { recursive: true });
235
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
236
+ const srcPath = path.join(src, entry.name);
237
+ const destPath = path.join(dest, entry.name);
238
+ if (entry.isDirectory()) {
239
+ copyDir(srcPath, destPath);
240
+ } else {
241
+ fs.copyFileSync(srcPath, destPath);
242
+ }
243
+ }
244
+ }
245
+
246
+ function dirSize(dir) {
247
+ let size = 0;
248
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
249
+ const p = path.join(dir, entry.name);
250
+ if (entry.isDirectory()) {
251
+ size += dirSize(p);
252
+ } else {
253
+ size += fs.statSync(p).size;
254
+ }
255
+ }
256
+ return size;
257
+ }
258
+
259
+ function countFiles(dir) {
260
+ let count = 0;
261
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
262
+ if (entry.isDirectory()) {
263
+ count += countFiles(path.join(dir, entry.name));
264
+ } else {
265
+ count++;
266
+ }
267
+ }
268
+ return count;
269
+ }
270
+
271
+ function findNextEdmVersion(targetDir) {
272
+ let v = 1;
273
+ while (true) {
274
+ const candidate = path.join(targetDir, 'edm-v' + v);
275
+ if (!fs.existsSync(candidate)) return candidate;
276
+ v++;
277
+ }
278
+ }
279
+
280
+ async function runInitMode({ initPath, template, snippet, config, all }) {
235
281
  const cwd = process.cwd();
236
282
 
283
+ // --all: copy entire EDM directory
284
+ if (all) {
285
+ let edmDir;
286
+ try {
287
+ edmDir = resolveEdmDir();
288
+ } catch (err) {
289
+ console.error(chalk.red(`\n ✘ ${err.message}\n`));
290
+ process.exit(1);
291
+ }
292
+ const target = all === true ? cwd : path.resolve(all);
293
+ const destDir = path.join(target, 'edm');
294
+
295
+ // Prevent deleting the source when target overlaps
296
+ if (path.resolve(edmDir) === path.resolve(destDir)) {
297
+ console.log(chalk.yellow('目标目录与 EDM 资源库相同,无需拷贝。\n'));
298
+ return;
299
+ }
300
+ console.log(chalk.cyan(`\n 拷贝 EDM 资源库...`));
301
+ console.log(chalk.gray(` 源:${edmDir}`));
302
+ console.log(chalk.gray(` 目标:${destDir}`));
303
+
304
+ if (fs.existsSync(destDir)) {
305
+ const { select } = await import('@inquirer/prompts');
306
+ const vName = findNextEdmVersion(target);
307
+ const action = await select({
308
+ message: `目录 ${destDir} 已存在:`,
309
+ choices: [
310
+ { name: '覆盖', value: 'overwrite' },
311
+ { name: `自动版本号(${path.basename(vName)})`, value: 'version' },
312
+ { name: '取消', value: 'cancel' },
313
+ ],
314
+ });
315
+ if (action === 'cancel') {
316
+ console.log(chalk.gray('已取消。\n'));
317
+ return;
318
+ }
319
+ if (action === 'version') {
320
+ copyDir(edmDir, vName);
321
+ const edmSize = dirSize(vName);
322
+ const fileCount = countFiles(vName);
323
+ console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库 → ${path.basename(vName)}(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
324
+ return;
325
+ }
326
+ // overwrite: remove and replace
327
+ fs.rmSync(destDir, { recursive: true, force: true });
328
+ }
329
+
330
+ copyDir(edmDir, destDir);
331
+ const edmSize = dirSize(destDir);
332
+ const fileCount = countFiles(destDir);
333
+ console.log(chalk.green(`\n✔ 已拷贝 EDM 资源库(${fileCount} 个文件,${fmtBytes(edmSize)})\n`));
334
+ return;
335
+ }
336
+
237
337
  // Direct copy modes
238
338
  if (template) {
239
339
  await directCopy(template, cwd);
package/src/view.js CHANGED
@@ -12,6 +12,27 @@ function fmtBytes(b) {
12
12
  return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
13
13
  }
14
14
 
15
+ /**
16
+ * Copy src to dest, auto-versioning (-v1, -v2, ...) if dest exists.
17
+ * Returns the actual destination path used.
18
+ */
19
+ function copyFileSafe(srcPath, destPath) {
20
+ if (!fs.existsSync(destPath)) {
21
+ fs.copyFileSync(srcPath, destPath);
22
+ return destPath;
23
+ }
24
+ const parsed = path.parse(destPath);
25
+ let v = 1;
26
+ while (true) {
27
+ const alt = path.join(parsed.dir, parsed.name + '-v' + v + parsed.ext);
28
+ if (!fs.existsSync(alt)) {
29
+ fs.copyFileSync(srcPath, alt);
30
+ return alt;
31
+ }
32
+ v++;
33
+ }
34
+ }
35
+
15
36
  // ─── Path Parser ──────────────────────────────────────────────────────────────
16
37
 
17
38
  /**
@@ -327,13 +348,17 @@ async function showCheckbox(title, choices) {
327
348
  * Trigger copy via the init module.
328
349
  */
329
350
  async function copyResource(type, resourcePath, cwd) {
330
- const { runInitMode } = require('./init');
331
- if (type === 'template') {
332
- await runInitMode({ template: resourcePath });
333
- } else if (type === 'snippet') {
334
- await runInitMode({ snippet: resourcePath });
335
- } else if (type === 'config') {
336
- await runInitMode({ config: resourcePath });
351
+ try {
352
+ const { runInitMode } = require('./init');
353
+ if (type === 'template') {
354
+ await runInitMode({ template: resourcePath });
355
+ } else if (type === 'snippet') {
356
+ await runInitMode({ snippet: resourcePath });
357
+ } else if (type === 'config') {
358
+ await runInitMode({ config: resourcePath });
359
+ }
360
+ } catch (err) {
361
+ console.error(chalk.red(` ✘ 拷贝失败:${err.message}`));
337
362
  }
338
363
  }
339
364
 
@@ -551,7 +576,7 @@ async function interactiveBrowse(edmDir, startParsed) {
551
576
  if (versions.length > 0) {
552
577
  const tplPath = versions[0].templatePath;
553
578
  copyItems.push({
554
- name: '📋 模板 HTML',
579
+ name: `📋 模板 HTML(${versions[0].meta.name || versions[0].name})`,
555
580
  value: 'template',
556
581
  description: path.basename(tplPath),
557
582
  checked: true,
@@ -588,7 +613,9 @@ async function interactiveBrowse(edmDir, startParsed) {
588
613
  });
589
614
 
590
615
  if (selected.length > 0) {
591
- const defaultBaseName = `${current.brand}-${current.series}-${current.variant}`;
616
+ // Include template version in default name for clarity
617
+ const versionName = versions.length > 0 ? versions[0].name : 'template';
618
+ const defaultBaseName = `${current.brand}-${versionName}-${current.series}-${current.variant}`;
592
619
  const { promptOutputName: pn } = require('./snippet');
593
620
  const outputBaseName = await pn(defaultBaseName, process.cwd());
594
621
  const cwd = process.cwd();
@@ -596,28 +623,22 @@ async function interactiveBrowse(edmDir, startParsed) {
596
623
 
597
624
  console.log(chalk.green('\n✔ 已拷贝:'));
598
625
  if (selected.includes('template') && versions.length > 0) {
599
- const tplPath = versions[0].templatePath;
600
- const dest = path.join(cwd, outputBaseName + path.extname(tplPath));
601
- fs.copyFileSync(tplPath, dest);
602
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
626
+ const src = versions[0].templatePath;
627
+ const dest = path.join(cwd, outputBaseName + path.extname(src));
628
+ const actual = copyFileSafe(src, dest);
629
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
603
630
  }
604
631
  if (selected.includes('snippet') && fs.existsSync(snipPath)) {
605
632
  const dest = path.join(cwd, outputBaseName + '-snippet' + path.extname(snipPath));
606
- fs.copyFileSync(snipPath, dest);
607
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
633
+ const actual = copyFileSafe(snipPath, dest);
634
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
608
635
  }
609
636
  if (selected.includes('config') && configs.length > 0) {
610
637
  const optimal = configs.find(c => c.isOptimal);
611
638
  const cfgPath = optimal ? optimal.path : configs[0].path;
612
639
  const dest = path.join(cwd, 'juice.yaml');
613
- if (fs.existsSync(dest)) {
614
- const alt = path.join(cwd, 'juice-v1.yaml');
615
- fs.copyFileSync(cfgPath, alt);
616
- console.log(` ${chalk.cyan('·')} ${cwdRel(alt)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(alt).size) + ')')}`);
617
- } else {
618
- fs.copyFileSync(cfgPath, dest);
619
- console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
620
- }
640
+ const actual = copyFileSafe(cfgPath, dest);
641
+ console.log(` ${chalk.cyan('·')} ${cwdRel(actual)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(actual).size) + ')')}`);
621
642
  }
622
643
  console.log();
623
644
  }