juice-email-cli 2.3.14 → 2.4.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 +37 -22
- package/package.json +9 -8
- package/src/context-menu.js +8 -7
- package/src/index.js +39 -72
- package/src/init.js +15 -13
- package/src/snippet.js +34 -44
- package/src/view.js +11 -13
package/bin/juice.js
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, resolve } from 'path';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { run } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
3
11
|
|
|
4
|
-
const { program } = require('commander');
|
|
5
|
-
const path = require('path');
|
|
6
|
-
const pkg = require('../package.json');
|
|
7
|
-
const { run } = require('../src/index');
|
|
8
12
|
let ExitPromptError;
|
|
9
13
|
try {
|
|
10
|
-
({ ExitPromptError } =
|
|
14
|
+
({ ExitPromptError } = await import('@inquirer/core'));
|
|
11
15
|
} catch (_) {}
|
|
12
16
|
|
|
13
|
-
/**
|
|
14
|
-
* Wrap an async action handler to catch Ctrl+C and other errors gracefully.
|
|
15
|
-
*/
|
|
16
17
|
function safeAction(fn) {
|
|
17
18
|
return async (...args) => {
|
|
18
19
|
try {
|
|
19
20
|
await fn(...args);
|
|
20
21
|
} catch (err) {
|
|
21
22
|
if (ExitPromptError && err instanceof ExitPromptError) {
|
|
22
|
-
// Ctrl+C — exit silently
|
|
23
23
|
process.exit(0);
|
|
24
24
|
}
|
|
25
|
-
console.error('\n' +
|
|
25
|
+
console.error('\n' + chalk.red(` ✘ ${err.message}`));
|
|
26
26
|
if (process.env.DEBUG) console.error(err);
|
|
27
27
|
process.exit(1);
|
|
28
28
|
}
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
const program = new Command();
|
|
33
|
+
|
|
32
34
|
program
|
|
33
35
|
.name('juice')
|
|
34
36
|
.description('生成符合各大邮件平台的内联 CSS HTML 邮件(同时输出标准版 + 压缩版)')
|
|
@@ -68,6 +70,24 @@ program
|
|
|
68
70
|
交互模式(逐步选择品牌、模板、片段、配置):
|
|
69
71
|
juice
|
|
70
72
|
|
|
73
|
+
════════════════════════════════════════════════════════════════
|
|
74
|
+
资源浏览
|
|
75
|
+
════════════════════════════════════════════════════════════════
|
|
76
|
+
|
|
77
|
+
查看资源树:
|
|
78
|
+
juice view 查看所有品牌、模板、系列、片段
|
|
79
|
+
juice view elabscience 查看指定品牌的模板和系列
|
|
80
|
+
juice view --templates 列出所有模板
|
|
81
|
+
juice view --series 列出所有系列
|
|
82
|
+
juice view --snippets 列出所有片段
|
|
83
|
+
|
|
84
|
+
拷贝到当前目录:
|
|
85
|
+
juice init 交互式选择并拷贝模板/片段/配置
|
|
86
|
+
juice init --template <file> 仅拷贝指定模板 HTML
|
|
87
|
+
juice init --snippet <file> 仅拷贝指定片段 HTML
|
|
88
|
+
juice init --config <file> 仅拷贝指定配置 YAML
|
|
89
|
+
juice init --all [target] 拷贝整个 EDM 资源库
|
|
90
|
+
|
|
71
91
|
════════════════════════════════════════════════════════════════
|
|
72
92
|
输出文件说明
|
|
73
93
|
════════════════════════════════════════════════════════════════
|
|
@@ -111,7 +131,7 @@ program
|
|
|
111
131
|
.option('--series', '列出所有系列')
|
|
112
132
|
.option('--snippets', '列出所有片段')
|
|
113
133
|
.action(safeAction(async (viewPath, options) => {
|
|
114
|
-
const { runViewMode } =
|
|
134
|
+
const { runViewMode } = await import('../src/view.js');
|
|
115
135
|
await runViewMode({
|
|
116
136
|
viewPath: viewPath || null,
|
|
117
137
|
interactive: !!options.interactive,
|
|
@@ -130,7 +150,7 @@ program
|
|
|
130
150
|
.option('-c, --config <file-path>', '仅拷贝配置 YAML 文件')
|
|
131
151
|
.option('--all [target]', '拷贝整个 EDM 资源目录到当前或指定目录')
|
|
132
152
|
.action(safeAction(async (initPath, options) => {
|
|
133
|
-
const { runInitMode } =
|
|
153
|
+
const { runInitMode } = await import('../src/init.js');
|
|
134
154
|
await runInitMode({
|
|
135
155
|
initPath: initPath || null,
|
|
136
156
|
template: options.template || null,
|
|
@@ -143,21 +163,19 @@ program
|
|
|
143
163
|
// ─── Default action (backward compatible) ───────────────────────────────
|
|
144
164
|
program
|
|
145
165
|
.action(safeAction(async (options) => {
|
|
146
|
-
// 注册/卸载右键菜单
|
|
147
166
|
if (options.install) {
|
|
148
|
-
const { registerContextMenu } =
|
|
167
|
+
const { registerContextMenu } = await import('../src/context-menu.js');
|
|
149
168
|
await registerContextMenu();
|
|
150
169
|
return;
|
|
151
170
|
}
|
|
152
171
|
if (options.uninstall) {
|
|
153
|
-
const { unregisterContextMenu } =
|
|
172
|
+
const { unregisterContextMenu } = await import('../src/context-menu.js');
|
|
154
173
|
await unregisterContextMenu();
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
|
|
158
|
-
// --snippet / -s 模式:片段组装
|
|
159
177
|
if (options.snippet) {
|
|
160
|
-
const { runSnippetMode } =
|
|
178
|
+
const { runSnippetMode } = await import('../src/snippet.js');
|
|
161
179
|
await runSnippetMode({
|
|
162
180
|
snippet: options.snippet,
|
|
163
181
|
template: options.file,
|
|
@@ -167,7 +185,6 @@ program
|
|
|
167
185
|
return;
|
|
168
186
|
}
|
|
169
187
|
|
|
170
|
-
// 普通模式:-f 指定文件
|
|
171
188
|
let inputFile = options.file;
|
|
172
189
|
if (!inputFile && program.args.length > 0) {
|
|
173
190
|
inputFile = program.args[0];
|
|
@@ -181,10 +198,8 @@ program
|
|
|
181
198
|
return;
|
|
182
199
|
}
|
|
183
200
|
|
|
184
|
-
|
|
185
|
-
const { runInteractiveMode } = require('../src/snippet');
|
|
201
|
+
const { runInteractiveMode } = await import('../src/snippet.js');
|
|
186
202
|
await runInteractiveMode({ config: options.config });
|
|
187
203
|
}));
|
|
188
204
|
|
|
189
|
-
program.allowUnknownOption(false);
|
|
190
205
|
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "juice-email-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "CLI tool to generate email-client-compatible HTML with juice (CSS inlining) + Mustache templating + minification",
|
|
5
6
|
"main": "src/index.js",
|
|
6
7
|
"bin": {
|
|
@@ -36,17 +37,17 @@
|
|
|
36
37
|
"url": "https://github.com/GuoSirius/juice-cli/issues"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@inquirer/prompts": "^
|
|
40
|
-
"chalk": "^
|
|
41
|
-
"commander": "^
|
|
40
|
+
"@inquirer/prompts": "^8.4.3",
|
|
41
|
+
"chalk": "^5.6.2",
|
|
42
|
+
"commander": "^14.0.3",
|
|
42
43
|
"html-minifier-terser": "^7.2.0",
|
|
43
|
-
"js-yaml": "^4.1.
|
|
44
|
-
"juice": "^
|
|
44
|
+
"js-yaml": "^4.1.1",
|
|
45
|
+
"juice": "^11.1.1",
|
|
45
46
|
"mustache": "^4.2.0",
|
|
46
|
-
"ora": "^
|
|
47
|
+
"ora": "^9.4.0"
|
|
47
48
|
},
|
|
48
49
|
"engines": {
|
|
49
|
-
"node": ">=
|
|
50
|
+
"node": ">=24.0.0"
|
|
50
51
|
},
|
|
51
52
|
"files": [
|
|
52
53
|
"bin/",
|
package/src/context-menu.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Windows 右键菜单注册 / 取消注册
|
|
5
3
|
*
|
|
@@ -32,10 +30,13 @@
|
|
|
32
30
|
* └── 📂 在此打开终端 (可选)
|
|
33
31
|
*/
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
import { spawnSync } from 'child_process';
|
|
34
|
+
import fs from 'fs';
|
|
35
|
+
import path from 'path';
|
|
36
|
+
import { fileURLToPath } from 'url';
|
|
37
|
+
import chalk from 'chalk';
|
|
38
|
+
|
|
39
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
40
|
|
|
40
41
|
// ─── 路径工具 ─────────────────────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -459,4 +460,4 @@ async function unregisterContextMenu() {
|
|
|
459
460
|
}
|
|
460
461
|
}
|
|
461
462
|
|
|
462
|
-
|
|
463
|
+
export { registerContextMenu, unregisterContextMenu };
|
package/src/index.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import yaml from 'js-yaml';
|
|
6
|
+
import juice from 'juice';
|
|
7
|
+
import Mustache from 'mustache';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import ora from 'ora';
|
|
10
|
+
import { minify as htmlMinify } from 'html-minifier-terser';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
|
|
13
14
|
// ─── 从 defaults/juice.yaml 加载默认配置 ─────────────────────────────────────
|
|
14
15
|
|
|
15
|
-
const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', 'defaults', 'juice.yaml');
|
|
16
|
+
export const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '..', 'defaults', 'juice.yaml');
|
|
16
17
|
|
|
17
18
|
function loadDefaultConfig() {
|
|
18
19
|
try {
|
|
19
20
|
const content = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8');
|
|
20
21
|
return yaml.load(content) || {};
|
|
21
22
|
} catch (e) {
|
|
22
|
-
// 如果 defaults/juice.yaml 不存在,使用内置最小配置
|
|
23
23
|
console.warn(chalk.yellow(` ⚠ 无法加载默认配置 ${DEFAULT_CONFIG_PATH},使用内置默认值\n`));
|
|
24
24
|
return {};
|
|
25
25
|
}
|
|
@@ -27,34 +27,20 @@ function loadDefaultConfig() {
|
|
|
27
27
|
|
|
28
28
|
const CODE_DEFAULTS = loadDefaultConfig();
|
|
29
29
|
|
|
30
|
-
// 用户主目录候选(支持 yaml/yml)
|
|
31
30
|
const HOME_CANDIDATES = [
|
|
32
31
|
path.join(os.homedir(), 'juice.yaml'),
|
|
33
32
|
path.join(os.homedir(), 'juice.yml'),
|
|
34
33
|
];
|
|
35
34
|
|
|
36
|
-
function resolveHomeConfig() {
|
|
35
|
+
export function resolveHomeConfig() {
|
|
37
36
|
return HOME_CANDIDATES.find((c) => fs.existsSync(c)) || null;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
// ─── 配置文件查找 ─────────────────────────────────────────────────────────────
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
* 查找用户配置文件
|
|
44
|
-
*
|
|
45
|
-
* 配置加载顺序(优先级从低到高):
|
|
46
|
-
* 1. CLI 内置默认值(defaults/juice.yaml)
|
|
47
|
-
* 2. 用户主目录 ~/juice.yaml(如果存在)
|
|
48
|
-
* 3. 优先配置文件(-c 指定 或 输入文件同级目录,二者互斥)
|
|
49
|
-
*
|
|
50
|
-
* 返回:
|
|
51
|
-
* - highPriorityPath: 高优先级配置路径(-c 指定 或 输入文件同级目录,二者互斥)
|
|
52
|
-
* - homePath: 用户主目录配置路径(可能不存在)
|
|
53
|
-
*/
|
|
54
|
-
function findConfigs(configPath, inputFile) {
|
|
41
|
+
export function findConfigs(configPath, inputFile) {
|
|
55
42
|
let highPriorityPath = null;
|
|
56
43
|
|
|
57
|
-
// 1. 优先使用 -c 指定的配置文件
|
|
58
44
|
if (configPath) {
|
|
59
45
|
const resolved = path.resolve(configPath);
|
|
60
46
|
if (!fs.existsSync(resolved)) {
|
|
@@ -62,7 +48,6 @@ function findConfigs(configPath, inputFile) {
|
|
|
62
48
|
}
|
|
63
49
|
highPriorityPath = resolved;
|
|
64
50
|
}
|
|
65
|
-
// 2. 否则使用输入文件同级目录下的配置(互斥)
|
|
66
51
|
else if (inputFile) {
|
|
67
52
|
const inputDir = path.dirname(path.resolve(inputFile));
|
|
68
53
|
const fileCandidates = [
|
|
@@ -82,7 +67,7 @@ function findConfigs(configPath, inputFile) {
|
|
|
82
67
|
|
|
83
68
|
// ─── 配置文件加载 ─────────────────────────────────────────────────────────────
|
|
84
69
|
|
|
85
|
-
function loadYaml(filePath) {
|
|
70
|
+
export function loadYaml(filePath) {
|
|
86
71
|
if (!filePath || !fs.existsSync(filePath)) return {};
|
|
87
72
|
try {
|
|
88
73
|
return yaml.load(fs.readFileSync(filePath, 'utf8')) || {};
|
|
@@ -91,10 +76,9 @@ function loadYaml(filePath) {
|
|
|
91
76
|
}
|
|
92
77
|
}
|
|
93
78
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
function deepMerge(base, ...overrides) {
|
|
79
|
+
// ─── 深度合并 ─────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export function deepMerge(base, ...overrides) {
|
|
98
82
|
let result = Object.assign({}, base);
|
|
99
83
|
for (const src of overrides) {
|
|
100
84
|
if (!src || typeof src !== 'object' || Array.isArray(src)) continue;
|
|
@@ -118,24 +102,17 @@ function deepMerge(base, ...overrides) {
|
|
|
118
102
|
return result;
|
|
119
103
|
}
|
|
120
104
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
* 合并顺序(优先级从低到高):
|
|
125
|
-
* CLI 内置默认值 < 用户目录 ~/juice.yaml < 优先配置(-c/输入目录)
|
|
126
|
-
*/
|
|
127
|
-
function buildConfig(highPriorityPath, homePath) {
|
|
105
|
+
// ─── 配置合并 ─────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export function buildConfig(highPriorityPath, homePath) {
|
|
128
108
|
const layers = [];
|
|
129
109
|
|
|
130
|
-
// 1. CLI 内置默认值(最低优先级)
|
|
131
110
|
layers.push({ label: 'CLI 内置默认值', data: CODE_DEFAULTS });
|
|
132
111
|
|
|
133
|
-
// 2. 用户目录配置(如果有)
|
|
134
112
|
if (homePath) {
|
|
135
113
|
layers.push({ label: `用户目录配置 (${homePath})`, data: loadYaml(homePath) });
|
|
136
114
|
}
|
|
137
115
|
|
|
138
|
-
// 3. 优先配置(最高优先级,-c 指定 或 输入文件同级目录,互斥)
|
|
139
116
|
if (highPriorityPath) {
|
|
140
117
|
layers.push({ label: `优先配置 (${highPriorityPath})`, data: loadYaml(highPriorityPath) });
|
|
141
118
|
}
|
|
@@ -146,10 +123,9 @@ function buildConfig(highPriorityPath, homePath) {
|
|
|
146
123
|
|
|
147
124
|
// ─── HTML 模板处理 ────────────────────────────────────────────────────────────
|
|
148
125
|
|
|
149
|
-
function processTemplate(inputFile, config) {
|
|
126
|
+
export function processTemplate(inputFile, config) {
|
|
150
127
|
const htmlRaw = fs.readFileSync(inputFile, 'utf8');
|
|
151
128
|
|
|
152
|
-
// 1. Mustache 变量替换(根据 rawHtml 配置决定是否转义 HTML)
|
|
153
129
|
const originalEscape = Mustache.escape;
|
|
154
130
|
let htmlWithVars;
|
|
155
131
|
try {
|
|
@@ -161,18 +137,16 @@ function processTemplate(inputFile, config) {
|
|
|
161
137
|
Mustache.escape = originalEscape;
|
|
162
138
|
}
|
|
163
139
|
|
|
164
|
-
// 2. 收集 extraCssFiles
|
|
165
140
|
const basePath = path.dirname(path.resolve(inputFile));
|
|
166
141
|
const extraCss = collectExtraCss(basePath, config);
|
|
167
142
|
|
|
168
|
-
// 3. juice CSS 内联
|
|
169
143
|
const juiceOpts = Object.assign({}, config.juice || {});
|
|
170
144
|
delete juiceOpts.extraCssFiles;
|
|
171
145
|
|
|
172
146
|
return juice(htmlWithVars, { ...juiceOpts, extraCss });
|
|
173
147
|
}
|
|
174
148
|
|
|
175
|
-
function collectExtraCss(basePath, config) {
|
|
149
|
+
export function collectExtraCss(basePath, config) {
|
|
176
150
|
const extraFiles = (config.juice && config.juice.extraCssFiles) || [];
|
|
177
151
|
if (!extraFiles.length) return '';
|
|
178
152
|
return extraFiles
|
|
@@ -189,7 +163,7 @@ function collectExtraCss(basePath, config) {
|
|
|
189
163
|
|
|
190
164
|
// ─── 压缩 ─────────────────────────────────────────────────────────────────────
|
|
191
165
|
|
|
192
|
-
async function minifyHtml(html, minifyConfig) {
|
|
166
|
+
export async function minifyHtml(html, minifyConfig) {
|
|
193
167
|
return htmlMinify(html, {
|
|
194
168
|
removeConditionalComments: false,
|
|
195
169
|
...(minifyConfig || {}),
|
|
@@ -208,39 +182,46 @@ function resolveOutputPaths(inputFile, config) {
|
|
|
208
182
|
};
|
|
209
183
|
}
|
|
210
184
|
|
|
185
|
+
// ─── 格式化 ───────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export function fmtSize(str) {
|
|
188
|
+
const b = Buffer.byteLength(str, 'utf8');
|
|
189
|
+
return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function savings(original, minified) {
|
|
193
|
+
const orig = Buffer.byteLength(original, 'utf8');
|
|
194
|
+
const mini = Buffer.byteLength(minified, 'utf8');
|
|
195
|
+
return (((orig - mini) / orig) * 100).toFixed(1) + '%';
|
|
196
|
+
}
|
|
197
|
+
|
|
211
198
|
// ─── 主入口 ───────────────────────────────────────────────────────────────────
|
|
212
199
|
|
|
213
|
-
async function run({ file, config: configPath }) {
|
|
200
|
+
export async function run({ file, config: configPath }) {
|
|
214
201
|
const spinner = ora({ text: '正在处理...', color: 'cyan' }).start();
|
|
215
202
|
|
|
216
203
|
try {
|
|
217
|
-
// 1. 输入文件
|
|
218
204
|
const inputFile = path.resolve(file);
|
|
219
205
|
if (!fs.existsSync(inputFile)) {
|
|
220
206
|
spinner.fail(chalk.red(`输入文件不存在:${inputFile}`));
|
|
221
207
|
process.exit(1);
|
|
222
208
|
}
|
|
223
209
|
|
|
224
|
-
// 2. 查找配置(-c 和输入文件同级目录互斥)+ 合并
|
|
225
210
|
const { highPriorityPath, homePath } = findConfigs(configPath, inputFile);
|
|
226
211
|
const { config, layers } = buildConfig(highPriorityPath, homePath);
|
|
227
212
|
const encoding = (config.output && config.output.encoding) || 'utf8';
|
|
228
213
|
|
|
229
|
-
// 3. CSS 内联
|
|
230
214
|
spinner.text = `CSS 内联处理:${path.basename(inputFile)}`;
|
|
231
215
|
const resultHtml = processTemplate(inputFile, config);
|
|
232
216
|
|
|
233
|
-
// 4. 输出路径
|
|
234
217
|
const outPaths = resolveOutputPaths(inputFile, config);
|
|
235
218
|
|
|
236
|
-
// 5. 写出标准版
|
|
237
219
|
spinner.text = '写出 .output.html ...';
|
|
238
220
|
if (fs.existsSync(outPaths.normal)) {
|
|
239
221
|
spinner.warn(chalk.yellow(`目标文件已存在,将覆盖:${outPaths.normal}`));
|
|
240
222
|
}
|
|
241
223
|
fs.writeFileSync(outPaths.normal, resultHtml, encoding);
|
|
242
224
|
|
|
243
|
-
// 6. 压缩并写出
|
|
244
225
|
spinner.text = '写出 .minified.html ...';
|
|
245
226
|
if (fs.existsSync(outPaths.minified)) {
|
|
246
227
|
spinner.warn(chalk.yellow(`目标文件已存在,将覆盖:${outPaths.minified}`));
|
|
@@ -248,7 +229,6 @@ async function run({ file, config: configPath }) {
|
|
|
248
229
|
const minified = await minifyHtml(resultHtml, config.minify);
|
|
249
230
|
fs.writeFileSync(outPaths.minified, minified, encoding);
|
|
250
231
|
|
|
251
|
-
// 7. 汇报
|
|
252
232
|
const layerLines = layers
|
|
253
233
|
.map((l) => ` ${chalk.gray('·')} ${l.label}`)
|
|
254
234
|
.join('\n');
|
|
@@ -267,16 +247,3 @@ async function run({ file, config: configPath }) {
|
|
|
267
247
|
process.exit(1);
|
|
268
248
|
}
|
|
269
249
|
}
|
|
270
|
-
|
|
271
|
-
function fmtSize(str) {
|
|
272
|
-
const b = Buffer.byteLength(str, 'utf8');
|
|
273
|
-
return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function savings(original, minified) {
|
|
277
|
-
const orig = Buffer.byteLength(original, 'utf8');
|
|
278
|
-
const mini = Buffer.byteLength(minified, 'utf8');
|
|
279
|
-
return (((orig - mini) / orig) * 100).toFixed(1) + '%';
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
module.exports = { run, findConfigs, buildConfig, processTemplate, minifyHtml, deepMerge, collectExtraCss, loadYaml, fmtSize, savings, DEFAULT_CONFIG_PATH, resolveHomeConfig };
|
package/src/init.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const chalk = require('chalk');
|
|
6
|
-
const {
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import {
|
|
7
5
|
resolveEdmDir, loadMeta, findBrands, findTemplateVersions,
|
|
8
|
-
findSeriesDirs, findSnippetVariants, findConfigs,
|
|
6
|
+
findSeriesDirs, filterSeries, findSnippetVariants, findConfigs,
|
|
9
7
|
promptOutputName,
|
|
10
|
-
}
|
|
8
|
+
} from './snippet.js';
|
|
11
9
|
|
|
12
10
|
function fmtBytes(b) {
|
|
13
11
|
return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
|
@@ -92,6 +90,7 @@ async function selectWithNav(message, choices, showBack) {
|
|
|
92
90
|
async function interactiveInit(edmDir, cwd) {
|
|
93
91
|
let step = 'brand';
|
|
94
92
|
let brand = null, version = null, series = null, variant = null;
|
|
93
|
+
let hadVariantStep = false; // track if variant step was actually shown
|
|
95
94
|
|
|
96
95
|
while (true) {
|
|
97
96
|
if (step === 'brand') {
|
|
@@ -122,7 +121,9 @@ async function interactiveInit(edmDir, cwd) {
|
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
if (step === 'series') {
|
|
125
|
-
|
|
124
|
+
let allSeries = findSeriesDirs(brand.path);
|
|
125
|
+
// Apply template version series filtering (allow/block in _meta.yaml)
|
|
126
|
+
allSeries = filterSeries(allSeries, version.meta);
|
|
126
127
|
if (allSeries.length === 0) {
|
|
127
128
|
series = null;
|
|
128
129
|
step = 'copy';
|
|
@@ -150,6 +151,7 @@ async function interactiveInit(edmDir, cwd) {
|
|
|
150
151
|
step = 'copy';
|
|
151
152
|
continue;
|
|
152
153
|
}
|
|
154
|
+
hadVariantStep = true;
|
|
153
155
|
const choices = variants.map(v => ({
|
|
154
156
|
name: formatName(v.meta, v.name) + (v.meta.description ? ' — ' + chalk.dim(v.meta.description) : ''),
|
|
155
157
|
value: v,
|
|
@@ -218,7 +220,7 @@ async function interactiveInit(edmDir, cwd) {
|
|
|
218
220
|
mainChoices,
|
|
219
221
|
true
|
|
220
222
|
);
|
|
221
|
-
if (action === 'back') { step =
|
|
223
|
+
if (action === 'back') { step = hadVariantStep ? 'variant' : (series ? 'series' : 'version'); break; }
|
|
222
224
|
if (action === 'exit') { console.log(chalk.gray('已退出。\n')); return; }
|
|
223
225
|
if (action === 'edit') {
|
|
224
226
|
const { checkbox } = await import('@inquirer/prompts');
|
|
@@ -234,7 +236,7 @@ async function interactiveInit(edmDir, cwd) {
|
|
|
234
236
|
// fall through to confirmation below
|
|
235
237
|
} else if (selected.length === 0) {
|
|
236
238
|
console.log(chalk.gray('未选择任何内容。\n'));
|
|
237
|
-
step =
|
|
239
|
+
step = hadVariantStep ? 'variant' : (series ? 'series' : 'version');
|
|
238
240
|
break;
|
|
239
241
|
}
|
|
240
242
|
|
|
@@ -429,7 +431,7 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
|
|
|
429
431
|
}
|
|
430
432
|
|
|
431
433
|
if (initPath) {
|
|
432
|
-
const { parseViewPath } =
|
|
434
|
+
const { parseViewPath } = await import('./view.js');
|
|
433
435
|
let edmDir;
|
|
434
436
|
try {
|
|
435
437
|
edmDir = resolveEdmDir();
|
|
@@ -522,4 +524,4 @@ async function runInitMode({ initPath, template, snippet, config, all }) {
|
|
|
522
524
|
await interactiveInit(edmDir, cwd);
|
|
523
525
|
}
|
|
524
526
|
|
|
525
|
-
|
|
527
|
+
export { runInitMode };
|
package/src/snippet.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const chalk = require('chalk');
|
|
9
|
-
const {
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import juice from 'juice';
|
|
5
|
+
import Mustache from 'mustache';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import {
|
|
10
8
|
loadYaml,
|
|
11
9
|
deepMerge,
|
|
12
10
|
collectExtraCss,
|
|
@@ -14,7 +12,7 @@ const {
|
|
|
14
12
|
fmtSize,
|
|
15
13
|
savings,
|
|
16
14
|
DEFAULT_CONFIG_PATH,
|
|
17
|
-
}
|
|
15
|
+
} from './index.js';
|
|
18
16
|
|
|
19
17
|
// ─── EDM 目录解析 ──────────────────────────────────────────────────────────────
|
|
20
18
|
|
|
@@ -204,24 +202,6 @@ function findConfigs(variantDir, sourceLabel) {
|
|
|
204
202
|
* Collect configs from multiple directories, deduplicating by path.
|
|
205
203
|
* Returns { name, path, isOptimal, source }[].
|
|
206
204
|
*/
|
|
207
|
-
function collectConfigs(dirs) {
|
|
208
|
-
const seen = new Set();
|
|
209
|
-
const result = [];
|
|
210
|
-
for (const { dir, label, markOptimal } of dirs) {
|
|
211
|
-
if (!fs.existsSync(dir)) continue;
|
|
212
|
-
const configs = findConfigs(dir, label);
|
|
213
|
-
for (const c of configs) {
|
|
214
|
-
if (seen.has(c.path)) continue;
|
|
215
|
-
seen.add(c.path);
|
|
216
|
-
if (markOptimal && c.name === 'juice.yaml') {
|
|
217
|
-
c.isOptimal = true;
|
|
218
|
-
}
|
|
219
|
-
result.push(c);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return result;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
205
|
function findFiles(dir, regex) {
|
|
226
206
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
227
207
|
return entries
|
|
@@ -425,11 +405,11 @@ async function assembleSnippet({ snippetPath, templatePath, config, cwd, outputB
|
|
|
425
405
|
const outPaths = resolveSnippetOutputPaths(outputBaseName, cwd);
|
|
426
406
|
const variables = Object.assign({}, config.variables || {});
|
|
427
407
|
|
|
428
|
-
// 1. 未渲染的片段插入模板 → .raw.html
|
|
408
|
+
// 1. 未渲染的片段插入模板 → .raw.html
|
|
429
409
|
const rawMarkup = insertIntoContent(templateHtml, snippetRaw);
|
|
430
410
|
fs.writeFileSync(outPaths.raw, rawMarkup, 'utf8');
|
|
431
411
|
|
|
432
|
-
// 2. Mustache
|
|
412
|
+
// 2. Mustache 渲染 → .html
|
|
433
413
|
const originalEscape = Mustache.escape;
|
|
434
414
|
let renderedHtml;
|
|
435
415
|
try {
|
|
@@ -442,34 +422,45 @@ async function assembleSnippet({ snippetPath, templatePath, config, cwd, outputB
|
|
|
442
422
|
}
|
|
443
423
|
fs.writeFileSync(outPaths.normal, renderedHtml, 'utf8');
|
|
444
424
|
|
|
445
|
-
// 3.
|
|
425
|
+
// 3. Juice CSS 内联 → .output.html
|
|
446
426
|
const templateDir = path.dirname(templatePath);
|
|
447
427
|
const extraCss = collectExtraCss(templateDir, config);
|
|
448
428
|
const juiceOpts = Object.assign({}, config.juice || {});
|
|
449
429
|
delete juiceOpts.extraCssFiles;
|
|
450
|
-
|
|
430
|
+
let processed;
|
|
431
|
+
try {
|
|
432
|
+
processed = juice(renderedHtml, { ...juiceOpts, extraCss });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
throw new Error(`CSS 内联失败:${err.message}`);
|
|
435
|
+
}
|
|
451
436
|
fs.writeFileSync(outPaths.output, processed, 'utf8');
|
|
452
437
|
|
|
453
438
|
// 4. 压缩 → .minified.html
|
|
454
|
-
|
|
439
|
+
let minified;
|
|
440
|
+
try {
|
|
441
|
+
minified = await minifyHtml(processed, config.minify);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
throw new Error(`压缩失败:${err.message}`);
|
|
444
|
+
}
|
|
455
445
|
fs.writeFileSync(outPaths.minified, minified, 'utf8');
|
|
456
446
|
|
|
457
447
|
// 5. 报告
|
|
448
|
+
const rel = (p) => './' + path.relative(cwd, p);
|
|
458
449
|
const layers = config._layers || [];
|
|
459
|
-
const layerLines = layers
|
|
460
|
-
.map((l) => ` ${chalk.gray('·')} ${l.label}`)
|
|
461
|
-
|
|
450
|
+
const layerLines = layers.length > 0
|
|
451
|
+
? layers.map((l) => ` ${chalk.gray('·')} ${l.label}`).join('\n') + '\n'
|
|
452
|
+
: '';
|
|
462
453
|
|
|
463
454
|
console.log(
|
|
464
455
|
chalk.green('\n✔ 片段组装完成') + '\n' +
|
|
465
456
|
` ${chalk.bold('片段:')} ${chalk.cyan(snippetPath)}\n` +
|
|
466
457
|
` ${chalk.bold('模板:')} ${chalk.cyan(templatePath)}\n` +
|
|
467
|
-
` ${chalk.bold('配置层(低→高):')}\n${layerLines}
|
|
458
|
+
` ${chalk.bold('配置层(低→高):')}\n${layerLines}` +
|
|
468
459
|
` ${chalk.bold('输出:')}\n` +
|
|
469
|
-
` ${chalk.green('·')} 原始组装 ${chalk.cyan(outPaths.raw)}
|
|
470
|
-
` ${chalk.green('·')} 已渲染 ${chalk.cyan(outPaths.normal)} ${chalk.gray(
|
|
471
|
-
` ${chalk.green('·')} 内联后 ${chalk.cyan(outPaths.output)} ${chalk.gray(
|
|
472
|
-
` ${chalk.green('·')} 压缩版 ${chalk.cyan(outPaths.minified)} ${chalk.gray(
|
|
460
|
+
` ${chalk.green('·')} 原始组装 ${chalk.cyan(rel(outPaths.raw))} ${chalk.gray(fmtSize(rawMarkup))}\n` +
|
|
461
|
+
` ${chalk.green('·')} 已渲染 ${chalk.cyan(rel(outPaths.normal))} ${chalk.gray(fmtSize(renderedHtml))}\n` +
|
|
462
|
+
` ${chalk.green('·')} 内联后 ${chalk.cyan(rel(outPaths.output))} ${chalk.gray(fmtSize(processed))}\n` +
|
|
463
|
+
` ${chalk.green('·')} 压缩版 ${chalk.cyan(rel(outPaths.minified))} ${chalk.gray(fmtSize(minified) + ',节省 ' + savings(processed, minified))}`
|
|
473
464
|
);
|
|
474
465
|
|
|
475
466
|
return outPaths;
|
|
@@ -715,7 +706,7 @@ async function promptConfirm(summary) {
|
|
|
715
706
|
console.log(` 片段变体: ${chalk.green(summary.variantName)} ${chalk.gray(`(${summary.variant})`)}`);
|
|
716
707
|
console.log(` 配置 YAML: ${chalk.green(summary.configFile)}`);
|
|
717
708
|
console.log(chalk.gray('───────────────────────────────────────────'));
|
|
718
|
-
console.log(` 输出目录: ${chalk.cyan(
|
|
709
|
+
console.log(` 输出目录: ${chalk.cyan('./')}`);
|
|
719
710
|
console.log(` 输出文件名: ${chalk.green(summary.outputBaseName)}`);
|
|
720
711
|
console.log(` 输出文件:`);
|
|
721
712
|
console.log(` ${chalk.green('·')} ${path.basename(outPaths.raw)} ${chalk.gray('(未渲染,无 CSS 内联)')}`);
|
|
@@ -955,7 +946,7 @@ async function runInteractiveMode({ config: cliConfigPath }) {
|
|
|
955
946
|
});
|
|
956
947
|
}
|
|
957
948
|
|
|
958
|
-
|
|
949
|
+
export {
|
|
959
950
|
runSnippetMode,
|
|
960
951
|
runInteractiveMode,
|
|
961
952
|
// EDM resolution & scanning
|
|
@@ -969,7 +960,6 @@ module.exports = {
|
|
|
969
960
|
findHtmlFiles,
|
|
970
961
|
findYamlFiles,
|
|
971
962
|
findLocalConfig,
|
|
972
|
-
collectConfigs,
|
|
973
963
|
getBrand,
|
|
974
964
|
filterSeries,
|
|
975
965
|
// config
|
package/src/view.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const chalk = require('chalk');
|
|
6
|
-
const {
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import {
|
|
7
5
|
resolveEdmDir, loadMeta, findBrands, findTemplateVersions,
|
|
8
6
|
findSeriesDirs, findSnippetVariants, findConfigs,
|
|
9
|
-
}
|
|
7
|
+
} from './snippet.js';
|
|
10
8
|
|
|
11
9
|
function fmtBytes(b) {
|
|
12
10
|
return b < 1024 ? `${b} B` : `${(b / 1024).toFixed(1)} KB`;
|
|
@@ -349,7 +347,7 @@ async function showCheckbox(title, choices) {
|
|
|
349
347
|
*/
|
|
350
348
|
async function copyResource(type, resourcePath, cwd) {
|
|
351
349
|
try {
|
|
352
|
-
const { runInitMode } =
|
|
350
|
+
const { runInitMode } = await import('./init.js');
|
|
353
351
|
if (type === 'template') {
|
|
354
352
|
await runInitMode({ template: resourcePath });
|
|
355
353
|
} else if (type === 'snippet') {
|
|
@@ -616,7 +614,7 @@ async function interactiveBrowse(edmDir, startParsed) {
|
|
|
616
614
|
// Include template version in default name for clarity
|
|
617
615
|
const versionName = versions.length > 0 ? versions[0].name : 'template';
|
|
618
616
|
const defaultBaseName = `${current.brand}-${versionName}-${current.series}-${current.variant}`;
|
|
619
|
-
const { promptOutputName: pn } =
|
|
617
|
+
const { promptOutputName: pn } = await import('./snippet.js');
|
|
620
618
|
const outputBaseName = await pn(defaultBaseName, process.cwd());
|
|
621
619
|
const cwd = process.cwd();
|
|
622
620
|
const cwdRel = (p) => './' + path.relative(cwd, p);
|
|
@@ -714,7 +712,7 @@ async function runViewMode({ viewPath, interactive, scope }) {
|
|
|
714
712
|
});
|
|
715
713
|
if (action === 'copy') {
|
|
716
714
|
console.log();
|
|
717
|
-
const { runInitMode } =
|
|
715
|
+
const { runInitMode } = await import('./init.js');
|
|
718
716
|
await runInitMode({ template: picked.version.templatePath });
|
|
719
717
|
console.log();
|
|
720
718
|
}
|
|
@@ -792,7 +790,7 @@ async function runViewMode({ viewPath, interactive, scope }) {
|
|
|
792
790
|
actions
|
|
793
791
|
);
|
|
794
792
|
if (action === 'exit') return;
|
|
795
|
-
const { runInitMode } =
|
|
793
|
+
const { runInitMode } = await import('./init.js');
|
|
796
794
|
console.log();
|
|
797
795
|
if (action === 'snippet') {
|
|
798
796
|
await runInitMode({ snippet: snipPath });
|
|
@@ -872,7 +870,7 @@ async function runViewMode({ viewPath, interactive, scope }) {
|
|
|
872
870
|
variantData: picked.variant,
|
|
873
871
|
};
|
|
874
872
|
} else {
|
|
875
|
-
const { runInitMode } =
|
|
873
|
+
const { runInitMode } = await import('./init.js');
|
|
876
874
|
console.log();
|
|
877
875
|
if (action === 'copy-snippet') {
|
|
878
876
|
await runInitMode({ snippet: picked.snippetPath });
|
|
@@ -938,4 +936,4 @@ async function runViewMode({ viewPath, interactive, scope }) {
|
|
|
938
936
|
printFullTree(edmDir);
|
|
939
937
|
}
|
|
940
938
|
|
|
941
|
-
|
|
939
|
+
export { runViewMode, parseViewPath };
|