@wordpress/build 0.5.0 → 0.5.1-next.06ee73755.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.
@@ -17,6 +17,7 @@ import rtlcss from 'rtlcss';
17
17
  import cssnano from 'cssnano';
18
18
  import babel from 'esbuild-plugin-babel';
19
19
  import { camelCase } from 'change-case';
20
+ import { NodePackageImporter } from 'sass-embedded';
20
21
 
21
22
  /**
22
23
  * Internal dependencies
@@ -43,7 +44,7 @@ const ROOT_DIR = process.cwd();
43
44
  const PACKAGES_DIR = path.join( ROOT_DIR, 'packages' );
44
45
  const BUILD_DIR = path.join( ROOT_DIR, 'build' );
45
46
 
46
- const SOURCE_EXTENSIONS = '{js,ts,tsx}';
47
+ const SOURCE_EXTENSIONS = '{js,mjs,ts,tsx}';
47
48
  const ASSET_EXTENSIONS = 'json';
48
49
  const IGNORE_PATTERNS = [
49
50
  '**/benchmark/**',
@@ -107,26 +108,59 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin(
107
108
  HANDLE_PREFIX
108
109
  );
109
110
 
110
- const styleBundlingPlugins = [
111
- // Handle CSS modules (.module.css and .module.scss)
112
- sassPlugin( {
113
- embedded: true,
114
- filter: /\.module\.(css|scss)$/,
115
- transform: postcssModules( {
116
- generateScopedName: '[name]__[local]__[hash:base64:5]',
111
+ /**
112
+ * Get SASS options for the given working directory.
113
+ *
114
+ * Uses NodePackageImporter from sass-embedded for resolving package imports
115
+ * (like @wordpress/base-styles) which works with any package manager (npm, pnpm, yarn).
116
+ *
117
+ * @param {string} workingDir - The directory where we're working (for NodePackageImporter).
118
+ * @return {Object} SASS options object with importers and loadPaths.
119
+ */
120
+ function getSassOptions( workingDir ) {
121
+ return {
122
+ importers: [ new NodePackageImporter( workingDir ) ],
123
+ // loadPaths for resolving @wordpress/base-styles imports and local base-styles imports
124
+ loadPaths: [
125
+ // Package's own node_modules (for pnpm isolated deps)
126
+ path.join( workingDir, 'node_modules' ),
127
+ // Root node_modules (for npm hoisted deps)
128
+ path.join( ROOT_DIR, 'node_modules' ),
129
+ // For local imports like @use "mixins"
130
+ path.join( PACKAGES_DIR, 'base-styles' ),
131
+ ],
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Create style bundling plugins with the given working directory.
137
+ *
138
+ * @param {string} workingDir - The directory where we're working (for NodePackageImporter).
139
+ * @return {object[]} Array of esbuild plugins for handling CSS/SCSS.
140
+ */
141
+ function createStyleBundlingPlugins( workingDir ) {
142
+ const sassOptions = getSassOptions( workingDir );
143
+ return [
144
+ // Handle CSS modules (.module.css and .module.scss)
145
+ sassPlugin( {
146
+ embedded: true,
147
+ filter: /\.module\.(css|scss)$/,
148
+ transform: postcssModules( {
149
+ generateScopedName: '[name]__[local]__[hash:base64:5]',
150
+ } ),
151
+ type: 'style',
152
+ ...sassOptions,
117
153
  } ),
118
- type: 'style',
119
- loadPaths: [ 'node_modules', path.join( PACKAGES_DIR, 'base-styles' ) ],
120
- } ),
121
- // Handle regular CSS/SCSS files
122
- // Note: .module.css and .module.scss already handled by plugin above
123
- sassPlugin( {
124
- embedded: true,
125
- filter: /\.(css|scss)$/,
126
- type: 'style',
127
- loadPaths: [ 'node_modules', path.join( PACKAGES_DIR, 'base-styles' ) ],
128
- } ),
129
- ];
154
+ // Handle regular CSS/SCSS files
155
+ // Note: .module.css and .module.scss already handled by plugin above
156
+ sassPlugin( {
157
+ embedded: true,
158
+ filter: /\.(css|scss)$/,
159
+ type: 'style',
160
+ ...sassOptions,
161
+ } ),
162
+ ];
163
+ }
130
164
 
