diff --git a/.pnpm-patches/@wordpress__build@0.14.0.patch b/.pnpm-patches/@wordpress__build@0.14.0.patch new file mode 100644 index 000000000000..c710b5576a19 --- /dev/null +++ b/.pnpm-patches/@wordpress__build@0.14.0.patch @@ -0,0 +1,635 @@ +Bridge to upstream Gutenberg PRs https://github.com/WordPress/gutenberg/pull/78822 +and https://github.com/WordPress/gutenberg/pull/77465. This patch is exactly those two +PRs applied to `@wordpress/build@0.14.0`, with no other changes (0.14.0 already has the +`createNodeRequire` import the 0.13.0 patch had to add). + +#78822 reads each script module's ID from `package.json#name`, externalizes +internal-package imports by exact name, and discovers script-module packages outside +`./packages/` via convention (any `dependencies` entry whose own `package.json` declares +`wpScriptModuleExports`). #77465 scopes the generated PHP `wp_deregister_script_module()` +calls to `@wordpress/*` IDs, so shared non-core packages registered by more than one +wp-build plugin get Core's idempotent first-wins semantics instead of last-plugin-wins. + +Convention discovery reads each shared package's `package.json` through its `exports` +field, so a discovered package must expose `./package.json` (e.g. +`@automattic/number-formatters` adds `"./package.json": "./package.json"`). Packages +that do not are skipped and bundled inline. + +When the upstream PRs merge and Jetpack bumps `@wordpress/build` to a version that +includes them, delete this file and remove its entry from `pnpm-workspace.yaml`. + +To rebase this patch to a new `@wordpress/build` version, without a Gutenberg checkout: + + VERSION=0.16.0 + WORK=$(mktemp -d) && cd "$WORK" + curl -sL "https://registry.npmjs.org/@wordpress/build/-/build-$VERSION.tgz" | tar xz + mv package pristine && cp -R pristine patched + for PR in 78822 77465; do + curl -sL "https://patch-diff.githubusercontent.com/raw/WordPress/gutenberg/pull/$PR.diff" \ + | awk '/^diff --git/{f=($4 ~ /^b\/packages\/wp-build\//)} f' \ + | sed 's|/packages/wp-build/|/|g' \ + | (cd patched && patch -p1 || true) + done + find patched \( -name '*.orig' -o -name '*.rej' \) -delete + # Versions <= 0.13.0 predate this import; add it if absent. + grep -q "createRequire as createNodeRequire" patched/lib/build.mjs || \ + perl -0pi -e "s/(import \{ createHash \} from 'node:crypto';\n)/\$1import { createRequire as createNodeRequire } from 'node:module';\n/" patched/lib/build.mjs + git diff --no-index --no-color pristine patched \ + | sed -E -e 's|^diff --git a/pristine/|diff --git a/|' \ + -e 's| b/patched/| b/|' \ + -e 's|^--- a/pristine/|--- a/|' \ + -e 's|^\+\+\+ b/patched/|+++ b/|' \ + > "$OLDPWD/.pnpm-patches/@wordpress__build@$VERSION.patch" + +Then prepend this header, update pnpm-workspace.yaml, delete the old patch file, and run +`pnpm install` + `pnpm --filter @automattic/jetpack-premium-analytics build` to validate. +`@automattic/number-formatters` must keep `./package.json` in its `exports` for +convention discovery to pick it up. + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index a7e9f3899a..c48aaa093a 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -4,6 +4,11 @@ + + ## 0.14.0 (2026-05-14) + ++### Enhancements ++ ++- Use each package's own `name` field as its script-module ID and externalize internal-package imports by exact name. Decouples script-module identity from `wpPlugin.packageNamespace`, so the npm name survives end-to-end (npm name === import specifier === script-module ID). No-op for Core; enables consumers whose owned npm scope differs from `packageNamespace` to keep a single identifier across npm, IDE, and the WordPress runtime. ++- Discover script-module packages outside `./packages/` via convention. Any entry in the plugin's `dependencies` whose `package.json` declares `wpScriptModuleExports` is registered as a script module, bundled, and externalized under its own npm name. No new config; local packages still take precedence on name collision. ++ + ### Bug Fixes + + - Register generated CSS module styles with `@wordpress/style-runtime` so they can be injected into registered documents, such as editor iframes ([#77965](https://github.com/WordPress/gutenberg/pull/77965)). +diff --git a/lib/build.mjs b/lib/build.mjs +index 39e746630b..b3fd659cde 100755 +--- a/lib/build.mjs ++++ b/lib/build.mjs +@@ -4,6 +4,7 @@ + * External dependencies + */ + import { readFile, writeFile, copyFile, mkdir, unlink } from 'fs/promises'; ++import { existsSync } from 'fs'; + import path from 'path'; + import { createHash } from 'node:crypto'; + import { createRequire as createNodeRequire } from 'node:module'; +@@ -95,20 +96,6 @@ const TEST_FILE_PATTERNS = [ + /\.(native|ios|android)\.(js|ts|tsx)$/, + ]; + +-/** +- * Get all package names from the packages directory. +- * +- * @return {string[]} Array of package names. +- */ +-function getAllPackages() { +- return glob +- .sync( normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) ) ) +- .map( ( packageJsonPath ) => +- path.basename( path.dirname( packageJsonPath ) ) +- ); +-} +- +-const PACKAGES = getAllPackages(); + const ROOT_PACKAGE_JSON = getPackageInfoFromFile( + path.join( ROOT_DIR, 'package.json' ) + ); +@@ -119,6 +106,116 @@ const HANDLE_PREFIX = WP_PLUGIN_CONFIG.handlePrefix || PACKAGE_NAMESPACE; + const EXTERNAL_NAMESPACES = WP_PLUGIN_CONFIG.externalNamespaces || {}; + const PAGES = WP_PLUGIN_CONFIG.pages || []; + ++/** ++ * A discovered package in the registry. ++ * ++ * @typedef {Object} PackageEntry ++ * @property {string} dir Absolute path to the package directory. ++ * @property {import('./package-utils.mjs').PackageJson} packageJson Parsed package.json contents. ++ * @property {boolean} external True when the package is pre-built outside this plugin (e.g. a workspace dep). External packages are bundled and externalized but not transpiled; local packages from `./packages/` are also transpiled from source. ++ */ ++ ++/** ++ * Build the registry of script-module packages this plugin builds. ++ * ++ * Two convention-driven discovery sources: ++ * ++ * 1. Local packages: every `./packages//package.json`. ++ * 2. Convention deps: every entry in the plugin's `dependencies` whose own ++ * `package.json` declares `wpScriptModuleExports`. Lets a plugin pull in ++ * shared script-module packages from outside `./packages/` (workspace ++ * siblings, npm-installed siblings) without any extra wp-build config. ++ * Local packages win first-match for any given name. ++ * ++ * @return {Map} Registry keyed by short identifier: ++ * directory name for local packages, full npm name for convention deps. ++ */ ++function getAllPackages() { ++ const registry = new Map(); ++ ++ // 1. Local packages from ./packages/* ++ const localPaths = glob.sync( ++ normalizePath( path.join( PACKAGES_DIR, '*', 'package.json' ) ) ++ ); ++ for ( const pkgJsonPath of localPaths ) { ++ const dir = path.dirname( pkgJsonPath ); ++ const key = path.basename( dir ); ++ registry.set( key, { ++ dir, ++ packageJson: getPackageInfoFromFile( pkgJsonPath ), ++ external: false, ++ } ); ++ } ++ ++ // 2. Convention deps with wpScriptModuleExports ++ const deps = Object.keys( ROOT_PACKAGE_JSON.dependencies || {} ); ++ const localNames = new Set( ++ Array.from( registry.values() ).map( ++ ( entry ) => entry.packageJson.name ++ ) ++ ); ++ const localRequire = createNodeRequire( ++ path.join( ROOT_DIR, 'package.json' ) ++ ); ++ for ( const depName of deps ) { ++ // First-match-wins: a local package with the same `name` already ++ // claimed this slot, skip the dep. ++ if ( localNames.has( depName ) || registry.has( depName ) ) { ++ continue; ++ } ++ ++ // Resolve the dep's package.json. Some packages don't expose it in ++ // their `exports`, so fall back to a direct node_modules lookup. ++ let pkgJsonPath; ++ try { ++ pkgJsonPath = localRequire.resolve( `${ depName }/package.json` ); ++ } catch { ++ const direct = path.join( ++ ROOT_DIR, ++ 'node_modules', ++ depName, ++ 'package.json' ++ ); ++ if ( ! existsSync( direct ) ) { ++ continue; ++ } ++ pkgJsonPath = direct; ++ } ++ ++ const depPackageJson = getPackageInfoFromFile( pkgJsonPath ); ++ if ( ! depPackageJson.wpScriptModuleExports ) { ++ continue; ++ } ++ ++ if ( depPackageJson.wpScript ) { ++ console.warn( ++ `wp-build: ${ depName } declares wpScript; ignored (external dependencies contribute script modules only).` ++ ); ++ } ++ ++ registry.set( depName, { ++ dir: path.dirname( pkgJsonPath ), ++ packageJson: depPackageJson, ++ external: true, ++ } ); ++ } ++ ++ return registry; ++} ++ ++const PACKAGES = getAllPackages(); ++ ++// Set of every discovered package's `name` field. Used by the externals ++// plugin to externalize internal-package imports by exact name, regardless ++// of `packageNamespace`. Decouples script-module identity from a config ++// string so a package's own `name` survives end-to-end (npm name === import ++// specifier === script-module ID). ++const INTERNAL_PACKAGE_NAMES = new Set( ++ Array.from( PACKAGES.values() ) ++ .map( ( entry ) => entry.packageJson.name ) ++ .filter( Boolean ) ++); ++ + /** + * Interprets a configuration value as a boolean, where `"true"` and `"1"` + * are considered true while all other values are false. +@@ -159,7 +256,8 @@ const wordpressExternalsPlugin = createWordpressExternalsPlugin( + PACKAGE_NAMESPACE, + SCRIPT_GLOBAL, + EXTERNAL_NAMESPACES, +- HANDLE_PREFIX ++ HANDLE_PREFIX, ++ INTERNAL_PACKAGE_NAMES + ); + + const styleRuntimeRequire = createNodeRequire( import.meta.url ); +@@ -522,23 +620,25 @@ function resolveEntryPoint( packageDir, packageJson ) { + */ + async function bundlePackage( packageName, options = {} ) { + const { +- sourceDir = PACKAGES_DIR, + handlePrefix = HANDLE_PREFIX, + scriptGlobal = SCRIPT_GLOBAL, + packageNamespace = PACKAGE_NAMESPACE, + } = options; + ++ const entry = PACKAGES.get( packageName ); ++ const packageDir = entry.dir; ++ const packageJson = entry.packageJson; ++ + const builtModules = []; + const builtScripts = []; + const builtStyles = []; +- const packageDir = path.join( sourceDir, packageName ); +- const packageJson = getPackageInfoFromFile( +- path.join( sourceDir, packageName, 'package.json' ) +- ); + + const builds = []; + +- if ( packageJson.wpScript ) { ++ // External (convention-discovered) packages contribute script modules only. ++ const buildAsScript = !! packageJson.wpScript && ! entry.external; ++ ++ if ( buildAsScript ) { + const entryPoint = resolveEntryPoint( packageDir, packageJson ); + const outputDir = path.join( BUILD_DIR, 'scripts', packageName ); + const target = browserslistToEsbuild(); +@@ -700,10 +800,16 @@ async function bundlePackage( packageName, options = {} ) { + ); + } + ++ // The script-module ID is the package's own `name` field. The ++ // PHP registry, the asset manifest, and `wp_register_script_module` ++ // all treat the ID as an opaque string, so this lets the npm name ++ // survive end-to-end without being rewritten by build configuration. ++ // Falls back to the legacy `@/` shape ++ // only when `name` is missing (e.g. an unnamed local package). ++ const packageId = ++ packageJson.name || `@${ packageNamespace }/${ packageName }`; + const scriptModuleId = +- exportName === '.' +- ? `@${ packageNamespace }/${ packageName }` +- : `@${ packageNamespace }/${ packageName }/${ fileName }`; ++ exportName === '.' ? packageId : `${ packageId }/${ fileName }`; + + builtModules.push( { + id: scriptModuleId, +@@ -715,7 +821,7 @@ async function bundlePackage( packageName, options = {} ) { + } + + let hasMainStyle = false; +- if ( packageJson.wpScript ) { ++ if ( buildAsScript ) { + const buildStyleDir = path.join( packageDir, 'build-style' ); + const outputDir = path.join( BUILD_DIR, 'styles', packageName ); + +@@ -909,7 +1015,7 @@ async function inferStyleDependencies( scriptDependencies, packageName ) { + + const styleDeps = []; + // Get the resolve directory for context-aware package resolution +- const resolveDir = path.join( PACKAGES_DIR, packageName ); ++ const resolveDir = PACKAGES.get( packageName )?.dir || PACKAGES_DIR; + + for ( const scriptHandle of scriptDependencies ) { + // Skip non-package dependencies (like 'react', 'lodash', etc.) +@@ -1270,17 +1376,17 @@ async function generatePagesPhp( pageData, replacements ) { + */ + async function transpilePackage( packageName ) { + const startTime = Date.now(); +- const packageDir = path.join( PACKAGES_DIR, packageName ); +- const packageJson = getPackageInfoFromFile( +- path.join( PACKAGES_DIR, packageName, 'package.json' ) +- ); ++ const entry = PACKAGES.get( packageName ); + +- if ( ! packageJson ) { ++ if ( ! entry ) { + throw new Error( + `Could not find package.json for package: ${ packageName }` + ); + } + ++ const packageDir = entry.dir; ++ const packageJson = entry.packageJson; ++ + const srcFiles = await glob( `src/**/*.${ SOURCE_EXTENSIONS }`, { + cwd: packageDir, + ignore: IGNORE_PATTERNS, +@@ -1489,10 +1595,9 @@ async function transpilePackage( packageName ) { + * @return {Promise} Build time in milliseconds, or null if no styles. + */ + async function compileStyles( packageName ) { +- const packageDir = path.join( PACKAGES_DIR, packageName ); +- const packageJson = getPackageInfoFromFile( +- path.join( PACKAGES_DIR, packageName, 'package.json' ) +- ); ++ const entry = PACKAGES.get( packageName ); ++ const packageDir = entry.dir; ++ const packageJson = entry.packageJson; + + // Get SCSS entry point patterns from package.json, default to root-level only + const scssEntryPointPatterns = packageJson.wpStyleEntryPoints || [ +@@ -1600,12 +1705,20 @@ function isPackageSourceFile( filename ) { + return false; + } + +- return PACKAGES.some( ( packageName ) => { ++ for ( const entry of PACKAGES.values() ) { ++ // External packages are not transpiled from source, so their files ++ // do not trigger rebuilds via this path. ++ if ( entry.external ) { ++ continue; ++ } + const packagePath = normalizePath( +- path.join( 'packages', packageName ) ++ path.relative( ROOT_DIR, entry.dir ) + ); +- return relativePath.startsWith( packagePath + '/' ); +- } ); ++ if ( relativePath.startsWith( packagePath + '/' ) ) { ++ return true; ++ } ++ } ++ return false; + } + + /** +@@ -1619,9 +1732,12 @@ function getPackageName( filename ) { + path.relative( process.cwd(), filename ) + ); + +- for ( const packageName of PACKAGES ) { ++ for ( const [ packageName, entry ] of PACKAGES ) { ++ if ( entry.external ) { ++ continue; ++ } + const packagePath = normalizePath( +- path.join( 'packages', packageName ) ++ path.relative( ROOT_DIR, entry.dir ) + ); + if ( relativePath.startsWith( packagePath + '/' ) ) { + return packageName; +@@ -2024,17 +2140,14 @@ async function buildAll( baseUrlExpression ) { + + const startTime = Date.now(); + +- // Build maps: short name ↔ full name ↔ package.json from package.json files ++ // Build maps: short name ↔ full name ↔ package.json from the registry. + const shortToFull = new Map(); + const fullToShort = new Map(); + const fullToPackageJson = new Map(); +- for ( const pkg of PACKAGES ) { +- const packageJson = getPackageInfoFromFile( +- path.join( PACKAGES_DIR, pkg, 'package.json' ) +- ); +- shortToFull.set( pkg, packageJson.name ); +- fullToShort.set( packageJson.name, pkg ); +- fullToPackageJson.set( packageJson.name, packageJson ); ++ for ( const [ pkg, entry ] of PACKAGES ) { ++ shortToFull.set( pkg, entry.packageJson.name ); ++ fullToShort.set( entry.packageJson.name, pkg ); ++ fullToPackageJson.set( entry.packageJson.name, entry.packageJson ); + } + + const levels = groupByDepth( fullToPackageJson ); +@@ -2045,6 +2158,15 @@ async function buildAll( baseUrlExpression ) { + await Promise.all( + level.map( async ( fullName ) => { + const packageName = fullToShort.get( fullName ); ++ const entry = PACKAGES.get( packageName ); ++ ++ // External packages are pre-built outside this plugin ++ // (e.g. a workspace dep). Skip transpilation; they are ++ // bundled and externalized in Phase 2. ++ if ( entry.external ) { ++ return; ++ } ++ + const buildTime = await transpilePackage( packageName ); + console.log( + ` ✔ Transpiled ${ packageName } (${ buildTime }ms)` +@@ -2058,7 +2180,7 @@ async function buildAll( baseUrlExpression ) { + const scripts = []; + const styles = []; + await Promise.all( +- PACKAGES.map( async ( packageName ) => { ++ Array.from( PACKAGES.keys() ).map( async ( packageName ) => { + const startBundleTime = Date.now(); + const ret = await bundlePackage( packageName ); + const buildTime = Date.now() - startBundleTime; +@@ -2203,17 +2325,14 @@ async function watchMode() { + let isRebuilding = false; + const needsRebuild = new Set(); + +- // Build maps: short name ↔ full name ↔ package.json from package.json files (once) ++ // Build maps: short name ↔ full name ↔ package.json from the registry (once) + const shortToFull = new Map(); + const fullToShort = new Map(); + const fullToPackageJson = new Map(); +- for ( const pkg of PACKAGES ) { +- const packageJson = getPackageInfoFromFile( +- path.join( PACKAGES_DIR, pkg, 'package.json' ) +- ); +- shortToFull.set( pkg, packageJson.name ); +- fullToShort.set( packageJson.name, pkg ); +- fullToPackageJson.set( packageJson.name, packageJson ); ++ for ( const [ pkg, entry ] of PACKAGES ) { ++ shortToFull.set( pkg, entry.packageJson.name ); ++ fullToShort.set( entry.packageJson.name, pkg ); ++ fullToPackageJson.set( entry.packageJson.name, entry.packageJson ); + } + + // Get all routes and widgets for dependency tracking +@@ -2246,8 +2365,14 @@ async function watchMode() { + async function rebuildPackage( packageName ) { + try { + const startTime = Date.now(); ++ const entry = PACKAGES.get( packageName ); + +- await transpilePackage( packageName ); ++ // External packages are pre-built outside this plugin; only ++ // rebundle them when their declared script-module entry is ++ // regenerated. Skip transpilation. ++ if ( ! entry?.external ) { ++ await transpilePackage( packageName ); ++ } + await bundlePackage( packageName ); + + const buildTime = Date.now() - startTime; +@@ -2346,9 +2471,9 @@ async function watchMode() { + await processNextRebuild(); + } + +- const watchPaths = PACKAGES.map( ( packageName ) => +- path.join( PACKAGES_DIR, packageName, 'src' ) +- ); ++ const watchPaths = Array.from( PACKAGES.values() ) ++ .filter( ( entry ) => ! entry.external ) ++ .map( ( entry ) => path.join( entry.dir, 'src' ) ); + + const watcher = chokidar.watch( watchPaths, { + ignored: [ +diff --git a/lib/wordpress-externals-plugin.mjs b/lib/wordpress-externals-plugin.mjs +index 2b7e01a66f..a07a310067 100644 +--- a/lib/wordpress-externals-plugin.mjs ++++ b/lib/wordpress-externals-plugin.mjs +@@ -46,17 +46,19 @@ async function generateContentHash( + * This plugin handles WordPress package externals and vendor libraries, + * treating them as external dependencies available via global variables. + * +- * @param {string} packageNamespace Custom package namespace (e.g., 'wordpress', 'my-plugin'). +- * @param {string|false} scriptGlobal Global variable name (e.g., 'wp', 'myPlugin') or false to disable globals. +- * @param {Object} externalNamespaces Additional namespaces to externalize (e.g., { 'woo': { global: 'woo', handlePrefix: 'woocommerce' } }). +- * @param {string} handlePrefix Handle prefix for main package (e.g., 'wp', 'mp'). Defaults to packageNamespace. ++ * @param {string} packageNamespace Custom package namespace (e.g., 'wordpress', 'my-plugin'). ++ * @param {string|false} scriptGlobal Global variable name (e.g., 'wp', 'myPlugin') or false to disable globals. ++ * @param {Object} externalNamespaces Additional namespaces to externalize (e.g., { 'woo': { global: 'woo', handlePrefix: 'woocommerce' } }). ++ * @param {string} handlePrefix Handle prefix for main package (e.g., 'wp', 'mp'). Defaults to packageNamespace. ++ * @param {Set} [internalPackageNames] `name` fields of every internal package (local + convention-discovered). Imports matching any of these are externalized by exact name, regardless of `packageNamespace`. + * @return {Function} Function that creates the esbuild plugin instance. + */ + export function createWordpressExternalsPlugin( + packageNamespace, + scriptGlobal, + externalNamespaces = {}, +- handlePrefix ++ handlePrefix, ++ internalPackageNames = new Set() + ) { + /** + * WordPress externals plugin for esbuild. +@@ -199,6 +201,83 @@ export function createWordpressExternalsPlugin( + ); + } + ++ // Externalize internal packages by exact name. Must precede the ++ // namespace pattern below so internal names win over the wildcard. ++ if ( internalPackageNames.size > 0 ) { ++ // Longest-first: avoids `@org/block` shadowing `@org/block-editor`. ++ const namesSorted = Array.from( internalPackageNames ).sort( ++ ( a, b ) => b.length - a.length ++ ); ++ const escapedNames = namesSorted.map( ( n ) => ++ n.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) ++ ); ++ const internalNamesFilter = new RegExp( ++ `^(?:${ escapedNames.join( '|' ) })(?:/|$)` ++ ); ++ ++ build.onResolve( ++ { filter: internalNamesFilter }, ++ /** @param {import('esbuild').OnResolveArgs} args */ ++ ( args ) => { ++ const head = args.path.startsWith( '@' ) ++ ? args.path.split( '/', 2 ).join( '/' ) ++ : args.path.split( '/', 1 )[ 0 ]; ++ ++ if ( ! internalPackageNames.has( head ) ) { ++ return undefined; ++ } ++ ++ const subpath = ++ args.path.length > head.length ++ ? args.path.slice( head.length + 1 ) ++ : null; ++ ++ const packageJson = getPackageInfo( ++ head, ++ args.resolveDir ++ ); ++ ++ if ( ! packageJson ) { ++ return undefined; ++ } ++ ++ const isScriptModule = isScriptModuleImport( ++ packageJson, ++ subpath ++ ); ++ const isScript = !! packageJson.wpScript; ++ ++ // Dual packages: IIFE yields to the namespace handler. ++ let externalize = isScriptModule; ++ if ( isScriptModule && isScript ) { ++ externalize = buildFormat === 'esm'; ++ } ++ if ( ! externalize ) { ++ return undefined; ++ } ++ ++ const kind = ++ args.kind === 'dynamic-import' ++ ? 'dynamic' ++ : 'static'; ++ ++ if ( kind === 'static' ) { ++ moduleDependencies.set( args.path, 'static' ); ++ } else if ( ++ ! moduleDependencies.has( args.path ) ++ ) { ++ moduleDependencies.set( args.path, 'dynamic' ); ++ } ++ ++ return { ++ path: args.path, ++ external: true, ++ sideEffects: !! packageJson.sideEffects, ++ }; ++ } ++ ); ++ } ++ + // Handle package namespace externals (wordpress and custom) + for ( const externalConfig of packageExternals ) { + build.onResolve( +diff --git a/templates/module-registration.php.template b/templates/module-registration.php.template +index 49ca9ab333..6940832a9f 100644 +--- a/templates/module-registration.php.template ++++ b/templates/module-registration.php.template +@@ -46,8 +46,12 @@ function {{PREFIX}}_register_script_modules() { + $asset = file_exists( $asset_path ) ? require $asset_path : array(); + + // Deregister first to override any previously registered version +- // (e.g., Core's default modules when running as a plugin). +- wp_deregister_script_module( $module['id'] ); ++ // of Core's default script modules. Scoped to `@wordpress/*` — the ++ // only namespace Core registers by default — so plugin-local and ++ // shared packages preserve Core's idempotent "first-wins" semantics. ++ if ( str_starts_with( $module['id'], '@wordpress/' ) ) { ++ wp_deregister_script_module( $module['id'] ); ++ } + + wp_register_script_module( + $module['id'], +diff --git a/templates/routes-registration.php.template b/templates/routes-registration.php.template +index 7ab43f1eb1..3aa6918c11 100644 +--- a/templates/routes-registration.php.template ++++ b/templates/routes-registration.php.template +@@ -54,8 +54,11 @@ function {{PREFIX}}_register_page_routes( $page_routes, $register_function_name + $content_handle = '{{HANDLE_PREFIX}}/routes/' . $route['name'] . '/content'; + $extension = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '.js' : '.min.js'; + // Deregister first to override any previously registered version +- // (e.g., Core's default modules when running as a plugin). +- wp_deregister_script_module( $content_handle ); ++ // of Core's default script modules. Scoped to `@wordpress/*` — the ++ // only namespace Core registers by default. ++ if ( str_starts_with( $content_handle, '@wordpress/' ) ) { ++ wp_deregister_script_module( $content_handle ); ++ } + wp_register_script_module( + $content_handle, + $build_constants['build_url'] . 'routes/' . $route['name'] . '/content' . $extension, +@@ -73,8 +76,11 @@ function {{PREFIX}}_register_page_routes( $page_routes, $register_function_name + $route_handle = '{{HANDLE_PREFIX}}/routes/' . $route['name'] . '/route'; + $extension = defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ? '.js' : '.min.js'; + // Deregister first to override any previously registered version +- // (e.g., Core's default modules when running as a plugin). +- wp_deregister_script_module( $route_handle ); ++ // of Core's default script modules. Scoped to `@wordpress/*` — the ++ // only namespace Core registers by default. ++ if ( str_starts_with( $route_handle, '@wordpress/' ) ) { ++ wp_deregister_script_module( $route_handle ); ++ } + wp_register_script_module( + $route_handle, + $build_constants['build_url'] . 'routes/' . $route['name'] . '/route' . $extension, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83651e740eb5..539abde232ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,9 @@ settings: pnpmfileChecksum: sha256-KrBHPLv8FlPoa5OUGbvotku6TLmuhZiq22jdIe7yBHc= patchedDependencies: + '@wordpress/build@0.14.0': + hash: 6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b + path: .pnpm-patches/@wordpress__build@0.14.0.patch react-autosize-textarea: hash: 5c09e1dee59caaaba3871f9d722f93e56b41169db486b059597e8f8c788aa464 path: .pnpm-patches/react-autosize-textarea.patch @@ -2125,7 +2128,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -2740,7 +2743,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) '@wordpress/date': specifier: 5.46.0 version: 5.46.0 @@ -3448,7 +3451,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -3789,7 +3792,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) '@wordpress/theme': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3826,12 +3829,18 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/number-formatters': + specifier: workspace:* + version: link:../../js-packages/number-formatters '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': specifier: 10.46.0 version: 10.46.0(react@18.3.1) + '@wordpress/element': + specifier: ^6.22.0 + version: 6.46.0 '@wordpress/i18n': specifier: ^6.9.0 version: 6.19.0 @@ -3853,7 +3862,7 @@ importers: version: 7.29.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: 4.28.2 version: 4.28.2 @@ -4062,7 +4071,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.5.14) @@ -4289,7 +4298,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -4712,7 +4721,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) autoprefixer: specifier: 10.4.20 version: 10.4.20(postcss@8.5.14) @@ -4785,7 +4794,7 @@ importers: version: 6.46.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) '@wordpress/icons': specifier: ^13.0.0 version: 13.1.0(react@18.3.1) @@ -23915,7 +23924,7 @@ snapshots: '@wordpress/browserslist-config@6.46.0': {} - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 '@wordpress/style-runtime': 0.2.0 @@ -23942,7 +23951,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.46.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 '@wordpress/style-runtime': 0.2.0 @@ -23970,7 +23979,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 '@wordpress/style-runtime': 0.2.0 @@ -23996,7 +24005,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.14.0(patch_hash=6e9b16f14409f6ca17944490bb6a232332496721dd138a9595856308c214200b)(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 '@wordpress/style-runtime': 0.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 473b6616f833..f114b8c6164d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -73,6 +73,12 @@ ignoredOptionalDependencies: # Dependencies needing patching. patchedDependencies: + # Decouple script-module identity from `wpPlugin.packageNamespace`, discover + # script-module packages outside `./packages/` via convention, and scope the + # deregister-before-register to `@wordpress/*`. + # Upstream PRs: https://github.com/WordPress/gutenberg/pull/78822 + /pull/77465 + '@wordpress/build@0.14.0': .pnpm-patches/@wordpress__build@0.14.0.patch + # Vite/esbuild doesn't like the `__esModule` + `exports["default"]` pattern, it winds up double-wrapping the default. # See also https://github.com/WordPress/gutenberg/issues/39619 react-autosize-textarea: .pnpm-patches/react-autosize-textarea.patch diff --git a/projects/js-packages/number-formatters/changelog/update-wp-build-package-sources b/projects/js-packages/number-formatters/changelog/update-wp-build-package-sources new file mode 100644 index 000000000000..547c72ce6cef --- /dev/null +++ b/projects/js-packages/number-formatters/changelog/update-wp-build-package-sources @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Add wpScriptModuleExports and expose ./package.json in exports so wp-build convention discovery can register this as a shared script module. diff --git a/projects/js-packages/number-formatters/package.json b/projects/js-packages/number-formatters/package.json index d46d2b494e2e..6cb3ad32313d 100644 --- a/projects/js-packages/number-formatters/package.json +++ b/projects/js-packages/number-formatters/package.json @@ -19,11 +19,13 @@ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js", "require": "./dist/cjs/index.cjs" - } + }, + "./package.json": "./package.json" }, "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", + "wpScriptModuleExports": ".", "scripts": { "build": "pnpm run clean && pnpm run build:ts", "build:ts": "duel --dirs", diff --git a/projects/packages/premium-analytics/changelog/update-wp-build-package-sources b/projects/packages/premium-analytics/changelog/update-wp-build-package-sources new file mode 100644 index 000000000000..f690d6bc7e56 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-wp-build-package-sources @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Patch `@wordpress/build` to read script-module IDs from `package.json#name`, discover script-module packages outside `./packages/` via convention, and scope the generated `wp_deregister_script_module()` calls to `@wordpress/*` so shared modules fall through to Core's first-wins. Pulls in `@automattic/number-formatters` as the first cross-directory shared script module, and renames the local `init` package to `@automattic/jetpack-premium-analytics-init` so its npm name, import specifier, and script-module ID all match. diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index c6ed3093c829..a0c3419f3bd0 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -16,7 +16,7 @@ { "id": "jetpack-premium-analytics", "init": [ - "@jetpack-premium-analytics/init" + "@automattic/jetpack-premium-analytics-init" ] } ], @@ -28,8 +28,10 @@ } }, "dependencies": { + "@automattic/number-formatters": "workspace:*", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", + "@wordpress/element": "^6.22.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/route": "0.12.0", diff --git a/projects/packages/premium-analytics/packages/init/package.json b/projects/packages/premium-analytics/packages/init/package.json index 6ee2ce1e4470..70de0aea9d5b 100644 --- a/projects/packages/premium-analytics/packages/init/package.json +++ b/projects/packages/premium-analytics/packages/init/package.json @@ -1,6 +1,6 @@ { "private": true, - "name": "_@jetpack-premium-analytics/init", + "name": "@automattic/jetpack-premium-analytics-init", "version": "0.1.0", "type": "module", "wpScript": true, diff --git a/projects/packages/premium-analytics/routes/dashboard/package.json b/projects/packages/premium-analytics/routes/dashboard/package.json index e71390452278..6fc64ed66e91 100644 --- a/projects/packages/premium-analytics/routes/dashboard/package.json +++ b/projects/packages/premium-analytics/routes/dashboard/package.json @@ -6,6 +6,9 @@ "page": "jetpack-premium-analytics" }, "dependencies": { + "@automattic/number-formatters": "workspace:*", + "@types/react": "18.3.28", + "@wordpress/element": "^6.22.0", "@wordpress/i18n": "^6.9.0" } } diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 034a332bc4eb..450172717251 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -1,10 +1,34 @@ +import { formatNumber } from '@automattic/number-formatters'; +import { lazy, Suspense } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -export const stage = () => { +const samplePageViews = 1234567; +const sampleUniqueVisitors = 98432; + +const UniqueVisitors = lazy( () => + import( '@automattic/number-formatters' ).then( ( { formatNumberCompact } ) => ( { + default: ( { value }: { value: number } ) => { formatNumberCompact( value ) }, + } ) ) +); + +const Stage = () => { return (

{ __( 'Analytics', 'jetpack-premium-analytics' ) }

-

{ __( 'Welcome to the Analytics dashboard.', 'jetpack-premium-analytics' ) }

+ +

+ { __( 'Unique visitors (dynamic):', 'jetpack-premium-analytics' ) }{ ' ' } + … }> + + +

+ +

+ { __( 'Total page views (static):', 'jetpack-premium-analytics' ) }{ ' ' } + { formatNumber( samplePageViews ) } +

); }; + +export { Stage as stage };