@epublishing/grunt-epublishing 1.1.4 → 1.2.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/lib/cli.js CHANGED
@@ -1,24 +1,23 @@
1
1
  /**
2
2
  * CLI Implementation
3
- *
3
+ *
4
4
  * Main entry point for the build tools.
5
5
  * Orchestrates webpack, sass, concat/minify tasks.
6
+ *
7
+ * Heavy modules (webpack, sass-embedded, terser) are lazy-loaded inside
8
+ * their task functions so that subprocess workers only pay for what they use.
6
9
  */
7
10
 
8
11
  'use strict';
9
12
 
13
+ const os = require('os');
10
14
  const fs = require('fs');
11
15
  const path = require('path');
12
16
  const { spawn } = require('child_process');
13
17
  const { program } = require('commander');
14
18
  const chalk = require('chalk');
15
19
  const ora = require('ora');
16
- const Resolver = require('@epublishing/jade-resolver');
17
20
  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
21
 
23
22
  // Package info for banner
24
23
  const pkg = require('../package.json');
@@ -28,7 +27,7 @@ const pkg = require('../package.json');
28
27
  */
29
28
  function printBanner(options) {
30
29
  if (options.noBanner) return;
31
-
30
+
32
31
  console.log(chalk.cyan(`
33
32
  ╔═══════════════════════════════════════════╗
34
33
  ║ ║
@@ -43,7 +42,7 @@ function printBanner(options) {
43
42
  */
44
43
  function printOptions(options) {
45
44
  if (options.noBanner) return;
46
-
45
+
47
46
  const flags = [];
48
47
  if (options.watch) flags.push('watch');
49
48
  if (options.noMinify) flags.push('no-minify');
@@ -51,7 +50,8 @@ function printOptions(options) {
51
50
  if (options.lint) flags.push('lint');
52
51
  if (options.verbose) flags.push('verbose');
53
52
  if (options.parallel) flags.push('parallel');
54
-
53
+ if (options.isolateTasks) flags.push('isolate-tasks');
54
+
55
55
  if (flags.length > 0) {
56
56
  console.log(chalk.gray(` Options: ${flags.join(', ')}\n`));
57
57
  }
@@ -64,6 +64,7 @@ function printOptions(options) {
64
64
  * @param {Object} options - Build options
65
65
  */
66
66
  async function runNpmInstall(config, options) {
67
+ const Resolver = require('@epublishing/jade-resolver');
67
68
  const paths = config.paths || {};
68
69
  // Build paths object with ONLY jade and jade child gem directories (absolute paths)
69
70
  const gemPaths = {};
@@ -125,35 +126,36 @@ async function runNpmInstall(config, options) {
125
126
  * @param {Object} options - Build options
126
127
  */
127
128
  async function runWebpackBuild(config, options) {
129
+ const { configureWebpack, runWebpack, splitEntries } = require('./webpack.config');
128
130
  const spinner = ora('Building webpack bundles...').start();
129
-
131
+
130
132
  try {
131
133
  const webpackConfigs = configureWebpack(config, options);
132
134
  const targets = Object.keys(webpackConfigs);
133
-
135
+
134
136
  if (targets.length === 0) {
135
137
  spinner.info('No webpack targets configured');
136
138
  return;
137
139
  }
138
-
140
+
139
141
  for (const targetName of targets) {
140
142
  const targetConfig = webpackConfigs[targetName];
141
-
143
+
142
144
  // Split entries if there are many (memory optimization)
143
145
  if (targetConfig.entry && Object.keys(targetConfig.entry).length > 10 && !options.parallel) {
144
146
  const batches = splitEntries(targetConfig.entry, 10);
145
147
  spinner.text = `Building ${targetName} (${batches.length} batches)...`;
146
-
148
+
147
149
  for (let i = 0; i < batches.length; i++) {
148
150
  const batchConfig = { ...targetConfig, entry: batches[i] };
149
151
  spinner.text = `Building ${targetName} batch ${i + 1}/${batches.length}...`;
150
-
152
+
151
153
  const stats = await runWebpack(batchConfig);
152
-
154
+
153
155
  if (options.verbose) {
154
156
  console.log(stats.toString({ colors: true }));
155
157
  }
156
-
158
+
157
159
  // Allow GC between batches
158
160
  if (global.gc) {
159
161
  global.gc();
@@ -162,13 +164,13 @@ async function runWebpackBuild(config, options) {
162
164
  } else {
163
165
  spinner.text = `Building ${targetName}...`;
164
166
  const stats = await runWebpack(targetConfig);
165
-
167
+
166
168
  if (options.verbose) {
167
169
  console.log(stats.toString({ colors: true }));
168
170
  }
169
171
  }
170
172
  }
171
-
173
+
172
174
  spinner.succeed(`Webpack: ${targets.length} target(s) built`);
173
175
  } catch (error) {
174
176
  spinner.fail('Webpack build failed');
@@ -182,56 +184,57 @@ async function runWebpackBuild(config, options) {
182
184
  * @param {Object} options - Build options
183
185
  */
184
186
  async function runWebpackWatch(config, options) {
187
+ const { configureWebpack, watchWebpack } = require('./webpack.config');
185
188
  const webpackConfigs = configureWebpack(config, { ...options, watch: true });
186
189
  const targets = Object.keys(webpackConfigs);
187
-
190
+
188
191
  if (targets.length === 0) {
189
192
  console.log(chalk.yellow('No webpack targets configured'));
190
193
  return;
191
194
  }
192
-
195
+
193
196
  console.log(chalk.cyan('Starting webpack watch mode...'));
194
-
197
+
195
198
  const watchers = [];
196
-
199
+
197
200
  for (const targetName of targets) {
198
201
  const targetConfig = webpackConfigs[targetName];
199
-
202
+
200
203
  const watcher = watchWebpack(targetConfig, (err, stats) => {
201
204
  if (err) {
202
205
  console.error(chalk.red(`[${targetName}] Error:`, err.message));
203
206
  return;
204
207
  }
205
-
208
+
206
209
  const info = stats.toJson();
207
-
210
+
208
211
  if (stats.hasErrors()) {
209
212
  console.error(chalk.red(`[${targetName}] Errors:`));
210
213
  info.errors.forEach(e => console.error(e.message));
211
214
  return;
212
215
  }
213
-
216
+
214
217
  if (stats.hasWarnings() && options.verbose) {
215
218
  console.warn(chalk.yellow(`[${targetName}] Warnings:`));
216
219
  info.warnings.forEach(w => console.warn(w.message));
217
220
  }
218
-
221
+
219
222
  console.log(chalk.green(`[${targetName}] Rebuilt in ${info.time}ms`));
220
223
  });
221
-
224
+
222
225
  watchers.push(watcher);
223
226
  }
224
-
227
+
225
228
  // Handle process termination
226
229
  const cleanup = () => {
227
230
  console.log(chalk.yellow('\nStopping watchers...'));
228
231
  watchers.forEach(w => w.close());
229
232
  process.exit(0);
230
233
  };
231
-
234
+
232
235
  process.on('SIGINT', cleanup);
233
236
  process.on('SIGTERM', cleanup);
234
-
237
+
235
238
  // Keep process alive
236
239
  await new Promise(() => {});
237
240
  }
@@ -242,17 +245,18 @@ async function runWebpackWatch(config, options) {
242
245
  * @param {Object} options - Build options
243
246
  */
244
247
  async function runSassBuild(config, options) {
248
+ const { compileSass } = require('./sass-compiler');
245
249
  const spinner = ora('Compiling Sass...').start();
246
-
250
+
247
251
  try {
248
252
  const results = await compileSass(config, {
249
253
  verbose: options.verbose,
250
254
  maxConcurrency: options.parallel ? 4 : 2,
251
255
  });
252
-
256
+
253
257
  const successful = results.filter(r => r.success).length;
254
258
  const failed = results.filter(r => !r.success).length;
255
-
259
+
256
260
  if (failed > 0) {
257
261
  spinner.warn(`Sass: ${successful} compiled, ${failed} failed`);
258
262
  results.filter(r => !r.success).forEach(r => {
@@ -275,29 +279,30 @@ async function runSassBuild(config, options) {
275
279
  * @param {Object} options - Build options
276
280
  */
277
281
  async function runConcatMinifyBuild(config, options) {
282
+ const { runConcatMinify } = require('./concat-minify');
278
283
  const spinner = ora('Concatenating and minifying...').start();
279
-
284
+
280
285
  try {
281
286
  const results = await runConcatMinify(config, {
282
287
  verbose: options.verbose,
283
288
  noMinify: options.noMinify,
284
289
  });
285
-
290
+
286
291
  const concatCount = results.concat.filter(r => r.success).length;
287
292
  const minifyCount = results.minify.filter(r => r.success).length;
288
-
293
+
289
294
  if (concatCount > 0 || minifyCount > 0) {
290
295
  spinner.succeed(`Concat: ${concatCount} bundles, Minify: ${minifyCount} files`);
291
296
  } else {
292
297
  spinner.info('No concat/minify targets configured');
293
298
  }
294
-
299
+
295
300
  // Report any failures
296
301
  const failures = [
297
302
  ...results.concat.filter(r => !r.success),
298
303
  ...results.minify.filter(r => !r.success),
299
304
  ];
300
-
305
+
301
306
  if (failures.length > 0) {
302
307
  console.warn(chalk.yellow(' Some targets failed:'));
303
308
  failures.forEach(f => {
@@ -317,30 +322,30 @@ async function runConcatMinifyBuild(config, options) {
317
322
  */
318
323
  async function runClean(config, options) {
319
324
  const spinner = ora('Cleaning...').start();
320
-
325
+
321
326
  try {
322
327
  const cleanConfig = config.clean || {};
323
328
  let cleaned = 0;
324
-
329
+
325
330
  for (const [targetName, targetConfig] of Object.entries(cleanConfig)) {
326
331
  const src = Array.isArray(targetConfig.src) ? targetConfig.src : [targetConfig.src];
327
-
332
+
328
333
  for (const pattern of src) {
329
334
  const resolvedPath = path.resolve(config.paths?.site || process.cwd(), pattern);
330
-
335
+
331
336
  // Simple glob handling for clean
332
337
  if (pattern.includes('*')) {
333
338
  // Skip complex globs for now
334
339
  continue;
335
340
  }
336
-
341
+
337
342
  if (fs.existsSync(resolvedPath)) {
338
343
  await fs.promises.rm(resolvedPath, { recursive: true, force: true });
339
344
  cleaned++;
340
345
  }
341
346
  }
342
347
  }
343
-
348
+
344
349
  spinner.succeed(`Cleaned ${cleaned} path(s)`);
345
350
  } catch (error) {
346
351
  spinner.fail('Clean failed');
@@ -348,6 +353,60 @@ async function runClean(config, options) {
348
353
  }
349
354
  }
350
355
 
356
+ /**
357
+ * Run a single build task in an isolated subprocess with a controlled heap.
358
+ * When the subprocess exits, the OS fully reclaims all its memory.
359
+ *
360
+ * @param {string} taskName - Task to run (webpack, sass, concat)
361
+ * @param {Object} configPaths - config.paths object (all strings, serializable)
362
+ * @param {Object} options - Serializable build options
363
+ * @param {number} heapSize - Max heap size in MB for the subprocess
364
+ * @returns {Promise<void>}
365
+ */
366
+ function runTaskInSubprocess(taskName, configPaths, options, heapSize) {
367
+ return new Promise((resolve, reject) => {
368
+ const tmpFile = path.join(os.tmpdir(), `epb-paths-${process.pid}-${taskName}.json`);
369
+ fs.writeFileSync(tmpFile, JSON.stringify(configPaths));
370
+
371
+ const workerPath = path.join(__dirname, 'task-worker.js');
372
+ const serializableOptions = {
373
+ verbose: options.verbose || false,
374
+ noMinify: options.noMinify || false,
375
+ analyze: options.analyze || false,
376
+ lint: options.lint || false,
377
+ parallel: false, // never parallel inside a subprocess
378
+ };
379
+
380
+ const nodeArgs = [
381
+ `--max-old-space-size=${heapSize}`,
382
+ workerPath,
383
+ taskName,
384
+ tmpFile,
385
+ JSON.stringify(serializableOptions),
386
+ ];
387
+
388
+ const proc = spawn(process.execPath, nodeArgs, {
389
+ stdio: 'inherit',
390
+ // Clear NODE_OPTIONS so our --max-old-space-size takes effect
391
+ env: { ...process.env, NODE_OPTIONS: '' },
392
+ });
393
+
394
+ proc.on('close', (code) => {
395
+ try { fs.unlinkSync(tmpFile); } catch {}
396
+ if (code === 0) {
397
+ resolve();
398
+ } else {
399
+ reject(new Error(`${taskName} subprocess exited with code ${code}`));
400
+ }
401
+ });
402
+
403
+ proc.on('error', (err) => {
404
+ try { fs.unlinkSync(tmpFile); } catch {}
405
+ reject(err);
406
+ });
407
+ });
408
+ }
409
+
351
410
  /**
352
411
  * Run full build
353
412
  * @param {Object} config - Build configuration
@@ -355,9 +414,10 @@ async function runClean(config, options) {
355
414
  */
356
415
  async function runFullBuild(config, options) {
357
416
  const startTime = Date.now();
358
-
417
+
359
418
  // Generate tsconfig files first
360
419
  try {
420
+ const { writeTsConfigs } = require('./tsconfig-gen');
361
421
  const written = await writeTsConfigs(process.cwd());
362
422
  if (options.verbose && written.length > 0) {
363
423
  console.log(chalk.gray(` Generated ${written.length} tsconfig.json file(s)`));
@@ -365,9 +425,19 @@ async function runFullBuild(config, options) {
365
425
  } catch {
366
426
  // TSConfig generation is optional, continue on error
367
427
  }
368
-
369
- // Run tasks sequentially for memory optimization (unless --parallel)
370
- if (options.parallel) {
428
+
429
+ if (options.isolateTasks) {
430
+ // Subprocess isolation: each task runs in its own Node process.
431
+ // When the subprocess exits, the OS fully reclaims all memory.
432
+ const heapSize = options.heapSize || 512;
433
+
434
+ console.log(chalk.gray(` Running tasks in isolated subprocesses (heap: ${heapSize}MB)\n`));
435
+
436
+ await runTaskInSubprocess('webpack', config.paths, options, heapSize);
437
+ await runTaskInSubprocess('sass', config.paths, options, heapSize);
438
+ await runTaskInSubprocess('concat', config.paths, options, heapSize);
439
+ } else if (options.parallel) {
440
+ // Run tasks in parallel (uses more memory)
371
441
  await Promise.all([
372
442
  runWebpackBuild(config, options),
373
443
  runSassBuild(config, options),
@@ -377,20 +447,21 @@ async function runFullBuild(config, options) {
377
447
  // Sequential execution with GC hints between tasks
378
448
  await runWebpackBuild(config, options);
379
449
  if (global.gc) global.gc();
380
-
450
+
381
451
  await runSassBuild(config, options);
382
452
  if (global.gc) global.gc();
383
-
453
+
384
454
  await runConcatMinifyBuild(config, options);
385
455
  }
386
-
456
+
387
457
  // Clean tsconfig files after build
388
458
  try {
459
+ const { cleanTsConfigs } = require('./tsconfig-gen');
389
460
  await cleanTsConfigs(process.cwd());
390
461
  } catch {
391
462
  // Cleanup is optional
392
463
  }
393
-
464
+
394
465
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
395
466
  console.log(chalk.green(`\n✓ Build completed in ${duration}s\n`));
396
467
  }
@@ -410,6 +481,8 @@ async function run(argv) {
410
481
  .option('--lint', 'Run ESLint')
411
482
  .option('-v, --verbose', 'Verbose output')
412
483
  .option('--parallel', 'Run tasks in parallel (uses more memory)')
484
+ .option('--isolate-tasks', 'Run each task in an isolated subprocess for lower memory usage')
485
+ .option('--heap-size <mb>', 'Max heap size in MB per subprocess (default: 512)', parseInt)
413
486
  .option('--no-banner', 'Hide banner')
414
487
  .option('--env <env>', 'Set NODE_ENV')
415
488
  .option('--gc-between-tasks', 'Force garbage collection between tasks')
@@ -437,7 +510,7 @@ async function run(argv) {
437
510
 
438
511
  // Load configuration
439
512
  const spinner = ora('Loading configuration...').start();
440
-
513
+
441
514
  let config;
442
515
  try {
443
516
  config = await loadConfig(process.cwd(), { verbose: options.verbose });
@@ -487,7 +560,8 @@ async function run(argv) {
487
560
  await runClean(config, options);
488
561
  break;
489
562
 
490
- case 'tsconfig':
563
+ case 'tsconfig': {
564
+ const { writeTsConfigs } = require('./tsconfig-gen');
491
565
  const tsconfigSpinner = ora('Generating tsconfig.json files...').start();
492
566
  try {
493
567
  const written = await writeTsConfigs(process.cwd());
@@ -497,8 +571,10 @@ async function run(argv) {
497
571
  throw error;
498
572
  }
499
573
  break;
574
+ }
500
575
 
501
- case 'clean-tsconfig':
576
+ case 'clean-tsconfig': {
577
+ const { cleanTsConfigs } = require('./tsconfig-gen');
502
578
  const cleanTsconfigSpinner = ora('Cleaning tsconfig.json files...').start();
503
579
  try {
504
580
  const removed = await cleanTsConfigs(process.cwd());
@@ -508,6 +584,7 @@ async function run(argv) {
508
584
  throw error;
509
585
  }
510
586
  break;
587
+ }
511
588
 
512
589
  case 'all':
513
590
  default:
@@ -532,4 +609,3 @@ module.exports = {
532
609
  runClean,
533
610
  runFullBuild,
534
611
  };
535
-
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Task Worker - Isolated subprocess for a single build task.
4
+ *
5
+ * Invoked by the main CLI in --isolate-tasks mode. Each task runs in
6
+ * its own Node process with a controlled heap size (via --max-old-space-size).
7
+ * When the subprocess exits, the OS fully reclaims all its memory.
8
+ *
9
+ * Usage: node --max-old-space-size=512 task-worker.js <task> <paths-file> [options-json]
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const args = process.argv.slice(2);
18
+ const taskName = args[0];
19
+ const pathsFile = args[1];
20
+ const optionsJson = args[2] || '{}';
21
+
22
+ if (!taskName || !pathsFile) {
23
+ console.error('Usage: task-worker.js <task> <paths-file> [options-json]');
24
+ process.exit(1);
25
+ }
26
+
27
+ // Read paths (written by the main process after bundle exec discovery)
28
+ const paths = JSON.parse(fs.readFileSync(pathsFile, 'utf8'));
29
+ const options = JSON.parse(optionsJson);
30
+
31
+ // Rebuild config from paths — re-reads grunt-config.js files but skips
32
+ // the expensive `bundle exec` gem-path discovery (already done by parent).
33
+ const { baseConfig, mergeConfig } = require('./config-loader');
34
+ const { resolveTemplates } = require('./template-resolver');
35
+
36
+ let config = JSON.parse(JSON.stringify(baseConfig));
37
+ config.paths = paths;
38
+
39
+ if (paths.jade) config = mergeConfig(config, paths.jade);
40
+ for (const [key, val] of Object.entries(paths)) {
41
+ if (key.startsWith('jade_') && val) config = mergeConfig(config, val);
42
+ }
43
+ if (paths.site) config = mergeConfig(config, paths.site);
44
+ config = resolveTemplates(config, config.paths);
45
+
46
+ // Import only the task function we need (lazy loading keeps memory low)
47
+ const {
48
+ runWebpackBuild,
49
+ runSassBuild,
50
+ runConcatMinifyBuild,
51
+ } = require('./cli');
52
+
53
+ async function main() {
54
+ switch (taskName) {
55
+ case 'webpack':
56
+ await runWebpackBuild(config, options);
57
+ break;
58
+ case 'sass':
59
+ await runSassBuild(config, options);
60
+ break;
61
+ case 'concat':
62
+ await runConcatMinifyBuild(config, options);
63
+ break;
64
+ default:
65
+ throw new Error(`Unknown task: ${taskName}`);
66
+ }
67
+ }
68
+
69
+ main()
70
+ .then(() => process.exit(0))
71
+ .catch((err) => {
72
+ console.error(`[task-worker] ${taskName} failed:`, err.message);
73
+ if (process.env.DEBUG) console.error(err.stack);
74
+ process.exit(1);
75
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@epublishing/grunt-epublishing",
3
3
  "description": "Modern front-end build tools for ePublishing Jade and client sites.",
4
- "version": "1.1.4",
4
+ "version": "1.2.1",
5
5
  "homepage": "https://www.epublishing.com",
6
6
  "contributors": [
7
7
  {
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@epublishing/get-gem-paths": "^0.1.1",
40
- "@epublishing/jade-resolver": "^0.1.2",
40
+ "@epublishing/jade-resolver": "^0.2.4",
41
41
  "@swc/core": "^1.7.0",
42
42
  "bourbon": "^7.3.0",
43
43
  "breakpoint-sass": "^2.7.0",