@wordpress/build
Advanced tools
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
+2
-0
@@ -5,2 +5,4 @@ <!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/HEAD/packages#maintaining-changelogs. --> | ||
| ## 0.3.0 (2025-11-12) | ||
| ## 0.2.0 (2025-10-29) | ||
@@ -7,0 +9,0 @@ |
+2
-2
| { | ||
| "name": "@wordpress/build", | ||
| "version": "0.2.1-next.2f1c7c01b.0", | ||
| "version": "0.3.0", | ||
| "description": "Build tool for WordPress plugins.", | ||
@@ -57,3 +57,3 @@ "author": "The WordPress Contributors", | ||
| }, | ||
| "gitHead": "c6ddcdf455bc02567a2c9e03de6862a2061b85e8" | ||
| "gitHead": "77aa1f194edceafe8ac2a1b9438bf84b557e76e3" | ||
| } |
+36
-2
@@ -204,2 +204,33 @@ # @wordpress/build | ||
| ### `wpPlugin.pages` (Experimental) | ||
| Define admin pages that support routes. Each page gets generated PHP functions for route registration and can be extended by other plugins: | ||
| ```json | ||
| { | ||
| "wpPlugin": { | ||
| "pages": ["my-admin-page"] | ||
| } | ||
| } | ||
| ``` | ||
| This generates two page modes: | ||
| - `build/pages/my-admin-page/page.php` - Full-page mode (takes over entire admin screen with custom sidebar) | ||
| - `build/pages/my-admin-page/page-wp-admin.php` - WP-Admin mode (integrates within standard wp-admin interface) | ||
| - `build/pages.php` - Loader for all pages | ||
| Each mode provides route/menu registration functions and a render callback. Routes are automatically registered for both modes. | ||
| **Registering a menu item for WP-Admin mode:** | ||
| ```php | ||
| // Build URL with initial route via 'p' query parameter | ||
| $url = admin_url( 'admin.php?page=my-admin-page-wp-admin&p=' . urlencode( '/my/route' ) ); | ||
| add_menu_page( 'Title', 'Menu', 'capability', $url, '', 'icon', 20 ); | ||
| ``` | ||
| **Registering a menu item for full-page mode:** | ||
| ```php | ||
| add_menu_page( 'Title', 'Menu', 'capability', 'my-admin-page', 'my_admin_page_render_page', 'icon', 20 ); | ||
| ``` | ||
| ### Example: WordPress Core (Gutenberg) | ||
@@ -258,3 +289,3 @@ | ||
| Routes provide a file-based routing system for WordPress admin pages. Create a `routes/` directory at your repository root with subdirectories for each route. | ||
| Routes provide a file-based routing system for WordPress admin pages. Each route must be associated with a page defined in `wpPlugin.pages` (see above). Create a `routes/` directory at your repository root with subdirectories for each route. | ||
@@ -279,3 +310,4 @@ ### Structure | ||
| "route": { | ||
| "path": "/" | ||
| "path": "/", | ||
| "page": "my-admin-page" | ||
| } | ||
@@ -285,2 +317,4 @@ } | ||
| The `page` field must match one of the pages defined in `wpPlugin.pages` in your root `package.json`. This tells the build system which page this route belongs to. It can also map to an existing page registered by another plugin. | ||
| ### Components | ||
@@ -287,0 +321,0 @@ |
+175
-13
@@ -32,4 +32,2 @@ #!/usr/bin/env node | ||
| getPhpReplacements, | ||
| generateRoutesPhp, | ||
| generateRoutesRegistry, | ||
| } from './php-generator.mjs'; | ||
@@ -41,2 +39,3 @@ import { getPackageInfo, getPackageInfoFromFile } from './package-utils.mjs'; | ||
| getRouteFiles, | ||
| getRouteMetadata, | ||
| generateContentEntryPoint, | ||
@@ -87,2 +86,3 @@ } from './route-utils.mjs'; | ||
| const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; | ||
| const PAGES = WP_PLUGIN_CONFIG.pages || []; | ||
@@ -790,2 +790,128 @@ const baseDefine = { | ||
| /** | ||
| * Generate global route registry file. | ||
| * Creates a single registry with all routes including page metadata. | ||
| * | ||
| * @param {Array} routes Array of route objects. | ||
| * @param {Record<string, string>} replacements PHP template replacements. | ||
| */ | ||
| async function generateRoutesRegistry( routes, replacements ) { | ||
| if ( routes.length === 0 ) { | ||
| // No routes to register, skip generating routes registry | ||
| return; | ||
| } | ||
| // Generate PHP array entries with page metadata | ||
| const routeEntries = routes | ||
| .map( ( route ) => { | ||
| const hasRouteStr = route.hasRoute ? 'true' : 'false'; | ||
| const hasContentStr = route.hasContent ? 'true' : 'false'; | ||
| return `\tarray( | ||
| 'name' => '${ route.name }', | ||
| 'path' => '${ route.path }', | ||
| 'page' => '${ route.page }', | ||
| 'has_route' => ${ hasRouteStr }, | ||
| 'has_content' => ${ hasContentStr }, | ||
| )`; | ||
| } ) | ||
| .join( ',\n' ); | ||
| // Generate single global registry at build/routes/index.php | ||
| await generatePhpFromTemplate( | ||
| 'route-registry.php.template', | ||
| path.join( BUILD_DIR, 'routes', 'index.php' ), | ||
| { ...replacements, '{{ROUTES}}': routeEntries } | ||
| ); | ||
| } | ||
| /** | ||
| * Generate routes.php file with route registration logic. | ||
| * Uses registry pattern with loop-based registration on page init hooks. | ||
| * | ||
| * @param {Array} routes Array of route objects. | ||
| * @param {Record<string, string>} replacements PHP template replacements. | ||
| */ | ||
| async function generateRoutesPhp( routes, replacements ) { | ||
| if ( routes.length === 0 ) { | ||
| // No routes to register, skip generating routes.php | ||
| return; | ||
| } | ||
| // Generate routes.php from template | ||
| await generatePhpFromTemplate( | ||
| 'routes-registration.php.template', | ||
| path.join( BUILD_DIR, 'routes.php' ), | ||
| { ...replacements, '{{HANDLE_PREFIX}}': HANDLE_PREFIX } | ||
| ); | ||
| } | ||
| /** | ||
| * Generate page-specific PHP files for all pages. | ||
| * | ||
| * @param {Array} pageData Array of page objects with routes. | ||
| * @param {Record<string, string>} replacements PHP template replacements. | ||
| */ | ||
| async function generatePagesPhp( pageData, replacements ) { | ||
| if ( pageData.length === 0 ) { | ||
| // No pages to generate | ||
| return; | ||
| } | ||
| // Generate files for each page | ||
| const pageGenerationPromises = pageData.map( async ( page ) => { | ||
| // Skip pages with no routes | ||
| if ( page.routes.length === 0 ) { | ||
| return; | ||
| } | ||
| const pageSlugUnderscore = page.slug.replace( /-/g, '_' ); | ||
| const prefixUnderscore = replacements[ '{{PREFIX}}' ].replace( | ||
| /-/g, | ||
| '_' | ||
| ); | ||
| const templateReplacements = { | ||
| ...replacements, | ||
| '{{PAGE_SLUG}}': page.slug, | ||
| '{{PAGE_SLUG_UNDERSCORE}}': pageSlugUnderscore, | ||
| '{{PREFIX}}': prefixUnderscore, | ||
| }; | ||
| // Generate both page.php and page-wp-admin.php | ||
| await Promise.all( [ | ||
| generatePhpFromTemplate( | ||
| 'page.php.template', | ||
| path.join( BUILD_DIR, 'pages', page.slug, 'page.php' ), | ||
| templateReplacements | ||
| ), | ||
| generatePhpFromTemplate( | ||
| 'page-wp-admin.php.template', | ||
| path.join( BUILD_DIR, 'pages', page.slug, 'page-wp-admin.php' ), | ||
| templateReplacements | ||
| ), | ||
| ] ); | ||
| // Generate empty loader.js (dummy module for dependencies) | ||
| await writeFile( | ||
| path.join( BUILD_DIR, 'pages', page.slug, 'loader.js' ), | ||
| '// Empty module loader for page dependencies\n' | ||
| ); | ||
| } ); | ||
| await Promise.all( pageGenerationPromises ); | ||
| // Generate pages.php loader | ||
| const pageIncludes = pageData | ||
| .map( ( page ) => { | ||
| return `require_once __DIR__ . '/pages/${ page.slug }/page.php';\nrequire_once __DIR__ . '/pages/${ page.slug }/page-wp-admin.php';`; | ||
| } ) | ||
| .join( '\n' ); | ||
| await generatePhpFromTemplate( | ||
| 'pages.php.template', | ||
| path.join( BUILD_DIR, 'pages.php' ), | ||
| { ...replacements, '{{PAGE_INCLUDES}}': pageIncludes } | ||
| ); | ||
| } | ||
| /** | ||
| * Transpile a single package's source files and copy JSON files. | ||
@@ -1288,2 +1414,25 @@ * | ||
| // Collect route and page data for PHP generation | ||
| const routes = getAllRoutes( ROOT_DIR ).map( ( routeName ) => { | ||
| const metadata = getRouteMetadata( ROOT_DIR, routeName ); | ||
| const routeFiles = getRouteFiles( | ||
| path.join( ROOT_DIR, 'routes', routeName ) | ||
| ); | ||
| return { | ||
| name: routeName, | ||
| path: metadata?.path, | ||
| page: metadata?.page, | ||
| hasRoute: routeFiles.hasRoute, | ||
| hasContent: routeFiles.hasStage || routeFiles.hasInspector, | ||
| }; | ||
| } ); | ||
| const pageData = PAGES.map( ( pageSlug ) => { | ||
| const pageRoutes = routes.filter( ( r ) => r.page === pageSlug ); | ||
| return { | ||
| slug: pageSlug, | ||
| routes: pageRoutes, | ||
| }; | ||
| } ); | ||
| console.log( '\n📄 Generating PHP registration files...\n' ); | ||
@@ -1297,13 +1446,5 @@ const phpReplacements = await getPhpReplacements( ROOT_DIR ); | ||
| generateVersionPhp( phpReplacements ), | ||
| generateRoutesRegistry( | ||
| ROOT_DIR, | ||
| BUILD_DIR, | ||
| phpReplacements[ '{{PREFIX}}' ] | ||
| ), | ||
| generateRoutesPhp( | ||
| ROOT_DIR, | ||
| BUILD_DIR, | ||
| HANDLE_PREFIX, | ||
| phpReplacements[ '{{PREFIX}}' ] | ||
| ), | ||
| generateRoutesRegistry( routes, phpReplacements ), | ||
| generateRoutesPhp( routes, phpReplacements ), | ||
| generatePagesPhp( pageData, phpReplacements ), | ||
| ] ); | ||
@@ -1318,2 +1459,11 @@ console.log( ' ✔ Generated build/modules.php' ); | ||
| console.log( ' ✔ Generated build/routes.php' ); | ||
| if ( PAGES.length > 0 ) { | ||
| console.log( ' ✔ Generated build/pages.php' ); | ||
| for ( const page of PAGES ) { | ||
| console.log( ` ✔ Generated build/pages/${ page }/page.php` ); | ||
| console.log( | ||
| ` ✔ Generated build/pages/${ page }/page-wp-admin.php` | ||
| ); | ||
| } | ||
| } | ||
| console.log( ' ✔ Generated build/index.php' ); | ||
@@ -1519,2 +1669,14 @@ | ||
| ignoreInitial: true, | ||
| ignored: ( filepath ) => { | ||
| const basename = path.basename( filepath ); | ||
| // Ignore .content-entry.js temporary build files | ||
| if ( basename === '.content-entry.js' ) { | ||
| return true; | ||
| } | ||
| // Ignore node_modules directories and contents | ||
| if ( filepath.includes( 'node_modules' ) ) { | ||
| return true; | ||
| } | ||
| return false; | ||
| }, | ||
| useFsEvents: true, | ||
@@ -1521,0 +1683,0 @@ depth: 10, |
@@ -40,3 +40,3 @@ /** | ||
| /** | ||
| * @typedef {PackageJson & { route: { path: string } }} RoutePackageJson | ||
| * @typedef {PackageJson & { route: { path: string; page?: string } }} RoutePackageJson | ||
| */ | ||
@@ -43,0 +43,0 @@ |
+32
-109
@@ -12,3 +12,2 @@ /** | ||
| import { getPackageInfoFromFile } from './package-utils.mjs'; | ||
| import { getAllRoutes, getRouteFiles } from './route-utils.mjs'; | ||
@@ -45,23 +44,9 @@ const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); | ||
| /** | ||
| * Generate a PHP file from a template with replacements. | ||
| * Apply template replacements to a template string. | ||
| * | ||
| * @param {string} templateName Template file name. | ||
| * @param {string} outputPath Full output path. | ||
| * @param {string} template Template string with placeholders. | ||
| * @param {Record<string, string>} replacements Replacements object (e.g. {'{{PREFIX}}': 'gutenberg'}). | ||
| * @return {string} Template with replacements applied. | ||
| */ | ||
| export async function generatePhpFromTemplate( | ||
| templateName, | ||
| outputPath, | ||
| replacements | ||
| ) { | ||
| // Templates directory | ||
| const templatesDir = path.join( __dirname, '..', 'templates' ); | ||
| // Read template | ||
| const template = await readFile( | ||
| path.join( templatesDir, templateName ), | ||
| 'utf8' | ||
| ); | ||
| // Apply all replacements | ||
| export function applyTemplateReplacements( template, replacements ) { | ||
| let content = template; | ||
@@ -71,106 +56,44 @@ for ( const [ placeholder, value ] of Object.entries( replacements ) ) { | ||
| } | ||
| // Write output file | ||
| await mkdir( path.dirname( outputPath ), { recursive: true } ); | ||
| await writeFile( outputPath, content ); | ||
| return content; | ||
| } | ||
| /** | ||
| * Generate routes/index.php file with route registry data. | ||
| * Render a template to a string with replacements. | ||
| * | ||
| * @param {string} rootDir Root directory path. | ||
| * @param {string} buildDir Build directory path. | ||
| * @param {string} prefix Package prefix. | ||
| * @param {string} templateName Template file name. | ||
| * @param {Record<string, string>} replacements Replacements object (e.g. {'{{PREFIX}}': 'gutenberg'}). | ||
| * @return {Promise<string>} Rendered template string. | ||
| */ | ||
| export async function generateRoutesRegistry( rootDir, buildDir, prefix ) { | ||
| const routeNames = getAllRoutes( rootDir ); | ||
| export async function renderTemplateToString( templateName, replacements ) { | ||
| // Templates directory | ||
| const templatesDir = path.join( __dirname, '..', 'templates' ); | ||
| if ( routeNames.length === 0 ) { | ||
| // No routes to register, skip generating routes registry | ||
| return; | ||
| } | ||
| // Read template | ||
| const template = await readFile( | ||
| path.join( templatesDir, templateName ), | ||
| 'utf8' | ||
| ); | ||
| // Build routes array | ||
| const routes = routeNames.map( ( routeName ) => { | ||
| // Read package.json to get route path | ||
| const routePackageJson = | ||
| /** @type {import('./package-utils.mjs').RoutePackageJson} */ ( | ||
| getPackageInfoFromFile( | ||
| path.join( rootDir, 'routes', routeName, 'package.json' ) | ||
| ) | ||
| ); | ||
| const routePath = routePackageJson.route.path; | ||
| // Check if route.js exists | ||
| const routeFiles = getRouteFiles( | ||
| path.join( rootDir, 'routes', routeName ) | ||
| ); | ||
| return { | ||
| name: routeName, | ||
| path: routePath, | ||
| has_route: routeFiles.hasRoute, | ||
| }; | ||
| } ); | ||
| // Generate PHP array entries | ||
| const routeEntries = routes | ||
| .map( ( route ) => { | ||
| const hasRouteStr = route.has_route ? 'true' : 'false'; | ||
| return `\tarray( | ||
| 'name' => '${ route.name }', | ||
| 'path' => '${ route.path }', | ||
| 'has_route' => ${ hasRouteStr }, | ||
| )`; | ||
| } ) | ||
| .join( ',\n' ); | ||
| // Generate routes/index.php | ||
| const replacements = { | ||
| '{{PREFIX}}': prefix, | ||
| '{{ROUTES}}': routeEntries, | ||
| }; | ||
| await generatePhpFromTemplate( | ||
| 'route-registry.php.template', | ||
| path.join( buildDir, 'routes', 'index.php' ), | ||
| replacements | ||
| ); | ||
| // Apply replacements | ||
| return applyTemplateReplacements( template, replacements ); | ||
| } | ||
| /** | ||
| * Generate routes.php file with route registration logic. | ||
| * Generate a PHP file from a template with replacements. | ||
| * | ||
| * @param {string} rootDir Root directory path. | ||
| * @param {string} buildDir Build directory path. | ||
| * @param {string} handlePrefix Handle prefix for script modules. | ||
| * @param {string} prefix Package prefix. | ||
| * @param {string} templateName Template file name. | ||
| * @param {string} outputPath Full output path. | ||
| * @param {Record<string, string>} replacements Replacements object (e.g. {'{{PREFIX}}': 'gutenberg'}). | ||
| */ | ||
| export async function generateRoutesPhp( | ||
| rootDir, | ||
| buildDir, | ||
| handlePrefix, | ||
| prefix | ||
| export async function generatePhpFromTemplate( | ||
| templateName, | ||
| outputPath, | ||
| replacements | ||
| ) { | ||
| const routeNames = getAllRoutes( rootDir ); | ||
| // Render template to string | ||
| const content = await renderTemplateToString( templateName, replacements ); | ||
| if ( routeNames.length === 0 ) { | ||
| // No routes to register, skip generating routes.php | ||
| return; | ||
| } | ||
| const namespace = handlePrefix.replace( /-/g, '_' ); | ||
| // Generate routes.php | ||
| const replacements = { | ||
| '{{PREFIX}}': prefix, | ||
| '{{NAMESPACE}}': namespace, | ||
| '{{HANDLE_PREFIX}}': handlePrefix, | ||
| }; | ||
| await generatePhpFromTemplate( | ||
| 'routes.php.template', | ||
| path.join( buildDir, 'routes.php' ), | ||
| replacements | ||
| ); | ||
| // Write output file | ||
| await mkdir( path.dirname( outputPath ), { recursive: true } ); | ||
| await writeFile( outputPath, content ); | ||
| } |
+38
-0
@@ -8,2 +8,7 @@ /** | ||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import { getPackageInfoFromFile } from './package-utils.mjs'; | ||
| /** | ||
| * Get all route names from the routes directory. | ||
@@ -28,2 +33,35 @@ * | ||
| /** | ||
| * @typedef {Object} RouteMetadata | ||
| * @property {string} name Route name. | ||
| * @property {string} path Route path. | ||
| * @property {string|null} page Page slug this route belongs to. | ||
| */ | ||
| /** | ||
| * Get route metadata from package.json. | ||
| * | ||
| * @param {string} rootDir Root directory of the project. | ||
| * @param {string} routeName Route name. | ||
| * @return {RouteMetadata|null} Route metadata object or null if not found. | ||
| */ | ||
| export function getRouteMetadata( rootDir, routeName ) { | ||
| const routePackageJson = | ||
| /** @type {import('./package-utils.mjs').RoutePackageJson|null} */ ( | ||
| getPackageInfoFromFile( | ||
| path.join( rootDir, 'routes', routeName, 'package.json' ) | ||
| ) | ||
| ); | ||
| if ( ! routePackageJson || ! routePackageJson.route ) { | ||
| return null; | ||
| } | ||
| return { | ||
| name: routeName, | ||
| path: routePackageJson.route.path, | ||
| page: routePackageJson.route.page || null, | ||
| }; | ||
| } | ||
| /** | ||
| * @typedef {Object} RouteFiles | ||
@@ -30,0 +68,0 @@ * @property {boolean} hasRoute Whether route file exists. |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
185810
15.94%26
13.04%2476
4.78%361
10.4%