Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.14% covered (success)
91.14%
329 / 361
58.82% covered (warning)
58.82%
20 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Block_Editor_Assets
91.90% covered (success)
91.90%
329 / 358
58.82% covered (warning)
58.82%
20 / 34
160.80
0.00% covered (danger)
0.00%
0 / 1
 get_core_block_types
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 get_items
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
2
 enqueue_core_editor_assets
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 enqueue_block_type_editor_assets
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 capture_scripts_output
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 preserve_allowed_plugin_assets
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 restore_preserved_assets
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 with_absolute_urls
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 capture_styles_output
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setup_block_editor_screen
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 remove_problematic_plugin_hooks
86.67% covered (warning)
86.67%
39 / 45
0.00% covered (danger)
0.00%
0 / 1
19.86
 unregister_disallowed_plugin_assets
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
9
 is_core_or_gutenberg_asset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_core_asset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_plugins_base_url
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_gutenberg_asset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parse_exclude_parameter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 should_exclude_asset
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
10.80
 should_exclude_inline_asset
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
8.30
 filter_assets_from_html
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
 filter_conditional_comments
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 should_exclude_conditional_script
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 should_exclude_conditional_link
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 filter_link_elements
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 filter_style_elements
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 filter_script_elements
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 extract_handle_from_element
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 is_protected_handle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_allowed_plugin_handle
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 make_url_absolute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 get_items_permissions_check
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_item_schema
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Retrieve resources (styles and scripts) loaded by the block editor.
4 *
5 * @package automattic/jetpack
6 */
7
8declare( strict_types = 1 );
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Core class used to retrieve the block editor assets via the REST API.
16 */
17class WPCOM_REST_API_V2_Endpoint_Block_Editor_Assets extends WP_REST_Controller {
18    const CACHE_BUSTER = '2025-02-28';
19
20    /**
21     * Pre-compiled regex pattern for removing common handle suffixes.
22     *
23     * @var string
24     */
25    private $handle_suffix_regex = '/-(js|css|extra|before|after)$/';
26
27    /**
28     * Cached base URL for the plugins directory.
29     *
30     * @var string|null
31     */
32    private $plugins_base_url = null;
33
34    /**
35     * List of allowed plugin handle prefixes whose assets should be preserved.
36     * Each entry should be a handle prefix that identifies assets from allowed plugins.
37     *
38     * @var array
39     */
40    const ALLOWED_PLUGIN_HANDLE_PREFIXES = array(
41        'jetpack-', // E.g., jetpack-blocks-editor, jetpack-connection
42        'jp-', // E.g., jp-forms-blocks
43        'videopress-', // E.g., videopress-add-resumable-upload-support
44        'wp-', // E.g., wp-block-styles, wp-jp-i18n-loader
45    );
46
47    /**
48     * List of core-provided handles that should never be unregistered.
49     *
50     * @var array
51     */
52    const PROTECTED_HANDLES = array(
53        'jquery',
54        'mediaelement',
55    );
56
57    /**
58     * List of allowed plugin-provided, non-core block types.
59     *
60     * @var array
61     */
62    const ALLOWED_PLUGIN_BLOCKS = array(
63        'a8c/blog-posts',
64        'a8c/posts-carousel',
65        'jetpack/address',
66        'jetpack/ai-assistant',
67        'jetpack/blog-stats',
68        'jetpack/blogging-prompt',
69        'jetpack/blogroll',
70        'jetpack/blogroll-item',
71        'jetpack/business-hours',
72        'jetpack/button',
73        'jetpack/calendly',
74        'jetpack/contact-info',
75        'jetpack/email',
76        'jetpack/event-countdown',
77        'jetpack/eventbrite',
78        'jetpack/gif',
79        'jetpack/goodreads',
80        'jetpack/google-calendar',
81        'jetpack/image-compare',
82        'jetpack/instagram-gallery',
83        'jetpack/like',
84        'jetpack/mailchimp',
85        'jetpack/map',
86        'jetpack/markdown',
87        'jetpack/nextdoor',
88        'jetpack/opentable',
89        'jetpack/payment-buttons',
90        'jetpack/payments-intro',
91        'jetpack/paypal-payment-buttons',
92        'jetpack/phone',
93        'jetpack/pinterest',
94        'jetpack/podcast-player',
95        'jetpack/rating-star',
96        'jetpack/recurring-payments',
97        'jetpack/related-posts',
98        'jetpack/repeat-visitor',
99        'jetpack/send-a-message',
100        'jetpack/sharing-button',
101        'jetpack/sharing-buttons',
102        'jetpack/simple-payments',
103        'jetpack/subscriber-login',
104        'jetpack/subscriptions',
105        'jetpack/tiled-gallery',
106        'jetpack/timeline',
107        'jetpack/timeline-item',
108        'jetpack/top-posts',
109        'jetpack/whatsapp-button',
110        'premium-content/buttons',
111        'premium-content/container',
112        'premium-content/logged-out-view',
113        'premium-content/login-button',
114        'premium-content/subscriber-view',
115    );
116
117    /**
118     * List of disallowed core block types.
119     *
120     * @var array
121     */
122    const DISALLOWED_CORE_BLOCKS = array(
123        'core/freeform', // Classic editor - TinyMCE is unavailable in the mobile editor
124    );
125
126    /**
127     * Get the list of allowed core block types.
128     *
129     * @return array List of core block types.
130     */
131    private function get_core_block_types() {
132        $core_blocks = array_filter(
133            array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ),
134            function ( $block_name ) {
135                return str_starts_with( $block_name, 'core/' );
136            }
137        );
138
139        // Remove disallowed core blocks
140        return array_diff( $core_blocks, self::DISALLOWED_CORE_BLOCKS );
141    }
142
143    /**
144     * Constructor.
145     */
146    public function __construct() {
147        $this->namespace = 'wpcom/v2';
148        $this->rest_base = 'editor-assets';
149        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
150    }
151
152    /**
153     * Registers the controller routes.
154     */
155    public function register_routes() {
156        register_rest_route(
157            $this->namespace,
158            '/' . $this->rest_base,
159            array(
160                array(
161                    // Disabled to allow return structure to match existing endpoints
162                    // @phan-suppress-next-line PhanPluginMixedKeyNoKey
163                    'methods'             => WP_REST_Server::READABLE,
164                    'callback'            => array( $this, 'get_items' ),
165                    'permission_callback' => array( $this, 'get_items_permissions_check' ),
166                    'args'                => array(
167                        'exclude' => array(
168                            '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' ),
169                            'type'              => 'string',
170                            'default'           => '',
171                            'sanitize_callback' => 'sanitize_text_field',
172                        ),
173                    ),
174                ),
175                'schema' => array( $this, 'get_public_item_schema' ),
176            )
177        );
178    }
179
180    /**
181     * Retrieves a collection of items.
182     *
183     * @param WP_REST_Request $request The request object.
184     *
185     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
186     */
187    public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
188        // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited
189        global $wp_styles, $wp_scripts;
190
191        // Save current asset state
192        $current_wp_styles  = $wp_styles;
193        $current_wp_scripts = $wp_scripts;
194
195        try {
196            // Preserve allowed plugin assets before reinitializing
197            $preserved = $this->preserve_allowed_plugin_assets();
198
199            // Initialize fresh asset registries to control what gets loaded
200            $wp_styles  = new WP_Styles();
201            $wp_scripts = new WP_Scripts();
202
203            // Restore preserved plugin assets
204            $this->restore_preserved_assets( $preserved );
205
206            // Set up a block editor screen context to prevent errors when
207            // plugins/themes call get_current_screen() during asset enqueueing
208            $this->setup_block_editor_screen();
209
210            // Trigger wp_loaded action that plugins frequently use to enqueue assets.
211            // This must happen after screen setup and before we collect enqueued assets.
212            do_action( 'wp_loaded' );
213
214            // Enqueue all core WordPress editor assets
215            $this->enqueue_core_editor_assets();
216
217            // Remove problematic plugin hooks before triggering block editor asset actions
218            $this->remove_problematic_plugin_hooks();
219
220            // Trigger block editor asset actions with forced script/style loading
221            add_filter( 'should_load_block_editor_scripts_and_styles', '__return_true' );
222            do_action( 'enqueue_block_assets' );
223            do_action( 'enqueue_block_editor_assets' );
224            remove_filter( 'should_load_block_editor_scripts_and_styles', '__return_true' );
225
226            // Enqueue editor-specific assets for all registered block types
227            $this->enqueue_block_type_editor_assets();
228
229            // Remove disallowed plugin assets before generating output
230            $this->unregister_disallowed_plugin_assets();
231
232            // Capture HTML output with absolute URLs
233            $html = $this->with_absolute_urls(
234                function () {
235                    return array(
236                        'styles'  => $this->capture_styles_output(),
237                        'scripts' => $this->capture_scripts_output(),
238                    );
239                }
240            );
241
242            // Apply filtering based on query parameter
243            $exclude_param = $request->get_param( 'exclude' );
244            $exclude_rules = $this->parse_exclude_parameter( $exclude_param );
245
246            if ( ! empty( $exclude_rules ) ) {
247                $html['styles']  = $this->filter_assets_from_html( $html['styles'], 'link', 'href', $exclude_rules );
248                $html['scripts'] = $this->filter_assets_from_html( $html['scripts'], 'script', 'src', $exclude_rules );
249            }
250
251            return rest_ensure_response(
252                array(
253                    'allowed_block_types' => array_merge(
254                        $this->get_core_block_types(),
255                        self::ALLOWED_PLUGIN_BLOCKS
256                    ),
257                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset -- Keys are guaranteed by callback above
258                    'scripts'             => $html['scripts'],
259                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset -- Keys are guaranteed by callback above
260                    'styles'              => $html['styles'],
261                )
262            );
263
264        } finally {
265            // Always restore original asset state, even if an exception occurred
266            $wp_styles  = $current_wp_styles;
267            $wp_scripts = $current_wp_scripts;
268        }
269    }
270
271    /**
272     * Enqueues core WordPress editor assets.
273     *
274     * This includes polyfills, block styles, theme styles, and foundational
275     * post editor scripts and styles.
276     */
277    private function enqueue_core_editor_assets() {
278        global $wp_styles;
279
280        // We generally do not need reset styles for the block editor. However, if
281        // it's a classic theme, margins will be added to every block, which is
282        // reset specifically for list items, so classic themes rely on these
283        // reset styles.
284        $wp_styles->done =
285            wp_theme_has_theme_json() ? array( 'wp-reset-editor-styles' ) : array();
286
287        wp_enqueue_script( 'wp-polyfill' );
288        // Enqueue the `editorStyle` handles for all core block, and dependencies.
289        wp_enqueue_style( 'wp-edit-blocks' );
290
291        if ( current_theme_supports( 'wp-block-styles' ) ) {
292            wp_enqueue_style( 'wp-block-library-theme' );
293        }
294
295        // Enqueue frequent dependent, admin-only `dashicon` asset.
296        wp_enqueue_style( 'dashicons' );
297
298        // Enqueue the admin-only `postbox` asset required for the block editor.
299        $suffix = wp_scripts_get_suffix();
300        wp_enqueue_script( 'postbox', "/wp-admin/js/postbox$suffix.js", array( 'jquery-ui-sortable', 'wp-a11y' ), self::CACHE_BUSTER, true );
301
302        // Enqueue foundational post editor assets.
303        wp_enqueue_script( 'wp-edit-post' );
304        wp_enqueue_style( 'wp-edit-post' );
305    }
306
307    /**
308     * Enqueues editor-specific assets for all registered block types.
309     *
310     * This includes editor_style_handles and editor_script_handles for each
311     * block, which contains editor-only styling and scripts.
312     */
313    private function enqueue_block_type_editor_assets() {
314        $block_registry = WP_Block_Type_Registry::get_instance();
315        foreach ( $block_registry->get_all_registered() as $block_type ) {
316            if ( isset( $block_type->editor_style_handles ) && is_array( $block_type->editor_style_handles ) ) {
317                foreach ( $block_type->editor_style_handles as $style_handle ) {
318                    wp_enqueue_style( $style_handle );
319                }
320            }
321            if ( isset( $block_type->editor_script_handles ) && is_array( $block_type->editor_script_handles ) ) {
322                foreach ( $block_type->editor_script_handles as $script_handle ) {
323                    wp_enqueue_script( $script_handle );
324                }
325            }
326        }
327    }
328
329    /**
330     * Captures the HTML output of enqueued scripts.
331     *
332     * @return string The HTML output of all enqueued scripts.
333     */
334    private function capture_scripts_output() {
335        ob_start();
336        wp_print_head_scripts();
337        wp_print_footer_scripts();
338        return ob_get_clean();
339    }
340
341    /**
342     * Preserves allowed plugin assets from the current asset registries.
343     *
344     * This method clones assets from allowed plugins that aren't core/Gutenberg
345     * assets, so they can be restored after reinitializing the asset registries.
346     *
347     * @return array Array with 'scripts' and 'styles' keys containing cloned assets.
348     */
349    private function preserve_allowed_plugin_assets() {
350        global $wp_scripts, $wp_styles;
351
352        $preserved = array(
353            'scripts' => array(),
354            'styles'  => array(),
355        );
356
357        foreach ( $wp_scripts->registered as $handle => $script ) {
358            if ( $this->is_allowed_plugin_handle( $handle ) && ! $this->is_core_or_gutenberg_asset( $script->src ) ) {
359                $preserved['scripts'][ $handle ] = clone $script;
360            }
361        }
362
363        foreach ( $wp_styles->registered as $handle => $style ) {
364            if ( $this->is_allowed_plugin_handle( $handle ) && ! $this->is_core_or_gutenberg_asset( $style->src ) ) {
365                $preserved['styles'][ $handle ] = clone $style;
366            }
367        }
368
369        return $preserved;
370    }
371
372    /**
373     * Restores previously preserved plugin assets to the asset registries.
374     *
375     * @param array $preserved Array with 'scripts' and 'styles' keys containing preserved assets.
376     */
377    private function restore_preserved_assets( $preserved ) {
378        global $wp_scripts, $wp_styles;
379
380        foreach ( $preserved['scripts'] as $handle => $script ) {
381            $wp_scripts->registered[ $handle ] = $script;
382        }
383
384        foreach ( $preserved['styles'] as $handle => $style ) {
385            $wp_styles->registered[ $handle ] = $style;
386        }
387    }
388
389    /**
390     * Executes a callback with absolute URL filters temporarily enabled.
391     *
392     * This ensures that all asset URLs are converted to absolute URLs during
393     * the callback execution, then removes the filters afterward.
394     *
395     * @param callable $callback The function to execute with absolute URL filters.
396     * @return mixed The return value of the callback.
397     */
398    private function with_absolute_urls( $callback ) {
399        add_filter( 'script_loader_src', array( $this, 'make_url_absolute' ), 10, 2 );
400        add_filter( 'style_loader_src', array( $this, 'make_url_absolute' ), 10, 2 );
401
402        $result = $callback();
403
404        remove_filter( 'script_loader_src', array( $this, 'make_url_absolute' ), 10 );
405        remove_filter( 'style_loader_src', array( $this, 'make_url_absolute' ), 10 );
406
407        return $result;
408    }
409
410    /**
411     * Captures the HTML output of enqueued styles with emoji handling.
412     *
413     * This temporarily removes the emoji styles action to prevent deprecation
414     * warnings, then restores it after capturing the output.
415     *
416     * @return string The HTML output of all enqueued styles.
417     */
418    private function capture_styles_output() {
419        // Remove the deprecated `print_emoji_styles` handler. It avoids breaking
420        // style generation with a deprecation message.
421        $has_emoji_styles = has_action( 'wp_print_styles', 'print_emoji_styles' );
422        if ( $has_emoji_styles ) {
423            remove_action( 'wp_print_styles', 'print_emoji_styles' );
424        }
425
426        ob_start();
427        wp_print_styles();
428        $styles = ob_get_clean();
429
430        if ( $has_emoji_styles ) {
431            add_action( 'wp_print_styles', 'print_emoji_styles' );
432        }
433
434        return $styles;
435    }
436
437    /**
438     * Sets up a mock block editor screen context for the REST API request.
439     *
440     * This ensures get_current_screen() is available and returns a proper
441     * block editor screen object, preventing fatal errors when plugins/themes
442     * call get_current_screen() during the enqueue_block_editor_assets action.
443     */
444    private function setup_block_editor_screen() {
445        // Ensure screen class and functions are available
446        if ( ! class_exists( 'WP_Screen' ) ) {
447            require_once ABSPATH . 'wp-admin/includes/class-wp-screen.php';
448        }
449        if ( ! function_exists( 'get_current_screen' ) ) {
450            require_once ABSPATH . 'wp-admin/includes/screen.php';
451        }
452
453        // Determine the post type for the screen context
454        $post_type = get_query_var( 'post_type', 'post' );
455        if ( is_array( $post_type ) ) {
456            $post_type = $post_type[0];
457        }
458
459        // Validate that the post type is registered
460        if ( ! post_type_exists( $post_type ) ) {
461            $post_type = 'post';
462        }
463
464        // Create a post editor screen context
465        set_current_screen( 'post' );
466
467        // Update the screen to indicate it's using the block editor
468        $current_screen = get_current_screen();
469        if ( $current_screen ) {
470            $current_screen->is_block_editor( true );
471            $current_screen->post_type = $post_type;
472        }
473    }
474
475    /**
476     * Removes hooks from problematic plugins that cause errors in this endpoint.
477     *
478     * Some plugins conditionally load admin-only code based on is_admin(), which
479     * returns false in REST API contexts. When these plugins hook into
480     * enqueue_block_editor_assets without checking the context, they may call
481     * undefined functions that were never loaded, causing fatal errors.
482     *
483     * This method preemptively removes hooks from known problematic plugins before
484     * the enqueue_block_editor_assets action fires, preventing fatal errors.
485     */
486    private function remove_problematic_plugin_hooks() {
487        global $wp_filter;
488
489        // Only target the enqueue_block_editor_assets hook
490        if ( ! isset( $wp_filter['enqueue_block_editor_assets'] ) ) {
491            return;
492        }
493
494        $problematic_plugins = array(
495            'wpforms-lite/wpforms.php',
496        );
497
498        // Early return if no problematic plugins are active
499        $has_active_problematic_plugin = false;
500        foreach ( $problematic_plugins as $plugin_file ) {
501            if ( is_plugin_active( $plugin_file ) ) {
502                $has_active_problematic_plugin = true;
503                break;
504            }
505        }
506
507        if ( ! $has_active_problematic_plugin ) {
508            return;
509        }
510
511        $plugin_slugs = array_map(
512            function ( $plugin_file ) {
513                return dirname( $plugin_file );
514            },
515            $problematic_plugins
516        );
517
518        // Collect callbacks to remove (improves performance by separating detection from removal)
519        $callbacks_to_remove = array();
520
521        foreach ( $wp_filter['enqueue_block_editor_assets']->callbacks as $priority => $callbacks ) {
522            foreach ( $callbacks as $callback_data ) {
523                $callback  = $callback_data['function'];
524                $file_path = null;
525
526                // Handle object method callbacks: [$object, 'method_name']
527                if ( is_array( $callback ) && count( $callback ) === 2 && is_object( $callback[0] ) ) {
528                    try {
529                        $reflection = new ReflectionClass( $callback[0] );
530                        $file_path  = $reflection->getFileName();
531                    } catch ( ReflectionException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
532                        // Skip if reflection fails
533                        continue;
534                    }
535                }
536
537                // Handle function name callbacks: 'function_name'
538                if ( is_string( $callback ) && function_exists( $callback ) && ! str_contains( $callback, '::' ) ) {
539                    try {
540                        $reflection = new ReflectionFunction( $callback );
541                        $file_path  = $reflection->getFileName();
542                    } catch ( ReflectionException $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
543                        // Skip if reflection fails
544                        continue;
545                    }
546                }
547
548                // Check if file belongs to any problematic plugin
549                if ( $file_path ) {
550                    $normalized_path = wp_normalize_path( $file_path );
551                    $plugin_dir      = wp_normalize_path( WP_PLUGIN_DIR );
552
553                    foreach ( $plugin_slugs as $plugin_slug ) {
554                        if ( str_contains( $normalized_path, $plugin_dir . '/' . $plugin_slug . '/' ) ) {
555                            $callbacks_to_remove[] = array(
556                                'callback' => $callback,
557                                'priority' => $priority,
558                            );
559                            break;
560                        }
561                    }
562                }
563            }
564        }
565
566        // Remove all identified callbacks
567        foreach ( $callbacks_to_remove as $item ) {
568            remove_action( 'enqueue_block_editor_assets', $item['callback'], $item['priority'] );
569        }
570    }
571
572    /**
573     * Unregisters all assets except those from core or allowed plugins.
574     */
575    private function unregister_disallowed_plugin_assets() {
576        global $wp_scripts, $wp_styles;
577
578        // Unregister disallowed plugin scripts
579        foreach ( $wp_scripts->registered as $handle => $script ) {
580            // Skip core scripts and protected handles
581            if ( $this->is_core_or_gutenberg_asset( $script->src ) || $this->is_protected_handle( $handle ) ) {
582                continue;
583            }
584
585            if ( ! $this->is_allowed_plugin_handle( $handle ) ) {
586                unset( $wp_scripts->registered[ $handle ] );
587            }
588        }
589
590        // Unregister disallowed plugin styles
591        foreach ( $wp_styles->registered as $handle => $style ) {
592            // Skip core styles and protected handles
593            if ( $this->is_core_or_gutenberg_asset( $style->src ) || $this->is_protected_handle( $handle ) ) {
594                continue;
595            }
596
597            if ( ! $this->is_allowed_plugin_handle( $handle ) ) {
598                unset( $wp_styles->registered[ $handle ] );
599            }
600        }
601    }
602
603    /**
604     * Check if an asset is a core or Gutenberg asset.
605     *
606     * @param string $src The asset source URL.
607     * @return bool True if the asset is a core or Gutenberg asset, false otherwise.
608     */
609    private function is_core_or_gutenberg_asset( $src ) {
610        return $this->is_core_asset( $src ) || $this->is_gutenberg_asset( $src );
611    }
612
613    /**
614     * Check if an asset is a core WordPress asset.
615     *
616     * @param string $src The asset source URL.
617     * @return bool True if the asset is a core WordPress asset, false otherwise.
618     */
619    private function is_core_asset( $src ) {
620        if ( ! is_string( $src ) ) {
621            return false;
622        }
623
624        return empty( $src ) ||
625            str_contains( $src, '/wp-includes/' ) ||
626            str_contains( $src, '/wp-admin/' );
627    }
628
629    /**
630     * Get the base URL for the plugins directory.
631     *
632     * Caches the result to avoid repeated function calls.
633     *
634     * @return string The base URL for the plugins directory with trailing slash.
635     */
636    private function get_plugins_base_url() {
637        if ( null === $this->plugins_base_url ) {
638            $this->plugins_base_url = trailingslashit( plugins_url() );
639        }
640        return $this->plugins_base_url;
641    }
642
643    /**
644     * Check if an asset is a Gutenberg plugin asset.
645     *
646     * @param string $src The asset source URL.
647     * @return bool True if the asset is a Gutenberg plugin asset, false otherwise.
648     */
649    private function is_gutenberg_asset( $src ) {
650        if ( ! is_string( $src ) ) {
651            return false;
652        }
653
654        $plugins_url = $this->get_plugins_base_url();
655
656        return str_contains( $src, $plugins_url . 'gutenberg/' ) ||
657            str_contains( $src, $plugins_url . 'gutenberg-core/' ); // WPCOM-specific path
658    }
659
660    /**
661     * Parses the exclude parameter into an array of exclusion rules.
662     *
663     * @param string $exclude_param Comma-separated list of exclusion rules.
664     * @return array Array of exclusion rules.
665     */
666    private function parse_exclude_parameter( $exclude_param ) {
667        if ( empty( $exclude_param ) ) {
668            return array();
669        }
670
671        return array_map( 'trim', explode( ',', $exclude_param ) );
672    }
673
674    /**
675     * Determines if an asset should be excluded based on the exclusion rules.
676     *
677     * @param string $url The asset URL.
678     * @param string $handle The asset handle.
679     * @param array  $exclude_rules Array of exclusion rules.
680     * @return bool True if the asset should be excluded, false otherwise.
681     */
682    private function should_exclude_asset( $url, $handle, $exclude_rules ) {
683        if ( empty( $exclude_rules ) ) {
684            return false;
685        }
686
687        foreach ( $exclude_rules as $rule ) {
688            // Check for 'core' exclusion
689            if ( 'core' === $rule && $this->is_core_asset( $url ) ) {
690                return true;
691            }
692
693            // Check for 'gutenberg' exclusion
694            if ( 'gutenberg' === $rule && $this->is_gutenberg_asset( $url ) ) {
695                return true;
696            }
697
698            // Check if handle starts with the rule (plugin handle prefix)
699            if ( ! empty( $handle ) && is_string( $handle ) && str_starts_with( $handle, $rule . '-' ) ) {
700                return true;
701            }
702        }
703
704        return false;
705    }
706
707    /**
708     * Determines if an inline asset should be excluded based on its handle.
709     *
710     * @param string $handle The asset handle.
711     * @param array  $exclude_rules Array of exclusion rules.
712     * @return bool True if the inline asset should be excluded, false otherwise.
713     */
714    private function should_exclude_inline_asset( $handle, $exclude_rules ) {
715        if ( empty( $exclude_rules ) || empty( $handle ) ) {
716            return false;
717        }
718
719        // Define core prefixes once
720        static $core_prefixes = array( 'wp-', 'utils-', 'moment-', 'mediaelement', 'media-', 'plupload', 'editor-' );
721
722        foreach ( $exclude_rules as $rule ) {
723            // For 'core' exclusion, check if handle starts with 'wp-' or common core prefixes
724            if ( 'core' === $rule ) {
725                foreach ( $core_prefixes as $prefix ) {
726                    if ( str_starts_with( $handle, $prefix ) ) {
727                        return true;
728                    }
729                }
730                continue; // Skip to next rule after checking core
731            }
732
733            // Check if handle starts with the rule (plugin handle prefix)
734            if ( str_starts_with( $handle, $rule . '-' ) ) {
735                return true;
736            }
737        }
738
739        return false;
740    }
741
742    /**
743     * Filters assets from HTML based on exclusion rules.
744     *
745     * @param string $html The HTML content to filter.
746     * @param string $tag_name The HTML tag name to filter ('link' or 'script').
747     * @param string $url_attribute The attribute containing the URL ('href' or 'src').
748     * @param array  $exclude_rules Array of exclusion rules.
749     * @return string The filtered HTML content.
750     */
751    private function filter_assets_from_html( $html, $tag_name, $url_attribute, $exclude_rules ) {
752        if ( empty( $html ) || empty( $exclude_rules ) ) {
753            return $html;
754        }
755
756        // First, handle conditional comments separately (they're not parsed by DOMDocument)
757        $html = $this->filter_conditional_comments( $html, $tag_name, $url_attribute, $exclude_rules );
758
759        // Suppress warnings for malformed HTML
760        libxml_use_internal_errors( true );
761
762        $dom = new DOMDocument();
763        // Use UTF-8 encoding and load HTML fragment without adding doctype/html/body wrappers
764        $dom->loadHTML(
765            '<?xml encoding="UTF-8">' . $html,
766            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
767        );
768
769        // Remove the XML encoding processing instruction
770        // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
771        foreach ( $dom->childNodes as $node ) {
772            // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
773            if ( $node->nodeType === XML_PI_NODE ) {
774                $dom->removeChild( $node );
775                break;
776            }
777        }
778
779        // Process <link> tags (and <style> when filtering styles)
780        if ( 'link' === $tag_name ) {
781            $this->filter_link_elements( $dom, $url_attribute, $exclude_rules );
782            $this->filter_style_elements( $dom, $exclude_rules );
783        }
784
785        // Process <script> tags
786        if ( 'script' === $tag_name ) {
787            $this->filter_script_elements( $dom, $url_attribute, $exclude_rules );
788        }
789
790        libxml_clear_errors();
791
792        return $dom->saveHTML();
793    }
794
795    /**
796     * Filters assets from conditional comments (<!--[if ...]>).
797     *
798     * IE conditional comments are not parsed as DOM elements by DOMDocument - they
799     * remain as DOMComment nodes with HTML as plain text. This means we must use
800     * regex to parse their content before DOM processing. This is the standard
801     * approach for handling conditional comments across all HTML parsers.
802     *
803     * @param string $html The HTML content.
804     * @param string $tag_name The HTML tag name ('link' or 'script').
805     * @param string $url_attribute The attribute containing the URL ('href' or 'src').
806     * @param array  $exclude_rules Array of exclusion rules.
807     * @return string The filtered HTML content.
808     */
809    private function filter_conditional_comments( $html, $tag_name, $url_attribute, $exclude_rules ) {
810        // Pattern matches: <!--[if CONDITION]>INNER_HTML<![endif]-->
811        // [^\]]* matches the condition (everything before the first ])
812        // (.*?) captures the inner HTML (non-greedy)
813        // /is flags: case-insensitive and . matches newlines
814        $pattern = '/<!--\[if[^\]]*\]>(.*?)<!\[endif\]-->/is';
815
816        return preg_replace_callback(
817            $pattern,
818            function ( $matches ) use ( $tag_name, $url_attribute, $exclude_rules ) {
819                $full_comment = $matches[0];
820                $inner_html   = $matches[1];
821
822                // Check if this conditional comment contains assets that should be excluded
823                if ( 'script' === $tag_name && $this->should_exclude_conditional_script( $inner_html, $url_attribute, $exclude_rules ) ) {
824                    return ''; // Remove the entire conditional comment
825                }
826
827                if ( 'link' === $tag_name && $this->should_exclude_conditional_link( $inner_html, $url_attribute, $exclude_rules ) ) {
828                    return ''; // Remove the entire conditional comment
829                }
830
831                return $full_comment; // Keep the conditional comment if not excluded
832            },
833            $html
834        );
835    }
836
837    /**
838     * Check if a conditional comment containing a script should be excluded.
839     *
840     * @param string $inner_html The HTML inside the conditional comment.
841     * @param string $url_attribute The attribute containing the URL ('src').
842     * @param array  $exclude_rules Array of exclusion rules.
843     * @return bool True if the script should be excluded, false otherwise.
844     */
845    private function should_exclude_conditional_script( $inner_html, $url_attribute, $exclude_rules ) {
846        if ( ! preg_match( '/<script[^>]*' . $url_attribute . '=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $script_match ) ) {
847            return false;
848        }
849
850        $url    = $script_match[1];
851        $handle = '';
852
853        if ( preg_match( '/id=["\']([^"\']+)["\']/i', $script_match[0], $id_match ) ) {
854            $handle = preg_replace( $this->handle_suffix_regex, '', $id_match[1] );
855        }
856
857        return $this->should_exclude_asset( $url, $handle, $exclude_rules );
858    }
859
860    /**
861     * Check if a conditional comment containing a link should be excluded.
862     *
863     * @param string $inner_html The HTML inside the conditional comment.
864     * @param string $url_attribute The attribute containing the URL ('href').
865     * @param array  $exclude_rules Array of exclusion rules.
866     * @return bool True if the link should be excluded, false otherwise.
867     */
868    private function should_exclude_conditional_link( $inner_html, $url_attribute, $exclude_rules ) {
869        if ( ! preg_match( '/<link[^>]*' . $url_attribute . '=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $link_match ) ) {
870            return false;
871        }
872
873        $url    = $link_match[1];
874        $handle = '';
875
876        if ( preg_match( '/id=["\']([^"\']+)["\']/i', $link_match[0], $id_match ) ) {
877            $handle = preg_replace( $this->handle_suffix_regex, '', $id_match[1] );
878        }
879
880        return $this->should_exclude_asset( $url, $handle, $exclude_rules );
881    }
882
883    /**
884     * Filters link elements from the DOM based on exclusion rules.
885     *
886     * @param DOMDocument $dom The DOM document.
887     * @param string      $url_attribute The attribute containing the URL.
888     * @param array       $exclude_rules Array of exclusion rules.
889     */
890    private function filter_link_elements( $dom, $url_attribute, $exclude_rules ) {
891        $links     = $dom->getElementsByTagName( 'link' );
892        $to_remove = array();
893
894        // Use two-pass approach: collect elements first, then remove them.
895        // This is necessary because getElementsByTagName() returns a live DOMNodeList
896        // that updates as the DOM changes. Removing elements during iteration can
897        // cause the iterator to skip elements.
898        foreach ( $links as $link ) {
899            $handle = $this->extract_handle_from_element( $link );
900            $url    = $link->getAttribute( $url_attribute );
901
902            if ( ! empty( $url ) && $this->should_exclude_asset( $url, $handle, $exclude_rules ) ) {
903                $to_remove[] = $link;
904            }
905        }
906
907        foreach ( $to_remove as $element ) {
908            // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
909            $element->parentNode->removeChild( $element );
910        }
911    }
912
913    /**
914     * Filters style elements from the DOM based on exclusion rules.
915     *
916     * @param DOMDocument $dom The DOM document.
917     * @param array       $exclude_rules Array of exclusion rules.
918     */
919    private function filter_style_elements( $dom, $exclude_rules ) {
920        $styles    = $dom->getElementsByTagName( 'style' );
921        $to_remove = array();
922
923        // Use two-pass approach: collect elements first, then remove them.
924        // This is necessary because getElementsByTagName() returns a live DOMNodeList
925        // that updates as the DOM changes. Removing elements during iteration can
926        // cause the iterator to skip elements.
927        foreach ( $styles as $style ) {
928            $handle = $this->extract_handle_from_element( $style );
929
930            if ( ! empty( $handle ) && $this->should_exclude_inline_asset( $handle, $exclude_rules ) ) {
931                $to_remove[] = $style;
932            }
933        }
934
935        foreach ( $to_remove as $element ) {
936            // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
937            $element->parentNode->removeChild( $element );
938        }
939    }
940
941    /**
942     * Filters script elements from the DOM based on exclusion rules.
943     *
944     * @param DOMDocument $dom The DOM document.
945     * @param string      $url_attribute The attribute containing the URL.
946     * @param array       $exclude_rules Array of exclusion rules.
947     */
948    private function filter_script_elements( $dom, $url_attribute, $exclude_rules ) {
949        $scripts   = $dom->getElementsByTagName( 'script' );
950        $to_remove = array();
951
952        // Use two-pass approach: collect elements first, then remove them.
953        // This is necessary because getElementsByTagName() returns a live DOMNodeList
954        // that updates as the DOM changes. Removing elements during iteration can
955        // cause the iterator to skip elements.
956        foreach ( $scripts as $script ) {
957            $handle = $this->extract_handle_from_element( $script );
958            $url    = $script->getAttribute( $url_attribute );
959
960            // Check URL-based exclusions
961            if ( ! empty( $url ) ) {
962                if ( $this->should_exclude_asset( $url, $handle, $exclude_rules ) ) {
963                    $to_remove[] = $script;
964                }
965            } elseif ( ! empty( $handle ) ) {
966                // Check handle-based exclusions for inline scripts
967                if ( $this->should_exclude_inline_asset( $handle, $exclude_rules ) ) {
968                    $to_remove[] = $script;
969                }
970            }
971        }
972
973        foreach ( $to_remove as $element ) {
974            // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
975            $element->parentNode->removeChild( $element );
976        }
977    }
978
979    /**
980     * Extracts the handle from a DOM element's ID attribute.
981     *
982     * @param DOMElement $element The DOM element.
983     * @return string The extracted handle, or empty string if not found.
984     */
985    private function extract_handle_from_element( $element ) {
986        $id = $element->getAttribute( 'id' );
987        if ( empty( $id ) ) {
988            return '';
989        }
990
991        // Remove common suffixes (-js, -css, -extra, -before, -after)
992        return preg_replace( $this->handle_suffix_regex, '', $id );
993    }
994
995    /**
996     * Check if a handle should be protected.
997     *
998     * @param string $handle The asset handle.
999     * @return bool True if the handle should be protected, false otherwise.
1000     */
1001    private function is_protected_handle( $handle ) {
1002        return in_array( $handle, self::PROTECTED_HANDLES, true );
1003    }
1004
1005    /**
1006     * Check if a handle is from an allowed plugin.
1007     *
1008     * @param string $handle The asset handle.
1009     * @return bool True if the handle is from an allowed plugin, false otherwise.
1010     */
1011    private function is_allowed_plugin_handle( $handle ) {
1012        if ( ! is_string( $handle ) || empty( $handle ) ) {
1013            return false;
1014        }
1015
1016        foreach ( self::ALLOWED_PLUGIN_HANDLE_PREFIXES as $allowed_prefix ) {
1017            if ( str_starts_with( $handle, $allowed_prefix ) ) {
1018                return true;
1019            }
1020        }
1021
1022        return false;
1023    }
1024
1025    /**
1026     * Convert relative URLs to absolute URLs.
1027     *
1028     * @param string $src The source URL.
1029     * @return string The absolute URL.
1030     */
1031    public function make_url_absolute( $src ) {
1032        if ( ! empty( $src ) && str_starts_with( $src, '/' ) && ! str_starts_with( $src, '//' ) ) {
1033            return site_url( $src );
1034        }
1035        return $src;
1036    }
1037
1038    /**
1039     * Checks the permissions for retrieving items.
1040     *
1041     * @param WP_REST_Request $request The REST request object.
1042     *
1043     * @return bool|WP_Error True if the request has permission, WP_Error object otherwise.
1044     */
1045    public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1046        if ( current_user_can( 'edit_posts' ) ) {
1047            return true;
1048        }
1049
1050        foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
1051            if ( current_user_can( $post_type->cap->edit_posts ) ) {
1052                return true;
1053            }
1054        }
1055
1056        return new WP_Error(
1057            'rest_cannot_read_block_editor_assets',
1058            __( 'Sorry, you are not allowed to read the block editor assets.', 'jetpack' ),
1059            array( 'status' => rest_authorization_required_code() )
1060        );
1061    }
1062
1063    /**
1064     * Retrieves the block editor assets schema, conforming to JSON Schema.
1065     *
1066     * @return array Item schema data.
1067     */
1068    public function get_item_schema() {
1069        if ( $this->schema ) {
1070            return $this->add_additional_fields_schema( $this->schema );
1071        }
1072
1073        $schema = array(
1074            'type'       => 'object',
1075            'properties' => array(
1076                'allowed_block_types' => array(
1077                    'description' => esc_html__( 'List of allowed block types for the editor.', 'jetpack' ),
1078                    'type'        => 'array',
1079                    'items'       => array(
1080                        'type' => 'string',
1081                    ),
1082                ),
1083                'scripts'             => array(
1084                    'description' => esc_html__( 'Script tags for the block editor.', 'jetpack' ),
1085                    'type'        => 'string',
1086                ),
1087                'styles'              => array(
1088                    'description' => esc_html__( 'Style link tags for the block editor.', 'jetpack' ),
1089                    'type'        => 'string',
1090                ),
1091            ),
1092        );
1093
1094        $this->schema = $schema;
1095
1096        return $this->add_additional_fields_schema( $this->schema );
1097    }
1098}
1099
1100wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Block_Editor_Assets' );