PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
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.3, at _inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-block-editor-assets.php

1,103 lines 34.5 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 '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 ) {
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 ) {
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 ) {
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 path for the plugins directory.
631 *
632 * Extracts only the path component from the plugins URL, making it
633 * CDN-safe by ignoring the domain. Caches the result to avoid repeated
634 * function calls.
635 *
636 * @return string The base path for the plugins directory with trailing slash.
637 */
638 private function get_plugins_base_path() {
639 if ( null === $this->plugins_base_path ) {
640 $this->plugins_base_path = trailingslashit( wp_parse_url( plugins_url(), PHP_URL_PATH ) );
641 }
642 return $this->plugins_base_path;
643 }
644
645 /**
646 * Check if an asset is a Gutenberg plugin asset.
647 *
648 * @param string $src The asset source URL.
649 * @return bool True if the asset is a Gutenberg plugin asset, false otherwise.
650 */
651 private function is_gutenberg_asset( $src ) {
652 if ( ! is_string( $src ) ) {
653 return false;
654 }
655
656 $plugins_path = $this->get_plugins_base_path();
657
658 return str_contains( $src, $plugins_path . 'gutenberg/' ) ||
659 str_contains( $src, $plugins_path . 'gutenberg-core/' ); // WPCOM-specific path
660 }
661
662 /**
663 * Parses the exclude parameter into an array of exclusion rules.
664 *
665 * @param string $exclude_param Comma-separated list of exclusion rules.
666 * @return array Array of exclusion rules.
667 */
668 private function parse_exclude_parameter( $exclude_param ) {
669 if ( empty( $exclude_param ) ) {
670 return array();
671 }
672
673 return array_map( 'trim', explode( ',', $exclude_param ) );
674 }
675
676 /**
677 * Determines if an asset should be excluded based on the exclusion rules.
678 *
679 * @param string $url The asset URL.
680 * @param string $handle The asset handle.
681 * @param array $exclude_rules Array of exclusion rules.
682 * @return bool True if the asset should be excluded, false otherwise.
683 */
684 private function should_exclude_asset( $url, $handle, $exclude_rules ) {
685 if ( empty( $exclude_rules ) ) {
686 return false;
687 }
688
689 foreach ( $exclude_rules as $rule ) {
690 // Check for 'core' exclusion
691 if ( 'core' === $rule && $this->is_core_asset( $url ) ) {
692 return true;
693 }
694
695 // Check for 'gutenberg' exclusion
696 if ( 'gutenberg' === $rule && $this->is_gutenberg_asset( $url ) ) {
697 return true;
698 }
699
700 // Check if handle starts with the rule (plugin handle prefix)
701 if ( ! empty( $handle ) && is_string( $handle ) && str_starts_with( $handle, $rule . '-' ) ) {
702 return true;
703 }
704 }
705
706 return false;
707 }
708
709 /**
710 * Determines if an inline asset should be excluded based on its handle.
711 *
712 * @param string $handle The asset handle.
713 * @param array $exclude_rules Array of exclusion rules.
714 * @return bool True if the inline asset should be excluded, false otherwise.
715 */
716 private function should_exclude_inline_asset( $handle, $exclude_rules ) {
717 if ( empty( $exclude_rules ) || empty( $handle ) ) {
718 return false;
719 }
720
721 // Define core prefixes once
722 static $core_prefixes = array( 'wp-', 'utils-', 'moment-', 'mediaelement', 'media-', 'plupload', 'editor-' );
723
724 foreach ( $exclude_rules as $rule ) {
725 // For 'core' exclusion, check if handle starts with 'wp-' or common core prefixes
726 if ( 'core' === $rule ) {
727 foreach ( $core_prefixes as $prefix ) {
728 if ( str_starts_with( $handle, $prefix ) ) {
729 return true;
730 }
731 }
732 continue; // Skip to next rule after checking core
733 }
734
735 // Check if handle starts with the rule (plugin handle prefix)
736 if ( str_starts_with( $handle, $rule . '-' ) ) {
737 return true;
738 }
739 }
740
741 return false;
742 }
743
744 /**
745 * Filters assets from HTML based on exclusion rules.
746 *
747 * @param string $html The HTML content to filter.
748 * @param string $tag_name The HTML tag name to filter ('link' or 'script').
749 * @param string $url_attribute The attribute containing the URL ('href' or 'src').
750 * @param array $exclude_rules Array of exclusion rules.
751 * @return string The filtered HTML content.
752 */
753 private function filter_assets_from_html( $html, $tag_name, $url_attribute, $exclude_rules ) {
754 if ( empty( $html ) || empty( $exclude_rules ) ) {
755 return $html;
756 }
757
758 // First, handle conditional comments separately (they're not parsed by DOMDocument)
759 $html = $this->filter_conditional_comments( $html, $tag_name, $url_attribute, $exclude_rules );
760
761 // Suppress warnings for malformed HTML
762 libxml_use_internal_errors( true );
763
764 $dom = new DOMDocument();
765 // Use UTF-8 encoding and load HTML fragment without adding doctype/html/body wrappers
766 $dom->loadHTML(
767 '<?xml encoding="UTF-8">' . $html,
768 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
769 );
770
771 // Remove the XML encoding processing instruction
772 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
773 foreach ( $dom->childNodes as $node ) {
774 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
775 if ( $node->nodeType === XML_PI_NODE ) {
776 $dom->removeChild( $node );
777 break;
778 }
779 }
780
781 // Process <link> tags (and <style> when filtering styles)
782 if ( 'link' === $tag_name ) {
783 $this->filter_link_elements( $dom, $url_attribute, $exclude_rules );
784 $this->filter_style_elements( $dom, $exclude_rules );
785 }
786
787 // Process <script> tags
788 if ( 'script' === $tag_name ) {
789 $this->filter_script_elements( $dom, $url_attribute, $exclude_rules );
790 }
791
792 libxml_clear_errors();
793
794 return $dom->saveHTML();
795 }
796
797 /**
798 * Filters assets from conditional comments (<!--[if ...]>).
799 *
800 * IE conditional comments are not parsed as DOM elements by DOMDocument - they
801 * remain as DOMComment nodes with HTML as plain text. This means we must use
802 * regex to parse their content before DOM processing. This is the standard
803 * approach for handling conditional comments across all HTML parsers.
804 *
805 * @param string $html The HTML content.
806 * @param string $tag_name The HTML tag name ('link' or 'script').
807 * @param string $url_attribute The attribute containing the URL ('href' or 'src').
808 * @param array $exclude_rules Array of exclusion rules.
809 * @return string The filtered HTML content.
810 */
811 private function filter_conditional_comments( $html, $tag_name, $url_attribute, $exclude_rules ) {
812 // Pattern matches: <!--[if CONDITION]>INNER_HTML<![endif]-->
813 // [^\]]* matches the condition (everything before the first ])
814 // (.*?) captures the inner HTML (non-greedy)
815 // /is flags: case-insensitive and . matches newlines
816 $pattern = '/<!--\[if[^\]]*\]>(.*?)<!\[endif\]-->/is';
817
818 return preg_replace_callback(
819 $pattern,
820 function ( $matches ) use ( $tag_name, $url_attribute, $exclude_rules ) {
821 $full_comment = $matches[0];
822 $inner_html = $matches[1];
823
824 // Check if this conditional comment contains assets that should be excluded
825 if ( 'script' === $tag_name && $this->should_exclude_conditional_script( $inner_html, $url_attribute, $exclude_rules ) ) {
826 return ''; // Remove the entire conditional comment
827 }
828
829 if ( 'link' === $tag_name && $this->should_exclude_conditional_link( $inner_html, $url_attribute, $exclude_rules ) ) {
830 return ''; // Remove the entire conditional comment
831 }
832
833 return $full_comment; // Keep the conditional comment if not excluded
834 },
835 $html
836 );
837 }
838
839 /**
840 * Check if a conditional comment containing a script should be excluded.
841 *
842 * @param string $inner_html The HTML inside the conditional comment.
843 * @param string $url_attribute The attribute containing the URL ('src').
844 * @param array $exclude_rules Array of exclusion rules.
845 * @return bool True if the script should be excluded, false otherwise.
846 */
847 private function should_exclude_conditional_script( $inner_html, $url_attribute, $exclude_rules ) {
848 if ( ! preg_match( '/<script[^>]*' . $url_attribute . '=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $script_match ) ) {
849 return false;
850 }
851
852 $url = $script_match[1];
853 $handle = '';
854
855 if ( preg_match( '/id=["\']([^"\']+)["\']/i', $script_match[0], $id_match ) ) {
856 $handle = preg_replace( $this->handle_suffix_regex, '', $id_match[1] );
857 }
858
859 return $this->should_exclude_asset( $url, $handle, $exclude_rules );
860 }
861
862 /**
863 * Check if a conditional comment containing a link should be excluded.
864 *
865 * @param string $inner_html The HTML inside the conditional comment.
866 * @param string $url_attribute The attribute containing the URL ('href').
867 * @param array $exclude_rules Array of exclusion rules.
868 * @return bool True if the link should be excluded, false otherwise.
869 */
870 private function should_exclude_conditional_link( $inner_html, $url_attribute, $exclude_rules ) {
871 if ( ! preg_match( '/<link[^>]*' . $url_attribute . '=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $link_match ) ) {
872 return false;
873 }
874
875 $url = $link_match[1];
876 $handle = '';
877
878 if ( preg_match( '/id=["\']([^"\']+)["\']/i', $link_match[0], $id_match ) ) {
879 $handle = preg_replace( $this->handle_suffix_regex, '', $id_match[1] );
880 }
881
882 return $this->should_exclude_asset( $url, $handle, $exclude_rules );
883 }
884
885 /**
886 * Filters link elements from the DOM based on exclusion rules.
887 *
888 * @param DOMDocument $dom The DOM document.
889 * @param string $url_attribute The attribute containing the URL.
890 * @param array $exclude_rules Array of exclusion rules.
891 */
892 private function filter_link_elements( $dom, $url_attribute, $exclude_rules ) {
893 $links = $dom->getElementsByTagName( 'link' );
894 $to_remove = array();
895
896 // Use two-pass approach: collect elements first, then remove them.
897 // This is necessary because getElementsByTagName() returns a live DOMNodeList
898 // that updates as the DOM changes. Removing elements during iteration can
899 // cause the iterator to skip elements.
900 foreach ( $links as $link ) {
901 $handle = $this->extract_handle_from_element( $link );
902 $url = $link->getAttribute( $url_attribute );
903
904 if ( ! empty( $url ) && $this->should_exclude_asset( $url, $handle, $exclude_rules ) ) {
905 $to_remove[] = $link;
906 }
907 }
908
909 foreach ( $to_remove as $element ) {
910 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
911 $element->parentNode->removeChild( $element );
912 }
913 }
914
915 /**
916 * Filters style elements from the DOM based on exclusion rules.
917 *
918 * @param DOMDocument $dom The DOM document.
919 * @param array $exclude_rules Array of exclusion rules.
920 */
921 private function filter_style_elements( $dom, $exclude_rules ) {
922 $styles = $dom->getElementsByTagName( 'style' );
923 $to_remove = array();
924
925 // Use two-pass approach: collect elements first, then remove them.
926 // This is necessary because getElementsByTagName() returns a live DOMNodeList
927 // that updates as the DOM changes. Removing elements during iteration can
928 // cause the iterator to skip elements.
929 foreach ( $styles as $style ) {
930 $handle = $this->extract_handle_from_element( $style );
931
932 if ( ! empty( $handle ) && $this->should_exclude_inline_asset( $handle, $exclude_rules ) ) {
933 $to_remove[] = $style;
934 }
935 }
936
937 foreach ( $to_remove as $element ) {
938 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
939 $element->parentNode->removeChild( $element );
940 }
941 }
942
943 /**
944 * Filters script elements from the DOM based on exclusion rules.
945 *
946 * @param DOMDocument $dom The DOM document.
947 * @param string $url_attribute The attribute containing the URL.
948 * @param array $exclude_rules Array of exclusion rules.
949 */
950 private function filter_script_elements( $dom, $url_attribute, $exclude_rules ) {
951 $scripts = $dom->getElementsByTagName( 'script' );
952 $to_remove = array();
953
954 // Use two-pass approach: collect elements first, then remove them.
955 // This is necessary because getElementsByTagName() returns a live DOMNodeList
956 // that updates as the DOM changes. Removing elements during iteration can
957 // cause the iterator to skip elements.
958 foreach ( $scripts as $script ) {
959 $handle = $this->extract_handle_from_element( $script );
960 $url = $script->getAttribute( $url_attribute );
961
962 // Check URL-based exclusions
963 if ( ! empty( $url ) ) {
964 if ( $this->should_exclude_asset( $url, $handle, $exclude_rules ) ) {
965 $to_remove[] = $script;
966 }
967 } elseif ( ! empty( $handle ) ) {
968 // Check handle-based exclusions for inline scripts
969 if ( $this->should_exclude_inline_asset( $handle, $exclude_rules ) ) {
970 $to_remove[] = $script;
971 }
972 }
973 }
974
975 foreach ( $to_remove as $element ) {
976 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
977 $element->parentNode->removeChild( $element );
978 }
979 }
980
981 /**
982 * Extracts the handle from a DOM element's ID attribute.
983 *
984 * @param DOMElement $element The DOM element.
985 * @return string The extracted handle, or empty string if not found.
986 */
987 private function extract_handle_from_element( $element ) {
988 $id = $element->getAttribute( 'id' );
989 if ( empty( $id ) ) {
990 return '';
991 }
992
993 // Remove common suffixes (-js, -css, -extra, -before, -after)
994 return preg_replace( $this->handle_suffix_regex, '', $id );
995 }
996
997 /**
998 * Check if a handle should be protected.
999 *
1000 * @param string $handle The asset handle.
1001 * @return bool True if the handle should be protected, false otherwise.
1002 */
1003 private function is_protected_handle( $handle ) {
1004 return in_array( $handle, self::PROTECTED_HANDLES, true );
1005 }
1006
1007 /**
1008 * Check if a handle is from an allowed plugin.
1009 *
1010 * @param string $handle The asset handle.
1011 * @return bool True if the handle is from an allowed plugin, false otherwise.
1012 */
1013 private function is_allowed_plugin_handle( $handle ) {
1014 if ( ! is_string( $handle ) || empty( $handle ) ) {
1015 return false;
1016 }
1017
1018 foreach ( self::ALLOWED_PLUGIN_HANDLE_PREFIXES as $allowed_prefix ) {
1019 if ( str_starts_with( $handle, $allowed_prefix ) ) {
1020 return true;
1021 }
1022 }
1023
1024 return false;
1025 }
1026
1027 /**
1028 * Convert relative URLs to absolute URLs.
1029 *
1030 * @param string $src The source URL.
1031 * @return string The absolute URL.
1032 */
1033 public function make_url_absolute( $src ) {
1034 if ( ! empty( $src ) && str_starts_with( $src, '/' ) && ! str_starts_with( $src, '//' ) ) {
1035 return site_url( $src );
1036 }
1037 return $src;
1038 }
1039
1040 /**
1041 * Checks the permissions for retrieving items.
1042 *
1043 * @param WP_REST_Request $request The REST request object.
1044 *
1045 * @return bool|WP_Error True if the request has permission, WP_Error object otherwise.
1046 */
1047 public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1048 if ( current_user_can( 'edit_posts' ) ) {
1049 return true;
1050 }
1051
1052 foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type ) {
1053 if ( current_user_can( $post_type->cap->edit_posts ) ) {
1054 return true;
1055 }
1056 }
1057
1058 return new WP_Error(
1059 'rest_cannot_read_block_editor_assets',
1060 __( 'Sorry, you are not allowed to read the block editor assets.', 'jetpack' ),
1061 array( 'status' => rest_authorization_required_code() )
1062 );
1063 }
1064
1065 /**
1066 * Retrieves the block editor assets schema, conforming to JSON Schema.
1067 *
1068 * @return array Item schema data.
1069 */
1070 public function get_item_schema() {
1071 if ( $this->schema ) {
1072 return $this->add_additional_fields_schema( $this->schema );
1073 }
1074
1075 $schema = array(
1076 'type' => 'object',
1077 'properties' => array(
1078 'allowed_block_types' => array(
1079 'description' => esc_html__( 'List of allowed block types for the editor.', 'jetpack' ),
1080 'type' => 'array',
1081 'items' => array(
1082 'type' => 'string',
1083 ),
1084 ),
1085 'scripts' => array(
1086 'description' => esc_html__( 'Script tags for the block editor.', 'jetpack' ),
1087 'type' => 'string',
1088 ),
1089 'styles' => array(
1090 'description' => esc_html__( 'Style link tags for the block editor.', 'jetpack' ),
1091 'type' => 'string',
1092 ),
1093 ),
1094 );
1095
1096 $this->schema = $schema;
1097
1098 return $this->add_additional_fields_schema( $this->schema );
1099 }
1100 }
1101
1102 wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Block_Editor_Assets' );
1103