PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Bloat/BloatModule.php +268 -16 1.1.3 → 1.3.7 View file →
@@ -6,9 +6,9 @@
6 6 * Each setting is a single toggle that adds (or doesn't add) one or
7 7 * two filters. Per the SETTINGS.md standard, every toggle ships with a
8 8 * label + description that names the actual ergonomic value.
9 9 *
10 - * Six toggles, all opt-in (default false). The defaults are
10 + * Every toggle is opt-in (default false). The defaults are
11 11 * conservative because every site has at least one plugin that quietly
12 12 * depends on the surface this module strips — better to make the user
13 13 * choose than to break themes on activation.
14 14 *
@@ -34,58 +34,125 @@
34 34 public const VERSION = '1.0.0';
35 35
36 36 public function ui_metadata(): array {
37 37 return array(
38 - 'label' => 'Bloat Control',
38 + 'label' => __( 'Bloat Control', 'xspeed' ),
39 39 'icon' => 'Sliders',
40 - 'description' => 'Turn off WordPress defaults you do not use — saves bytes, requests, and attack surface.',
40 + 'description' => __( 'Turns off WordPress features you do not use, so pages load less.', 'xspeed' ),
41 + 'group' => 'performance',
41 42 );
42 43 }
43 44
44 45 public function settings_schema(): array {
45 46 return array(
47 + 'disable_emojis' => array(
48 + 'type' => 'bool',
49 + 'default' => false,
50 + 'label' => __( 'Disable emojis', 'xspeed' ),
51 + 'description' => __( 'Remove the emoji detection script and its inline styles from every page. Modern browsers draw emojis natively, so visitors still see them. Saves a script and an inline stylesheet per page.', 'xspeed' ),
52 + ),
46 53 'disable_dashicons_frontend' => array(
47 54 'type' => 'bool',
48 55 'default' => false,
49 - 'label' => 'Disable Dashicons on Frontend',
50 - 'description' => 'Drop the dashicons stylesheet from non-admin pages. Most themes do not need it. Saves ~45 KB per visitor.',
56 + 'label' => __( 'Remove Dashicons for visitors', 'xspeed' ),
57 + 'description' => __( 'Removes the WordPress admin icon font for logged-out visitors. Most themes do not use it, and it saves about 45 KB.', 'xspeed' ),
51 58 ),
52 59 'disable_oembed' => array(
53 60 'type' => 'bool',
54 61 'default' => false,
55 - 'label' => 'Disable oEmbed Discovery + wp-embed.min.js',
56 - 'description' => 'Strip the auto-embed handlers + the embed script. Posts that paste a YouTube URL will no longer auto-render the player — embed it via a block instead. Saves a request per page.',
62 + 'label' => __( 'Disable auto-embeds', 'xspeed' ),
63 + 'description' => __( 'Removes the embed script, saving one request per page. A pasted YouTube link no longer turns into a player, so use an embed block.', 'xspeed' ),
57 64 ),
58 65 'disable_rss_feeds' => array(
59 66 'type' => 'bool',
60 67 'default' => false,
61 - 'label' => 'Disable RSS Feeds',
62 - 'description' => 'Return a 404 on /feed/ and similar endpoints. Useful for sites that do not publish feeds and want to cut feed-fetcher traffic.',
68 + 'label' => __( 'Disable RSS feeds', 'xspeed' ),
69 + 'description' => __( 'Feed addresses such as /feed/ return "not found". Use this if your site has no feed readers.', 'xspeed' ),
63 70 ),
64 71 'disable_xmlrpc' => array(
65 72 'type' => 'bool',
66 73 'default' => false,
67 - 'label' => 'Disable XML-RPC',
68 - 'description' => 'Disable the legacy xmlrpc.php endpoint. Cuts pingback brute-force noise; safe to disable unless you use a remote WP client (Jetpack, WordPress mobile app).',
74 + 'label' => __( 'Disable XML-RPC', 'xspeed' ),
75 + 'description' => __( 'Turns off the old xmlrpc.php file that attackers often target. Leave this off if you use Jetpack or the WordPress mobile app.', 'xspeed' ),
69 76 ),
70 77 'strip_jquery_migrate' => array(
71 78 'type' => 'bool',
72 79 'default' => false,
73 - 'label' => 'Strip jQuery Migrate on Frontend',
74 - 'description' => 'Remove the jquery-migrate compatibility shim from non-admin pages. Saves ~10 KB; safe on modern themes / plugins.',
80 + 'label' => __( 'Remove jQuery Migrate', 'xspeed' ),
81 + 'description' => __( 'Removes a script that old themes and plugins need, from pages visitors see. Saves about 10 KB and is safe on current themes.', 'xspeed' ),
75 82 ),
83 + 'strip_editor_styles' => array(
84 + 'type' => 'bool',
85 + 'default' => false,
86 + 'label' => __( 'Remove editor styles for visitors', 'xspeed' ),
87 + 'description' => __( 'Removes block editor CSS that some plugins load on public pages by mistake, which can add hundreds of KB. Block styles for visitors stay.', 'xspeed' ),
88 + ),
89 + 'remove_rsd_link' => array(
90 + 'type' => 'bool',
91 + 'default' => false,
92 + 'label' => __( 'Remove RSD link', 'xspeed' ),
93 + 'description' => __( 'Drop the Really Simple Discovery link from the page head. Only old desktop blogging clients read it.', 'xspeed' ),
94 + ),
95 + 'remove_shortlink' => array(
96 + 'type' => 'bool',
97 + 'default' => false,
98 + 'label' => __( 'Remove shortlink', 'xspeed' ),
99 + 'description' => __( 'Drop the ?p=123 shortlink tag and header from posts and pages. The shortlinks keep working; they are just no longer advertised.', 'xspeed' ),
100 + ),
101 + 'remove_rest_api_links' => array(
102 + 'type' => 'bool',
103 + 'default' => false,
104 + 'label' => __( 'Remove REST API links', 'xspeed' ),
105 + 'description' => __( 'Drop the /wp-json/ discovery link tag and Link header. The REST API itself stays on; to block it, use the setting below.', 'xspeed' ),
106 + ),
107 + 'hide_wp_version' => array(
108 + 'type' => 'bool',
109 + 'default' => false,
110 + 'label' => __( 'Hide WordPress version', 'xspeed' ),
111 + 'description' => __( 'Remove the WordPress generator tag from pages and feeds, so it no longer states the WordPress version. Other plugins print their own tags; those stay. Script and style URLs still carry ?ver= numbers.', 'xspeed' ),
112 + ),
113 + 'disable_self_pingbacks' => array(
114 + 'type' => 'bool',
115 + 'default' => false,
116 + 'label' => __( 'Disable self-pingbacks', 'xspeed' ),
117 + 'description' => __( 'Stop WordPress from sending a pingback to your own site when a post links to another of your posts. Pingbacks to other sites are not affected.', 'xspeed' ),
118 + ),
76 119 'restrict_rest_to_authed' => array(
77 120 'type' => 'bool',
78 121 'default' => false,
79 - 'label' => 'Restrict REST API to Logged-In Users',
80 - 'description' => 'Block /wp-json/ for anonymous requests. WooCommerce checkout, contact-form submissions, and many block-editor previews need anonymous REST — keep this off unless you know your site does not depend on it.',
122 + 'label' => __( 'REST API for logged-in users only', 'xspeed' ),
123 + 'description' => __( 'Blocks /wp-json/ for logged-out visitors. This breaks WooCommerce checkout and many contact forms, so keep it off unless you are sure.', 'xspeed' ),
124 + 'advanced' => true,
81 125 ),
82 126 );
83 127 }
84 128
85 129 public function boot(): void {
130 + /*
131 + * Deferred to `init` priority 0. This reads the module's settings,
132 + * which builds settings_schema(), whose labels go through __(), and
133 + * boot() runs on `plugins_loaded` — before `after_setup_theme`, the
134 + * earliest point WordPress 6.7+ treats as safe to translate.
135 + *
136 + * Priority 0 (not the default 10) because the body itself registers
137 + * an `init` callback at priority 9: adding a hook to the action that
138 + * is currently running only takes effect if the new priority is still
139 + * ahead of the running position, so we have to be first. Every other
140 + * hook it registers fires later than `init`.
141 + */
142 + add_action( 'init', array( $this, 'boot_on_init' ), 0 );
143 + }
144 +
145 + /**
146 + * The real boot body — see boot() for why it runs on `init`.
147 + */
148 + public function boot_on_init(): void {
86 149 $opts = Settings_Manager::get( self::SLUG );
87 150
151 + if ( ! empty( $opts['disable_emojis'] ) ) {
152 + self::disable_emojis();
153 + }
154 +
88 155 if ( ! empty( $opts['disable_dashicons_frontend'] ) ) {
89 156 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_dashicons' ), 100 );
90 157 }
91 158
@@ -110,10 +177,50 @@
110 177 }
111 178
112 179 if ( ! empty( $opts['strip_jquery_migrate'] ) ) {
113 180 add_action( 'wp_default_scripts', array( __CLASS__, 'strip_jquery_migrate' ) );
181 + /*
182 + * `wp_default_scripts` fires once, when something first builds the
183 + * script registry. A plugin that registers a script while it loads
184 + * (Elementor Pro's Forms module registers its reCAPTCHA script) does
185 + * that before this runs on `init`, so the hook above never fires and
186 + * Migrate stays. Strip it from the registry that already exists,
187 + * before the page enqueues anything. (#587)
188 + */
189 + if ( did_action( 'wp_default_scripts' ) ) {
190 + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'strip_jquery_migrate_now' ), 0 );
191 + }
114 192 }
115 193
194 + if ( ! empty( $opts['strip_editor_styles'] ) ) {
195 + // Late, so anything enqueued at normal priority is already queued.
196 + add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_editor_styles' ), PHP_INT_MAX );
197 + }
198 +
199 + if ( ! empty( $opts['remove_rsd_link'] ) ) {
200 + remove_action( 'wp_head', 'rsd_link' );
201 + }
202 +
203 + if ( ! empty( $opts['remove_shortlink'] ) ) {
204 + remove_action( 'wp_head', 'wp_shortlink_wp_head' );
205 + remove_action( 'template_redirect', 'wp_shortlink_header', 11 );
206 + }
207 +
208 + if ( ! empty( $opts['remove_rest_api_links'] ) ) {
209 + remove_action( 'wp_head', 'rest_output_link_wp_head' );
210 + remove_action( 'template_redirect', 'rest_output_link_header', 11 );
211 + remove_action( 'xmlrpc_rsd_apis', 'rest_output_rsd' );
212 + }
213 +
214 + if ( ! empty( $opts['hide_wp_version'] ) ) {
215 + remove_action( 'wp_head', 'wp_generator' );
216 + add_filter( 'the_generator', '__return_empty_string' );
217 + }
218 +
219 + if ( ! empty( $opts['disable_self_pingbacks'] ) ) {
220 + add_action( 'pre_ping', array( __CLASS__, 'strip_self_pings' ) );
221 + }
222 +
116 223 if ( ! empty( $opts['restrict_rest_to_authed'] ) ) {
117 224 add_filter( 'rest_authentication_errors', array( __CLASS__, 'restrict_rest' ) );
118 225 }
119 226 }
@@ -125,8 +232,42 @@
125 232 wp_dequeue_style( 'dashicons' );
126 233 wp_deregister_style( 'dashicons' );
127 234 }
128 235
236 + /**
237 + * The front-end hooks live in default-filters.php, which loads before
238 + * `init`, so removing them here works. The admin ones are added by
239 + * wp-admin/includes/admin-filters.php, which loads after `init`; they
240 + * are removed on `admin_init` instead. wp_enqueue_emoji_styles is the
241 + * WP 6.4+ path; print_emoji_styles is kept for older cores.
242 + */
243 + public static function disable_emojis(): void {
244 + remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
245 + remove_action( 'embed_head', 'print_emoji_detection_script' );
246 + remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );
247 + remove_action( 'enqueue_embed_scripts', 'wp_enqueue_emoji_styles' );
248 + remove_action( 'wp_print_styles', 'print_emoji_styles' );
249 + remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
250 + remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
251 + remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
252 + add_filter( 'tiny_mce_plugins', array( __CLASS__, 'strip_tinymce_emoji' ) );
253 + add_action( 'admin_init', array( __CLASS__, 'disable_admin_emojis' ) );
254 + }
255 +
256 + public static function disable_admin_emojis(): void {
257 + remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
258 + remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );
259 + remove_action( 'admin_print_styles', 'print_emoji_styles' );
260 + }
261 +
262 + /**
263 + * @param mixed $plugins
264 + * @return mixed
265 + */
266 + public static function strip_tinymce_emoji( $plugins ) {
267 + return is_array( $plugins ) ? array_values( array_diff( $plugins, array( 'wpemoji' ) ) ) : $plugins;
268 + }
269 +
129 270 public static function disable_oembed(): void {
130 271 // Strip discovery <link> from <head>.
131 272 remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
132 273 remove_action( 'wp_head', 'wp_oembed_add_host_js' );
@@ -155,8 +296,99 @@
155 296 }
156 297 );
157 298 }
158 299
300 + /**
301 + * Editor-only style handles that have no business on an anonymous
302 + * frontend page. Deliberately NOT wp-block-library /
303 + * wp-block-library-theme / global-styles — those style the blocks
304 + * visitors actually see. Observed live: a plugin pulled wp-editor +
305 + * wp-components (and their deps) onto a marketing homepage, several
306 + * hundred KB of render-blocking CSS nothing on the page used.
307 + */
308 + private const EDITOR_STYLE_HANDLES = array(
309 + 'wp-editor',
310 + 'wp-block-editor',
311 + 'wp-block-directory',
312 + 'wp-components',
313 + 'wp-preferences',
314 + 'wp-media-utils',
315 + 'wp-reusable-blocks',
316 + 'wp-patterns',
317 + 'wp-edit-blocks',
318 + 'wp-edit-post',
319 + 'wp-edit-site',
320 + 'wp-edit-widgets',
321 + 'wp-format-library',
322 + 'wp-list-reusable-blocks',
323 + 'wp-nux',
324 + );
325 +
326 + public static function dequeue_editor_styles(): void {
327 + // Logged-in views legitimately reach editor surfaces (front-end
328 + // editing, admin bar flows), and a builder editing screen is a
329 + // front-end URL — same guard set as the other frontend strips.
330 + if ( is_user_logged_in() || is_admin() || \XSpeed\Builder_Editor::is_active() ) {
331 + return;
332 + }
333 + $styles = wp_styles();
334 + foreach ( self::EDITOR_STYLE_HANDLES as $handle ) {
335 + wp_dequeue_style( $handle );
336 + }
337 + // Dequeue alone is not enough: dependencies are resolved again at
338 + // print time, so any queued sheet that lists one of these as a dep
339 + // pulls it straight back. Strip the handles from every registered
340 + // sheet's deps too — same technique strip_jquery_migrate() uses.
341 + foreach ( $styles->registered as $dependency ) {
342 + if ( is_array( $dependency->deps ?? null ) && array_intersect( $dependency->deps, self::EDITOR_STYLE_HANDLES ) ) {
343 + $dependency->deps = array_values( array_diff( $dependency->deps, self::EDITOR_STYLE_HANDLES ) );
344 + }
345 + }
346 + }
347 +
348 + /**
349 + * `pre_ping` passes the link list by reference.
350 + *
351 + * @param array $links
352 + */
353 + public static function strip_self_pings( &$links ): void {
354 + if ( ! is_array( $links ) ) {
355 + return;
356 + }
357 + $links = array_values(
358 + array_filter(
359 + $links,
360 + static function ( $link ): bool {
361 + return ! self::is_own_url( (string) $link );
362 + }
363 + )
364 + );
365 + }
366 +
367 + /**
368 + * Same host (any scheme, any case, with or without www.) and a path
369 + * inside the home path. A plain prefix check missed http:// links on
370 + * an https site, which migrated sites still carry, and matched
371 + * example.test.evil.test as home.
372 + */
373 + public static function is_own_url( string $url ): bool {
374 + $home_parts = wp_parse_url( (string) home_url() );
375 + $parts = wp_parse_url( $url );
376 + if ( ! is_array( $home_parts ) || ! is_array( $parts ) || empty( $parts['host'] ) || empty( $home_parts['host'] ) ) {
377 + return false;
378 + }
379 + $strip = static function ( string $host ): string {
380 + $host = strtolower( $host );
381 + return 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : $host;
382 + };
383 + if ( $strip( $parts['host'] ) !== $strip( $home_parts['host'] ) ) {
384 + return false;
385 + }
386 + $home_path = rtrim( (string) ( $home_parts['path'] ?? '' ), '/' );
387 + $path = (string) ( $parts['path'] ?? '' );
388 + return '' === $home_path || $path === $home_path || 0 === strpos( $path, $home_path . '/' );
389 + }
390 +
159 391 public static function block_feed(): void {
160 392 wp_die(
161 393 esc_html__( 'Feeds are disabled.', 'xspeed' ),
162 394 '',
@@ -178,9 +410,12 @@
178 410 /**
179 411 * @param \WP_Scripts $scripts
180 412 */
181 413 public static function strip_jquery_migrate( $scripts ): void {
182 - if ( is_admin() || ! isset( $scripts->registered['jquery'] ) ) {
414 + // Builders and their add-ons still rely on jQuery Migrate shims; a
415 + // builder editing screen is a front-end URL, so is_admin() misses it
416 + // and the editor loses methods it calls. (#281)
417 + if ( is_admin() || \XSpeed\Builder_Editor::is_active() || ! isset( $scripts->registered['jquery'] ) ) {
183 418 return;
184 419 }
185 420 $jquery = $scripts->registered['jquery'];
186 421 if ( is_array( $jquery->deps ?? null ) ) {
@@ -188,8 +423,16 @@
188 423 }
189 424 }
190 425
191 426 /**
427 + * strip_jquery_migrate() on the registry that exists now, for a request
428 + * where `wp_default_scripts` fired before this module hooked it. (#587)
429 + */
430 + public static function strip_jquery_migrate_now(): void {
431 + self::strip_jquery_migrate( wp_scripts() );
432 + }
433 +
434 + /**
192 435 * Block anonymous /wp-json/ access. Logged-in users + already-errored
193 436 * requests pass through untouched.
194 437 *
195 438 * @param \WP_Error|null|true $result
@@ -214,8 +457,9 @@
214 457 array(
215 458 'name' => 'xspeed bloat',
216 459 'callback' => array( $this, 'cli_handler' ),
217 460 'shortdesc' => 'Show which bloat-removal toggles are active.',
461 + 'ai_hint' => 'What unnecessary WordPress output is being stripped (emojis, embeds, jQuery Migrate, dashicons)? Use when asked why extra scripts still load on the frontend, or before recommending bloat removal.',
218 462 'synopsis' => array(),
219 463 ),
220 464 );
221 465 }
@@ -224,6 +468,14 @@
224 468 $opts = Settings_Manager::get( self::SLUG );
225 469 foreach ( $opts as $key => $value ) {
226 470 \WP_CLI::log( sprintf( '%-30s %s', $key, $value ? 'on' : 'off' ) );
227 471 }
472 + }
473 +
474 + /**
475 + * Bloat has no master switch -- it is on when any of its boolean
476 + * flags is set. (#363)
477 + */
478 + public function is_active(): ?bool {
479 + return $this->any_bool_flag_on();
228 480 }
229 481 }