juice-email-cli 2.2.2 → 2.3.1

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
@@ -75,7 +75,46 @@ program
75
75
  └── 📂 打开 PowerShell (仅已安装 pwsh 时出现)
76
76
 
77
77
  更多信息:https://gitee.com/siriussupreme/juice-cli
78
- `)
78
+ `);
79
+
80
+ // ─── Subcommand: juice view ─────────────────────────────────────────────
81
+ program
82
+ .command('view [path]')
83
+ .description('查看 EDM 资源:品牌、模板、系列、片段变体')
84
+ .option('-i, --interactive', '交互式浏览,可上下翻层级')
85
+ .option('--templates', '列出所有模板')
86
+ .option('--series', '列出所有系列')
87
+ .option('--snippets', '列出所有片段')
88
+ .action(async (viewPath, options) => {
89
+ const { runViewMode } = require('../src/view');
90
+ await runViewMode({
91
+ viewPath: viewPath || null,
92
+ interactive: !!options.interactive,
93
+ scope: options.templates ? 'templates'
94
+ : (options.series ? 'series'
95
+ : (options.snippets ? 'snippets' : null)),
96
+ });
97
+ });
98
+
99
+ // ─── Subcommand: juice init ─────────────────────────────────────────────
100
+ program
101
+ .command('init [path]')
102
+ .description('从 EDM 资源拷贝模板/片段/配置到当前目录')
103
+ .option('-t, --template <file-path>', '仅拷贝模板 HTML 文件')
104
+ .option('-s, --snippet <file-path>', '仅拷贝片段 HTML 文件')
105
+ .option('-c, --config <file-path>', '仅拷贝配置 YAML 文件')
106
+ .action(async (initPath, options) => {
107
+ const { runInitMode } = require('../src/init');
108
+ await runInitMode({
109
+ initPath: initPath || null,
110
+ template: options.template || null,
111
+ snippet: options.snippet || null,
112
+ config: options.config || null,
113
+ });
114
+ });
115
+
116
+ // ─── Default action (backward compatible) ───────────────────────────────
117
+ program
79
118
  .action(async (options) => {
80
119
  // 注册/卸载右键菜单
81
120
  if (options.install) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juice-email-cli",
3
- "version": "2.2.2",
3
+ "version": "2.3.1",
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": {
@@ -56,19 +56,28 @@ function regAdd(key, valueName, type, data) {
56
56
  if (type) args.push('/t', type);
57
57
  if (data !== undefined) args.push('/d', data);
58
58
 
59
- const result = spawnSync('reg', args, { stdio: 'pipe' });
60
- if (result.status !== 0) {
61
- const msg = (result.stderr || '').toString().trim();
62
- console.warn(chalk.yellow(` ⚠ reg add 失败\n 键:${key}\n 原因:${msg}`));
59
+ try {
60
+ const result = spawnSync('reg', args, { stdio: 'pipe', timeout: 10000 });
61
+ if (result.status !== 0) {
62
+ const msg = (result.stderr || '').toString().trim();
63
+ console.warn(chalk.yellow(` ⚠ reg add 失败\n 键:${key}\n 原因:${msg}`));
64
+ return false;
65
+ }
66
+ return true;
67
+ } catch (e) {
68
+ console.warn(chalk.yellow(` ⚠ reg add 超时:${key}`));
63
69
  return false;
64
70
  }
65
- return true;
66
71
  }
67
72
 
68
73
  function regDelete(key) {
69
74
  if (!isWindows) return false;
70
- const result = spawnSync('reg', ['delete', key, '/f'], { stdio: 'pipe' });
71
- return result.status === 0;
75
+ try {
76
+ const result = spawnSync('reg', ['delete', key, '/f'], { stdio: 'pipe', timeout: 10000 });
77
+ return result.status === 0;
78
+ } catch (_) {
79
+ return false;
80
+ }
72
81
  }
73
82
 
74
83
  // ─── 菜单结构常量 ─────────────────────────────────────────────────────────────
@@ -90,10 +99,13 @@ const YAML_ROOTS = [
90
99
 
91
100
  // 子命令名称常量
92
101
  const SUBCMDS = {
93
- generate: 'JuiceEmail.Generate',
94
- snippet: 'JuiceEmail.Snippet',
95
- config: 'JuiceEmail.WithConfig',
96
- pwsh: 'JuiceEmail.OpenPwsh',
102
+ generate: 'JuiceEmail.Generate',
103
+ snippet: 'JuiceEmail.Snippet',
104
+ config: 'JuiceEmail.WithConfig',
105
+ viewEdm: 'JuiceEmail.ViewEdm',
106
+ viewEdmInt:'JuiceEmail.ViewEdmInteractive',
107
+ initEdm: 'JuiceEmail.InitEdm',
108
+ pwsh: 'JuiceEmail.OpenPwsh',
97
109
  };
98
110
 
99
111
  const PARENT_KEY_NAME = 'JuiceEmail';
@@ -101,6 +113,8 @@ const PARENT_KEY_NAME = 'JuiceEmail';
101
113
  // ExtendedSubCommandsKey 指向的容器路径(相对于 HKCU\Software\Classes)
102
114
  const SUB_CMDS_CONTAINER_HTML = 'JuiceEmail.SubCommands';
103
115
  const SUB_CMDS_CONTAINER_YAML = 'JuiceEmail.SubCommands.Yaml';
116
+ const SUB_CMDS_CONTAINER_DIR = 'JuiceEmail.SubCommands.Dir';
117
+ const SUB_CMDS_CONTAINER_BG = 'JuiceEmail.SubCommands.Bg';
104
118
 
105
119
  // ─── 旧版残留清理 ─────────────────────────────────────────────────────────────
106
120
 
@@ -209,6 +223,19 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
209
223
  regAdd(`${cfgKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, '-c %1'));
210
224
  }
211
225
 
226
+ // Common items for file-type containers (html + yaml)
227
+ if (kind === 'html' || kind === 'yaml') {
228
+ const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
229
+ regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
230
+ regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
231
+ regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
232
+
233
+ const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
234
+ regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
235
+ regAdd(viewIntKey, 'Icon', 'REG_SZ', iconPath);
236
+ regAdd(`${viewIntKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
237
+ }
238
+
212
239
  // pwsh — 始终显示(可选)
213
240
  if (pwshPath) {
214
241
  const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
@@ -219,6 +246,54 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
219
246
  }
220
247
  }
221
248
 
249
+ // ─── Directory / Background 菜单注册 ──────────────────────────────────────────
250
+
251
+ function registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath) {
252
+ // Register directory container subcommands
253
+ const dirContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`;
254
+ const bgContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`;
255
+
256
+ for (const containerPath of [dirContainer, bgContainer]) {
257
+ // 📥 从资源库拷贝到此处
258
+ const initKey = `${containerPath}\\shell\\${SUBCMDS.initEdm}`;
259
+ regAdd(initKey, 'MUIVerb', 'REG_SZ', '📥 从资源库拷贝到此处');
260
+ regAdd(initKey, 'Icon', 'REG_SZ', iconPath);
261
+ regAdd(`${initKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'init'));
262
+
263
+ // 📦 查看资源列表
264
+ const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
265
+ regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
266
+ regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
267
+ regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
268
+
269
+ // 📋 浏览资源库
270
+ const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
271
+ regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
272
+ regAdd(viewIntKey, 'Icon', 'REG_SZ', iconPath);
273
+ regAdd(`${viewIntKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
274
+
275
+ // 📂 在此打开终端
276
+ if (pwshPath) {
277
+ const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
278
+ regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
279
+ regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
280
+ regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
281
+ `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%1'"`);
282
+ }
283
+ }
284
+
285
+ // Parent keys: Directory and Directory\Background
286
+ const dirParent = `${HKCU_SHELL}\\Directory\\shell\\${PARENT_KEY_NAME}`;
287
+ regAdd(dirParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
288
+ regAdd(dirParent, 'Icon', 'REG_SZ', iconPath);
289
+ regAdd(dirParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_DIR);
290
+
291
+ const bgParent = `${HKCU_SHELL}\\Directory\\Background\\shell\\${PARENT_KEY_NAME}`;
292
+ regAdd(bgParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
293
+ regAdd(bgParent, 'Icon', 'REG_SZ', iconPath);
294
+ regAdd(bgParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_BG);
295
+ }
296
+
222
297
  // ─── 注册 ─────────────────────────────────────────────────────────────────────
223
298
 
224
299
  async function registerContextMenu() {
@@ -241,14 +316,19 @@ async function registerContextMenu() {
241
316
  let ok = true;
242
317
 
243
318
  // 先删除旧容器再重建,避免注册残留旧子命令(如旧版 HTML 容器的 WithConfig)
319
+ console.log(chalk.gray(' 清理旧版菜单...'));
244
320
  regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`);
245
321
  regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`);
322
+ regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`);
323
+ regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`);
246
324
 
247
325
  // HTML 容器:generate + snippet + pwsh
326
+ console.log(chalk.gray(' 注册 .html 文件菜单...'));
248
327
  const htmlContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`;
249
328
  registerSubCommands(htmlContainer, 'html', nodePath, scriptPath, iconPath, pwshPath);
250
329
 
251
330
  // YAML 容器:config + pwsh
331
+ console.log(chalk.gray(' 注册 .yaml 文件菜单...'));
252
332
  const yamlContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`;
253
333
  registerSubCommands(yamlContainer, 'yaml', nodePath, scriptPath, iconPath, pwshPath);
254
334
 
@@ -268,6 +348,10 @@ async function registerContextMenu() {
268
348
  regAdd(parentKey, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_YAML);
269
349
  }
270
350
 
351
+ // Directory / Background → 独立容器
352
+ console.log(chalk.gray(' 注册文件夹/空白处菜单...'));
353
+ registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath);
354
+
271
355
  if (!ok) {
272
356
  console.log(chalk.yellow(' ⚠ 部分注册表项写入失败,右键菜单可能不完整。\n'));
273
357
  }
@@ -279,13 +363,21 @@ async function registerContextMenu() {
279
363
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
280
364
  ` ├── 📄 作为模板,生成邮件 HTML → juice -f(后台执行)\n` +
281
365
  ` ├── 🧩 作为片段,拼接邮件 HTML → juice -s(交互选择模板)\n` +
366
+ ` ├── 📦 查看资源列表 → juice view\n` +
367
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
282
368
  (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
283
369
  `\n ${chalk.bold('.yaml / .yml')} 文件右键:\n` +
284
370
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
285
- (pwshPath
286
- ? ` ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n` +
287
- ` └── 📂 打开 PowerShell\n`
288
- : ` └── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n`) +
371
+ ` ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n` +
372
+ ` ├── 📦 查看资源列表 → juice view\n` +
373
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
374
+ (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
375
+ `\n ${chalk.bold('文件夹 / 空白处')} 右键:\n` +
376
+ ` ${chalk.bold('📧 juice 邮件工具')}\n` +
377
+ ` ├── 📥 从资源库拷贝到此处 → juice init\n` +
378
+ ` ├── 📦 查看资源列表 → juice view\n` +
379
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
380
+ (pwshPath ? ` └── 📂 在此打开终端\n` : '') +
289
381
  '\n' +
290
382
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
291
383
  );
@@ -303,15 +395,22 @@ async function unregisterContextMenu() {
303
395
 
304
396
  let removed = 0;
305
397
 
306
- // 清理 HKCU 父菜单(4 个文件类型)
307
- const allRoots = [...HTML_ROOTS, ...YAML_ROOTS];
398
+ // 清理 HKCU 父菜单(4 个文件类型 + Directory + Background)
399
+ const allRoots = [
400
+ ...HTML_ROOTS,
401
+ ...YAML_ROOTS,
402
+ `${HKCU_SHELL}\\Directory\\shell`,
403
+ `${HKCU_SHELL}\\Directory\\Background\\shell`,
404
+ ];
308
405
  for (const root of allRoots) {
309
406
  if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
310
407
  }
311
408
 
312
- // 清理两个子命令容器
409
+ // 清理所有子命令容器
313
410
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`)) removed++;
314
411
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`)) removed++;
412
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`)) removed++;
413
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`)) removed++;
315
414
 
316
415
  // 清理旧版共享容器(v2.1.14 之前只有一个容器)
317
416
  regDelete(`${HKCU_SHELL}\\JuiceEmail.SubCommands`);
package/src/init.js ADDED
@@ -0,0 +1,346 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+ const {
7
+ resolveEdmDir, loadMeta, findBrands, findTemplateVersions,
8
+ findSeriesDirs, findSnippetVariants, findConfigs,
9
+ promptBrand, promptTemplateVersion, promptSeries, promptSnippetVariant,
10
+ promptOutputName, checkOutputConflicts, findNextVersion,
11
+ } = require('./snippet');
12
+
13
+ function fmtBytes(b) {
14
+ return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
15
+ }
16
+
17
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
18
+
19
+ function formatName(meta, dirName) {
20
+ const display = meta.name || dirName;
21
+ return display !== dirName
22
+ ? chalk.bold.cyan(display) + ' ' + chalk.gray(`(${dirName})`)
23
+ : chalk.bold.cyan(dirName);
24
+ }
25
+
26
+ /**
27
+ * Copy a single file to CWD with an optional custom name.
28
+ * Returns the destination path.
29
+ */
30
+ function copyFileToCwd(srcPath, cwd, destName) {
31
+ const dest = path.join(cwd, destName || path.basename(srcPath));
32
+ if (fs.existsSync(dest)) {
33
+ // Find a non-conflicting name
34
+ const parsed = path.parse(dest);
35
+ let v = 1;
36
+ while (true) {
37
+ const candidate = path.join(cwd, parsed.name + '-v' + v + parsed.ext);
38
+ if (!fs.existsSync(candidate)) {
39
+ const altName = parsed.name + '-v' + v + parsed.ext;
40
+ const altPath = path.join(cwd, altName);
41
+ console.log(chalk.yellow(` ⚠ ${destName || path.basename(srcPath)} 已存在,使用:${altName}`));
42
+ fs.copyFileSync(srcPath, altPath);
43
+ return altPath;
44
+ }
45
+ v++;
46
+ }
47
+ }
48
+ fs.copyFileSync(srcPath, dest);
49
+ return dest;
50
+ }
51
+
52
+ // ─── Direct Copy (--template / --snippet / --config) ─────────────────────────
53
+
54
+ /**
55
+ * Derive a human-friendly default name from an EDM file path.
56
+ * e.g. edm/elabscience/templates/standard/template.html → elabscience-standard
57
+ * edm/elabscience/series/literature/default/snippet.html → elabscience-literature-default-snippet
58
+ */
59
+ function deriveDefaultName(filePath) {
60
+ const normalized = filePath.replace(/\\/g, '/');
61
+ // Try to match edm/<brand>/templates/<version>/...
62
+ const tplMatch = normalized.match(/edm\/([^/]+)\/templates\/([^/]+)/);
63
+ if (tplMatch) return `${tplMatch[1]}-${tplMatch[2]}`;
64
+ // Try to match edm/<brand>/series/<series>/<variant>/...
65
+ const snipMatch = normalized.match(/edm\/([^/]+)\/series\/([^/]+)\/([^/]+)/);
66
+ if (snipMatch) return `${snipMatch[1]}-${snipMatch[2]}-${snipMatch[3]}`;
67
+ // Fallback: source file basename
68
+ return path.parse(filePath).name;
69
+ }
70
+
71
+ async function directCopy(srcPath, cwd) {
72
+ const resolved = path.resolve(srcPath);
73
+ if (!fs.existsSync(resolved)) {
74
+ console.error(chalk.red(`\n ✘ 文件不存在:${resolved}\n`));
75
+ process.exit(1);
76
+ }
77
+
78
+ const destName = await promptOutputName(
79
+ deriveDefaultName(resolved),
80
+ cwd
81
+ );
82
+
83
+ const dest = path.join(cwd, destName + path.extname(resolved));
84
+ fs.copyFileSync(resolved, dest);
85
+ const stat = fs.statSync(dest);
86
+
87
+ console.log(chalk.green('\n✔ 已拷贝:'));
88
+ console.log(` ${chalk.cyan('·')} ./${path.relative(cwd, dest)} ${chalk.gray('(' + fmtBytes(stat.size) + ')')}`);
89
+ console.log();
90
+ }
91
+
92
+ // ─── Interactive Init ────────────────────────────────────────────────────────
93
+
94
+ async function interactiveInit(edmDir, cwd) {
95
+ const brands = findBrands(edmDir);
96
+ const brand = await promptBrand(brands);
97
+ const versions = findTemplateVersions(brand.path);
98
+ const version = await promptTemplateVersion(versions);
99
+
100
+ // Series selection
101
+ const allSeries = findSeriesDirs(brand.path);
102
+ let series = null;
103
+ let variant = null;
104
+
105
+ if (allSeries.length > 0) {
106
+ const { select } = await import('@inquirer/prompts');
107
+ const seriesChoices = [
108
+ { name: '[跳过] 仅使用模板', value: null },
109
+ ...allSeries.map(s => {
110
+ const meta = s.meta;
111
+ const display = meta.name
112
+ ? formatName(meta, s.name)
113
+ : chalk.bold.cyan(s.name);
114
+ return {
115
+ name: display,
116
+ value: s,
117
+ description: meta.description || undefined,
118
+ };
119
+ }),
120
+ ];
121
+ series = await select({
122
+ message: '选择片段系列(可选):',
123
+ choices: seriesChoices,
124
+ });
125
+
126
+ if (series) {
127
+ const variants = findSnippetVariants(series.path);
128
+ if (variants.length > 0) {
129
+ variant = await promptSnippetVariant(variants);
130
+ }
131
+ }
132
+ }
133
+
134
+ // Multi-select what to copy
135
+ const copyItems = [];
136
+ copyItems.push({
137
+ name: '📋 模板 HTML',
138
+ value: 'template',
139
+ description: path.basename(version.templatePath),
140
+ checked: true,
141
+ });
142
+
143
+ if (variant) {
144
+ const snipPath = path.join(variant.path, 'snippet.html');
145
+ if (fs.existsSync(snipPath)) {
146
+ copyItems.push({
147
+ name: '🧩 片段 HTML',
148
+ value: 'snippet',
149
+ description: path.basename(snipPath),
150
+ checked: true,
151
+ });
152
+ }
153
+
154
+ const configs = findConfigs(variant.path);
155
+ if (configs.length > 0) {
156
+ copyItems.push({
157
+ name: '⚙️ 配置文件',
158
+ value: 'config',
159
+ description: configs.map(c => c.name).join(', '),
160
+ checked: true,
161
+ });
162
+ }
163
+ }
164
+
165
+ const { checkbox } = await import('@inquirer/prompts');
166
+ const selected = await checkbox({
167
+ message: '选择要拷贝的内容:',
168
+ choices: copyItems,
169
+ });
170
+
171
+ if (selected.length === 0) {
172
+ console.log(chalk.gray('未选择任何内容,已取消。\n'));
173
+ return;
174
+ }
175
+
176
+ // Output name
177
+ const defaultBaseName = variant
178
+ ? `${brand.name}-${version.name}-${series.name}-${variant.name}`
179
+ : `${brand.name}-${version.name}`;
180
+ const outputBaseName = await promptOutputName(defaultBaseName, cwd);
181
+
182
+ // Copy files
183
+ console.log(chalk.green('\n✔ 已拷贝:'));
184
+ const cwdRel = (p) => './' + path.relative(cwd, p);
185
+
186
+ if (selected.includes('template')) {
187
+ const destName = outputBaseName + path.extname(version.templatePath);
188
+ const dest = copyFileToCwd(version.templatePath, cwd, destName);
189
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
190
+ }
191
+
192
+ if (selected.includes('snippet') && variant) {
193
+ const snipPath = path.join(variant.path, 'snippet.html');
194
+ const destName = outputBaseName + '-snippet' + path.extname(snipPath);
195
+ const dest = copyFileToCwd(snipPath, cwd, destName);
196
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
197
+ }
198
+
199
+ if (selected.includes('config') && variant) {
200
+ const configs = findConfigs(variant.path);
201
+ let cfgPath;
202
+ if (configs.length === 1) {
203
+ cfgPath = configs[0].path;
204
+ } else {
205
+ // Use optimal config
206
+ const optimal = configs.find(c => c.isOptimal);
207
+ cfgPath = optimal ? optimal.path : configs[0].path;
208
+ }
209
+ const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
210
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
211
+ }
212
+
213
+ // Summary
214
+ console.log();
215
+ if (selected.includes('snippet') && selected.includes('template')) {
216
+ const snipFile = outputBaseName + '-snippet' + path.extname(path.join(variant.path, 'snippet.html'));
217
+ const tplFile = outputBaseName + path.extname(version.templatePath);
218
+ console.log(
219
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
220
+ ' ' + chalk.cyan(`juice -s ${snipFile} -f ${tplFile}`) +
221
+ (selected.includes('config') ? chalk.cyan(' -c juice.yaml') : '') + '\n'
222
+ );
223
+ } else if (selected.includes('template')) {
224
+ const tplFile = outputBaseName + path.extname(version.templatePath);
225
+ console.log(
226
+ ' ' + chalk.dim('💡 下一步:') + '\n' +
227
+ ' ' + chalk.cyan(`juice -f ${tplFile}`) + '\n'
228
+ );
229
+ }
230
+ }
231
+
232
+ // ─── Main Entry ──────────────────────────────────────────────────────────────
233
+
234
+ async function runInitMode({ initPath, template, snippet, config }) {
235
+ const cwd = process.cwd();
236
+
237
+ // Direct copy modes
238
+ if (template) {
239
+ await directCopy(template, cwd);
240
+ return;
241
+ }
242
+ if (snippet) {
243
+ await directCopy(snippet, cwd);
244
+ return;
245
+ }
246
+ if (config) {
247
+ await directCopy(config, cwd);
248
+ return;
249
+ }
250
+
251
+ // Path-based from EDM
252
+ if (initPath) {
253
+ const { parseViewPath } = require('./view');
254
+ let edmDir;
255
+ try {
256
+ edmDir = resolveEdmDir();
257
+ } catch (err) {
258
+ console.error(chalk.red(`\n ✘ ${err.message}\n`));
259
+ process.exit(1);
260
+ }
261
+
262
+ let parsed;
263
+ try {
264
+ parsed = parseViewPath(initPath, edmDir);
265
+ } catch (err) {
266
+ console.error(chalk.red(`\n ✘ ${err.message}\n`));
267
+ process.exit(1);
268
+ }
269
+
270
+ if (parsed.type === 'template') {
271
+ await directCopy(parsed.versionData.templatePath, cwd);
272
+ } else if (parsed.type === 'variant') {
273
+ // Multi-select copy
274
+ const snipPath = path.join(parsed.variantData.path, 'snippet.html');
275
+ const configs = findConfigs(parsed.variantData.path);
276
+ const brandDir = path.join(edmDir, parsed.brand);
277
+ const versions = findTemplateVersions(brandDir);
278
+ const version = versions[0]; // default to first version
279
+ const tplPath = version.templatePath;
280
+
281
+ const copyItems = [
282
+ { name: '📋 模板 HTML', value: 'template', description: path.basename(tplPath), checked: true },
283
+ ];
284
+ if (fs.existsSync(snipPath)) {
285
+ copyItems.push({ name: '🧩 片段 HTML', value: 'snippet', description: path.basename(snipPath), checked: true });
286
+ }
287
+ if (configs.length > 0) {
288
+ copyItems.push({ name: '⚙️ 配置文件', value: 'config', description: configs.map(c => c.name).join(', '), checked: true });
289
+ }
290
+
291
+ const { checkbox } = await import('@inquirer/prompts');
292
+ const selected = await checkbox({
293
+ message: '选择要拷贝的内容:',
294
+ choices: copyItems,
295
+ });
296
+
297
+ if (selected.length === 0) {
298
+ console.log(chalk.gray('未选择任何内容,已取消。\n'));
299
+ return;
300
+ }
301
+
302
+ const defaultBaseName = `${parsed.brand}-${parsed.seriesData ? parsed.series : 'series'}-${parsed.variant}`;
303
+ const outputBaseName = await promptOutputName(defaultBaseName, cwd);
304
+
305
+ console.log(chalk.green('\n✔ 已拷贝:'));
306
+ const cwdRel = (p) => './' + path.relative(cwd, p);
307
+
308
+ if (selected.includes('template')) {
309
+ const destName = outputBaseName + path.extname(tplPath);
310
+ const dest = copyFileToCwd(tplPath, cwd, destName);
311
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(模板, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
312
+ }
313
+ if (selected.includes('snippet') && fs.existsSync(snipPath)) {
314
+ const destName = outputBaseName + '-snippet' + path.extname(snipPath);
315
+ const dest = copyFileToCwd(snipPath, cwd, destName);
316
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(片段, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
317
+ }
318
+ if (selected.includes('config') && configs.length > 0) {
319
+ const optimal = configs.find(c => c.isOptimal);
320
+ const cfgPath = optimal ? optimal.path : configs[0].path;
321
+ const dest = copyFileToCwd(cfgPath, cwd, 'juice.yaml');
322
+ console.log(` ${chalk.cyan('·')} ${cwdRel(dest)} ${chalk.gray('(配置, ' + fmtBytes(fs.statSync(dest).size) + ')')}`);
323
+ }
324
+ console.log();
325
+ } else {
326
+ console.error(chalk.red(
327
+ `\n ✘ 无法从「${initPath}」初始化。\n` +
328
+ ` 请指定:<brand>/templates/<version> 或 <brand>/<series>/<variant>\n`
329
+ ));
330
+ process.exit(1);
331
+ }
332
+ return;
333
+ }
334
+
335
+ // No arguments: interactive mode
336
+ let edmDir;
337
+ try {
338
+ edmDir = resolveEdmDir();
339
+ } catch (err) {
340
+ console.error(chalk.red(`\n ✘ ${err.message}\n`));
341
+ process.exit(1);
342
+ }
343
+ await interactiveInit(edmDir, cwd);
344
+ }
345
+
346
+ module.exports = { runInitMode };
package/src/snippet.js CHANGED
@@ -881,4 +881,39 @@ async function runInteractiveMode({ config: cliConfigPath }) {
881
881
  });
882
882
  }
883
883
 
884
- module.exports = { runSnippetMode, runInteractiveMode };
884
+ module.exports = {
885
+ runSnippetMode,
886
+ runInteractiveMode,
887
+ // EDM resolution & scanning
888
+ resolveEdmDir,
889
+ loadMeta,
890
+ findBrands,
891
+ findTemplateVersions,
892
+ findSeriesDirs,
893
+ findSnippetVariants,
894
+ findConfigs,
895
+ findHtmlFiles,
896
+ findYamlFiles,
897
+ findLocalConfig,
898
+ getBrand,
899
+ filterSeries,
900
+ // config
901
+ buildSnippetConfig,
902
+ // content
903
+ insertIntoContent,
904
+ reindentHtml,
905
+ resolveSnippetOutputPaths,
906
+ assembleSnippet,
907
+ // prompts
908
+ promptBrand,
909
+ promptTemplateVersion,
910
+ promptSeries,
911
+ promptSnippetVariant,
912
+ promptConfig,
913
+ promptConfirm,
914
+ promptOutputName,
915
+ // output helpers
916
+ checkOutputConflicts,
917
+ findNextVersion,
918
+ copyTemplateToCwd,
919
+ };