@wordpress/build 0.2.0 → 0.3.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 CHANGED
@@ -3,19 +3,15 @@
3
3
  /**
4
4
  * External dependencies
5
5
  */
6
- import { readFile, writeFile, copyFile, mkdir } from 'fs/promises';
7
- import { readdirSync } from 'fs';
6
+ import { readFile, writeFile, copyFile, mkdir, unlink } from 'fs/promises';
8
7
  import path from 'path';
9
8
  import { parseArgs } from 'node:util';
10
9
  import esbuild from 'esbuild';
11
10
  import glob from 'fast-glob';
12
11
  import chokidar from 'chokidar';
13
- // See https://github.com/WordPress/gutenberg/issues/72136
14
- // eslint-disable-next-line import/no-unresolved
15
12
  import browserslistToEsbuild from 'browserslist-to-esbuild';
16
- import { sassPlugin } from 'esbuild-sass-plugin';
13
+ import { sassPlugin, postcssModules } from 'esbuild-sass-plugin';
17
14
  import postcss from 'postcss';
18
- import postcssModulesPlugin from 'postcss-modules';
19
15
  import autoprefixer from 'autoprefixer';
20
16
  import rtlcss from 'rtlcss';
21
17
  import cssnano from 'cssnano';
@@ -25,28 +21,42 @@ import { camelCase } from 'change-case';
25
21
  /**
26
22
  * Internal dependencies
27
23
  */
28
- import { groupByDepth, findScriptsToRebundle } from './dependency-graph.mjs';
24
+ import {
25
+ groupByDepth,
26
+ findScriptsToRebundle,
27
+ findRoutesToRebuild,
28
+ } from './dependency-graph.mjs';
29
29
  import {
30
30
  generatePhpFromTemplate,
31
31
  getPhpReplacements,
32
32
  } from './php-generator.mjs';
33
33
  import { getPackageInfo, getPackageInfoFromFile } from './package-utils.mjs';
34
34
  import { createWordpressExternalsPlugin } from './wordpress-externals-plugin.mjs';
35
+ import {
36
+ getAllRoutes,
37
+ getRouteFiles,
38
+ getRouteMetadata,
39
+ generateContentEntryPoint,
40
+ } from './route-utils.mjs';
35
41
 
36
42
  const ROOT_DIR = process.cwd();
37
43
  const PACKAGES_DIR = path.join( ROOT_DIR, 'packages' );
38
44
  const BUILD_DIR = path.join( ROOT_DIR, 'build' );
39
45
 
40
46
  const SOURCE_EXTENSIONS = '{js,ts,tsx}';
47
+ const ASSET_EXTENSIONS = 'json';
41
48
  const IGNORE_PATTERNS = [
42
49
  '**/benchmark/**',
43
50
  '**/{__mocks__,__tests__,test}/**',
44
51
  '**/{storybook,stories}/**',
45
52
  '**/*.native.*',
53
+ '**/*.ios.*',
54
+ '**/*.android.*',
46
55
  ];
47
56
  const TEST_FILE_PATTERNS = [
48
57
  /\/(benchmark|__mocks__|__tests__|test|storybook|stories)\/.+/,
49
58
  /\.(spec|test)\.(js|ts|tsx)$/,
59
+ /\.(native|ios|android)\.(js|ts|tsx)$/,
50
60
  ];
51
61
 
52
62
  /**
@@ -55,19 +65,23 @@ const TEST_FILE_PATTERNS = [
55
65
  * @return {string[]} Array of package names.
56
66
  */
57
67
  function getAllPackages() {
58
- return readdirSync( PACKAGES_DIR, { withFileTypes: true } )
59
- .filter( ( dirent ) => dirent.isDirectory() )
60
- .map( ( dirent ) => dirent.name );
68
+ return glob
69
+ .sync( normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) ) )
70
+ .map( ( packageJsonPath ) =>
71
+ path.basename( path.dirname( packageJsonPath ) )
72
+ );
61
73
  }
