@epublishing/grunt-epublishing 1.0.7 → 1.1.2

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/lib/cli.js ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * CLI Implementation
3
+ *
4
+ * Main entry point for the build tools.
5
+ * Orchestrates webpack, sass, concat/minify tasks.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawn } = require('child_process');
13
+ const { program } = require('commander');
14
+ const chalk = require('chalk');
15
+ const ora = require('ora');
16
+ const Resolver = require('@epublishing/jade-resolver');
17
+ const { loadConfig } = require('./config-loader');
18
+ const { configureWebpack, runWebpack, watchWebpack, splitEntries } = require('./webpack.config');
19
+ const { compileSass } = require('./sass-compiler');
20
+ const { runConcatMinify } = require('./concat-minify');
21
+ const { writeTsConfigs, cleanTsConfigs } = require('./tsconfig-gen');
22
+
23
+ // Package info for banner
24
+ const pkg = require('../package.json');
25
+
26
+ /**
27
+ * Print banner
28
+ */
29
+ function printBanner(options) {
30
+ if (options.noBanner) return;
31
+
32
+ console.log(chalk.cyan(`
33
+ ╔═══════════════════════════════════════════╗
34
+ ║ ║
35
+ ║ ePublishing Build Tools v${pkg.version.padEnd(13)}║
36
+ ║ ║
37
+ ╚═══════════════════════════════════════════╝
38
+ `));
39
+ }
40
+
41
+ /**
42
+ * Print available options
43
+ */
44
+ function printOptions(options) {
45
+ if (options.noBanner) return;
46
+
47
+ const flags = [];
48
+ if (options.watch) flags.push('watch');
49
+ if (options.noMinify) flags.push('no-minify');
50
+ if (options.analyze) flags.push('analyze');
51
+ if (options.lint) flags.push('lint');
52
+ if (options.verbose) flags.push('verbose');
53
+ if (options.parallel) flags.push('parallel');
54
+
55
+ if (flags.length > 0) {
56
+ console.log(chalk.gray(` Options: ${flags.join(', ')}\n`));
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Run npm install in jade and jade child gem directories.
62
+ * Installs packages from each gem's package.json (excluding site).
63
+ * @param {Object} config - Build configuration with paths
64
+ * @param {Object} options - Build options
65
+ */
66
+ async function runNpmInstall(config, options) {
67
+ const paths = config.paths || {};
68
+ const resolver = new Resolver({ ...paths }, { includeSite: false });
69
+ resolver.removePath('site');
70
+
71
+ const packages = resolver.find('package.json');
72
+ if (!packages || packages.length === 0) {
73
+ return;
74
+ }
75
+
76
+ const origCwd = process.cwd();
77
+ const dirs = [...new Set(packages.map((p) => path.dirname(p)))];
78
+
79
+ for (const dir of dirs) {
80
+ const basename = path.basename(dir);
81
+ const spinner = ora(`Installing npm modules in ${basename}...`).start();
82
+
83
+ try {
84
+ const hasLock = fs.existsSync(path.join(dir, 'package-lock.json'));
85
+ const cmd = hasLock ? 'ci' : 'install';
86
+ await new Promise((resolve, reject) => {
87
+ process.chdir(dir);
88
+ const proc = spawn('npm', [cmd], {
89
+ stdio: options.verbose ? 'inherit' : 'pipe',
90
+ shell: true,
91
+ });
92
+ proc.on('close', (code) => {
93
+ process.chdir(origCwd);
94
+ if (code === 0) {
95
+ spinner.succeed(`npm ${cmd} completed in ${basename}`);
96
+ resolve();
97
+ } else {
98
+ spinner.fail(`npm ${cmd} failed in ${basename}`);
99
+ reject(new Error(`npm ${cmd} exited with code ${code}`));
100
+ }
101
+ });
102
+ proc.on('error', (err) => {
103
+ process.chdir(origCwd);
104
+ spinner.fail(`npm ${cmd} failed in ${basename}`);
105
+ reject(err);
106
+ });
107
+ });
108
+ } catch (err) {
109
+ process.chdir(origCwd);
110
+ throw err;
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Run webpack build
117
+ * @param {Object} config - Build configuration
118
+ * @param {Object} options - Build options
119
+ */
120
+ async function runWebpackBuild(config, options) {
121
+ const spinner = ora('Building webpack bundles...').start();
122
+
123
+ try {
124
+ const webpackConfigs = configureWebpack(config, options);
125
+ const targets = Object.keys(webpackConfigs);
126
+
127
+ if (targets.length === 0) {
128
+ spinner.info('No webpack targets configured');
129
+ return;
130
+ }
131
+
132
+ for (const targetName of targets) {
133
+ const targetConfig = webpackConfigs[targetName];
134
+
135
+ // Split entries if there are many (memory optimization)
136
+ if (targetConfig.entry && Object.keys(targetConfig.entry).length > 10 && !options.parallel) {
137
+ const batches = splitEntries(targetConfig.entry, 10);
138
+ spinner.text = `Building ${targetName} (${batches.length} batches)...`;
139
+
140
+ for (let i = 0; i < batches.length; i++) {
141
+ const batchConfig = { ...targetConfig, entry: batches[i] };
142
+ spinner.text = `Building ${targetName} batch ${i + 1}/${batches.length}...`;
143
+
144
+ const stats = await runWebpack(batchConfig);
145
+
146
+ if (options.verbose) {
147
+ console.log(stats.toString({ colors: true }));
148
+ }
149
+
150
+ // Allow GC between batches
151
+ if (global.gc) {
152
+ global.gc();
153
+ }
154
+ }
155
+ } else {
156
+ spinner.text = `Building ${targetName}...`;
157
+ const stats = await runWebpack(targetConfig);
158
+
159
+ if (options.verbose) {
160
+ console.log(stats.toString({ colors: true }));
161
+ }
162
+ }
163
+ }
164
+
165
+ spinner.succeed(`Webpack: ${targets.length} target(s) built`);
166
+ } catch (error) {
167
+ spinner.fail('Webpack build failed');
168
+ throw error;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Run webpack in watch mode
174
+ * @param {Object} config - Build configuration
175
+ * @param {Object} options - Build options
176
+ */
177
+ async function runWebpackWatch(config, options) {
178
+ const webpackConfigs = configureWebpack(config, { ...options, watch: true });
179
+ const targets = Object.keys(webpackConfigs);
180
+
181
+ if (targets.length === 0) {
182
+ console.log(chalk.yellow('No webpack targets configured'));
183
+ return;
184
+ }
185
+
186
+ console.log(chalk.cyan('Starting webpack watch mode...'));
187
+
188
+ const watchers = [];
189
+
190
+ for (const targetName of targets) {
191
+ const targetConfig = webpackConfigs[targetName];
192
+
193
+ const watcher = watchWebpack(targetConfig, (err, stats) => {
194
+ if (err) {
195
+ console.error(chalk.red(`[${targetName}] Error:`, err.message));
196
+ return;
197
+ }
198
+
199
+ const info = stats.toJson();
200
+
201
+ if (stats.hasErrors()) {
202
+ console.error(chalk.red(`[${targetName}] Errors:`));
203
+ info.errors.forEach(e => console.error(e.message));
204
+ return;
205
+ }
206
+
207
+ if (stats.hasWarnings() && options.verbose) {
208
+ console.warn(chalk.yellow(`[${targetName}] Warnings:`));
209
+ info.warnings.forEach(w => console.warn(w.message));
210
+ }
211
+
212
+ console.log(chalk.green(`[${targetName}] Rebuilt in ${info.time}ms`));
213
+ });
214
+
215
+ watchers.push(watcher);
216
+ }
217
+
218
+ // Handle process termination
219
+ const cleanup = () => {
220
+ console.log(chalk.yellow('\nStopping watchers...'));
221
+ watchers.forEach(w => w.close());
222
+ process.exit(0);
223
+ };
224
+
225
+ process.on('SIGINT', cleanup);
226
+ process.on('SIGTERM', cleanup);
227
+
228
+ // Keep process alive
229
+ await new Promise(() => {});
230
+ }
231
+
232
+ /**
233
+ * Run sass build
234
+ * @param {Object} config - Build configuration
235
+ * @param {Object} options - Build options
236
+ */
237
+ async function runSassBuild(config, options) {
238
+ const spinner = ora('Compiling Sass...').start();
239
+
240
+ try {
241
+ const results = await compileSass(config, {
242
+ verbose: options.verbose,
243
+ maxConcurrency: options.parallel ? 4 : 2,
244
+ });
245
+
246
+ const successful = results.filter(r => r.success).length;
247
+ const failed = results.filter(r => !r.success).length;
248
+
249
+ if (failed > 0) {
250
+ spinner.warn(`Sass: ${successful} compiled, ${failed} failed`);
251
+ results.filter(r => !r.success).forEach(r => {
252
+ console.error(chalk.red(` ${path.basename(r.src)}: ${r.error.message}`));
253
+ });
254
+ } else if (successful > 0) {
255
+ spinner.succeed(`Sass: ${successful} file(s) compiled`);
256
+ } else {
257
+ spinner.info('Sass: No files to compile');
258
+ }
259
+ } catch (error) {
260
+ spinner.fail('Sass compilation failed');
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Run concat/minify build
267
+ * @param {Object} config - Build configuration
268
+ * @param {Object} options - Build options
269
+ */
270
+ async function runConcatMinifyBuild(config, options) {
271
+ const spinner = ora('Concatenating and minifying...').start();
272
+
273
+ try {
274
+ const results = await runConcatMinify(config, {
275
+ verbose: options.verbose,
276
+ noMinify: options.noMinify,
277
+ });
278
+
279
+ const concatCount = results.concat.filter(r => r.success).length;
280
+ const minifyCount = results.minify.filter(r => r.success).length;
281
+
282
+ if (concatCount > 0 || minifyCount > 0) {
283
+ spinner.succeed(`Concat: ${concatCount} bundles, Minify: ${minifyCount} files`);
284
+ } else {
285
+ spinner.info('No concat/minify targets configured');
286
+ }
287
+
288
+ // Report any failures
289
+ const failures = [
290
+ ...results.concat.filter(r => !r.success),
291
+ ...results.minify.filter(r => !r.success),
292
+ ];
293
+
294
+ if (failures.length > 0) {
295
+ console.warn(chalk.yellow(' Some targets failed:'));
296
+ failures.forEach(f => {
297
+ console.warn(chalk.red(` ${f.target}: ${f.error?.message || 'Unknown error'}`));
298
+ });
299
+ }
300
+ } catch (error) {
301
+ spinner.fail('Concat/minify failed');
302
+ throw error;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Run clean task
308
+ * @param {Object} config - Build configuration
309
+ * @param {Object} options - Build options
310
+ */
311
+ async function runClean(config, options) {
312
+ const spinner = ora('Cleaning...').start();
313
+
314
+ try {
315
+ const cleanConfig = config.clean || {};
316
+ let cleaned = 0;
317
+
318
+ for (const [targetName, targetConfig] of Object.entries(cleanConfig)) {
319
+ const src = Array.isArray(targetConfig.src) ? targetConfig.src : [targetConfig.src];
320
+
321
+ for (const pattern of src) {
322
+ const resolvedPath = path.resolve(config.paths?.site || process.cwd(), pattern);
323
+
324
+ // Simple glob handling for clean
325
+ if (pattern.includes('*')) {
326
+ // Skip complex globs for now
327
+ continue;
328
+ }
329
+
330
+ if (fs.existsSync(resolvedPath)) {
331
+ await fs.promises.rm(resolvedPath, { recursive: true, force: true });
332
+ cleaned++;
333
+ }
334
+ }
335
+ }
336
+
337
+ spinner.succeed(`Cleaned ${cleaned} path(s)`);
338
+ } catch (error) {
339
+ spinner.fail('Clean failed');
340
+ throw error;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Run full build
346
+ * @param {Object} config - Build configuration
347
+ * @param {Object} options - Build options
348
+ */
349
+ async function runFullBuild(config, options) {
350
+ const startTime = Date.now();
351
+
352
+ // Generate tsconfig files first
353
+ try {
354
+ const written = await writeTsConfigs(process.cwd());
355
+ if (options.verbose && written.length > 0) {
356
+ console.log(chalk.gray(` Generated ${written.length} tsconfig.json file(s)`));
357
+ }
358
+ } catch {
359
+ // TSConfig generation is optional, continue on error
360
+ }
361
+
362
+ // Run tasks sequentially for memory optimization (unless --parallel)
363
+ if (options.parallel) {
364
+ await Promise.all([
365
+ runWebpackBuild(config, options),
366
+ runSassBuild(config, options),
367
+ runConcatMinifyBuild(config, options),
368
+ ]);
369
+ } else {
370
+ // Sequential execution with GC hints between tasks
371
+ await runWebpackBuild(config, options);
372
+ if (global.gc) global.gc();
373
+
374
+ await runSassBuild(config, options);
375
+ if (global.gc) global.gc();
376
+
377
+ await runConcatMinifyBuild(config, options);
378
+ }
379
+
380
+ // Clean tsconfig files after build
381
+ try {
382
+ await cleanTsConfigs(process.cwd());
383
+ } catch {
384
+ // Cleanup is optional
385
+ }
386
+
387
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
388
+ console.log(chalk.green(`\n✓ Build completed in ${duration}s\n`));
389
+ }
390
+
391
+ /**
392
+ * Main CLI runner
393
+ * @param {Array<string>} argv - Command line arguments
394
+ */
395
+ async function run(argv) {
396
+ program
397
+ .name('epublishing-build')
398
+ .description('Modern build tools for ePublishing sites')
399
+ .version(pkg.version)
400
+ .option('-w, --watch', 'Watch mode')
401
+ .option('--no-minify', 'Skip minification')
402
+ .option('--analyze', 'Generate bundle analysis')
403
+ .option('--lint', 'Run ESLint')
404
+ .option('-v, --verbose', 'Verbose output')
405
+ .option('--parallel', 'Run tasks in parallel (uses more memory)')
406
+ .option('--no-banner', 'Hide banner')
407
+ .option('--env <env>', 'Set NODE_ENV')
408
+ .option('--gc-between-tasks', 'Force garbage collection between tasks')
409
+ .argument('[task]', 'Task to run (webpack, sass, concat, clean, or all)')
410
+ .parse(['node', 'epublishing-build', ...argv]);
411
+
412
+ const options = program.opts();
413
+ const task = program.args[0] || 'all';
414
+
415
+ // Set NODE_ENV if specified
416
+ if (options.env) {
417
+ process.env.NODE_ENV = options.env;
418
+ }
419
+
420
+ // Enable manual GC if requested
421
+ if (options.gcBetweenTasks && !global.gc) {
422
+ console.warn(chalk.yellow('Warning: --gc-between-tasks requires running with --expose-gc'));
423
+ }
424
+
425
+ printBanner(options);
426
+ printOptions(options);
427
+
428
+ // Load configuration
429
+ const spinner = ora('Loading configuration...').start();
430
+
431
+ let config;
432
+ try {
433
+ config = await loadConfig(process.cwd(), { verbose: options.verbose });
434
+ spinner.succeed('Configuration loaded');
435
+ } catch (error) {
436
+ spinner.fail('Failed to load configuration');
437
+ throw error;
438
+ }
439
+
440
+ // Install npm packages from jade hierarchy (jade, jadechild) before build
441
+ if (task === 'webpack' || task === 'all') {
442
+ try {
443
+ await runNpmInstall(config, options);
444
+ } catch (error) {
445
+ throw error;
446
+ }
447
+ }
448
+
449
+ // Run requested task
450
+ switch (task) {
451
+ case 'webpack':
452
+ if (options.watch) {
453
+ await runWebpackWatch(config, options);
454
+ } else {
455
+ await runWebpackBuild(config, options);
456
+ }
457
+ break;
458
+
459
+ case 'sass':
460
+ await runSassBuild(config, options);
461
+ break;
462
+
463
+ case 'concat':
464
+ await runConcatMinifyBuild(config, options);
465
+ break;
466
+
467
+ case 'clean':
468
+ await runClean(config, options);
469
+ break;
470
+
471
+ case 'tsconfig':
472
+ const tsconfigSpinner = ora('Generating tsconfig.json files...').start();
473
+ try {
474
+ const written = await writeTsConfigs(process.cwd());
475
+ tsconfigSpinner.succeed(`Generated ${written.length} tsconfig.json file(s)`);
476
+ } catch (error) {
477
+ tsconfigSpinner.fail('TSConfig generation failed');
478
+ throw error;
479
+ }
480
+ break;
481
+
482
+ case 'clean-tsconfig':
483
+ const cleanTsconfigSpinner = ora('Cleaning tsconfig.json files...').start();
484
+ try {
485
+ const removed = await cleanTsConfigs(process.cwd());
486
+ cleanTsconfigSpinner.succeed(`Removed ${removed.length} tsconfig.json file(s)`);
487
+ } catch (error) {
488
+ cleanTsconfigSpinner.fail('TSConfig cleanup failed');
489
+ throw error;
490
+ }
491
+ break;
492
+
493
+ case 'all':
494
+ default:
495
+ if (options.watch) {
496
+ // In watch mode, run sass once then watch webpack
497
+ await runSassBuild(config, options);
498
+ await runConcatMinifyBuild(config, options);
499
+ await runWebpackWatch(config, options);
500
+ } else {
501
+ await runFullBuild(config, options);
502
+ }
503
+ break;
504
+ }
505
+ }
506
+
507
+ module.exports = {
508
+ run,
509
+ runWebpackBuild,
510
+ runWebpackWatch,
511
+ runSassBuild,
512
+ runConcatMinifyBuild,
513
+ runClean,
514
+ runFullBuild,
515
+ };
516
+