131
165
  /**
132
166
  * Normalize path separators for cross-platform compatibility.
@@ -199,16 +233,12 @@ function momentTimezoneAliasPlugin() {
199
233
 
200
234
  // Cached paths - resolved lazily on first use
201
235
  let preBuiltBundlePath;
202
- let momentTimezoneUtilsPath;
203
236
  const resolvePaths = () => {
204
237
  if ( preBuiltBundlePath ) {
205
238
  return;
206
239
  }
207
240
  preBuiltBundlePath = require.resolve(
208
- 'moment-timezone/builds/moment-timezone-with-data-1970-2030'
209
- );
210
- momentTimezoneUtilsPath = require.resolve(
211
- 'moment-timezone/moment-timezone-utils.js'
241
+ 'moment-timezone/builds/moment-timezone-with-data-1970-2030.js'
212
242
  );
213
243
  };
214
244
 
@@ -221,17 +251,6 @@ function momentTimezoneAliasPlugin() {
221
251
  }
222
252
  );
223
253
 
224
- // For utils, we need to load it but ensure it works with the pre-built bundle.
225
- // The utils file tries to require('./') which would load index.js.
226
- // We need to make sure it gets the pre-built bundle instead.
227
- build.onResolve(
228
- { filter: /^moment-timezone\/moment-timezone-utils$/ },
229
- () => {
230
- resolvePaths();
231
- return { path: momentTimezoneUtilsPath };
232
- }
233
- );
234
-
235
254
  // Intercept the require('./') call inside moment-timezone-utils
236
255
  // and redirect it to the pre-built bundle.
237
256
  build.onResolve( { filter: /^\.\/$/ }, ( args ) => {
@@ -344,14 +363,7 @@ async function bundlePackage( packageName, options = {} ) {
344
363
  };
345
364
  }
346
365
 
347
- const bundlePlugins = [
348
- momentTimezoneAliasPlugin(),
349
- wordpressExternalsPlugin(
350
- 'index.min',
351
- 'iife',
352
- packageJson.wpScriptExtraDependencies || []
353
- ),
354
- ];
366
+ const baseBundlePlugins = [ momentTimezoneAliasPlugin() ];
355
367
 
356
368
  builds.push(
357
369
  esbuild.build( {
@@ -359,14 +371,30 @@ async function bundlePackage( packageName, options = {} ) {
359
371
  outfile: path.join( outputDir, 'index.min.js' ),
360
372
  minify: true,
361
373
  define: getDefine( false ),
362
- plugins: bundlePlugins,
374
+ plugins: [
375
+ ...baseBundlePlugins,
376
+ wordpressExternalsPlugin(
377
+ 'index.min',
378
+ 'iife',
379
+ packageJson.wpScriptExtraDependencies || [],
380
+ true // Generate asset file for minified build
381
+ ),
382
+ ],
363
383
  } ),
364
384
  esbuild.build( {
365
385
  ...baseConfig,
366
386
  outfile: path.join( outputDir, 'index.js' ),
367
387
  minify: false,
368
388
  define: getDefine( true ),
369
- plugins: bundlePlugins,
389
+ plugins: [
390
+ ...baseBundlePlugins,
391
+ wordpressExternalsPlugin(
392
+ 'index.min',
393
+ 'iife',
394
+ packageJson.wpScriptExtraDependencies || [],
395
+ false // Skip asset file for non-minified build
396
+ ),
397
+ ],
370
398
  } )
371
399
  );
372
400
 
@@ -397,9 +425,6 @@ async function bundlePackage( packageName, options = {} ) {
397
425
  : exportName.replace( /^\.\//, '' );
398
426
  const entryPoint = path.join( packageDir, exportPath );
399
427
  const baseFileName = path.basename( fileName );
400
- const modulePlugins = [
401
- wordpressExternalsPlugin( `${ baseFileName }.min`, 'esm' ),
402
- ];
403
428
 
404
429
  builds.push(
405
430
  esbuild.build( {
@@ -415,7 +440,14 @@ async function bundlePackage( packageName, options = {} ) {
415
440
  platform: 'browser',
416
441
  minify: true,
417
442
  define: getDefine( false ),
418
- plugins: modulePlugins,
443
+ plugins: [
444
+ wordpressExternalsPlugin(
445
+ `${ baseFileName }.min`,
446
+ 'esm',
447
+ [],
448
+ true // Generate asset file for minified build
449
+ ),
450
+ ],
419
451
  } ),
420
452
  esbuild.build( {
421
453
  entryPoints: [ entryPoint ],
@@ -430,14 +462,21 @@ async function bundlePackage( packageName, options = {} ) {
430
462
  platform: 'browser',
431
463
  minify: false,
432
464
  define: getDefine( true ),
433
- plugins: modulePlugins,
465
+ plugins: [
466
+ wordpressExternalsPlugin(
467
+ `${ baseFileName }.min`,
468
+ 'esm',
469
+ [],
470
+ false // Skip asset file for non-minified build
471
+ ),
472
+ ],
434
473
  } )
435
474
  );
436
475
 
437
476
  const scriptModuleId =
438
477
  exportName === '.'
439
- ? `@wordpress/${ packageName }`
440
- : `@wordpress/${ packageName }/${ fileName }`;
478
+ ? `@${ packageNamespace }/${ packageName }`
479
+ : `@${ packageNamespace }/${ packageName }/${ fileName }`;
441
480
 
442
481
  builtModules.push( {
443
482
  id: scriptModuleId,
@@ -451,7 +490,6 @@ async function bundlePackage( packageName, options = {} ) {
451
490
  if ( packageJson.wpScript ) {
452
491
  const buildStyleDir = path.join( packageDir, 'build-style' );
453
492
  const outputDir = path.join( BUILD_DIR, 'styles', packageName );
454
- const isProduction = process.env.NODE_ENV === 'production';
455
493
 
456
494
  const cssFiles = await glob(
457
495
  normalizePath( path.join( buildStyleDir, '**/*.css' ) )
