PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.5 1.1.6 1.1.7 All 30 releases
xspeed / includes / modules / Bloat / BloatModule.php

BloatModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.4, at includes/modules/Bloat/BloatModule.php

320 lines 11.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bloat — disable WordPress features site owners rarely use but every
4 * frontend pays for in bytes / requests / attack surface.
5 *
6 * Each setting is a single toggle that adds (or doesn't add) one or
7 * two filters. Per the SETTINGS.md standard, every toggle ships with a
8 * label + description that names the actual ergonomic value.
9 *
10 * Six toggles, all opt-in (default false). The defaults are
11 * conservative because every site has at least one plugin that quietly
12 * depends on the surface this module strips — better to make the user
13 * choose than to break themes on activation.
14 *
15 * Tier: Free (FEATURES.md "Others" §10-§15 — declared in commit
16 * `4e36051` before this implementation).
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed\Modules\Bloat;
24
25 defined( 'ABSPATH' ) || exit;
26
27 use XSpeed\Module;
28 use XSpeed\Settings_Manager;
29
30 final class BloatModule extends Module {
31
32 public const SLUG = 'bloat';
33 public const TIER = self::TIER_FREE;
34 public const VERSION = '1.0.0';
35
36 public function ui_metadata(): array {
37 return array(
38 'label' => __( 'Bloat Control', 'xspeed' ),
39 'icon' => 'Sliders',
40 'description' => __( 'Turn off WordPress defaults you do not use — saves bytes, requests, and attack surface.', 'xspeed' ),
41 );
42 }
43
44 public function settings_schema(): array {
45 return array(
46 'disable_dashicons_frontend' => array(
47 'type' => 'bool',
48 'default' => false,
49 'label' => __( 'Disable Dashicons on Frontend', 'xspeed' ),
50 'description' => __( 'Drop the dashicons stylesheet from non-admin pages. Most themes do not need it. Saves ~45 KB per visitor.', 'xspeed' ),
51 ),
52 'disable_oembed' => array(
53 'type' => 'bool',
54 'default' => false,
55 'label' => __( 'Disable oEmbed Discovery + wp-embed.min.js', 'xspeed' ),
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.', 'xspeed' ),
57 ),
58 'disable_rss_feeds' => array(
59 'type' => 'bool',
60 'default' => false,
61 'label' => __( 'Disable RSS Feeds', 'xspeed' ),
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.', 'xspeed' ),
63 ),
64 'disable_xmlrpc' => array(
65 'type' => 'bool',
66 'default' => false,
67 'label' => __( 'Disable XML-RPC', 'xspeed' ),
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).', 'xspeed' ),
69 ),
70 'strip_jquery_migrate' => array(
71 'type' => 'bool',
72 'default' => false,
73 'label' => __( 'Strip jQuery Migrate on Frontend', 'xspeed' ),
74 'description' => __( 'Remove the jquery-migrate compatibility shim from non-admin pages. Saves ~10 KB; safe on modern themes / plugins.', 'xspeed' ),
75 ),
76 'strip_editor_styles' => array(
77 'type' => 'bool',
78 'default' => false,
79 'label' => __( 'Strip Block-Editor Styles on Frontend', 'xspeed' ),
80 'description' => __( 'Drop editor-only stylesheets (wp-editor, wp-components, and friends) from anonymous pages. A plugin that enqueues them on the frontend usually does so by accident — they can add hundreds of KB of render-blocking CSS. Frontend block styles (wp-block-library) are never touched.', 'xspeed' ),
81 ),
82 'restrict_rest_to_authed' => array(
83 'type' => 'bool',
84 'default' => false,
85 'label' => __( 'Restrict REST API to Logged-In Users', 'xspeed' ),
86 '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.', 'xspeed' ),
87 ),
88 );
89 }
90
91 public function boot(): void {
92 /*
93 * Deferred to `init` priority 0. This reads the module's settings,
94 * which builds settings_schema(), whose labels go through __(), and
95 * boot() runs on `plugins_loaded` — before `after_setup_theme`, the
96 * earliest point WordPress 6.7+ treats as safe to translate.
97 *
98 * Priority 0 (not the default 10) because the body itself registers
99 * an `init` callback at priority 9: adding a hook to the action that
100 * is currently running only takes effect if the new priority is still
101 * ahead of the running position, so we have to be first. Every other
102 * hook it registers fires later than `init`.
103 */
104 add_action( 'init', array( $this, 'boot_on_init' ), 0 );
105 }
106
107 /**
108 * The real boot body — see boot() for why it runs on `init`.
109 */
110 public function boot_on_init(): void {
111 $opts = Settings_Manager::get( self::SLUG );
112
113 if ( ! empty( $opts['disable_dashicons_frontend'] ) ) {
114 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_dashicons' ), 100 );
115 }
116
117 if ( ! empty( $opts['disable_oembed'] ) ) {
118 add_action( 'init', array( __CLASS__, 'disable_oembed' ), 9 );
119 }
120
121 if ( ! empty( $opts['disable_rss_feeds'] ) ) {
122 add_action( 'do_feed', array( __CLASS__, 'block_feed' ), 1 );
123 add_action( 'do_feed_rdf', array( __CLASS__, 'block_feed' ), 1 );
124 add_action( 'do_feed_rss', array( __CLASS__, 'block_feed' ), 1 );
125 add_action( 'do_feed_rss2', array( __CLASS__, 'block_feed' ), 1 );
126 add_action( 'do_feed_atom', array( __CLASS__, 'block_feed' ), 1 );
127 add_action( 'do_feed_rss2_comments', array( __CLASS__, 'block_feed' ), 1 );
128 add_action( 'do_feed_atom_comments', array( __CLASS__, 'block_feed' ), 1 );
129 }
130
131 if ( ! empty( $opts['disable_xmlrpc'] ) ) {
132 add_filter( 'xmlrpc_enabled', '__return_false' );
133 add_filter( 'wp_headers', array( __CLASS__, 'strip_xmlrpc_header' ) );
134 add_filter( 'pings_open', '__return_false' );
135 }
136
137 if ( ! empty( $opts['strip_jquery_migrate'] ) ) {
138 add_action( 'wp_default_scripts', array( __CLASS__, 'strip_jquery_migrate' ) );
139 }
140
141 if ( ! empty( $opts['strip_editor_styles'] ) ) {
142 // Late, so anything enqueued at normal priority is already queued.
143 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_editor_styles' ), PHP_INT_MAX );
144 }
145
146 if ( ! empty( $opts['restrict_rest_to_authed'] ) ) {
147 add_filter( 'rest_authentication_errors', array( __CLASS__, 'restrict_rest' ) );
148 }
149 }
150
151 public static function dequeue_dashicons(): void {
152 if ( is_admin_bar_showing() || is_user_logged_in() ) {
153 return; // the admin bar uses dashicons; only strip on truly anonymous pages.
154 }
155 wp_dequeue_style( 'dashicons' );
156 wp_deregister_style( 'dashicons' );
157 }
158
159 public static function disable_oembed(): void {
160 // Strip discovery <link> from <head>.
161 remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
162 remove_action( 'wp_head', 'wp_oembed_add_host_js' );
163 // Drop the auto-embed filter (paste-a-URL-becomes-embed).
164 remove_filter( 'the_content', array( $GLOBALS['wp_embed'] ?? null, 'autoembed' ), 8 );
165 // Drop wp-embed.min.js + the rewrite rule.
166 add_action(
167 'wp_footer',
168 static function () {
169 wp_dequeue_script( 'wp-embed' );
170 },
171 1
172 );
173 add_filter(
174 'rewrite_rules_array',
175 static function ( $rules ) {
176 if ( ! is_array( $rules ) ) {
177 return $rules;
178 }
179 foreach ( $rules as $rule => $rewrite ) {
180 if ( false !== strpos( (string) $rewrite, 'embed=true' ) ) {
181 unset( $rules[ $rule ] );
182 }
183 }
184 return $rules;
185 }
186 );
187 }
188
189 /**
190 * Editor-only style handles that have no business on an anonymous
191 * frontend page. Deliberately NOT wp-block-library /
192 * wp-block-library-theme / global-styles — those style the blocks
193 * visitors actually see. Observed live: a plugin pulled wp-editor +
194 * wp-components (and their deps) onto a marketing homepage, several
195 * hundred KB of render-blocking CSS nothing on the page used.
196 */
197 private const EDITOR_STYLE_HANDLES = array(
198 'wp-editor',
199 'wp-block-editor',
200 'wp-block-directory',
201 'wp-components',
202 'wp-preferences',
203 'wp-media-utils',
204 'wp-reusable-blocks',
205 'wp-patterns',
206 'wp-edit-blocks',
207 'wp-edit-post',
208 'wp-edit-site',
209 'wp-edit-widgets',
210 'wp-format-library',
211 'wp-list-reusable-blocks',
212 'wp-nux',
213 );
214
215 public static function dequeue_editor_styles(): void {
216 // Logged-in views legitimately reach editor surfaces (front-end
217 // editing, admin bar flows), and a builder editing screen is a
218 // front-end URL — same guard set as the other frontend strips.
219 if ( is_user_logged_in() || is_admin() || \XSpeed\Builder_Editor::is_active() ) {
220 return;
221 }
222 $styles = wp_styles();
223 foreach ( self::EDITOR_STYLE_HANDLES as $handle ) {
224 wp_dequeue_style( $handle );
225 }
226 // Dequeue alone is not enough: dependencies are resolved again at
227 // print time, so any queued sheet that lists one of these as a dep
228 // pulls it straight back. Strip the handles from every registered
229 // sheet's deps too — same technique strip_jquery_migrate() uses.
230 foreach ( $styles->registered as $dependency ) {
231 if ( is_array( $dependency->deps ?? null ) && array_intersect( $dependency->deps, self::EDITOR_STYLE_HANDLES ) ) {
232 $dependency->deps = array_values( array_diff( $dependency->deps, self::EDITOR_STYLE_HANDLES ) );
233 }
234 }
235 }
236
237 public static function block_feed(): void {
238 wp_die(
239 esc_html__( 'Feeds are disabled.', 'xspeed' ),
240 '',
241 array( 'response' => 404 )
242 );
243 }
244
245 /**
246 * @param array $headers
247 * @return array
248 */
249 public static function strip_xmlrpc_header( $headers ) {
250 if ( is_array( $headers ) ) {
251 unset( $headers['X-Pingback'] );
252 }
253 return $headers;
254 }
255
256 /**
257 * @param \WP_Scripts $scripts
258 */
259 public static function strip_jquery_migrate( $scripts ): void {
260 // Builders and their add-ons still rely on jQuery Migrate shims; a
261 // builder editing screen is a front-end URL, so is_admin() misses it
262 // and the editor loses methods it calls. (#281)
263 if ( is_admin() || \XSpeed\Builder_Editor::is_active() || ! isset( $scripts->registered['jquery'] ) ) {
264 return;
265 }
266 $jquery = $scripts->registered['jquery'];
267 if ( is_array( $jquery->deps ?? null ) ) {
268 $jquery->deps = array_values( array_diff( $jquery->deps, array( 'jquery-migrate' ) ) );
269 }
270 }
271
272 /**
273 * Block anonymous /wp-json/ access. Logged-in users + already-errored
274 * requests pass through untouched.
275 *
276 * @param \WP_Error|null|true $result
277 * @return \WP_Error|null|true
278 */
279 public static function restrict_rest( $result ) {
280 if ( ! empty( $result ) ) {
281 return $result; // upstream auth already decided.
282 }
283 if ( is_user_logged_in() ) {
284 return $result;
285 }
286 return new \WP_Error(
287 'rest_forbidden_anonymous',
288 __( 'Anonymous REST access is disabled on this site.', 'xspeed' ),
289 array( 'status' => 401 )
290 );
291 }
292
293 public function cli_commands(): array {
294 return array(
295 array(
296 'name' => 'xspeed bloat',
297 'callback' => array( $this, 'cli_handler' ),
298 'shortdesc' => 'Show which bloat-removal toggles are active.',
299 '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.',
300 'synopsis' => array(),
301 ),
302 );
303 }
304
305 public function cli_handler( array $args, array $assoc ): void {
306 $opts = Settings_Manager::get( self::SLUG );
307 foreach ( $opts as $key => $value ) {
308 \WP_CLI::log( sprintf( '%-30s %s', $key, $value ? 'on' : 'off' ) );
309 }
310 }
311
312 /**
313 * Bloat has no master switch -- it is on when any of its disable_* /
314 * strip_* / restrict_* flags is set. (#363)
315 */
316 public function is_active(): ?bool {
317 return $this->any_bool_flag_on();
318 }
319 }
320