@wordpress/build 0.2.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/src/build.mjs ADDED
@@ -0,0 +1,1345 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * External dependencies
5
+ */
6
+ import { readFile, writeFile, copyFile, mkdir } from 'fs/promises';
7
+ import { readdirSync } from 'fs';
8
+ import path from 'path';
9
+ import { parseArgs } from 'node:util';
10
+ import esbuild from 'esbuild';
11
+ import glob from 'fast-glob';
12
+ import chokidar from 'chokidar';
13
+ // See https://github.com/WordPress/gutenberg/issues/72136
14
+ // eslint-disable-next-line import/no-unresolved
15
+ import browserslistToEsbuild from 'browserslist-to-esbuild';
16
+ import { sassPlugin } from 'esbuild-sass-plugin';
17
+ import postcss from 'postcss';
18
+ import postcssModulesPlugin from 'postcss-modules';
19
+ import autoprefixer from 'autoprefixer';
20
+ import rtlcss from 'rtlcss';
21
+ import cssnano from 'cssnano';
22
+ import babel from 'esbuild-plugin-babel';
23
+ import { camelCase } from 'change-case';
24
+
25
+ /**
26
+ * Internal dependencies
27
+ */
28
+ import { groupByDepth, findScriptsToRebundle } from './dependency-graph.mjs';
29
+ import {
30
+ generatePhpFromTemplate,
31
+ getPhpReplacements,
32
+ } from './php-generator.mjs';
33
+ import { getPackageInfo, getPackageInfoFromFile } from './package-utils.mjs';
34
+ import { createWordpressExternalsPlugin } from './wordpress-externals-plugin.mjs';
35
+
36
+ const ROOT_DIR = process.cwd();
37
+ const PACKAGES_DIR = path.join( ROOT_DIR, 'packages' );
38
+ const BUILD_DIR = path.join( ROOT_DIR, 'build' );
39
+
40
+ const SOURCE_EXTENSIONS = '{js,ts,tsx}';
41
+ const IGNORE_PATTERNS = [
42
+ '**/benchmark/**',
43
+ '**/{__mocks__,__tests__,test}/**',
44
+ '**/{storybook,stories}/**',
45
+ '**/*.native.*',
46
+ ];
47
+ const TEST_FILE_PATTERNS = [
48
+ /\/(benchmark|__mocks__|__tests__|test|storybook|stories)\/.+/,
49
+ /\.(spec|test)\.(js|ts|tsx)$/,
50
+ ];
51
+
52
+ /**
53
+ * Get all package names from the packages directory.
54
+ *
55
+ * @return {string[]} Array of package names.
56
+ */
57
+ function getAllPackages() {
58
+ return readdirSync( PACKAGES_DIR, { withFileTypes: true } )
59
+ .filter( ( dirent ) => dirent.isDirectory() )
60
+ .map( ( dirent ) => dirent.name );
61
+ }
62
+
63
+ const PACKAGES = getAllPackages();
64
+ const ROOT_PACKAGE_JSON = getPackageInfoFromFile( path.join( ROOT_DIR, 'package.json' ) );
65
+ const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {};
66
+ const SCRIPT_GLOBAL = WP_PLUGIN_CONFIG.scriptGlobal;
67
+ const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace;
68
+ const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE;
69
+ const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {};
70
+
71
+
72
+ const baseDefine = {
73
+ 'globalThis.IS_GUTENBERG_PLUGIN': JSON.stringify(
74
+ Boolean( process.env.npm_package_config_IS_GUTENBERG_PLUGIN )
75
+ ),
76
+ 'globalThis.IS_WORDPRESS_CORE': JSON.stringify(
77
+ Boolean( process.env.npm_package_config_IS_WORDPRESS_CORE )
78
+ ),
79
+ };
80
+ const getDefine = ( scriptDebug ) => ( {
81
+ ...baseDefine,
82
+ 'globalThis.SCRIPT_DEBUG': JSON.stringify( scriptDebug ),
83
+ } );
84
+
85
+ /**
86
+ * Initialize WordPress externals plugin with custom namespace configuration.
87
+ */
88
+ const wordpressExternalsPlugin = createWordpressExternalsPlugin(
89
+ PACKAGE_NAMESPACE,
90
+ SCRIPT_GLOBAL,
91
+ EXTERNAL_NAMESPACES,
92
+ HANDLE_PREFIX
93
+ );
94
+
95
+ /**
96
+ * Create emotion babel plugin for esbuild.
97
+ * This plugin enables emotion's babel transformations for proper CSS-in-JS handling.
98
+ *
99
+ * @return {Object} esbuild plugin.
100
+ */
101
+ function emotionBabelPlugin() {
102
+ return babel( {
103
+ filter: /\.[jt]sx?$/,
104
+ config: {
105
+ plugins: [ '@emotion/babel-plugin' ],
106
+ },
107
+ } );
108
+ }
109
+
110
+ /**
111
+ * Normalize path separators for cross-platform compatibility.
112
+ *
113
+ * @param {string} p Path to normalize.
114
+ * @return {string} Normalized path with forward slashes.
115
+ */
116
+ function normalizePath( p ) {
117
+ return p.replace( /\\/g, '/' );
118
+ }
119
+
120
+ function transformPhpContent( content, transforms ) {
121
+ const {
122
+ functionPrefix = '',
123
+ classSuffix = '',
124
+ prefixFunctions = [],
125
+ suffixClasses = [],
126
+ addActionPriority,
127
+ } = transforms;
128
+
129
+ content = content.toString();
130
+
131
+ if ( prefixFunctions.length ) {
132
+ content = content.replace(
133
+ new RegExp( prefixFunctions.join( '|' ), 'g' ),
134
+ ( match ) => `${ functionPrefix }${ match.replace( /^wp_/, '' ) }`
135
+ );
136
+ }
137
+
138
+ if ( suffixClasses.length ) {
139
+ content = content.replace(
140
+ new RegExp( suffixClasses.join( '|' ), 'g' ),
141
+ ( match ) => `${ match }${ classSuffix }`
142
+ );
143
+ }
144
+
145
+ if ( functionPrefix ) {
146
+ content = Array.from(
147
+ content.matchAll( /^\s*function ([^\(]+)/gm )
148
+ ).reduce( ( result, [ , functionName ] ) => {
149
+ return result.replace(
150
+ new RegExp( functionName + '(?![a-zA-Z0-9_])', 'g' ),
151
+ ( match ) => functionPrefix + match.replace( /^wp_/, '' )
152
+ );
153
+ }, content );
154
+ }
155
+
156
+ if ( addActionPriority ) {
157
+ content = content.replace(
158
+ /(add_action\(\s*'init',\s*'gutenberg_register_block_[^']+'(?!,))/,
159
+ `$1, ${ addActionPriority }`
160
+ );
161
+ }
162
+
163
+ return content;
164
+ }
165
+
166
+ /**
167
+ * Plugin to handle moment-timezone aliases.
168
+ * Redirects moment-timezone imports to use pre-built bundles with limited data.
169
+ *
170
+ * @return {Object} esbuild plugin.
171
+ */
172
+ function momentTimezoneAliasPlugin() {
173
+ return {
174
+ name: 'moment-timezone-alias',
175
+ async setup( build ) {
176
+ const { createRequire } = await import( 'module' );
177
+ const require = createRequire( import.meta.url );
178
+
179
+ const preBuiltBundlePath = require.resolve(
180
+ 'moment-timezone/builds/moment-timezone-with-data-1970-2030'
181
+ );
182
+ const momentTimezoneUtilsPath = require.resolve(
183
+ 'moment-timezone/moment-timezone-utils.js'
184
+ );
185
+
186
+ // Redirect main moment-timezone files to pre-built bundle
187
+ build.onResolve(
188
+ { filter: /^moment-timezone\/moment-timezone$/ },
189
+ () => {
190
+ return { path: preBuiltBundlePath };
191
+ }
192
+ );
193
+
194
+ // For utils, we need to load it but ensure it works with the pre-built bundle.
195
+ // The utils file tries to require('./') which would load index.js.
196
+ // We need to make sure it gets the pre-built bundle instead.
197
+ build.onResolve(
198
+ { filter: /^moment-timezone\/moment-timezone-utils$/ },
199
+ () => {
200
+ return { path: momentTimezoneUtilsPath };
201
+ }
202
+ );
203
+
204
+ // Intercept the require('./') call inside moment-timezone-utils
205
+ // and redirect it to the pre-built bundle.
206
+ build.onResolve( { filter: /^\.\/$/ }, ( args ) => {
207
+ if (
208
+ args.importer &&
209
+ args.importer.includes( 'moment-timezone-utils' )
210
+ ) {
211
+ return { path: preBuiltBundlePath };
212
+ }
213
+ } );
214
+ },
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Resolve the entry point for bundling from package.json exports field.
220
+ * Falls back to build-module/index.js if no exports field is found.
221
+ *
222
+ * @param {string} packageDir Package directory path.
223
+ * @param {Object} packageJson Package.json object.
224
+ * @return {string} Resolved entry point path.
225
+ */
226
+ function resolveEntryPoint( packageDir, packageJson ) {
227
+ if ( packageJson.exports ) {
228
+ const rootExport = packageJson.exports[ '.' ];
229
+ if ( rootExport ) {
230
+ // If it's an object with conditions, prefer 'import' over 'default'
231
+ if ( typeof rootExport === 'object' ) {
232
+ const entryFile =
233
+ rootExport.import ||
234
+ rootExport.default ||
235
+ rootExport.require;
236
+ if ( entryFile ) {
237
+ return path.join( packageDir, entryFile );
238
+ }
239
+ }
240
+ if ( typeof rootExport === 'string' ) {
241
+ return path.join( packageDir, rootExport );
242
+ }
243
+ }
244
+ }
245
+
246
+ // Fallback: try module field, then main field, then build-module/index.js
247
+ if ( packageJson.module ) {
248
+ return path.join( packageDir, packageJson.module );
249
+ }
250
+ if ( packageJson.main ) {
251
+ return path.join( packageDir, packageJson.main );
252
+ }
253
+
254
+ return path.join( packageDir, 'build-module', 'index.js' );
255
+ }
256
+
257
+ /**
258
+ * Bundle a package for WordPress using esbuild.
259
+ *
260
+ * @param {string} packageName Package name.
261
+ * @return {Promise<boolean>} True if the package was bundled, false otherwise.
262
+ */
263
+ async function bundlePackage( packageName ) {
264
+ const builtModules = [];
265
+ const builtScripts = [];
266
+ const builtStyles = [];
267
+ const packageDir = path.join( PACKAGES_DIR, packageName );
268
+ const packageJson = getPackageInfoFromFile(
269
+ path.join( PACKAGES_DIR, packageName, 'package.json' )
270
+ );
271
+
272
+ const builds = [];
273
+
274
+ if ( packageJson.wpScript ) {
275
+ const entryPoint = resolveEntryPoint( packageDir, packageJson );
276
+ const outputDir = path.join( BUILD_DIR, 'scripts', packageName );
277
+ const target = browserslistToEsbuild();
278
+
279
+ // Check if package matches the namespace and should expose a global
280
+ const packageFullName = packageJson.name;
281
+ const matchesNamespace = packageFullName.startsWith( `@${ PACKAGE_NAMESPACE }/` );
282
+ const shouldExposeGlobal = matchesNamespace && SCRIPT_GLOBAL !== false;
283
+
284
+ const globalName = shouldExposeGlobal
285
+ ? `${ SCRIPT_GLOBAL }.${ camelCase( packageName ) }`
286
+ : undefined;
287
+
288
+ const baseConfig = {
289
+ entryPoints: [ entryPoint ],
290
+ bundle: true,
291
+ sourcemap: true,
292
+ format: 'iife',
293
+ target,
294
+ platform: 'browser',
295
+ globalName,
296
+ };
297
+
298
+ // For packages with default exports, add a footer to properly expose the default
299
+ if ( packageJson.wpScriptDefaultExport && globalName ) {
300
+ baseConfig.footer = {
301
+ js: `if (typeof ${ globalName } === 'object' && ${ globalName }.default) { ${ globalName } = ${ globalName }.default; }`,
302
+ };
303
+ }
304
+
305
+ const bundlePlugins = [
306
+ momentTimezoneAliasPlugin(),
307
+ wordpressExternalsPlugin(
308
+ 'index.min',
309
+ 'iife',
310
+ packageJson.wpScriptExtraDependencies || []
311
+ ),
312
+ ];
313
+
314
+ builds.push(
315
+ esbuild.build( {
316
+ ...baseConfig,
317
+ outfile: path.join( outputDir, 'index.min.js' ),
318
+ minify: true,
319
+ define: getDefine( false ),
320
+ plugins: bundlePlugins,
321
+ } ),
322
+ esbuild.build( {
323
+ ...baseConfig,
324
+ outfile: path.join( outputDir, 'index.js' ),
325
+ minify: false,
326
+ define: getDefine( true ),
327
+ plugins: bundlePlugins,
328
+ } )
329
+ );
330
+
331
+ builtScripts.push( {
332
+ handle: `wp-${ packageName }`,
333
+ path: `${ packageName }/index`,
334
+ asset: `${ packageName }/index.min.asset.php`,
335
+ } );
336
+ }
337
+
338
+ if ( packageJson.wpScriptModuleExports ) {
339
+ const target = browserslistToEsbuild();
340
+ const rootBuildModuleDir = path.join(
341
+ BUILD_DIR,
342
+ 'modules',
343
+ packageName
344
+ );
345
+
346
+ const exports =
347
+ typeof packageJson.wpScriptModuleExports === 'string'
348
+ ? { '.': packageJson.wpScriptModuleExports }
349
+ : packageJson.wpScriptModuleExports;
350
+
351
+ for ( const [ exportName, exportPath ] of Object.entries( exports ) ) {
352
+ const fileName =
353
+ exportName === '.'
354
+ ? 'index'
355
+ : exportName.replace( /^\.\//, '' );
356
+ const entryPoint = path.join( packageDir, exportPath );
357
+ const baseFileName = path.basename( fileName );
358
+ const modulePlugins = [
359
+ wordpressExternalsPlugin( `${ baseFileName }.min`, 'esm' ),
360
+ ];
361
+
362
+ builds.push(
363
+ esbuild.build( {
364
+ entryPoints: [ entryPoint ],
365
+ outfile: path.join(
366
+ rootBuildModuleDir,
367
+ `${ fileName }.min.js`
368
+ ),
369
+ bundle: true,
370
+ sourcemap: true,
371
+ format: 'esm',
372
+ target,
373
+ platform: 'browser',
374
+ minify: true,
375
+ define: getDefine( false ),
376
+ plugins: modulePlugins,
377
+ } ),
378
+ esbuild.build( {
379
+ entryPoints: [ entryPoint ],
380
+ outfile: path.join(
381
+ rootBuildModuleDir,
382
+ `${ fileName }.js`
383
+ ),
384
+ bundle: true,
385
+ sourcemap: true,
386
+ format: 'esm',
387
+ target,
388
+ platform: 'browser',
389
+ minify: false,
390
+ define: getDefine( true ),
391
+ plugins: modulePlugins,
392
+ } )
393
+ );
394
+
395
+ const scriptModuleId =
396
+ exportName === '.'
397
+ ? `@wordpress/${ packageName }`
398
+ : `@wordpress/${ packageName }/${ fileName }`;
399
+
400
+ builtModules.push( {
401
+ id: scriptModuleId,
402
+ path: `${ packageName }/${ fileName }`,
403
+ asset: `${ packageName }/${ fileName }.min.asset.php`,
404
+ } );
405
+ }
406
+ }
407
+
408
+ let hasMainStyle = false;
409
+ if ( packageJson.wpScript ) {
410
+ const buildStyleDir = path.join( packageDir, 'build-style' );
411
+ const outputDir = path.join( BUILD_DIR, 'styles', packageName );
412
+ const isProduction = process.env.NODE_ENV === 'production';
413
+
414
+ const cssFiles = await glob(
415
+ normalizePath( path.join( buildStyleDir, '**/*.css' ) )
416
+ );
417
+
418
+ for ( const cssFile of cssFiles ) {
419
+ const relativePath = path.relative( buildStyleDir, cssFile );
420
+ const destPath = path.join( outputDir, relativePath );
421
+ const destDir = path.dirname( destPath );
422
+
423
+ // Track if this package has a main style.css for auto-registration
424
+ if ( relativePath === 'style.css' ) {
425
+ hasMainStyle = true;
426
+ }
427
+
428
+ if ( isProduction ) {
429
+ builds.push(
430
+ ( async () => {
431
+ await mkdir( destDir, { recursive: true } );
432
+ const content = await readFile( cssFile, 'utf8' );
433
+ const result = await postcss( [
434
+ cssnano( {
435
+ preset: [
436
+ 'default',
437
+ {
438
+ discardComments: {
439
+ removeAll: true,
440
+ },
441
+ },
442
+ ],
443
+ } ),
444
+ ] ).process( content, {
445
+ from: cssFile,
446
+ to: destPath,
447
+ } );
448
+ await writeFile( destPath, result.css );
449
+ } )()
450
+ );
451
+ } else {
452
+ builds.push(
453
+ mkdir( destDir, { recursive: true } ).then( () =>
454
+ copyFile( cssFile, destPath )
455
+ )
456
+ );
457
+ }
458
+ }
459
+ }
460
+
461
+ if ( packageJson.wpCopyFiles ) {
462
+ const { files, transforms = {} } = packageJson.wpCopyFiles;
463
+ const sourceDir = path.join( packageDir, 'src' );
464
+ const outputDir = path.join( BUILD_DIR, 'scripts', packageName );
465
+
466
+ for ( const filePattern of files ) {
467
+ const matchedFiles = await glob(
468
+ normalizePath( path.join( packageDir, filePattern ) )
469
+ );
470
+
471
+ for ( const sourceFile of matchedFiles ) {
472
+ const relativePath = path.relative( sourceDir, sourceFile );
473
+ const destPath = path.join( outputDir, relativePath );
474
+ const destDir = path.dirname( destPath );
475
+
476
+ if ( sourceFile.endsWith( '.php' ) && transforms.php ) {
477
+ builds.push(
478
+ ( async () => {
479
+ let finalPath = destPath;
480
+ let finalDir = destDir;
481
+
482
+ const content = await readFile(
483
+ sourceFile,
484
+ 'utf8'
485
+ );
486
+ const transformed = transformPhpContent(
487
+ content,
488
+ transforms.php
489
+ );
490
+
491
+ if ( transforms.php.filenameSuffix ) {
492
+ const ext = path.extname( destPath );
493
+ const base = path.basename( destPath, ext );
494
+ finalPath = path.join(
495
+ destDir,
496
+ `${ base }${ transforms.php.filenameSuffix }${ ext }`
497
+ );
498
+ }
499
+
500
+ // Check if we should flatten index.php files
501
+ if (
502
+ transforms.php.flattenIndexFiles &&
503
+ path.basename( sourceFile ) === 'index.php'
504
+ ) {
505
+ // Flatten: button/index.php → button.php
506
+ const parentDir = path.dirname( finalPath );
507
+ const blockName = path.basename( parentDir );
508
+ finalPath = path.join(
509
+ path.dirname( parentDir ),
510
+ `${ blockName }.php`
511
+ );
512
+ finalDir = path.dirname( finalPath );
513
+ }
514
+
515
+ await mkdir( finalDir, { recursive: true } );
516
+ await writeFile( finalPath, transformed );
517
+ } )()
518
+ );
519
+ } else {
520
+ builds.push(
521
+ mkdir( destDir, { recursive: true } ).then( () =>
522
+ copyFile( sourceFile, destPath )
523
+ )
524
+ );
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ if ( builds.length === 0 ) {
531
+ return false;
532
+ }
533
+
534
+ await Promise.all( builds );
535
+
536
+ // Collect style metadata after builds complete (so asset files exist)
537
+ // Only register the main style.css file - complex cases handled manually in lib/client-assets.php
538
+ if ( hasMainStyle ) {
539
+ // Read script asset file to get dependencies
540
+ const scriptAssetPath = path.join(
541
+ BUILD_DIR,
542
+ 'scripts',
543
+ packageName,
544
+ 'index.min.asset.php'
545
+ );
546
+
547
+ const assetContent = await readFile( scriptAssetPath, 'utf8' );
548
+ const depsMatch = assetContent.match(
549
+ /'dependencies' => array\((.*?)\)/s
550
+ );
551
+
552
+ let scriptDependencies = [];
553
+ if ( depsMatch ) {
554
+ const depsString = depsMatch[ 1 ];
555
+ scriptDependencies =
556
+ depsString
557
+ .match( /'([^']+)'/g )
558
+ ?.map( ( d ) => d.replace( /'/g, '' ) ) || [];
559
+ }
560
+
561
+ const styleDeps = await inferStyleDependencies( scriptDependencies );
562
+
563
+ builtStyles.push( {
564
+ handle: `wp-${ packageName }`,
565
+ path: `${ packageName }/style`,
566
+ dependencies: styleDeps,
567
+ } );
568
+ }
569
+
570
+ return {
571
+ modules: builtModules,
572
+ scripts: builtScripts,
573
+ styles: builtStyles,
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Infer style dependencies from script dependencies.
579
+ * Only includes dependencies that:
580
+ * 1. Are @wordpress packages (start with 'wp-')
581
+ * 2. Have wpScript: true in their package.json
582
+ * 3. Actually have a built style.css file
583
+ *
584
+ * @param {string[]} scriptDependencies Array of script handles from asset file.
585
+ * @return {Promise<string[]>} Array of style handles to depend on.
586
+ */
587
+ async function inferStyleDependencies( scriptDependencies ) {
588
+ if ( ! scriptDependencies || scriptDependencies.length === 0 ) {
589
+ return [];
590
+ }
591
+
592
+ const styleDeps = [];
593
+
594
+ for ( const scriptHandle of scriptDependencies ) {
595
+ // Skip non-package dependencies (like 'react', 'lodash', etc.)
596
+ if ( ! scriptHandle.startsWith( 'wp-' ) ) {
597
+ continue;
598
+ }
599
+
600
+ // Convert handle to package name: 'wp-components' → 'components'
601
+ const shortName = scriptHandle.replace( 'wp-', '' );
602
+ const depPackageName = `@wordpress/${ shortName }`;
603
+
604
+ // Read the dependency's package.json
605
+ try {
606
+ const depPackageJson = getPackageInfo( depPackageName );
607
+
608
+ if ( ! depPackageJson ) {
609
+ continue;
610
+ }
611
+
612
+ // ONLY include if it has wpScript: true (which means it builds styles)
613
+ if ( depPackageJson.wpScript === true ) {
614
+ // Double-check the style file actually exists
615
+ const styleFile = path.join(
616
+ BUILD_DIR,
617
+ 'styles',
618
+ shortName,
619
+ 'style.css'
620
+ );
621
+ try {
622
+ await readFile( styleFile );
623
+ styleDeps.push( scriptHandle );
624
+ } catch {
625
+ // Style file doesn't exist, skip it
626
+ }
627
+ }
628
+ } catch {
629
+ // Package not found or can't read package.json - skip it
630
+ continue;
631
+ }
632
+ }
633
+
634
+ return styleDeps;
635
+ }
636
+
637
+ /**
638
+ * Generate PHP files for script module registration.
639
+ *
640
+ * @param {Array} modules Array of module info objects.
641
+ * @param {Record<string, string>} replacements PHP template replacements.
642
+ */
643
+ async function generateModuleRegistrationPhp( modules, replacements ) {
644
+ // Generate modules array for registry
645
+ const modulesArray = modules
646
+ .map(
647
+ ( module ) =>
648
+ `\tarray(\n` +
649
+ `\t\t'id' => '${ module.id }',\n` +
650
+ `\t\t'path' => '${ module.path }',\n` +
651
+ `\t\t'asset' => '${ module.asset }',\n` +
652
+ `\t),`
653
+ )
654
+ .join( '\n' );
655
+
656
+ await Promise.all( [
657
+ generatePhpFromTemplate(
658
+ 'module-registry.php.template',
659
+ path.join( BUILD_DIR, 'modules', 'index.php' ),
660
+ { ...replacements, '{{MODULES}}': modulesArray }
661
+ ),
662
+ generatePhpFromTemplate(
663
+ 'module-registration.php.template',
664
+ path.join( BUILD_DIR, 'modules.php' ),
665
+ replacements
666
+ ),
667
+ ] );
668
+ }
669
+
670
+ /**
671
+ * Generate PHP files for script registration.
672
+ *
673
+ * @param {Array} scripts Array of script info objects.
674
+ * @param {Record<string, string>} replacements PHP template replacements.
675
+ */
676
+ async function generateScriptRegistrationPhp( scripts, replacements ) {
677
+ // Generate scripts array for registry
678
+ const scriptsArray = scripts
679
+ .map(
680
+ ( script ) =>
681
+ `\tarray(\n` +
682
+ `\t\t'handle' => '${ script.handle }',\n` +
683
+ `\t\t'path' => '${ script.path }',\n` +
684
+ `\t\t'asset' => '${ script.asset }',\n` +
685
+ `\t),`
686
+ )
687
+ .join( '\n' );
688
+
689
+ await Promise.all( [
690
+ generatePhpFromTemplate(
691
+ 'script-registry.php.template',
692
+ path.join( BUILD_DIR, 'scripts', 'index.php' ),
693
+ { ...replacements, '{{SCRIPTS}}': scriptsArray }
694
+ ),
695
+ generatePhpFromTemplate(
696
+ 'script-registration.php.template',
697
+ path.join( BUILD_DIR, 'scripts.php' ),
698
+ replacements
699
+ ),
700
+ ] );
701
+ }
702
+
703
+ /**
704
+ * Generate PHP file for version constant.
705
+ *
706
+ * @param {Record<string, string>} replacements PHP template replacements.
707
+ */
708
+ async function generateVersionPhp( replacements ) {
709
+ await generatePhpFromTemplate(
710
+ 'version.php.template',
711
+ path.join( BUILD_DIR, 'version.php' ),
712
+ replacements
713
+ );
714
+ }
715
+
716
+ /**
717
+ * Generate PHP files for style registration.
718
+ *
719
+ * @param {Array} styles Array of style info objects.
720
+ * @param {Record<string, string>} replacements PHP template replacements.
721
+ */
722
+ async function generateStyleRegistrationPhp( styles, replacements ) {
723
+ // Generate styles array for registry
724
+ const stylesArray = styles
725
+ .map(
726
+ ( style ) =>
727
+ `\tarray(\n` +
728
+ `\t\t'handle' => '${ style.handle }',\n` +
729
+ `\t\t'path' => '${ style.path }',\n` +
730
+ `\t\t'dependencies' => array(${ style.dependencies
731
+ .map( ( dep ) => `'${ dep }'` )
732
+ .join( ', ' ) }),\n` +
733
+ `\t),`
734
+ )
735
+ .join( '\n' );
736
+
737
+ await Promise.all( [
738
+ generatePhpFromTemplate(
739
+ 'style-registry.php.template',
740
+ path.join( BUILD_DIR, 'styles', 'index.php' ),
741
+ { ...replacements, '{{STYLES}}': stylesArray }
742
+ ),
743
+ generatePhpFromTemplate(
744
+ 'style-registration.php.template',
745
+ path.join( BUILD_DIR, 'styles.php' ),
746
+ replacements
747
+ ),
748
+ ] );
749
+ }
750
+
751
+ /**
752
+ * Generate main index.php that loads both modules and scripts.
753
+ *
754
+ * @param {Record<string, string>} replacements PHP template replacements.
755
+ */
756
+ async function generateMainIndexPhp( replacements ) {
757
+ await generatePhpFromTemplate(
758
+ 'index.php.template',
759
+ path.join( BUILD_DIR, 'index.php' ),
760
+ replacements
761
+ );
762
+ }
763
+
764
+ /**
765
+ * Transpile a single package's source files and copy JSON files.
766
+ *
767
+ * @param {string} packageName Package name.
768
+ * @return {Promise<number>} Build time in milliseconds.
769
+ */
770
+ async function transpilePackage( packageName ) {
771
+ const startTime = Date.now();
772
+ const packageDir = path.join( PACKAGES_DIR, packageName );
773
+ const packageJson = getPackageInfoFromFile(
774
+ path.join( PACKAGES_DIR, packageName, 'package.json' )
775
+ );
776
+
777
+ if ( ! packageJson ) {
778
+ throw new Error(
779
+ `Could not find package.json for package: ${ packageName }`
780
+ );
781
+ }
782
+
783
+ const srcFiles = await glob(
784
+ normalizePath(
785
+ path.join( packageDir, `src/**/*.${ SOURCE_EXTENSIONS }` )
786
+ ),
787
+ {
788
+ ignore: IGNORE_PATTERNS,
789
+ }
790
+ );
791
+
792
+ const jsonFiles = await glob(
793
+ normalizePath( path.join( packageDir, 'src/**/*.json' ) ),
794
+ {
795
+ ignore: IGNORE_PATTERNS,
796
+ }
797
+ );
798
+
799
+ const buildDir = path.join( packageDir, 'build' );
800
+ const buildModuleDir = path.join( packageDir, 'build-module' );
801
+ const srcDir = path.join( packageDir, 'src' );
802
+ const target = browserslistToEsbuild();
803
+
804
+ const builds = [];
805
+
806
+ // Check if this is the components package that needs emotion babel plugin.
807
+ // Ideally we should remove this exception and move away from emotion.
808
+ const needsEmotionPlugin = packageName === 'components';
809
+ const plugins = needsEmotionPlugin ? [ emotionBabelPlugin() ] : [];
810
+
811
+ if ( packageJson.main ) {
812
+ builds.push(
813
+ esbuild.build( {
814
+ entryPoints: srcFiles,
815
+ outdir: buildDir,
816
+ outbase: srcDir,
817
+ bundle: false,
818
+ platform: 'node',
819
+ format: 'cjs',
820
+ sourcemap: true,
821
+ target,
822
+ jsx: 'automatic',
823
+ jsxImportSource: 'react',
824
+ loader: {
825
+ '.js': 'jsx',
826
+ },
827
+ plugins,
828
+ } )
829
+ );
830
+
831
+ for ( const jsonFile of jsonFiles ) {
832
+ const relativePath = path.relative( srcDir, jsonFile );
833
+ const destPath = path.join( buildDir, relativePath );
834
+ const destDir = path.dirname( destPath );
835
+ builds.push(
836
+ mkdir( destDir, { recursive: true } ).then( () =>
837
+ copyFile( jsonFile, destPath )
838
+ )
839
+ );
840
+ }
841
+ }
842
+
843
+ if ( packageJson.module ) {
844
+ builds.push(
845
+ esbuild.build( {
846
+ entryPoints: srcFiles,
847
+ outdir: buildModuleDir,
848
+ outbase: srcDir,
849
+ bundle: false,
850
+ platform: 'neutral',
851
+ format: 'esm',
852
+ sourcemap: true,
853
+ target,
854
+ jsx: 'automatic',
855
+ jsxImportSource: 'react',
856
+ loader: {
857
+ '.js': 'jsx',
858
+ },
859
+ plugins,
860
+ } )
861
+ );
862
+
863
+ for ( const jsonFile of jsonFiles ) {
864
+ const relativePath = path.relative( srcDir, jsonFile );
865
+ const destPath = path.join( buildModuleDir, relativePath );
866
+ const destDir = path.dirname( destPath );
867
+ builds.push(
868
+ mkdir( destDir, { recursive: true } ).then( () =>
869
+ copyFile( jsonFile, destPath )
870
+ )
871
+ );
872
+ }
873
+ }
874
+
875
+ await Promise.all( builds );
876
+
877
+ await compileStyles( packageName );
878
+
879
+ return Date.now() - startTime;
880
+ }
881
+
882
+ /**
883
+ * Compile styles for a single package.
884
+ *
885
+ * Discovers and compiles SCSS entry points based on package configuration
886
+ * (supporting wpStyleEntryPoints in package.json for custom entry point patterns),
887
+ * and all .module.css files in src/ directory.
888
+ *
889
+ * @param {string} packageName Package name.
890
+ * @return {Promise<number|null>} Build time in milliseconds, or null if no styles.
891
+ */
892
+ async function compileStyles( packageName ) {
893
+ const packageDir = path.join( PACKAGES_DIR, packageName );
894
+ const packageJson = getPackageInfoFromFile(
895
+ path.join( PACKAGES_DIR, packageName, 'package.json' )
896
+ );
897
+
898
+ // Get SCSS entry point patterns from package.json, default to root-level only
899
+ const scssEntryPointPatterns = packageJson.wpStyleEntryPoints || [
900
+ 'src/*.scss',
901
+ ];
902
+
903
+ // Find all matching SCSS files
904
+ const scssEntries = await glob(
905
+ scssEntryPointPatterns.map( ( pattern ) =>
906
+ normalizePath( path.join( packageDir, pattern ) )
907
+ )
908
+ );
909
+
910
+ // Get CSS modules from anywhere in src/
911
+ const cssModuleEntries = await glob(
912
+ normalizePath( path.join( packageDir, 'src/**/*.module.css' ) ),
913
+ { ignore: IGNORE_PATTERNS }
914
+ );
915
+
916
+ if ( scssEntries.length === 0 && cssModuleEntries.length === 0 ) {
917
+ return null;
918
+ }
919
+
920
+ const startTime = Date.now();
921
+ const buildStyleDir = path.join( packageDir, 'build-style' );
922
+ const srcDir = path.join( packageDir, 'src' );
923
+
924
+ // Process .module.css files and generate JS modules
925
+ const cssResults = await Promise.all(
926
+ cssModuleEntries.map( async ( styleEntryPath ) => {
927
+ const buildDir = path.join( packageDir, 'build' );
928
+ const buildModuleDir = path.join( packageDir, 'build-module' );
929
+
930
+ const cssContent = await readFile( styleEntryPath, 'utf8' );
931
+ const relativePath = path.relative( srcDir, styleEntryPath );
932
+
933
+ let mappings = {};
934
+ const result = await postcss( [
935
+ postcssModulesPlugin( {
936
+ getJSON: ( _, json ) => ( mappings = json ),
937
+ } ),
938
+ ] ).process( cssContent, { from: styleEntryPath } );
939
+
940
+ // Write processed CSS to build-style (preserving directory structure)
941
+ const cssOutPath = path.join(
942
+ buildStyleDir,
943
+ relativePath.replace( '.module.css', '.css' )
944
+ );
945
+ await mkdir( path.dirname( cssOutPath ), { recursive: true } );
946
+ await writeFile( cssOutPath, result.css );
947
+
948
+ // Generate JS modules with class name mappings (preserving directory structure)
949
+ const jsExport = JSON.stringify( mappings );
950
+ const jsPath = `${ relativePath }.js`;
951
+ await Promise.all( [
952
+ mkdir( path.dirname( path.join( buildDir, jsPath ) ), {
953
+ recursive: true,
954
+ } ),
955
+ mkdir( path.dirname( path.join( buildModuleDir, jsPath ) ), {
956
+ recursive: true,
957
+ } ),
958
+ ] );
959
+ await Promise.all( [
960
+ writeFile(
961
+ path.join( buildDir, jsPath ),
962
+ `"use strict";\nmodule.exports = ${ jsExport };\n`
963
+ ),
964
+ writeFile(
965
+ path.join( buildModuleDir, jsPath ),
966
+ `export default ${ jsExport };\n`
967
+ ),
968
+ ] );
969
+
970
+ // Return the processed CSS for combining
971
+ return result.css;
972
+ } )
973
+ );
974
+
975
+ // Generate combined stylesheet from all CSS modules
976
+ if ( cssResults.length > 0 ) {
977
+ const combinedCss = cssResults.join( '\n' );
978
+ await mkdir( buildStyleDir, { recursive: true } );
979
+ await writeFile( path.join( buildStyleDir, 'style.css' ), combinedCss );
980
+ }
981
+
982
+ // Process SCSS files
983
+ await Promise.all(
984
+ scssEntries.map( async ( styleEntryPath ) => {
985
+ // Calculate relative path from src/ to preserve directory structure
986
+ const relativePath = path.relative( srcDir, styleEntryPath );
987
+ const relativeDir = path.dirname( relativePath );
988
+ const entryName = path.basename( styleEntryPath, '.scss' );
989
+
990
+ const outputDir =
991
+ relativeDir === '.'
992
+ ? buildStyleDir
993
+ : path.join( buildStyleDir, relativeDir );
994
+
995
+ await mkdir( outputDir, { recursive: true } );
996
+
997
+ await esbuild.build( {
998
+ entryPoints: [ styleEntryPath ],
999
+ outdir: outputDir,
1000
+ bundle: true,
1001
+ write: false,
1002
+ loader: {
1003
+ '.scss': 'css',
1004
+ },
1005
+ plugins: [
1006
+ sassPlugin( {
1007
+ embedded: true,
1008
+ loadPaths: [
1009
+ 'node_modules',
1010
+ path.join( PACKAGES_DIR, 'base-styles' ),
1011
+ ],
1012
+ async transform( source ) {
1013
+ // Process with autoprefixer for LTR version
1014
+ const ltrResult = await postcss( [
1015
+ autoprefixer( { grid: true } ),
1016
+ ] ).process( source, { from: undefined } );
1017
+
1018
+ // Process with rtlcss for RTL version
1019
+ const rtlResult = await postcss( [
1020
+ rtlcss(),
1021
+ ] ).process( ltrResult.css, { from: undefined } );
1022
+
1023
+ await Promise.all( [
1024
+ writeFile(
1025
+ path.join(
1026
+ outputDir,
1027
+ `${ entryName }.css`
1028
+ ),
1029
+ ltrResult.css
1030
+ ),
1031
+ writeFile(
1032
+ path.join(
1033
+ outputDir,
1034
+ `${ entryName }-rtl.css`
1035
+ ),
1036
+ rtlResult.css
1037
+ ),
1038
+ ] );
1039
+
1040
+ return '';
1041
+ },
1042
+ } ),
1043
+ ],
1044
+ } );
1045
+ } )
1046
+ );
1047
+
1048
+ return Date.now() - startTime;
1049
+ }
1050
+
1051
+ /**
1052
+ * Determine if a file is a source file in a package.
1053
+ *
1054
+ * @param {string} filename File path.
1055
+ * @return {boolean} True if the file is a package source file.
1056
+ */
1057
+ function isPackageSourceFile( filename ) {
1058
+ const relativePath = normalizePath(
1059
+ path.relative( process.cwd(), filename )
1060
+ );
1061
+
1062
+ if ( ! /\/src\/.+/.test( relativePath ) ) {
1063
+ return false;
1064
+ }
1065
+
1066
+ if ( TEST_FILE_PATTERNS.some( ( regex ) => regex.test( relativePath ) ) ) {
1067
+ return false;
1068
+ }
1069
+
1070
+ return PACKAGES.some( ( packageName ) => {
1071
+ const packagePath = normalizePath(
1072
+ path.join( 'packages', packageName )
1073
+ );
1074
+ return relativePath.startsWith( packagePath + '/' );
1075
+ } );
1076
+ }
1077
+
1078
+ /**
1079
+ * Get the package name from a file path.
1080
+ *
1081
+ * @param {string} filename File path.
1082
+ * @return {string|null} Package name or null if not found.
1083
+ */
1084
+ function getPackageName( filename ) {
1085
+ const relativePath = normalizePath(
1086
+ path.relative( process.cwd(), filename )
1087
+ );
1088
+
1089
+ for ( const packageName of PACKAGES ) {
1090
+ const packagePath = normalizePath(
1091
+ path.join( 'packages', packageName )
1092
+ );
1093
+ if ( relativePath.startsWith( packagePath + '/' ) ) {
1094
+ return packageName;
1095
+ }
1096
+ }
1097
+ return null;
1098
+ }
1099
+
1100
+ /**
1101
+ * Main build function.
1102
+ */
1103
+ async function buildAll() {
1104
+ console.log( '🔨 Building packages...\n' );
1105
+
1106
+ const startTime = Date.now();
1107
+
1108
+ // Build maps: short name ↔ full name from package.json
1109
+ const shortToFull = new Map();
1110
+ const fullToShort = new Map();
1111
+ for ( const pkg of PACKAGES ) {
1112
+ const packageJson = getPackageInfoFromFile(
1113
+ path.join( PACKAGES_DIR, pkg, 'package.json' )
1114
+ );
1115
+ shortToFull.set( pkg, packageJson.name );
1116
+ fullToShort.set( packageJson.name, pkg );
1117
+ }
1118
+
1119
+ const levels = groupByDepth( Array.from( shortToFull.values() ) );
1120
+
1121
+ console.log( '📝 Phase 1: Transpiling packages...\n' );
1122
+
1123
+ for ( const level of levels ) {
1124
+ await Promise.all(
1125
+ level.map( async ( fullName ) => {
1126
+ const packageName = fullToShort.get( fullName );
1127
+ const buildTime = await transpilePackage( packageName );
1128
+ console.log(
1129
+ ` ✔ Transpiled ${ packageName } (${ buildTime }ms)`
1130
+ );
1131
+ } )
1132
+ );
1133
+ }
1134
+
1135
+ console.log( '\n📦 Phase 2: Bundling packages...\n' );
1136
+ const modules = [];
1137
+ const scripts = [];
1138
+ const styles = [];
1139
+ await Promise.all(
1140
+ PACKAGES.map( async ( packageName ) => {
1141
+ const startBundleTime = Date.now();
1142
+ const ret = await bundlePackage( packageName );
1143
+ const buildTime = Date.now() - startBundleTime;
1144
+ if ( ret ) {
1145
+ console.log(
1146
+ ` ✔ Bundled ${ packageName } (${ buildTime }ms)`
1147
+ );
1148
+
1149
+ if ( ret.modules ) {
1150
+ modules.push( ...ret.modules );
1151
+ }
1152
+ if ( ret.scripts ) {
1153
+ scripts.push( ...ret.scripts );
1154
+ }
1155
+ if ( ret.styles ) {
1156
+ styles.push( ...ret.styles );
1157
+ }
1158
+ }
1159
+ } )
1160
+ );
1161
+
1162
+ console.log( '\n📄 Generating PHP registration files...\n' );
1163
+ const phpReplacements = await getPhpReplacements( ROOT_DIR );
1164
+ await Promise.all( [
1165
+ generateMainIndexPhp( phpReplacements ),
1166
+ generateModuleRegistrationPhp( modules, phpReplacements ),
1167
+ generateScriptRegistrationPhp( scripts, phpReplacements ),
1168
+ generateStyleRegistrationPhp( styles, phpReplacements ),
1169
+ generateVersionPhp( phpReplacements ),
1170
+ ] );
1171
+ console.log( ' ✔ Generated build/modules.php' );
1172
+ console.log( ' ✔ Generated build/modules/index.php' );
1173
+ console.log( ' ✔ Generated build/scripts.php' );
1174
+ console.log( ' ✔ Generated build/scripts/index.php' );
1175
+ console.log( ' ✔ Generated build/styles.php' );
1176
+ console.log( ' ✔ Generated build/styles/index.php' );
1177
+ console.log( ' ✔ Generated build/version.php' );
1178
+ console.log( ' ✔ Generated build/index.php' );
1179
+
1180
+ const totalTime = Date.now() - startTime;
1181
+ console.log(
1182
+ `\n🎉 All packages built successfully! (${ totalTime }ms total)`
1183
+ );
1184
+ }
1185
+
1186
+ /**
1187
+ * Watch mode for development.
1188
+ */
1189
+ async function watchMode() {
1190
+ let isRebuilding = false;
1191
+ const needsRebuild = new Set();
1192
+
1193
+ // Build maps: short name ↔ full name from package.json (once)
1194
+ const shortToFull = new Map();
1195
+ const fullToShort = new Map();
1196
+ for ( const pkg of PACKAGES ) {
1197
+ const packageJson = getPackageInfoFromFile(
1198
+ path.join( PACKAGES_DIR, pkg, 'package.json' )
1199
+ );
1200
+ shortToFull.set( pkg, packageJson.name );
1201
+ fullToShort.set( packageJson.name, pkg );
1202
+ }
1203
+ const allFullNames = Array.from( shortToFull.values() );
1204
+
1205
+ /**
1206
+ * Rebuild a package and any affected scripts/modules.
1207
+ *
1208
+ * @param {string} packageName Package to rebuild (short name).
1209
+ */
1210
+ async function rebuildPackage( packageName ) {
1211
+ try {
1212
+ const startTime = Date.now();
1213
+
1214
+ await transpilePackage( packageName );
1215
+ await bundlePackage( packageName );
1216
+
1217
+ const buildTime = Date.now() - startTime;
1218
+ console.log( `✅ ${ packageName } (${ buildTime }ms)` );
1219
+
1220
+ const fullName = shortToFull.get( packageName );
1221
+ const affectedScripts = findScriptsToRebundle(
1222
+ fullName,
1223
+ allFullNames
1224
+ );
1225
+
1226
+ for ( const fullScript of affectedScripts ) {
1227
+ const script = fullToShort.get( fullScript );
1228
+ try {
1229
+ const rebundleStartTime = Date.now();
1230
+ await bundlePackage( script );
1231
+ const rebundleTime = Date.now() - rebundleStartTime;
1232
+ console.log(
1233
+ `✅ ${ script } (rebundled) (${ rebundleTime }ms)`
1234
+ );
1235
+ } catch ( error ) {
1236
+ console.log(
1237
+ `❌ ${ script } - Rebundle error: ${ error.message }`
1238
+ );
1239
+ }
1240
+ }
1241
+ } catch ( error ) {
1242
+ console.log( `❌ ${ packageName } - Error: ${ error.message }` );
1243
+ }
1244
+ }
1245
+
1246
+ async function processNextRebuild() {
1247
+ if ( needsRebuild.size === 0 ) {
1248
+ isRebuilding = false;
1249
+ return;
1250
+ }
1251
+
1252
+ const packagesToRebuild = Array.from( needsRebuild );
1253
+ needsRebuild.clear();
1254
+
1255
+ for ( const packageName of packagesToRebuild ) {
1256
+ await rebuildPackage( packageName );
1257
+ }
1258
+
1259
+ await processNextRebuild();
1260
+ }
1261
+
1262
+ const watchPaths = PACKAGES.map( ( packageName ) =>
1263
+ path.join( PACKAGES_DIR, packageName, 'src' )
1264
+ );
1265
+
1266
+ const watcher = chokidar.watch( watchPaths, {
1267
+ ignored: [
1268
+ '**/{__mocks__,__tests__,test,storybook,stories}/**',
1269
+ '**/*.{spec,test}.{js,ts,tsx}',
1270
+ '**/*.native.*',
1271
+ ],
1272
+ persistent: true,
1273
+ ignoreInitial: true,
1274
+ // Reduce file descriptor usage on macOS
1275
+ useFsEvents: true,
1276
+ depth: 10,
1277
+ awaitWriteFinish: {
1278
+ stabilityThreshold: 100,
1279
+ pollInterval: 50,
1280
+ },
1281
+ } );
1282
+
1283
+ watcher.on( 'error', ( error ) => {
1284
+ if ( error.code === 'EMFILE' ) {
1285
+ console.error(
1286
+ '\n❌ Too many open files. Try increasing the limit:\n' +
1287
+ ' Run: ulimit -n 10240\n' +
1288
+ ' Or add to ~/.zshrc: ulimit -n 10240\n'
1289
+ );
1290
+ process.exit( 1 );
1291
+ }
1292
+ console.error( '❌ Watcher error:', error );
1293
+ } );
1294
+
1295
+ const handleFileChange = async ( filename ) => {
1296
+ if ( ! isPackageSourceFile( filename ) ) {
1297
+ return;
1298
+ }
1299
+
1300
+ const packageName = getPackageName( filename );
1301
+ if ( ! packageName ) {
1302
+ return;
1303
+ }
1304
+
1305
+ if ( isRebuilding ) {
1306
+ needsRebuild.add( packageName );
1307
+ return;
1308
+ }
1309
+
1310
+ isRebuilding = true;
1311
+ await rebuildPackage( packageName );
1312
+ await processNextRebuild();
1313
+ };
1314
+
1315
+ watcher.on( 'change', handleFileChange );
1316
+ watcher.on( 'add', handleFileChange );
1317
+ watcher.on( 'unlink', handleFileChange );
1318
+ }
1319
+
1320
+ /**
1321
+ * Main entry point.
1322
+ */
1323
+ async function main() {
1324
+ const { values } = parseArgs( {
1325
+ options: {
1326
+ watch: {
1327
+ type: 'boolean',
1328
+ short: 'w',
1329
+ default: false,
1330
+ },
1331
+ },
1332
+ } );
1333
+
1334
+ await buildAll();
1335
+
1336
+ if ( values.watch ) {
1337
+ console.log( '\n👀 Watching for changes...\n' );
1338
+ await watchMode();
1339
+ }
1340
+ }
1341
+
1342
+ main().catch( ( error ) => {
1343
+ console.error( '❌ Build failed:', error );
1344
+ process.exit( 1 );
1345
+ } );