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