@@ -467,36 +505,39 @@ async function bundlePackage( packageName, options = {} ) {
467
505
  hasMainStyle = true;
468
506
  }
469
507
 
470
- if ( isProduction ) {
471
- builds.push(
472
- ( async () => {
473
- await mkdir( destDir, { recursive: true } );
474
- const content = await readFile( cssFile, 'utf8' );
475
- const result = await postcss( [
476
- cssnano( {
477
- preset: [
478
- 'default',
479
- {
480
- discardComments: {
481
- removeAll: true,
482
- },
508
+ // Generate minified path: style.css -> style.min.css, style-rtl.css -> style-rtl.min.css
509
+ const minifiedPath = destPath.replace( /\.css$/, '.min.css' );
510
+
511
+ // Always produce both versions (like JavaScript does):
512
+ // 1. Non-minified version (for SCRIPT_DEBUG=true)
513
+ // 2. Minified version (for SCRIPT_DEBUG=false)
514
+ builds.push(
515
+ ( async () => {
516
+ await mkdir( destDir, { recursive: true } );
517
+ const content = await readFile( cssFile, 'utf8' );
518
+
519
+ // Write non-minified version
520
+ await writeFile( destPath, content );
521
+
522
+ // Write minified version
523
+ const result = await postcss( [
524
+ cssnano( {
525
+ preset: [
526
+ 'default',
527
+ {
528
+ discardComments: {
529
+ removeAll: true,
483
530
  },
484
- ],
485
- } ),
486
- ] ).process( content, {
487
- from: cssFile,
488
- to: destPath,
489
- } );
490
- await writeFile( destPath, result.css );
491
- } )()
492
- );
493
- } else {
494
- builds.push(
495
- mkdir( destDir, { recursive: true } ).then( () =>
496
- copyFile( cssFile, destPath )
497
- )
498
- );
499
- }
531
+ },
532
+ ],
533
+ } ),
534
+ ] ).process( content, {
535
+ from: cssFile,
536
+ to: minifiedPath,
537
+ } );
538
+ await writeFile( minifiedPath, result.css );
539
+ } )()
540
+ );
500
541
  }
