PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / _inc / lib / core-api / wpcom-endpoints / class-wpcom-rest-api-v2-endpoint-block-editor-assets.php

class-wpcom-rest-api-v2-endpoint-block-editor-assets.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-block-editor-assets.php

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