diff --git a/projects/plugins/debug-helper/changelog/add-package-provenance-helper b/projects/plugins/debug-helper/changelog/add-package-provenance-helper new file mode 100644 index 000000000000..60c6905cc67b --- /dev/null +++ b/projects/plugins/debug-helper/changelog/add-package-provenance-helper @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Package Provenance: Add a module showing which runtime serves each WordPress package on the current screen. diff --git a/projects/plugins/debug-helper/modules/class-package-provenance-helper.php b/projects/plugins/debug-helper/modules/class-package-provenance-helper.php new file mode 100644 index 000000000000..77d47e5fe080 --- /dev/null +++ b/projects/plugins/debug-helper/modules/class-package-provenance-helper.php @@ -0,0 +1,460 @@ + + */ + private $printed_src = array(); + + /** + * Construction. + */ + public function __construct() { + add_action( 'wp_before_admin_bar_render', array( $this, 'register_admin_bar_menu' ), 1000000 ); + add_filter( 'script_loader_src', array( $this, 'record_script_src' ), PHP_INT_MAX, 2 ); + // Late on admin_print_footer_scripts, not admin_footer: this must run + // after _wp_footer_scripts so wp_scripts()->done is complete. Handles + // that core concatenates into load-scripts.php are marked done but + // print no per-handle tag, so the DOM cannot answer this. + add_action( 'admin_print_footer_scripts', array( $this, 'render' ), PHP_INT_MAX ); + } + + /** + * Records the final src of every classic script core prints. + * + * @param string $src Fully resolved script URL. + * @param string $handle Script handle. + * @return string The unmodified `$src`. + */ + public function record_script_src( $src, $handle ) { + $this->printed_src[ $handle ] = (string) $src; + return $src; + } + + /** + * Gutenberg plugin version, or null when inactive. + * + * @return string|null + */ + private function gutenberg_version() { + return defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null; + } + + /** + * Registers the admin bar badge. + */ + public function register_admin_bar_menu() { + global $wp_admin_bar; + + // The panel only renders on admin screens; skip the badge elsewhere. + if ( ! is_admin() ) { + return; + } + + $gutenberg = $this->gutenberg_version(); + $wp_admin_bar->add_menu( + array( + 'id' => 'package-provenance', + 'title' => sprintf( + '📦 WP %s · GB %s', + esc_html( $GLOBALS['wp_version'] ?? '?' ), + esc_html( $gutenberg ?? 'off' ) + ), + 'href' => '#', + 'parent' => 'top-secondary', + ) + ); + } + + /** + * Resolves a registered `ver` the way core does when printing. + * + * `null` and `false` are not interchangeable: core omits the query arg + * entirely for `null`, and substitutes the WordPress version for `false`. + * Core registers a number of `wp-*` handles with `false` (`wp-util`, + * `wp-backbone`, `wp-api-request`, …), so casting both to `''` reports an + * empty version for files that are served with `?ver=`. + * `WP_Scripts::do_item()` and `WP_Script_Modules::get_src()` share the rule. + * + * @param string|bool|null $ver Registered version. + * @return string Version to display, or '' when core prints none. + */ + private function resolve_version( $ver ) { + if ( null === $ver ) { + return ''; + } + return (string) ( $ver ? $ver : wp_scripts()->default_version ); + } + + /** + * Makes a registered src absolute the way core does when printing. + * + * Core registers its own packages root-relative (`/wp-includes/js/dist/…`) + * and prepends `WP_Scripts::$base_url` at print time, so a subdirectory + * install resolves them under the subdirectory. Mirrors the protocol and + * content-url checks in `WP_Scripts::do_item()`. + * + * @param string $src Registered src. + * @return string Absolute URL, or '' when `$src` is empty. + */ + private function absolute_src( $src ) { + if ( '' === $src ) { + return ''; + } + + $scripts = wp_scripts(); + $content_url = $scripts->content_url; + + if ( preg_match( '|^(https?:)?//|', $src ) ) { + return $src; + } + if ( $content_url && 0 === strpos( $src, $content_url ) ) { + return $src; + } + + return $scripts->base_url . $src; + } + + /** + * Collects registered classic scripts and script modules with their sources. + * + * Runs late in the request so it sees everything the current screen + * registered, including conditional registrations like the wp-build + * polyfills (which only register on dashboard requests). + * + * @return array{wp: string, gutenberg: string|null, scripts: array, modules: array} + */ + private function collect() { + $scripts = array(); + foreach ( wp_scripts()->registered as $handle => $dependency ) { + if ( 0 !== strpos( $handle, 'wp-' ) || ! is_string( $dependency->src ) || '' === $dependency->src ) { + continue; + } + + $ver = $this->resolve_version( $dependency->ver ); + + // Prefer the URL core actually printed. Fall back to reconstructing + // it for handles registered but never printed on this screen. + if ( isset( $this->printed_src[ $handle ] ) ) { + $url = $this->printed_src[ $handle ]; + } else { + $url = $this->absolute_src( $dependency->src ); + if ( '' !== $ver ) { + $url = add_query_arg( 'ver', $ver, $url ); + } + } + + $scripts[ $handle ] = array( + 'src' => $dependency->src, + 'url' => $url, + 'ver' => $ver, + 'printed' => in_array( $handle, wp_scripts()->done, true ), + ); + } + ksort( $scripts ); + + $modules = array(); + if ( function_exists( 'wp_script_modules' ) ) { + try { + $property = new \ReflectionProperty( wp_script_modules(), 'registered' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + foreach ( (array) $property->getValue( wp_script_modules() ) as $id => $module ) { + $ver = $this->resolve_version( + array_key_exists( 'version', $module ) ? $module['version'] : null + ); + $src = $this->absolute_src( (string) ( $module['src'] ?? '' ) ); + + $modules[ (string) $id ] = array( + 'src' => $src, + 'url' => ( '' !== $src && '' !== $ver ) ? add_query_arg( 'ver', $ver, $src ) : $src, + 'ver' => $ver, + ); + } + } catch ( \ReflectionException $e ) { + // Private property moved; the client-side import map still covers modules. + $modules = array(); + } + } + ksort( $modules ); + + return array( + 'wp' => (string) ( $GLOBALS['wp_version'] ?? '' ), + 'gutenberg' => $this->gutenberg_version(), + 'scripts' => $scripts, + 'modules' => $modules, + ); + } + + /** + * Prints the floating panel and its data. + */ + public function render() { + $data = $this->collect(); + ?> + + + + + 'Broken XML-RPC', 'description' => 'Break XML-RPC API any way you want.', ), + 'package-provenance' => array( + 'file' => 'class-package-provenance-helper.php', + 'name' => 'Package Provenance', + 'description' => 'Admin-bar badge with WP + Gutenberg versions and a floating panel showing which runtime (core, Gutenberg, wp-build polyfills, app) serves each WordPress package on the current screen.', + ), ); require_once __DIR__ . '/class-admin.php';