501
542
  }
502
543
 
@@ -603,7 +644,10 @@ async function bundlePackage( packageName, options = {} ) {
603
644
  ?.map( ( d ) => d.replace( /'/g, '' ) ) || [];
604
645
  }
605
646
 
606
- const styleDeps = await inferStyleDependencies( scriptDependencies );
647
+ const styleDeps = await inferStyleDependencies(
648
+ scriptDependencies,
649
+ packageName
650
+ );
607
651
 
608
652
  builtStyles.push( {
609
653
  handle: `${ handlePrefix }-${ packageName }`,
@@ -627,14 +671,17 @@ async function bundlePackage( packageName, options = {} ) {
627
671
  * 3. Actually have a built style.css file
628
672
  *
629
673
  * @param {string[]} scriptDependencies Array of script handles from asset file.
674
+ * @param {string} packageName Package name (short name) being bundled, for context-aware resolution.
630
675
  * @return {Promise<string[]>} Array of style handles to depend on.
631
676
  */
632
- async function inferStyleDependencies( scriptDependencies ) {
677
+ async function inferStyleDependencies( scriptDependencies, packageName ) {
633
678
  if ( ! scriptDependencies || scriptDependencies.length === 0 ) {
634
679
  return [];
635
680
  }
636
681
 
637
682
  const styleDeps = [];
683
+ // Get the resolve directory for context-aware package resolution
684
+ const resolveDir = path.join( PACKAGES_DIR, packageName );
638
685
 
639
686
  for ( const scriptHandle of scriptDependencies ) {
640
687
  // Skip non-package dependencies (like 'react', 'lodash', etc.)
@@ -642,13 +689,13 @@ async function inferStyleDependencies( scriptDependencies ) {
642
689
  continue;
643
690
  }
644
691
 
645
- // Convert handle to package name: 'wp-components' → 'components'
692
+ // Convert handle to package name: 'wp-components' → '@wordpress/components'
646
693
  const shortName = scriptHandle.replace( 'wp-', '' );
647
694
  const depPackageName = `@wordpress/${ shortName }`;
648
695
 
649
- // Read the dependency's package.json
696
+ // Read the dependency's package.json with context-aware resolution
650
697
  try {
651
- const depPackageJson = getPackageInfo( depPackageName );
698
+ const depPackageJson = getPackageInfo( depPackageName, resolveDir );
652
699
 
653
700
  if ( ! depPackageJson ) {
654
701
  continue;
@@ -746,14 +793,14 @@ async function generateScriptRegistrationPhp( scripts, replacements ) {
746
793
  }
747
794
 
748
795
  /**
749
- * Generate PHP file for version constant.
796
+ * Generate PHP file for constants (version and build URL).
750
797
  *
751
798
  * @param {Record<string, string>} replacements PHP template replacements.
752
799
  */
753
- async function generateVersionPhp( replacements ) {
800
+ async function generateConstantsPhp( replacements ) {
754
801
  await generatePhpFromTemplate(
755
- 'version.php.template',
756
- path.join( BUILD_DIR, 'version.php' ),
802
+ 'constants.php.template',
803
+ path.join( BUILD_DIR, 'constants.php' ),
757
804
  replacements
758
805
  );
759
806
  }
@@ -1002,7 +1049,12 @@ async function transpilePackage( packageName ) {
1002
1049
  name: 'externalize-except-css',
1003
1050
  setup( build ) {
1004
1051
  // Externalize all non-CSS imports
1005
- build.onResolve( { filter: /.*/ }, ( args ) => {
1052
+ build.onResolve( { filter: /.*/ }, async ( args ) => {
1053
+ // Skip recursive calls.
1054
+ if ( args.pluginData?.__fromExternalize ) {
1055
+ return null;
1056
+ }
1057
+
1006
1058
  // Skip entry points
1007
1059
  if ( args.kind === 'entry-point' ) {
1008
1060
  return null;
@@ -1013,6 +1065,44 @@ async function transpilePackage( packageName ) {
1013
1065
  return null;
1014
1066
  }
1015
1067
 
1068
+ // Fully resolve local dependencies without file extension
1069
+ // and replace the extension with the target extension.
1070
+ if ( args.path.startsWith( '.' ) ) {
1071
+ const resolved = await build.resolve( args.path, {
1072
+ namespace: args.namespace,
1073
+ importer: args.importer,
1074
+ kind: args.kind,
1075
+ resolveDir: args.resolveDir,
1076
+ with: args.with,
1077
+ pluginData: {
1078
+ ...args.pluginData,
1079
+ __fromExternalize: true,
1080
+ },
1081
+ } );
1082
+
1083
+ if ( resolved.errors.length > 0 ) {
1084
+ return resolved;
1085
+ }
1086
+
1087
+ // Relativize path: make it relative to resolveDir with leading ./
1088
+ let relativePath = normalizePath(
1089
+ path.relative( args.resolveDir, resolved.path )
1090
+ );
1091
+ if ( ! relativePath.startsWith( '.' ) ) {
1092
+ relativePath = './' + relativePath;
1093
+ }
1094
+
1095
+ // Replace extension: make sure that file extension is always `.mjs` or `.cjs`.
1096
+ const newExt =
1097
+ build.initialOptions.format === 'cjs' ? '.cjs' : '.mjs';
1098
+ relativePath = relativePath.replace( /\.[jt]sx?$/, newExt );
1099
+
1100
+ return {
1101
+ path: relativePath,
1102
+ external: true,
1103
+ };
1104
+ }
1105
+
1016
1106
  // Externalize everything else (keep imports as-is)
1017
1107
  return { path: args.path, external: true };
1018
1108
  } );
@@ -1021,7 +1111,7 @@ async function transpilePackage( packageName ) {
1021
1111
  const plugins = [
1022
1112
  needsEmotionPlugin && emotionPlugin,
1023
1113
  externalizeAllExceptCssPlugin,
1024
- ...styleBundlingPlugins,
1114
+ ...createStyleBundlingPlugins( packageDir ),
1025
1115
  ].filter( Boolean );
1026
1116
 
1027
1117
  if ( packageJson.main ) {
@@ -1030,6 +1120,7 @@ async function transpilePackage( packageName ) {
1030
1120
  entryPoints: srcFiles,
1031
1121
  outdir: buildDir,
1032
1122
  outbase: srcDir,
1123
+ outExtension: { '.js': '.cjs' },
1033
1124
  bundle: true,
1034
1125
  platform: 'node',
1035
1126
  format: 'cjs',
@@ -1037,9 +1128,7 @@ async function transpilePackage( packageName ) {
1037
1128
  target,
1038
1129
  jsx: 'automatic',
1039
1130
  jsxImportSource: 'react',
1040
- loader: {
1041
- '.js': 'jsx',
1042
- },
1131
+ loader: { '.js': 'jsx' },
1043
1132
  plugins,
1044
1133
  } )
1045
1134
  );
@@ -1062,6 +1151,7 @@ async function transpilePackage( packageName ) {
1062
1151
  entryPoints: srcFiles,
1063
1152
  outdir: buildModuleDir,
1064
1153
  outbase: srcDir,
1154
+ outExtension: { '.js': '.mjs' },
1065
1155
  bundle: true,
1066
1156
  platform: 'neutral',
1067
1157
  format: 'esm',
@@ -1069,9 +1159,7 @@ async function transpilePackage( packageName ) {
1069
1159
  target,
1070
1160
  jsx: 'automatic',
1071
1161
  jsxImportSource: 'react',
1072
- loader: {
1073
- '.js': 'jsx',
1074
- },
1162
+ loader: { '.js': 'jsx' },
1075
1163
  plugins,
1076
1164
  } )
1077
1165
  );
@@ -1157,10 +1245,7 @@ async function compileStyles( packageName ) {
1157
1245
  plugins: [
1158
1246
  sassPlugin( {
1159
1247
  embedded: true,
1160
- loadPaths: [
1161
- 'node_modules',
1162
- path.join( PACKAGES_DIR, 'base-styles' ),
1163
- ],
1248
+ ...getSassOptions( packageDir ),
1164
1249
  async transform( source ) {
1165
1250
  // Process with autoprefixer for LTR version
1166
1251
  const ltrResult = await postcss( [
@@ -1279,10 +1364,6 @@ async function buildRoute( routeName ) {
1279
1364
  } );
1280
1365
 
1281
1366
  if ( routeEntryPoints.length > 0 ) {
1282
- const routePlugins = [
1283
- wordpressExternalsPlugin( 'route.min', 'esm' ),
1284
- ];
1285
-
1286
1367
  // Build both minified and non-minified versions in parallel
1287
1368
  await Promise.all( [
1288
1369
  esbuild.build( {
@@ -1293,7 +1374,14 @@ async function buildRoute( routeName ) {
1293
1374
  target: browserslistToEsbuild(),
1294
1375
  minify: true,
1295
1376
  define: getDefine( false ),
1296
- plugins: routePlugins,
1377
+ plugins: [
1378
+ wordpressExternalsPlugin(
1379
+ 'route.min',
1380
+ 'esm',
1381
+ [],
1382
+ true // Generate asset file for minified build
1383
+ ),
1384
+ ],
1297
1385
  } ),
1298
1386
  esbuild.build( {
1299
1387
  entryPoints: routeEntryPoints,
@@ -1303,7 +1391,14 @@ async function buildRoute( routeName ) {
1303
1391
  target: browserslistToEsbuild(),
1304
1392
  minify: false,
1305
1393
  define: getDefine( true ),
1306
- plugins: routePlugins,
1394
+ plugins: [
1395
+ wordpressExternalsPlugin(
1396
+ 'route.min',
1397
+ 'esm',
1398
+ [],
1399
+ false // Skip asset file for non-minified build
1400
+ ),
1401
+ ],
1307
1402
  } ),
1308
1403
  ] );
1309
1404
  }
@@ -1318,11 +1413,6 @@ async function buildRoute( routeName ) {
1318
1413
  // Write temporary entry file
1319
1414
  await writeFile( tempEntryPath, syntheticEntry );
1320
1415
 
1321
- const contentPlugins = [
1322
- wordpressExternalsPlugin( 'content.min', 'esm' ),
1323
- ...styleBundlingPlugins,
1324
- ];
1325
-
1326
1416
  // Build both minified and non-minified versions in parallel
1327
1417
  await Promise.all( [
1328
1418
  esbuild.build( {
@@ -1333,7 +1423,15 @@ async function buildRoute( routeName ) {
1333
1423
  target: browserslistToEsbuild(),
1334
1424
  minify: true,
1335
1425
  define: getDefine( false ),
1336
- plugins: contentPlugins,
1426
+ plugins: [
1427
+ wordpressExternalsPlugin(
1428
+ 'content.min',
1429
+ 'esm',
1430
+ [],
1431
+ true // Generate asset file for minified build
1432
+ ),
1433
+ ...createStyleBundlingPlugins( routeDir ),
1434
+ ],
1337
1435
  } ),
1338
1436
  esbuild.build( {
1339
1437
  entryPoints: [ tempEntryPath ],
@@ -1343,7 +1441,15 @@ async function buildRoute( routeName ) {
1343
1441
  target: browserslistToEsbuild(),
1344
1442
  minify: false,
1345
1443
  define: getDefine( true ),
1346
- plugins: contentPlugins,
1444
+ plugins: [
1445
+ wordpressExternalsPlugin(
1446
+ 'content.min',
1447
+ 'esm',
1448
+ [],
1449
+ false // Skip asset file for non-minified build
1450
+ ),
1451
+ ...createStyleBundlingPlugins( routeDir ),
1452
+ ],
1347
1453
  } ),
1348
1454
  ] );
1349
1455
 
@@ -1380,24 +1486,28 @@ async function buildAllRoutes() {
1380
1486
 
1381
1487
  /**
1382
1488
  * Main build function.
1489
+ *
1490
+ * @param {string?} baseUrlExpression
1383
1491
  */
1384
- async function buildAll() {
1492
+ async function buildAll( baseUrlExpression ) {
1385
1493
  console.log( '🔨 Building packages...\n' );
1386
1494
 
1387
1495
  const startTime = Date.now();
1388
1496
 
1389
- // Build maps: short name ↔ full name from package.json
1497
+ // Build maps: short name ↔ full name ↔ package.json from package.json files
1390
1498
  const shortToFull = new Map();
1391
1499
  const fullToShort = new Map();
1500
+ const fullToPackageJson = new Map();
1392
1501
  for ( const pkg of PACKAGES ) {
1393
1502
  const packageJson = getPackageInfoFromFile(
1394
1503
  path.join( PACKAGES_DIR, pkg, 'package.json' )
1395
1504
  );
1396
1505
  shortToFull.set( pkg, packageJson.name );
1397
1506
  fullToShort.set( packageJson.name, pkg );
1507
+ fullToPackageJson.set( packageJson.name, packageJson );
1398
1508
  }
1399
1509
 
1400
- const levels = groupByDepth( Array.from( shortToFull.values() ) );
1510
+ const levels = groupByDepth( fullToPackageJson );
1401
1511
 
1402
1512
  console.log( '📝 Phase 1: Transpiling packages...\n' );
1403
1513
 
@@ -1444,26 +1554,40 @@ async function buildAll() {
1444
1554
  await buildAllRoutes();
1445
1555
 
1446
1556
  // Collect route and page data for PHP generation
1447
- const routes = getAllRoutes( ROOT_DIR ).map( ( routeName ) => {
1557
+ // Use flatMap to expand routes with multiple pages into separate entries
1558
+ const routes = getAllRoutes( ROOT_DIR ).flatMap( ( routeName ) => {
1448
1559
  const metadata = getRouteMetadata( ROOT_DIR, routeName );
1560
+
1561
+ // Skip routes without pages
1562
+ if ( ! metadata || ! metadata.pages || metadata.pages.length === 0 ) {
1563
+ return [];
1564
+ }
1449
1565
  const routeFiles = getRouteFiles(
1450
1566
  path.join( ROOT_DIR, 'routes', routeName )
1451
1567
  );
1452
- return {
1453
- name: routeName,
1454
- path: metadata?.path,
1455
- page: metadata?.page,
1456
- hasRoute: routeFiles.hasRoute,
1457
- hasContent: routeFiles.hasStage || routeFiles.hasInspector,
1458
- };
1568
+
1569
+ // Create a route entry for each page
1570
+ return metadata.pages.map( ( page ) => {
1571
+ return {
1572
+ name: routeName,
1573
+ path: metadata.path,
1574
+ page,
1575
+ hasRoute: routeFiles.hasRoute,
1576
+ hasContent: routeFiles.hasStage || routeFiles.hasInspector,
1577
+ };
1578
+ } );
1459
1579
  } );
1460
1580
 
1461
1581
  // Normalize PAGES config to support both string and object formats
1462
1582
  const normalizedPages = PAGES.map( ( page ) => {
1463
1583
  if ( typeof page === 'string' ) {
1464
- return { id: page, init: [] };
1584
+ return { id: page, init: [], title: undefined };
1465
1585
  }
1466
- return { id: page.id, init: page.init || [] };
1586
+ return {
1587
+ id: page.id,
1588
+ init: page.init || [],
1589
+ title: page.title || undefined,
1590
+ };
1467
1591
  } );
1468
1592
 
1469
1593
  const pageData = normalizedPages.map( ( page ) => {
@@ -1472,6 +1596,7 @@ async function buildAll() {
1472
1596
  slug: page.id,
1473
1597
  routes: pageRoutes,
1474
1598
  initModules: page.init,
1599
+ title: page.title,
1475
1600
  };
1476
1601
  } );
1477
1602
 
@@ -1521,13 +1646,16 @@ async function buildAll() {
1521
1646
  }
1522
1647
 
1523
1648
  console.log( '\n📄 Generating PHP registration files...\n' );
1524
- const phpReplacements = await getPhpReplacements( ROOT_DIR );
1649
+ const phpReplacements = await getPhpReplacements(
1650
+ ROOT_DIR,
1651
+ baseUrlExpression
1652
+ );
1525
1653
  await Promise.all( [
1526
1654
  generateMainIndexPhp( phpReplacements ),
1527
1655
  generateModuleRegistrationPhp( modules, phpReplacements ),
1528
1656
  generateScriptRegistrationPhp( scripts, phpReplacements ),
1529
1657
  generateStyleRegistrationPhp( styles, phpReplacements ),
1530
- generateVersionPhp( phpReplacements ),
1658
+ generateConstantsPhp( phpReplacements ),
1531
1659
  generateRoutesRegistry( routes, phpReplacements ),
1532
1660
  generateRoutesPhp( routes, phpReplacements ),
1533
1661
  generatePagesPhp( pageData, phpReplacements ),
@@ -1566,17 +1694,18 @@ async function watchMode() {
1566
1694
  let isRebuilding = false;
1567
1695
  const needsRebuild = new Set();
1568
1696
 
1569
- // Build maps: short name ↔ full name from package.json (once)
1697
+ // Build maps: short name ↔ full name ↔ package.json from package.json files (once)
1570
1698
  const shortToFull = new Map();
1571
1699
  const fullToShort = new Map();
1700
+ const fullToPackageJson = new Map();
1572
1701
  for ( const pkg of PACKAGES ) {
1573
1702
  const packageJson = getPackageInfoFromFile(
1574
1703
  path.join( PACKAGES_DIR, pkg, 'package.json' )
1575
1704
  );
1576
1705
  shortToFull.set( pkg, packageJson.name );
1577
1706
  fullToShort.set( packageJson.name, pkg );
1707
+ fullToPackageJson.set( packageJson.name, packageJson );
1578
1708
  }
1579
- const allFullNames = Array.from( shortToFull.values() );
1580
1709
 
1581
1710
  // Get all routes for dependency tracking
1582
1711
  const allRoutes = getAllRoutes( ROOT_DIR );
@@ -1599,7 +1728,7 @@ async function watchMode() {
1599
1728
  const fullName = shortToFull.get( packageName );
1600
1729
  const affectedScripts = findScriptsToRebundle(
1601
1730
  fullName,
1602
- allFullNames
1731
+ fullToPackageJson
1603
1732
  );
1604
1733
 
1605
1734
  for ( const fullScript of affectedScripts ) {
@@ -1622,7 +1751,7 @@ async function watchMode() {
1622
1751
  // Find and rebuild affected routes
1623
1752
  const affectedRoutes = findRoutesToRebuild(
1624
1753
  fullName,
1625
- allFullNames,
1754
+ fullToPackageJson,
1626
1755
  ROOT_DIR,
1627
1756
  allRoutes
1628
1757
  );
@@ -1815,10 +1944,16 @@ async function main() {
1815
1944
  short: 'w',
1816
1945
  default: false,
1817
1946
  },
1947
+ 'base-url': {
1948
+ type: 'string',
1949
+ default: 'plugin_dir_url( __FILE__ )',
1950
+ },
1818
1951
  },
1819
1952
  } );
1820
1953
 
1821
- await buildAll();
1954
+ const baseUrlExpression = values[ 'base-url' ];
1955
+
1956
+ await buildAll( baseUrlExpression );
1822
1957
 
1823
1958
  if ( values.watch ) {
1824
1959
  console.log( '\n👀 Watching for changes...\n' );