62
74
 
63
75
  const PACKAGES = getAllPackages();
64
- const ROOT_PACKAGE_JSON = getPackageInfoFromFile( path.join( ROOT_DIR, 'package.json' ) );
76
+ const ROOT_PACKAGE_JSON = getPackageInfoFromFile(
77
+ path.join( ROOT_DIR, 'package.json' )
78
+ );
65
79
  const WP_PLUGIN_CONFIG = ROOT_PACKAGE_JSON.wpPlugin || {};
66
80
  const SCRIPT_GLOBAL = WP_PLUGIN_CONFIG.scriptGlobal;
67
81
  const PACKAGE_NAMESPACE = WP_PLUGIN_CONFIG.packageNamespace;
68
82
  const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE;
69
83
  const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {};
70
-
84
+ const PAGES = WP_PLUGIN_CONFIG.pages || [];
71
85
 
72
86
  const baseDefine = {
73
87
  'globalThis.IS_GUTENBERG_PLUGIN': JSON.stringify(
@@ -92,20 +106,26 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin(
92
106
  HANDLE_PREFIX
93
107
  );
94
108
 
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
+ const styleBundlingPlugins = [
110
+ // Handle CSS modules (.module.css and .module.scss)
111
+ sassPlugin( {
112
+ embedded: true,
113
+ filter: /\.module\.(css|scss)$/,
114
+ transform: postcssModules( {
115
+ generateScopedName: '[name]__[local]__[hash:base64:5]',
116
+ } ),
117
+ type: 'style',
118
+ loadPaths: [ 'node_modules', path.join( PACKAGES_DIR, 'base-styles' ) ],
119
+ } ),
120
+ // Handle regular CSS/SCSS files
121
+ // Note: .module.css and .module.scss already handled by plugin above
122
+ sassPlugin( {
123
+ embedded: true,
124
+ filter: /\.(css|scss)$/,
125
+ type: 'style',
126
+ loadPaths: [ 'node_modules', path.join( PACKAGES_DIR, 'base-styles' ) ],
127
+ } ),
128
+ ];
109
129
 
110
130
  /**
111
131
  * Normalize path separators for cross-platform compatibility.
@@ -278,7 +298,9 @@ async function bundlePackage( packageName ) {
278
298
 
279
299
  // Check if package matches the namespace and should expose a global
280
300
  const packageFullName = packageJson.name;
281
- const matchesNamespace = packageFullName.startsWith( `@${ PACKAGE_NAMESPACE }/` );
301
+ const matchesNamespace = packageFullName.startsWith(
302
+ `@${ PACKAGE_NAMESPACE }/`
303
+ );
282
304
  const shouldExposeGlobal = matchesNamespace && SCRIPT_GLOBAL !== false;
283
305
 
284
306
  const globalName = shouldExposeGlobal
@@ -761,6 +783,132 @@ async function generateMainIndexPhp( replacements ) {
761
783
  );
762
784
  }
763
785
 
786
+ /**
787
+ * Generate global route registry file.
788
+ * Creates a single registry with all routes including page metadata.
789
+ *
790
+ * @param {Array} routes Array of route objects.
791
+ * @param {Record<string, string>} replacements PHP template replacements.
792
+ */
793
+ async function generateRoutesRegistry( routes, replacements ) {
794
+ if ( routes.length === 0 ) {
795
+ // No routes to register, skip generating routes registry
796
+ return;
797
+ }
798
+
799
+ // Generate PHP array entries with page metadata
800
+ const routeEntries = routes
801
+ .map( ( route ) => {
802
+ const hasRouteStr = route.hasRoute ? 'true' : 'false';
803
+ const hasContentStr = route.hasContent ? 'true' : 'false';
804
+ return `\tarray(
805
+ 'name' => '${ route.name }',
806
+ 'path' => '${ route.path }',
807
+ 'page' => '${ route.page }',
808
+ 'has_route' => ${ hasRouteStr },
809
+ 'has_content' => ${ hasContentStr },
810
+ )`;
811
+ } )
812
+ .join( ',\n' );
813
+
814
+ // Generate single global registry at build/routes/index.php
815
+ await generatePhpFromTemplate(
816
+ 'route-registry.php.template',
817
+ path.join( BUILD_DIR, 'routes', 'index.php' ),
818
+ { ...replacements, '{{ROUTES}}': routeEntries }
819
+ );
820
+ }
821
+
822
+ /**
823
+ * Generate routes.php file with route registration logic.
824
+ * Uses registry pattern with loop-based registration on page init hooks.
825
+ *
826
+ * @param {Array} routes Array of route objects.
827
+ * @param {Record<string, string>} replacements PHP template replacements.
828
+ */
829
+ async function generateRoutesPhp( routes, replacements ) {
830
+ if ( routes.length === 0 ) {
831
+ // No routes to register, skip generating routes.php
832
+ return;
833
+ }
834
+
835
+ // Generate routes.php from template
836
+ await generatePhpFromTemplate(
837
+ 'routes-registration.php.template',
838
+ path.join( BUILD_DIR, 'routes.php' ),
839
+ { ...replacements, '{{HANDLE_PREFIX}}': HANDLE_PREFIX }
840
+ );
841
+ }
842
+
843
+ /**
844
+ * Generate page-specific PHP files for all pages.
845
+ *
846
+ * @param {Array} pageData Array of page objects with routes.
847
+ * @param {Record<string, string>} replacements PHP template replacements.
848
+ */
849
+ async function generatePagesPhp( pageData, replacements ) {
850
+ if ( pageData.length === 0 ) {
851
+ // No pages to generate
852
+ return;
853
+ }
854
+
855
+ // Generate files for each page
856
+ const pageGenerationPromises = pageData.map( async ( page ) => {
857
+ // Skip pages with no routes
858
+ if ( page.routes.length === 0 ) {
859
+ return;
860
+ }
861
+
862
+ const pageSlugUnderscore = page.slug.replace( /-/g, '_' );
863
+ const prefixUnderscore = replacements[ '{{PREFIX}}' ].replace(
864
+ /-/g,
865
+ '_'
866
+ );
867
+
868
+ const templateReplacements = {
869
+ ...replacements,
870
+ '{{PAGE_SLUG}}': page.slug,
871
+ '{{PAGE_SLUG_UNDERSCORE}}': pageSlugUnderscore,
872
+ '{{PREFIX}}': prefixUnderscore,
873
+ };
874
+
875
+ // Generate both page.php and page-wp-admin.php
876
+ await Promise.all( [
877
+ generatePhpFromTemplate(
878
+ 'page.php.template',
879
+ path.join( BUILD_DIR, 'pages', page.slug, 'page.php' ),
880
+ templateReplacements
881
+ ),
882
+ generatePhpFromTemplate(
883
+ 'page-wp-admin.php.template',
884
+ path.join( BUILD_DIR, 'pages', page.slug, 'page-wp-admin.php' ),
885
+ templateReplacements
886
+ ),
887
+ ] );
888
+
889
+ // Generate empty loader.js (dummy module for dependencies)
890
+ await writeFile(
891
+ path.join( BUILD_DIR, 'pages', page.slug, 'loader.js' ),
892
+ '// Empty module loader for page dependencies\n'
893
+ );
894
+ } );
895
+
896
+ await Promise.all( pageGenerationPromises );
897
+
898
+ // Generate pages.php loader
899
+ const pageIncludes = pageData
900
+ .map( ( page ) => {
901
+ return `require_once __DIR__ . '/pages/${ page.slug }/page.php';\nrequire_once __DIR__ . '/pages/${ page.slug }/page-wp-admin.php';`;
902
+ } )
903
+ .join( '\n' );
904
+
905
+ await generatePhpFromTemplate(
906
+ 'pages.php.template',
907
+ path.join( BUILD_DIR, 'pages.php' ),
908
+ { ...replacements, '{{PAGE_INCLUDES}}': pageIncludes }
909
+ );
910
+ }
911
+
764
912
  /**
765
913
  * Transpile a single package's source files and copy JSON files.
766
914
  *
@@ -789,8 +937,10 @@ async function transpilePackage( packageName ) {
789
937
  }
790
938
  );
791
939
 
792
- const jsonFiles = await glob(
793
- normalizePath( path.join( packageDir, 'src/**/*.json' ) ),
940
+ const assetFiles = await glob(
941
+ normalizePath(
942
+ path.join( packageDir, `src/**/*.${ ASSET_EXTENSIONS }` )
943
+ ),
794
944
  {
795
945
  ignore: IGNORE_PATTERNS,
796
946
  }
@@ -806,7 +956,37 @@ async function transpilePackage( packageName ) {
806
956
  // Check if this is the components package that needs emotion babel plugin.
807
957
  // Ideally we should remove this exception and move away from emotion.
808
958
  const needsEmotionPlugin = packageName === 'components';
809
- const plugins = needsEmotionPlugin ? [ emotionBabelPlugin() ] : [];
959
+ const emotionPlugin = babel( {
960
+ filter: /\.[jt]sx?$/,
961
+ config: {
962
+ plugins: [ '@emotion/babel-plugin' ],
963
+ },
964
+ } );
965
+ const externalizeAllExceptCssPlugin = {
966
+ name: 'externalize-except-css',
967
+ setup( build ) {
968
+ // Externalize all non-CSS imports
969
+ build.onResolve( { filter: /.*/ }, ( args ) => {
970
+ // Skip entry points
971
+ if ( args.kind === 'entry-point' ) {
972
+ return null;
973
+ }
974
+
975
+ // Let CSS/SCSS files be processed by sassPlugin
976
+ if ( args.path.match( /\.(css|scss)$/ ) ) {
977
+ return null;
978
+ }
979
+
980
+ // Externalize everything else (keep imports as-is)
981
+ return { path: args.path, external: true };
982
+ } );
983
+ },
984
+ };
985
+ const plugins = [
986
+ needsEmotionPlugin && emotionPlugin,
987
+ externalizeAllExceptCssPlugin,
988
+ ...styleBundlingPlugins,
989
+ ].filter( Boolean );
810
990
 
811
991
  if ( packageJson.main ) {
812
992
  builds.push(
@@ -814,7 +994,7 @@ async function transpilePackage( packageName ) {
814
994
  entryPoints: srcFiles,
815
995
  outdir: buildDir,
816
996
  outbase: srcDir,
817
- bundle: false,
997
+ bundle: true,
818
998
  platform: 'node',
819
999
  format: 'cjs',
820
1000
  sourcemap: true,
@@ -828,13 +1008,13 @@ async function transpilePackage( packageName ) {
828
1008
  } )
829
1009
  );
830
1010
 
831
- for ( const jsonFile of jsonFiles ) {
832
- const relativePath = path.relative( srcDir, jsonFile );
1011
+ for ( const assetFile of assetFiles ) {
1012
+ const relativePath = path.relative( srcDir, assetFile );
833
1013
  const destPath = path.join( buildDir, relativePath );
834
1014
  const destDir = path.dirname( destPath );
835
1015
  builds.push(
836
1016
  mkdir( destDir, { recursive: true } ).then( () =>
837
- copyFile( jsonFile, destPath )
1017
+ copyFile( assetFile, destPath )
838
1018
  )
839
1019
  );
840
1020
  }
@@ -846,7 +1026,7 @@ async function transpilePackage( packageName ) {
846
1026
  entryPoints: srcFiles,
847
1027
  outdir: buildModuleDir,
848
1028
  outbase: srcDir,
849
- bundle: false,
1029
+ bundle: true,
850
1030
  platform: 'neutral',
851
1031
  format: 'esm',
852
1032
  sourcemap: true,
@@ -860,7 +1040,7 @@ async function transpilePackage( packageName ) {
860
1040
  } )
861
1041
  );
862
1042
 
863
- for ( const jsonFile of jsonFiles ) {
1043
+ for ( const jsonFile of assetFiles ) {
864
1044
  const relativePath = path.relative( srcDir, jsonFile );
865
1045
  const destPath = path.join( buildModuleDir, relativePath );
866
1046
  const destDir = path.dirname( destPath );
@@ -882,9 +1062,9 @@ async function transpilePackage( packageName ) {
882
1062
  /**
883
1063
  * Compile styles for a single package.
884
1064
  *
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.
1065
+ * Discovers and compiles SCSS entry points based on package configuration,
1066
+ * supporting wpStyleEntryPoints in package.json for custom entry point
1067
+ * patterns.
888
1068
  *
889
1069
  * @param {string} packageName Package name.
890
1070
  * @return {Promise<number|null>} Build time in milliseconds, or null if no styles.
@@ -907,13 +1087,7 @@ async function compileStyles( packageName ) {
907
1087
  )
908
1088
  );
909
1089
 
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 ) {
1090
+ if ( scssEntries.length === 0 ) {
917
1091
  return null;
918
1092
  }
919
1093
 
@@ -921,64 +1095,6 @@ async function compileStyles( packageName ) {
921
1095
  const buildStyleDir = path.join( packageDir, 'build-style' );
922
1096
  const srcDir = path.join( packageDir, 'src' );
923
1097
 
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
1098
  // Process SCSS files
983
1099
  await Promise.all(
984
1100
  scssEntries.map( async ( styleEntryPath ) => {
@@ -1097,6 +1213,135 @@ function getPackageName( filename ) {
1097
1213
  return null;
1098
1214
  }
1099
1215
 
1216
+ /**
1217
+ * Build a single route's files.
1218
+ *
1219
+ * @param {string} routeName Route name.
1220
+ * @return {Promise<number>} Build time in milliseconds.
1221
+ */
1222
+ async function buildRoute( routeName ) {
1223
+ const startTime = Date.now();
1224
+ const routeDir = path.join( ROOT_DIR, 'routes', routeName );
1225
+ const outputDir = path.join( BUILD_DIR, 'routes', routeName );
1226
+
1227
+ // Ensure output directory exists
1228
+ await mkdir( outputDir, { recursive: true } );
1229
+
1230
+ // Copy package.json
1231
+ await copyFile(
1232
+ path.join( routeDir, 'package.json' ),
1233
+ path.join( outputDir, 'package.json' )
1234
+ );
1235
+
1236
+ const files = getRouteFiles( routeDir );
1237
+
1238
+ // Build route.js if it exists
1239
+ if ( files.hasRoute ) {
1240
+ const routeEntryPoints = await glob( `route.${ SOURCE_EXTENSIONS }`, {
1241
+ cwd: routeDir,
1242
+ absolute: true,
1243
+ } );
1244
+
1245
+ if ( routeEntryPoints.length > 0 ) {
1246
+ const routePlugins = [
1247
+ wordpressExternalsPlugin( 'route.min', 'esm' ),
1248
+ ];
1249
+
1250
+ // Build both minified and non-minified versions in parallel
1251
+ await Promise.all( [
1252
+ esbuild.build( {
1253
+ entryPoints: routeEntryPoints,
1254
+ outfile: path.join( outputDir, 'route.min.js' ),
1255
+ bundle: true,
1256
+ format: 'esm',
1257
+ target: browserslistToEsbuild(),
1258
+ minify: true,
1259
+ define: getDefine( false ),
1260
+ plugins: routePlugins,
1261
+ } ),
1262
+ esbuild.build( {
1263
+ entryPoints: routeEntryPoints,
1264
+ outfile: path.join( outputDir, 'route.js' ),
1265
+ bundle: true,
1266
+ format: 'esm',
1267
+ target: browserslistToEsbuild(),
1268
+ minify: false,
1269
+ define: getDefine( true ),
1270
+ plugins: routePlugins,
1271
+ } ),
1272
+ ] );
1273
+ }
1274
+ }
1275
+
1276
+ // Build content.js if stage or inspector exists
1277
+ if ( files.hasStage || files.hasInspector ) {
1278
+ // Create synthetic entry point
1279
+ const syntheticEntry = generateContentEntryPoint( files );
1280
+ const tempEntryPath = path.join( routeDir, '.content-entry.js' );
1281
+
1282
+ // Write temporary entry file
1283
+ await writeFile( tempEntryPath, syntheticEntry );
1284
+
1285
+ const contentPlugins = [
1286
+ wordpressExternalsPlugin( 'content.min', 'esm' ),
1287
+ ...styleBundlingPlugins,
1288
+ ];
1289
+
1290
+ // Build both minified and non-minified versions in parallel
1291
+ await Promise.all( [
1292
+ esbuild.build( {
1293
+ entryPoints: [ tempEntryPath ],
1294
+ outfile: path.join( outputDir, 'content.min.js' ),
1295
+ bundle: true,
1296
+ format: 'esm',
1297
+ target: browserslistToEsbuild(),
1298
+ minify: true,
1299
+ define: getDefine( false ),
1300
+ plugins: contentPlugins,
1301
+ } ),
1302
+ esbuild.build( {
1303
+ entryPoints: [ tempEntryPath ],
1304
+ outfile: path.join( outputDir, 'content.js' ),
1305
+ bundle: true,
1306
+ format: 'esm',
1307
+ target: browserslistToEsbuild(),
1308
+ minify: false,
1309
+ define: getDefine( true ),
1310
+ plugins: contentPlugins,
1311
+ } ),
1312
+ ] );
1313
+
1314
+ await unlink( tempEntryPath );
1315
+ }
1316
+
1317
+ return Date.now() - startTime;
1318
+ }
1319
+
1320
+ /**
1321
+ * Build all discovered routes.
1322
+ *
1323
+ * @return {Promise<void>}
1324
+ */
1325
+ async function buildAllRoutes() {
1326
+ console.log( '\n🚦 Phase 3: Building routes...\n' );
1327
+
1328
+ const routes = getAllRoutes( ROOT_DIR );
1329
+
1330
+ if ( routes.length === 0 ) {
1331
+ console.log( ' No routes found, skipping.\n' );
1332
+ return;
1333
+ }
1334
+
1335
+ await Promise.all(
1336
+ routes.map( async ( routeName ) => {
1337
+ const buildTime = await buildRoute( routeName );
1338
+ console.log(
1339
+ ` ✔ Built route ${ routeName } (${ buildTime }ms)`
1340
+ );
1341
+ } )
1342
+ );
1343
+ }
1344
+
1100
1345
  /**
1101
1346
  * Main build function.
1102
1347
  */
@@ -1159,6 +1404,32 @@ async function buildAll() {
1159
1404
  } )
1160
1405
  );
1161
1406
 
1407
+ // Build routes
1408
+ await buildAllRoutes();
1409
+
1410
+ // Collect route and page data for PHP generation
1411
+ const routes = getAllRoutes( ROOT_DIR ).map( ( routeName ) => {
1412
+ const metadata = getRouteMetadata( ROOT_DIR, routeName );
1413
+ const routeFiles = getRouteFiles(
1414
+ path.join( ROOT_DIR, 'routes', routeName )
1415
+ );
1416
+ return {
1417
+ name: routeName,
1418
+ path: metadata?.path,
1419
+ page: metadata?.page,
1420
+ hasRoute: routeFiles.hasRoute,
1421
+ hasContent: routeFiles.hasStage || routeFiles.hasInspector,
1422
+ };
1423
+ } );
1424
+
1425
+ const pageData = PAGES.map( ( pageSlug ) => {
1426
+ const pageRoutes = routes.filter( ( r ) => r.page === pageSlug );
1427
+ return {
1428
+ slug: pageSlug,
1429
+ routes: pageRoutes,
1430
+ };
1431
+ } );
1432
+
1162
1433
  console.log( '\n📄 Generating PHP registration files...\n' );
1163
1434
  const phpReplacements = await getPhpReplacements( ROOT_DIR );
1164
1435
  await Promise.all( [
@@ -1167,6 +1438,9 @@ async function buildAll() {
1167
1438
  generateScriptRegistrationPhp( scripts, phpReplacements ),
1168
1439
  generateStyleRegistrationPhp( styles, phpReplacements ),
1169
1440
  generateVersionPhp( phpReplacements ),
1441
+ generateRoutesRegistry( routes, phpReplacements ),
1442
+ generateRoutesPhp( routes, phpReplacements ),
1443
+ generatePagesPhp( pageData, phpReplacements ),
1170
1444
  ] );
1171
1445
  console.log( ' ✔ Generated build/modules.php' );
1172
1446
  console.log( ' ✔ Generated build/modules/index.php' );
@@ -1175,6 +1449,16 @@ async function buildAll() {
1175
1449
  console.log( ' ✔ Generated build/styles.php' );
1176
1450
  console.log( ' ✔ Generated build/styles/index.php' );
1177
1451
  console.log( ' ✔ Generated build/version.php' );
1452
+ console.log( ' ✔ Generated build/routes.php' );
1453
+ if ( PAGES.length > 0 ) {
1454
+ console.log( ' ✔ Generated build/pages.php' );
1455
+ for ( const page of PAGES ) {
1456
+ console.log( ` ✔ Generated build/pages/${ page }/page.php` );
1457
+ console.log(
1458
+ ` ✔ Generated build/pages/${ page }/page-wp-admin.php`
1459
+ );
1460
+ }
1461
+ }
1178
1462
  console.log( ' ✔ Generated build/index.php' );
1179
1463
 
1180
1464
  const totalTime = Date.now() - startTime;
@@ -1202,6 +1486,9 @@ async function watchMode() {
1202
1486
  }
1203
1487
  const allFullNames = Array.from( shortToFull.values() );
1204
1488
 
1489
+ // Get all routes for dependency tracking
1490
+ const allRoutes = getAllRoutes( ROOT_DIR );
1491
+
1205
1492
  /**
1206
1493
  * Rebuild a package and any affected scripts/modules.
1207
1494
  *
@@ -1238,22 +1525,69 @@ async function watchMode() {
1238
1525
  );
1239
1526
  }
1240
1527
  }
1528
+
1529
+ // Find and rebuild affected routes
1530
+ const affectedRoutes = findRoutesToRebuild(
1531
+ fullName,
1532
+ allFullNames,
1533
+ ROOT_DIR,
1534
+ allRoutes
1535
+ );
1536
+
1537
+ for ( const route of affectedRoutes ) {
1538
+ try {
1539
+ const rebuildStartTime = Date.now();
1540
+ await buildRoute( route );
1541
+ const rebuildTime = Date.now() - rebuildStartTime;
1542
+ console.log(
1543
+ `✅ routes/${ route } (rebuilt) (${ rebuildTime }ms)`
1544
+ );
1545
+ } catch ( error ) {
1546
+ console.log(
1547
+ `❌ routes/${ route } - Rebuild error: ${ error.message }`
1548
+ );
1549
+ }
1550
+ }
1241
1551
  } catch ( error ) {
1242
1552
  console.log( `❌ ${ packageName } - Error: ${ error.message }` );
1243
1553
  }
1244
1554
  }
1245
1555
 
1556
+ /**
1557
+ * Rebuild a route.
1558
+ *
1559
+ * @param {string} routeName Route to rebuild.
1560
+ */
1561
+ async function rebuildRoute( routeName ) {
1562
+ try {
1563
+ const startTime = Date.now();
1564
+ await buildRoute( routeName );
1565
+ const buildTime = Date.now() - startTime;
1566
+ console.log( `✅ routes/${ routeName } (${ buildTime }ms)` );
1567
+ } catch ( error ) {
1568
+ console.log(
1569
+ `❌ routes/${ routeName } - Error: ${ error.message }`
1570
+ );
1571
+ }
1572
+ }
1573
+
1246
1574
  async function processNextRebuild() {
1247
1575
  if ( needsRebuild.size === 0 ) {
1248
1576
  isRebuilding = false;
1249
1577
  return;
1250
1578
  }
1251
1579
 
1252
- const packagesToRebuild = Array.from( needsRebuild );
1580
+ const itemsToRebuild = Array.from( needsRebuild );
1253
1581
  needsRebuild.clear();
1254
1582
 
1255
- for ( const packageName of packagesToRebuild ) {
1256
- await rebuildPackage( packageName );
1583
+ for ( const item of itemsToRebuild ) {
1584
+ // Check if it's a route (prefixed with 'route:')
1585
+ if ( item.startsWith( 'route:' ) ) {
1586
+ const routeName = item.slice( 6 ); // Remove 'route:' prefix
1587
+ await rebuildRoute( routeName );
1588
+ } else {
1589
+ await rebuildPackage( item );
1590
+ }
1257
1591
  }
1258
1592
 
1259
1593
  await processNextRebuild();
@@ -1315,6 +1649,66 @@ async function watchMode() {
1315
1649
  watcher.on( 'change', handleFileChange );
1316
1650
  watcher.on( 'add', handleFileChange );
1317
1651
  watcher.on( 'unlink', handleFileChange );
1652
+
1653
+ // Watch route files if routes exist
1654
+ if ( allRoutes.length > 0 ) {
1655
+ const routeWatchPaths = allRoutes.map( ( routeName ) =>
1656
+ path.join( ROOT_DIR, 'routes', routeName )
1657
+ );
1658
+
1659
+ const routeWatcher = chokidar.watch( routeWatchPaths, {
1660
+ persistent: true,
1661
+ ignoreInitial: true,
1662
+ ignored: ( filepath ) => {
1663
+ const basename = path.basename( filepath );
1664
+ // Ignore .content-entry.js temporary build files
1665
+ if ( basename === '.content-entry.js' ) {
1666
+ return true;
1667
+ }
1668
+ // Ignore node_modules directories and contents
1669
+ if ( filepath.includes( 'node_modules' ) ) {
1670
+ return true;
1671
+ }
1672
+ return false;
1673
+ },
1674
+ useFsEvents: true,
1675
+ depth: 10,
1676
+ awaitWriteFinish: {
1677
+ stabilityThreshold: 100,
1678
+ pollInterval: 50,
1679
+ },
1680
+ } );
1681
+
1682
+ routeWatcher.on( 'error', ( error ) => {
1683
+ console.error( '❌ Route watcher error:', error );
1684
+ } );
1685
+
1686
+ const handleRouteFileChange = async ( filename ) => {
1687
+ // Extract route name from path: routes/{routeName}/...
1688
+ const routeMatch = filename.match( /routes[/\\]([^/\\]+)[/\\]/ );
1689
+ if ( ! routeMatch ) {
1690
+ return;
1691
+ }
1692
+
1693
+ const routeName = routeMatch[ 1 ];
1694
+ if ( ! allRoutes.includes( routeName ) ) {
1695
+ return;
1696
+ }
1697
+
1698
+ if ( isRebuilding ) {
1699
+ needsRebuild.add( `route:${ routeName }` );
1700
+ return;
1701
+ }
1702
+
1703
+ isRebuilding = true;
1704
+ await rebuildRoute( routeName );
1705
+ await processNextRebuild();
1706
+ };
1707
+
1708
+ routeWatcher.on( 'change', handleRouteFileChange );
1709
+ routeWatcher.on( 'add', handleRouteFileChange );
1710
+ routeWatcher.on( 'unlink', handleRouteFileChange );
1711
+ }
1318
1712
  }
1319
1713
 
1320
1714
  /**