juice-email-cli 2.2.1 → 2.3.0

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.1",
3
+ "version": "2.3.0",
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": {
@@ -90,10 +90,13 @@ const YAML_ROOTS = [
90
90
 
91
91
  // 子命令名称常量
92
92
  const SUBCMDS = {
93
- generate: 'JuiceEmail.Generate',
94
- snippet: 'JuiceEmail.Snippet',
95
- config: 'JuiceEmail.WithConfig',
96
- pwsh: 'JuiceEmail.OpenPwsh',
93
+ generate: 'JuiceEmail.Generate',
94
+ snippet: 'JuiceEmail.Snippet',
95
+ config: 'JuiceEmail.WithConfig',
96
+ viewEdm: 'JuiceEmail.ViewEdm',
97
+ viewEdmInt:'JuiceEmail.ViewEdmInteractive',
98
+ initEdm: 'JuiceEmail.InitEdm',
99
+ pwsh: 'JuiceEmail.OpenPwsh',
97
100
  };
98
101
 
99
102
  const PARENT_KEY_NAME = 'JuiceEmail';
@@ -101,6 +104,8 @@ const PARENT_KEY_NAME = 'JuiceEmail';
101
104
  // ExtendedSubCommandsKey 指向的容器路径(相对于 HKCU\Software\Classes)
102
105
  const SUB_CMDS_CONTAINER_HTML = 'JuiceEmail.SubCommands';
103
106
  const SUB_CMDS_CONTAINER_YAML = 'JuiceEmail.SubCommands.Yaml';
107
+ const SUB_CMDS_CONTAINER_DIR = 'JuiceEmail.SubCommands.Dir';
108
+ const SUB_CMDS_CONTAINER_BG = 'JuiceEmail.SubCommands.Bg';
104
109
 
105
110
  // ─── 旧版残留清理 ─────────────────────────────────────────────────────────────
106
111
 
@@ -209,6 +214,19 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
209
214
  regAdd(`${cfgKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, '-c %1'));
210
215
  }
211
216
 
217
+ // Common items for file-type containers (html + yaml)
218
+ if (kind === 'html' || kind === 'yaml') {
219
+ const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
220
+ regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
221
+ regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
222
+ regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
223
+
224
+ const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
225
+ regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
226
+ regAdd(viewIntKey, 'Icon', 'REG_SZ', iconPath);
227
+ regAdd(`${viewIntKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
228
+ }
229
+
212
230
  // pwsh — 始终显示(可选)
213
231
  if (pwshPath) {
214
232
  const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
@@ -219,6 +237,54 @@ function registerSubCommands(containerPath, kind, nodePath, scriptPath, iconPath
219
237
  }
220
238
  }
221
239
 
240
+ // ─── Directory / Background 菜单注册 ──────────────────────────────────────────
241
+
242
+ function registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath) {
243
+ // Register directory container subcommands
244
+ const dirContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`;
245
+ const bgContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`;
246
+
247
+ for (const containerPath of [dirContainer, bgContainer]) {
248
+ // 📥 从资源库拷贝到此处
249
+ const initKey = `${containerPath}\\shell\\${SUBCMDS.initEdm}`;
250
+ regAdd(initKey, 'MUIVerb', 'REG_SZ', '📥 从资源库拷贝到此处');
251
+ regAdd(initKey, 'Icon', 'REG_SZ', iconPath);
252
+ regAdd(`${initKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'init'));
253
+
254
+ // 📦 查看资源列表
255
+ const viewKey = `${containerPath}\\shell\\${SUBCMDS.viewEdm}`;
256
+ regAdd(viewKey, 'MUIVerb', 'REG_SZ', '📦 查看资源列表');
257
+ regAdd(viewKey, 'Icon', 'REG_SZ', iconPath);
258
+ regAdd(`${viewKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view'));
259
+
260
+ // 📋 浏览资源库
261
+ const viewIntKey = `${containerPath}\\shell\\${SUBCMDS.viewEdmInt}`;
262
+ regAdd(viewIntKey, 'MUIVerb', 'REG_SZ', '📋 浏览资源库');
263
+ regAdd(viewIntKey, 'Icon', 'REG_SZ', iconPath);
264
+ regAdd(`${viewIntKey}\\command`, '', 'REG_SZ', wrapInteractive(nodePath, scriptPath, 'view -i'));
265
+
266
+ // 📂 在此打开终端
267
+ if (pwshPath) {
268
+ const pwshKey = `${containerPath}\\shell\\${SUBCMDS.pwsh}`;
269
+ regAdd(pwshKey, 'MUIVerb', 'REG_SZ', '📂 在此打开终端');
270
+ regAdd(pwshKey, 'Icon', 'REG_SZ', pwshPath);
271
+ regAdd(`${pwshKey}\\command`, '', 'REG_SZ',
272
+ `"${pwshPath}" -NoExit -Command "Set-Location -LiteralPath '%1'"`);
273
+ }
274
+ }
275
+
276
+ // Parent keys: Directory and Directory\Background
277
+ const dirParent = `${HKCU_SHELL}\\Directory\\shell\\${PARENT_KEY_NAME}`;
278
+ regAdd(dirParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
279
+ regAdd(dirParent, 'Icon', 'REG_SZ', iconPath);
280
+ regAdd(dirParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_DIR);
281
+
282
+ const bgParent = `${HKCU_SHELL}\\Directory\\Background\\shell\\${PARENT_KEY_NAME}`;
283
+ regAdd(bgParent, 'MUIVerb', 'REG_SZ', '📧 juice 邮件工具');
284
+ regAdd(bgParent, 'Icon', 'REG_SZ', iconPath);
285
+ regAdd(bgParent, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_BG);
286
+ }
287
+
222
288
  // ─── 注册 ─────────────────────────────────────────────────────────────────────
223
289
 
224
290
  async function registerContextMenu() {
@@ -243,6 +309,8 @@ async function registerContextMenu() {
243
309
  // 先删除旧容器再重建,避免注册残留旧子命令(如旧版 HTML 容器的 WithConfig)
244
310
  regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`);
245
311
  regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`);
312
+ regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`);
313
+ regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`);
246
314
 
247
315
  // HTML 容器:generate + snippet + pwsh
248
316
  const htmlContainer = `${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`;
@@ -268,6 +336,9 @@ async function registerContextMenu() {
268
336
  regAdd(parentKey, 'ExtendedSubCommandsKey', 'REG_SZ', SUB_CMDS_CONTAINER_YAML);
269
337
  }
270
338
 
339
+ // Directory / Background → 独立容器
340
+ registerDirBgMenus(nodePath, scriptPath, iconPath, pwshPath);
341
+
271
342
  if (!ok) {
272
343
  console.log(chalk.yellow(' ⚠ 部分注册表项写入失败,右键菜单可能不完整。\n'));
273
344
  }
@@ -279,13 +350,21 @@ async function registerContextMenu() {
279
350
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
280
351
  ` ├── 📄 作为模板,生成邮件 HTML → juice -f(后台执行)\n` +
281
352
  ` ├── 🧩 作为片段,拼接邮件 HTML → juice -s(交互选择模板)\n` +
353
+ ` ├── 📦 查看资源列表 → juice view\n` +
354
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
282
355
  (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
283
356
  `\n ${chalk.bold('.yaml / .yml')} 文件右键:\n` +
284
357
  ` ${chalk.bold('📧 用 juice 生成邮件 HTML')}\n` +
285
- (pwshPath
286
- ? ` ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n` +
287
- ` └── 📂 打开 PowerShell\n`
288
- : ` └── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n`) +
358
+ ` ├── ⚙️ 作为配置,拼接邮件 HTML → juice -c(交互选择品牌/模板/片段)\n` +
359
+ ` ├── 📦 查看资源列表 → juice view\n` +
360
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
361
+ (pwshPath ? ` └── 📂 打开 PowerShell\n` : '') +
362
+ `\n ${chalk.bold('文件夹 / 空白处')} 右键:\n` +
363
+ ` ${chalk.bold('📧 juice 邮件工具')}\n` +
364
+ ` ├── 📥 从资源库拷贝到此处 → juice init\n` +
365
+ ` ├── 📦 查看资源列表 → juice view\n` +
366
+ ` ├── 📋 浏览资源库 → juice view -i\n` +
367
+ (pwshPath ? ` └── 📂 在此打开终端\n` : '') +
289
368
  '\n' +
290
369
  chalk.gray(' 注意:如菜单未出现,请重启文件资源管理器(explorer.exe)。\n')
291
370
  );
@@ -303,15 +382,22 @@ async function unregisterContextMenu() {
303
382
 
304
383
  let removed = 0;
305
384
 
306
- // 清理 HKCU 父菜单(4 个文件类型)
307
- const allRoots = [...HTML_ROOTS, ...YAML_ROOTS];
385
+ // 清理 HKCU 父菜单(4 个文件类型 + Directory + Background)
386
+ const allRoots = [
387
+ ...HTML_ROOTS,
388
+ ...YAML_ROOTS,
389
+ `${HKCU_SHELL}\\Directory\\shell`,
390
+ `${HKCU_SHELL}\\Directory\\Background\\shell`,
391
+ ];
308
392
  for (const root of allRoots) {
309
393
  if (regDelete(`${root}\\${PARENT_KEY_NAME}`)) removed++;
310
394
  }
311
395
 
312
- // 清理两个子命令容器
396
+ // 清理所有子命令容器
313
397
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_HTML}`)) removed++;
314
398
  if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_YAML}`)) removed++;
399
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_DIR}`)) removed++;
400
+ if (regDelete(`${HKCU_SHELL}\\${SUB_CMDS_CONTAINER_BG}`)) removed++;
315
401
 
316
402
  // 清理旧版共享容器(v2.1.14 之前只有一个容器)
317
403
  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
+ };