get_all_registered() ),
function ( $block_name ) {
return str_starts_with( $block_name, 'core/' );
}
);
// Remove disallowed core blocks
return array_diff( $core_blocks, self::DISALLOWED_CORE_BLOCKS );
}
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'editor-assets';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Registers the controller routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
array(
// Disabled to allow return structure to match existing endpoints
// @phan-suppress-next-line PhanPluginMixedKeyNoKey
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => array(
'exclude' => array(
'description' => __( 'Comma-separated list of asset types to exclude from the response. Supported values: "core" (WordPress core assets), "gutenberg" (Gutenberg plugin assets), or plugin handle prefixes (e.g., "contact-form-7").', 'jetpack' ),
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_text_field',
),
),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
);
}
/**
* Retrieves a collection of items.
*
* @param WP_REST_Request $request The request object.
*
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
// phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited
global $wp_styles, $wp_scripts;
// Save current asset state
$current_wp_styles = $wp_styles;
$current_wp_scripts = $wp_scripts;
try {
// Preserve allowed plugin assets before reinitializing
$preserved = $this->preserve_allowed_plugin_assets();
// Initialize fresh asset registries to control what gets loaded
$wp_styles = new WP_Styles();
$wp_scripts = new WP_Scripts();
// Restore preserved plugin assets
$this->restore_preserved_assets( $preserved );
// Set up a block editor screen context to prevent errors when
// plugins/themes call get_current_screen() during asset enqueueing
$this->setup_block_editor_screen();
// Trigger wp_loaded action that plugins frequently use to enqueue assets.
// This must happen after screen setup and before we collect enqueued assets.
do_action( 'wp_loaded' );
// Enqueue all core WordPress editor assets
$this->enqueue_core_editor_assets();
// Remove problematic plugin hooks before triggering block editor asset actions
$this->remove_problematic_plugin_hooks();
// Trigger block editor asset actions with forced script/style loading
add_filter( 'should_load_block_editor_scripts_and_styles', '__return_true' );
do_action( 'enqueue_block_assets' );
do_action( 'enqueue_block_editor_assets' );
remove_filter( 'should_load_block_editor_scripts_and_styles', '__return_true' );
// Enqueue editor-specific assets for all registered block types
$this->enqueue_block_type_editor_assets();
// Remove disallowed plugin assets before generating output
$this->unregister_disallowed_plugin_assets();
// Capture HTML output with absolute URLs
$html = $this->with_absolute_urls(
function () {
return array(
'styles' => $this->capture_styles_output(),
'scripts' => $this->capture_scripts_output(),
);
}
);
// Apply filtering based on query parameter
$exclude_param = $request->get_param( 'exclude' );
$exclude_rules = $this->parse_exclude_parameter( $exclude_param );
if ( ! empty( $exclude_rules ) ) {
$html['styles'] = $this->filter_assets_from_html( $html['styles'], 'link', 'href', $exclude_rules );
$html['scripts'] = $this->filter_assets_from_html( $html['scripts'], 'script', 'src', $exclude_rules );
}
return rest_ensure_response(
array(
'allowed_block_types' => array_merge(
$this->get_core_block_types(),
self::ALLOWED_PLUGIN_BLOCKS
),
// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset -- Keys are guaranteed by callback above
'scripts' => $html['scripts'],
// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset -- Keys are guaranteed by callback above
'styles' => $html['styles'],
)
);
} finally {
// Always restore original asset state, even if an exception occurred
$wp_styles = $current_wp_styles;
$wp_scripts = $current_wp_scripts;
}
}
/**
* Enqueues core WordPress editor assets.
*
* This includes polyfills, block styles, theme styles, and foundational
* post editor scripts and styles.
*/
private function enqueue_core_editor_assets() {
global $wp_styles;
// We generally do not need reset styles for the block editor. However, if
// it's a classic theme, margins will be added to every block, which is
// reset specifically for list items, so classic themes rely on these
// reset styles.
$wp_styles->done =
wp_theme_has_theme_json() ? array( 'wp-reset-editor-styles' ) : array();
wp_enqueue_script( 'wp-polyfill' );
// Enqueue the `editorStyle` handles for all core block, and dependencies.
wp_enqueue_style( 'wp-edit-blocks' );
if ( current_theme_supports( 'wp-block-styles' ) ) {
wp_enqueue_style( 'wp-block-library-theme' );
}
// Enqueue frequent dependent, admin-only `dashicon` asset.
wp_enqueue_style( 'dashicons' );
// Enqueue the admin-only `postbox` asset required for the block editor.
$suffix = wp_scripts_get_suffix();
wp_enqueue_script( 'postbox', "/wp-admin/js/postbox$suffix.js", array( 'jquery-ui-sortable', 'wp-a11y' ), self::CACHE_BUSTER, true );
// Enqueue foundational post editor assets.
wp_enqueue_script( 'wp-edit-post' );
wp_enqueue_style( 'wp-edit-post' );
}
/**
* Enqueues editor-specific assets for all registered block types.
*
* This includes editor_style_handles and editor_script_handles for each
* block, which contains editor-only styling and scripts.
*/
private function enqueue_block_type_editor_assets() {
$block_registry = WP_Block_Type_Registry::get_instance();
foreach ( $block_registry->get_all_registered() as $block_type ) {
if ( isset( $block_type->editor_style_handles ) && is_array( $block_type->editor_style_handles ) ) {
foreach ( $block_type->editor_style_handles as $style_handle ) {
wp_enqueue_style( $style_handle );
}
}
if ( isset( $block_type->editor_script_handles ) && is_array( $block_type->editor_script_handles ) ) {
foreach ( $block_type->editor_script_handles as $script_handle ) {
wp_enqueue_script( $script_handle );
}
}
}
}
/**
* Captures the HTML output of enqueued scripts.
*
* @return string The HTML output of all enqueued scripts.
*/
private function capture_scripts_output() {
ob_start();
wp_print_head_scripts();
wp_print_footer_scripts();
return ob_get_clean();
}
/**
* Preserves allowed plugin assets from the current asset registries.
*
* This method clones assets from allowed plugins that aren't core/Gutenberg
* assets, so they can be restored after reinitializing the asset registries.
*
* @return array Array with 'scripts' and 'styles' keys containing cloned assets.
*/
private function preserve_allowed_plugin_assets() {
global $wp_scripts, $wp_styles;
$preserved = array(
'scripts' => array(),
'styles' => array(),
);
foreach ( $wp_scripts->registered as $handle => $script ) {
if ( $this->is_allowed_plugin_handle( $handle ) && ! $this->is_core_or_gutenberg_asset( $script->src ) ) {
$preserved['scripts'][ $handle ] = clone $script;
}
}
foreach ( $wp_styles->registered as $handle => $style ) {
if ( $this->is_allowed_plugin_handle( $handle ) && ! $this->is_core_or_gutenberg_asset( $style->src ) ) {
$preserved['styles'][ $handle ] = clone $style;
}
}
return $preserved;
}
/**
* Restores previously preserved plugin assets to the asset registries.
*
* @param array $preserved Array with 'scripts' and 'styles' keys containing preserved assets.
*/
private function restore_preserved_assets( $preserved ) {
global $wp_scripts, $wp_styles;
foreach ( $preserved['scripts'] as $handle => $script ) {
$wp_scripts->registered[ $handle ] = $script;
}
foreach ( $preserved['styles'] as $handle => $style ) {
$wp_styles->registered[ $handle ] = $style;
}
}
/**
* Executes a callback with absolute URL filters temporarily enabled.
*
* This ensures that all asset URLs are converted to absolute URLs during
* the callback execution, then removes the filters afterward.
*
* @param callable $callback The function to execute with absolute URL filters.
* @return mixed The return value of the callback.
*/
private function with_absolute_urls( $callback ) {
add_filter( 'script_loader_src', array( $this, 'make_url_absolute' ), 10, 2 );
add_filter( 'style_loader_src', array( $this, 'make_url_absolute' ), 10, 2 );
$result = $callback();
remove_filter( 'script_loader_src', array( $this, 'make_url_absolute' ), 10 );
remove_filter( 'style_loader_src', array( $this, 'make_url_absolute' ), 10 );
return $result;
}
/**
* Captures the HTML output of enqueued styles with emoji handling.
*
* This temporarily removes the emoji styles action to prevent deprecation
* warnings, then restores it after capturing the output.
*
* @return string The HTML output of all enqueued styles.
*/
private function capture_styles_output() {
// Remove the deprecated `print_emoji_styles` handler. It avoids breaking
// style generation with a deprecation message.
$has_emoji_styles = has_action( 'wp_print_styles', 'print_emoji_styles' );
if ( $has_emoji_styles ) {
remove_action( 'wp_print_styles', 'print_emoji_styles' );
}
ob_start();
wp_print_styles();
$styles = ob_get_clean();
if ( $has_emoji_styles ) {
add_action( 'wp_print_styles', 'print_emoji_styles' );
}
return $styles;
}
/**
* Sets up a mock block editor screen context for the REST API request.
*
* This ensures get_current_screen() is available and returns a proper
* block editor screen object, preventing fatal errors when plugins/themes
* call get_current_screen() during the enqueue_block_editor_assets action.
*/
private function setup_block_editor_screen() {
// Ensure screen class and functions are available
if ( ! class_exists( 'WP_Screen' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php';
}
if ( ! function_exists( 'get_current_screen' ) ) {
require_once ABSPATH . 'wp-admin/includes/screen.php';
}
// Determine the post type for the screen context
$post_type = get_query_var( 'post_type', 'post' );
if ( is_array( $post_type ) ) {
$post_type = $post_type[0];
}
// Validate that the post type is registered
if ( ! post_type_exists( $post_type ) ) {
$post_type = 'post';
}
// Create a post editor screen context
set_current_screen( 'post' );
// Update the screen to indicate it's using the block editor
$current_screen = get_current_screen();
if ( $current_screen ) {
$current_screen->is_block_editor( true );
$current_screen->post_type = $post_type;
}
}
/**
* Removes hooks from problematic plugins that cause errors in this endpoint.
*
* Some plugins conditionally load admin-only code based on is_admin(), which
* returns false in REST API contexts. When these plugins hook into
* enqueue_block_editor_assets without checking the context, they may call
* undefined functions that were never loaded, causing fatal errors.
*
* This method preemptively removes hooks from known problematic plugins before
* the enqueue_block_editor_assets action fires, preventing fatal errors.
*/
private function remove_problematic_plugin_hooks() {
global $wp_filter;
// Only target the enqueue_block_editor_assets hook
if ( ! isset( $wp_filter['enqueue_block_editor_assets'] ) ) {
return;
}
$problematic_plugins = array(
'wpforms-lite/wpforms.php',
);
// Early return if no problematic plugins are active
$has_active_problematic_plugin = false;
foreach ( $problematic_plugins as $plugin_file ) {
if ( is_plugin_active( $plugin_file ) ) {
$has_active_problematic_plugin = true;
break;
}
}
if ( ! $has_active_problematic_plugin ) {
return;
}
$plugin_slugs = array_map(
function ( $plugin_file ) {
return dirname( $plugin_file );
},
$problematic_plugins
);
// Collect callbacks to remove (improves performance by separating detection from removal)
$callbacks_to_remove = array();
foreach ( $wp_filter['enqueue_block_editor_assets']->callbacks as $priority => $callbacks ) {
foreach ( $callbacks as $callback_data ) {
$callback = $callback_data['function'];
$file_path = null;
// Handle object method callbacks: [$object, 'method_name']
if ( is_array( $callback ) && count( $callback ) === 2 && is_object( $callback[0] ) ) {
try {
$reflection = new ReflectionClass( $callback[0] );
$file_path = $reflection->getFileName();
} catch ( ReflectionException $e ) {
// Skip if reflection fails
continue;
}
}
// Handle function name callbacks: 'function_name'
if ( is_string( $callback ) && function_exists( $callback ) && ! str_contains( $callback, '::' ) ) {
try {
$reflection = new ReflectionFunction( $callback );
$file_path = $reflection->getFileName();
} catch ( ReflectionException $e ) {
// Skip if reflection fails
continue;
}
}
// Check if file belongs to any problematic plugin
if ( $file_path ) {
$normalized_path = wp_normalize_path( $file_path );
$plugin_dir = wp_normalize_path( WP_PLUGIN_DIR );
foreach ( $plugin_slugs as $plugin_slug ) {
if ( str_contains( $normalized_path, $plugin_dir . '/' . $plugin_slug . '/' ) ) {
$callbacks_to_remove[] = array(
'callback' => $callback,
'priority' => $priority,
);
break;
}
}
}
}
}
// Remove all identified callbacks
foreach ( $callbacks_to_remove as $item ) {
remove_action( 'enqueue_block_editor_assets', $item['callback'], $item['priority'] );
}
}
/**
* Unregisters all assets except those from core or allowed plugins.
*/
private function unregister_disallowed_plugin_assets() {
global $wp_scripts, $wp_styles;
// Unregister disallowed plugin scripts
foreach ( $wp_scripts->registered as $handle => $script ) {
// Skip core scripts and protected handles
if ( $this->is_core_or_gutenberg_asset( $script->src ) || $this->is_protected_handle( $handle ) ) {
continue;
}
if ( ! $this->is_allowed_plugin_handle( $handle ) ) {
unset( $wp_scripts->registered[ $handle ] );
}
}
// Unregister disallowed plugin styles
foreach ( $wp_styles->registered as $handle => $style ) {
// Skip core styles and protected handles
if ( $this->is_core_or_gutenberg_asset( $style->src ) || $this->is_protected_handle( $handle ) ) {
continue;
}
if ( ! $this->is_allowed_plugin_handle( $handle ) ) {
unset( $wp_styles->registered[ $handle ] );
}
}
}
/**
* Check if an asset is a core or Gutenberg asset.
*
* @param string $src The asset source URL.
* @return bool True if the asset is a core or Gutenberg asset, false otherwise.
*/
private function is_core_or_gutenberg_asset( $src ) {
return $this->is_core_asset( $src ) || $this->is_gutenberg_asset( $src );
}
/**
* Check if an asset is a core WordPress asset.
*
* @param string $src The asset source URL.
* @return bool True if the asset is a core WordPress asset, false otherwise.
*/
private function is_core_asset( $src ) {
if ( ! is_string( $src ) ) {
return false;
}
return empty( $src ) ||
str_contains( $src, '/wp-includes/' ) ||
str_contains( $src, '/wp-admin/' );
}
/**
* Get the base path for the plugins directory.
*
* Extracts only the path component from the plugins URL, making it
* CDN-safe by ignoring the domain. Caches the result to avoid repeated
* function calls.
*
* @return string The base path for the plugins directory with trailing slash.
*/
private function get_plugins_base_path() {
if ( null === $this->plugins_base_path ) {
$this->plugins_base_path = trailingslashit( wp_parse_url( plugins_url(), PHP_URL_PATH ) );
}
return $this->plugins_base_path;
}
/**
* Check if an asset is a Gutenberg plugin asset.
*
* @param string $src The asset source URL.
* @return bool True if the asset is a Gutenberg plugin asset, false otherwise.
*/
private function is_gutenberg_asset( $src ) {
if ( ! is_string( $src ) ) {
return false;
}
$plugins_path = $this->get_plugins_base_path();
return str_contains( $src, $plugins_path . 'gutenberg/' ) ||
str_contains( $src, $plugins_path . 'gutenberg-core/' ); // WPCOM-specific path
}
/**
* Parses the exclude parameter into an array of exclusion rules.
*
* @param string $exclude_param Comma-separated list of exclusion rules.
* @return array Array of exclusion rules.
*/
private function parse_exclude_parameter( $exclude_param ) {
if ( empty( $exclude_param ) ) {
return array();
}
return array_map( 'trim', explode( ',', $exclude_param ) );
}
/**
* Determines if an asset should be excluded based on the exclusion rules.
*
* @param string $url The asset URL.
* @param string $handle The asset handle.
* @param array $exclude_rules Array of exclusion rules.
* @return bool True if the asset should be excluded, false otherwise.
*/
private function should_exclude_asset( $url, $handle, $exclude_rules ) {
if ( empty( $exclude_rules ) ) {
return false;
}
foreach ( $exclude_rules as $rule ) {
// Check for 'core' exclusion
if ( 'core' === $rule && $this->is_core_asset( $url ) ) {
return true;
}
// Check for 'gutenberg' exclusion
if ( 'gutenberg' === $rule && $this->is_gutenberg_asset( $url ) ) {
return true;
}
// Check if handle starts with the rule (plugin handle prefix)
if ( ! empty( $handle ) && is_string( $handle ) && str_starts_with( $handle, $rule . '-' ) ) {
return true;
}
}
return false;
}
/**
* Determines if an inline asset should be excluded based on its handle.
*
* @param string $handle The asset handle.
* @param array $exclude_rules Array of exclusion rules.
* @return bool True if the inline asset should be excluded, false otherwise.
*/
private function should_exclude_inline_asset( $handle, $exclude_rules ) {
if ( empty( $exclude_rules ) || empty( $handle ) ) {
return false;
}
// Define core prefixes once
static $core_prefixes = array( 'wp-', 'utils-', 'moment-', 'mediaelement', 'media-', 'plupload', 'editor-' );
foreach ( $exclude_rules as $rule ) {
// For 'core' exclusion, check if handle starts with 'wp-' or common core prefixes
if ( 'core' === $rule ) {
foreach ( $core_prefixes as $prefix ) {
if ( str_starts_with( $handle, $prefix ) ) {
return true;
}
}
continue; // Skip to next rule after checking core
}
// Check if handle starts with the rule (plugin handle prefix)
if ( str_starts_with( $handle, $rule . '-' ) ) {
return true;
}
}
return false;
}
/**
* Filters assets from HTML based on exclusion rules.
*
* @param string $html The HTML content to filter.
* @param string $tag_name The HTML tag name to filter ('link' or 'script').
* @param string $url_attribute The attribute containing the URL ('href' or 'src').
* @param array $exclude_rules Array of exclusion rules.
* @return string The filtered HTML content.
*/
private function filter_assets_from_html( $html, $tag_name, $url_attribute, $exclude_rules ) {
if ( empty( $html ) || empty( $exclude_rules ) ) {
return $html;
}
// First, handle conditional comments separately (they're not parsed by DOMDocument)
$html = $this->filter_conditional_comments( $html, $tag_name, $url_attribute, $exclude_rules );
// Suppress warnings for malformed HTML
libxml_use_internal_errors( true );
$dom = new DOMDocument();
// Use UTF-8 encoding and load HTML fragment without adding doctype/html/body wrappers
$dom->loadHTML(
'' . $html,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
// Remove the XML encoding processing instruction
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
foreach ( $dom->childNodes as $node ) {
// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( $node->nodeType === XML_PI_NODE ) {
$dom->removeChild( $node );
break;
}
}
// Process tags (and