| @@ -29,8 +29,9 @@ | ||
| 29 | 29 | use XSpeed\Server; |
| 30 | 30 | use XSpeed\Admin; |
| 31 | 31 | use XSpeed\Settings; |
| 32 | 32 | use XSpeed\Settings_Manager; |
| 33 | +use XSpeed\Module_Registry; | |
| 33 | 34 | use XSpeed\Pro_Audit; |
| 34 | 35 | use XSpeed\Cache_Benchmark; |
| 35 | 36 | use XSpeed\Tier_Registry; |
| 36 | 37 | use XSpeed\Database_Cleaner; |
| @@ -37,11 +38,16 @@ | ||
| 37 | 38 | |
| 38 | 39 | defined( 'ABSPATH' ) || exit; |
| 39 | 40 | |
| 40 | 41 | final class Mcp_Tools { |
| 42 | + /** Pro extension contract supported by this Free build. */ | |
| 43 | + public const EXTENSION_API = 1; | |
| 41 | 44 | |
| 45 | + /** Raw broker tool envelope cap, enforced before JSON decoding. */ | |
| 46 | + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024; | |
| 47 | + | |
| 42 | 48 | /** Valid cache purge types. */ |
| 43 | - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' ); | |
| 49 | + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' ); | |
| 44 | 50 | |
| 45 | 51 | /** |
| 46 | 52 | * Per-call read-only override. Null means "defer to the pairing token's |
| 47 | 53 | * scope" (the JSON-RPC path that predates OAuth). true/false is set by |
| @@ -144,9 +150,9 @@ | ||
| 144 | 150 | 'write' => false, |
| 145 | 151 | 'handler' => array( self::class, 'get_site_info' ), |
| 146 | 152 | ), |
| 147 | 153 | 'optimize_site' => array( |
| 148 | - 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s last recorded score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan.', | |
| 154 | + 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.', | |
| 149 | 155 | 'inputSchema' => self::object_schema( |
| 150 | 156 | array( |
| 151 | 157 | 'aggressiveness' => array( |
| 152 | 158 | 'type' => 'string', |
| @@ -156,8 +162,24 @@ | ||
| 156 | 162 | 'dry_run' => array( |
| 157 | 163 | 'type' => 'boolean', |
| 158 | 164 | 'description' => 'Return the plan without changing anything.', |
| 159 | 165 | ), |
| 166 | + 'measure_score' => array( | |
| 167 | + 'type' => 'string', | |
| 168 | + 'enum' => array( 'auto', 'never', 'always' ), | |
| 169 | + 'description' => 'Whether to take a fresh PageSpeed measurement. auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.', | |
| 170 | + ), | |
| 171 | + 'target_score' => array( | |
| 172 | + 'type' => 'integer', | |
| 173 | + 'minimum' => 1, | |
| 174 | + 'maximum' => 100, | |
| 175 | + 'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.', | |
| 176 | + ), | |
| 177 | + 'max_rounds' => array( | |
| 178 | + 'type' => 'integer', | |
| 179 | + 'minimum' => 1, | |
| 180 | + 'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.', | |
| 181 | + ), | |
| 160 | 182 | ), |
| 161 | 183 | array() |
| 162 | 184 | ), |
| 163 | 185 | 'write' => true, |
| @@ -175,9 +197,9 @@ | ||
| 175 | 197 | 'write' => false, |
| 176 | 198 | 'handler' => array( self::class, 'get_pro_audit' ), |
| 177 | 199 | ), |
| 178 | 200 | 'purge_cache' => array( |
| 179 | - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.', | |
| 201 | + 'description' => 'Purge the site cache and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. Defaults to all.', | |
| 180 | 202 | 'inputSchema' => self::object_schema( |
| 181 | 203 | array( |
| 182 | 204 | 'type' => array( |
| 183 | 205 | 'type' => 'string', |
| @@ -279,9 +301,9 @@ | ||
| 279 | 301 | 'write' => true, |
| 280 | 302 | 'handler' => array( self::class, 'start_preloader' ), |
| 281 | 303 | ), |
| 282 | 304 | 'run_score' => array( |
| 283 | - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site and return the score plus Core Web Vitals. Available on every install. Spends the site\'s own configured API quota. Use get_score_history to read past runs without starting a new one.', | |
| 305 | + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.', | |
| 284 | 306 | 'inputSchema' => self::object_schema( |
| 285 | 307 | array( |
| 286 | 308 | 'target' => array( |
| 287 | 309 | 'type' => 'string', |
| @@ -317,9 +339,9 @@ | ||
| 317 | 339 | 'write' => true, |
| 318 | 340 | 'handler' => array( self::class, 'run_score' ), |
| 319 | 341 | ), |
| 320 | 342 | 'run_pagespeed' => array( |
| 321 | - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.', | |
| 343 | + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.', | |
| 322 | 344 | 'inputSchema' => self::object_schema( |
| 323 | 345 | array( |
| 324 | 346 | 'url' => array( |
| 325 | 347 | 'type' => 'string', |
| @@ -351,10 +373,18 @@ | ||
| 351 | 373 | 'write' => true, |
| 352 | 374 | 'handler' => array( self::class, 'run_pagespeed' ), |
| 353 | 375 | ), |
| 354 | 376 | 'generate_critical_css' => array( |
| 355 | - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.', | |
| 356 | - 'inputSchema' => self::object_schema( array(), array() ), | |
| 377 | + 'description' => 'Generate above-the-fold Critical CSS for one page (Pro). Calls the external generator and stores the result for that page\'s template. Defaults to the site home page; pass url to build it for another page or template.', | |
| 378 | + 'inputSchema' => self::object_schema( | |
| 379 | + array( | |
| 380 | + 'url' => array( | |
| 381 | + 'type' => 'string', | |
| 382 | + 'description' => 'Page on this site to generate Critical CSS for, as a full URL or a path such as /pricing/. Defaults to the site home page.', | |
| 383 | + ), | |
| 384 | + ), | |
| 385 | + array() | |
| 386 | + ), | |
| 357 | 387 | 'write' => true, |
| 358 | 388 | 'handler' => array( self::class, 'generate_critical_css' ), |
| 359 | 389 | ), |
| 360 | 390 | 'get_health' => array( |
| @@ -619,8 +649,9 @@ | ||
| 619 | 649 | 'update_settings' => 'xspeed settings', |
| 620 | 650 | 'run_pagespeed' => 'xspeed psi', |
| 621 | 651 | 'get_health' => 'xspeed health', |
| 622 | 652 | 'get_score_history' => 'xspeed score', |
| 653 | + 'purge_cache' => 'xspeed purge', | |
| 623 | 654 | ); |
| 624 | 655 | |
| 625 | 656 | $commands = Cli_Bridge::commands(); |
| 626 | 657 | foreach ( $conditional as $tool => $command ) { |
| @@ -652,8 +683,49 @@ | ||
| 652 | 683 | $catalog[ $name ] = $spec; |
| 653 | 684 | } |
| 654 | 685 | } |
| 655 | 686 | |
| 687 | + /** | |
| 688 | + * Add private tools owned by an installed extension. | |
| 689 | + * | |
| 690 | + * Extensions receive an EMPTY map and may only add new names. Core tool | |
| 691 | + * definitions cannot be replaced through this seam. A `hidden` tool is | |
| 692 | + * callable through the authenticated per-site proxy but omitted from | |
| 693 | + * tools/list. This is for broker-run workflows whose public tool must not | |
| 694 | + * be advertised until the broker-side worker exists. | |
| 695 | + * | |
| 696 | + * `hidden` is not a permission: the proxy takes the same pairing token | |
| 697 | + * the public channel does. It withholds a tool from discovery and from | |
| 698 | + * an OAuth grant, nothing more. See the gate in invoke(). | |
| 699 | + * | |
| 700 | + * `hidden` must be a real bool when present. It decides whether a tool | |
| 701 | + * is reachable from the public channel at all, so a truthy string is | |
| 702 | + * the kind of near-miss that should be rejected rather than guessed at. | |
| 703 | + * Specs that fail any check here are skipped, not repaired. | |
| 704 | + * | |
| 705 | + * @param array<string,array<string,mixed>> $tools Extension tool specs. | |
| 706 | + */ | |
| 707 | + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() ); | |
| 708 | + if ( is_array( $extensions ) ) { | |
| 709 | + foreach ( $extensions as $name => $spec ) { | |
| 710 | + if ( | |
| 711 | + ! is_string( $name ) | |
| 712 | + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name ) | |
| 713 | + || isset( $catalog[ $name ] ) | |
| 714 | + || ! is_array( $spec ) | |
| 715 | + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] ) | |
| 716 | + || ! is_string( $spec['description'] ) | |
| 717 | + || ! is_array( $spec['inputSchema'] ) | |
| 718 | + || ! is_callable( $spec['handler'] ) | |
| 719 | + || ! is_bool( $spec['write'] ) | |
| 720 | + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) ) | |
| 721 | + ) { | |
| 722 | + continue; | |
| 723 | + } | |
| 724 | + $catalog[ $name ] = $spec; | |
| 725 | + } | |
| 726 | + } | |
| 727 | + | |
| 656 | 728 | return $catalog; |
| 657 | 729 | } |
| 658 | 730 | |
| 659 | 731 | /** |
| @@ -886,8 +958,11 @@ | ||
| 886 | 958 | */ |
| 887 | 959 | public static function list(): array { |
| 888 | 960 | $out = array(); |
| 889 | 961 | foreach ( self::catalog() as $name => $spec ) { |
| 962 | + if ( ! empty( $spec['hidden'] ) ) { | |
| 963 | + continue; | |
| 964 | + } | |
| 890 | 965 | $out[] = array( |
| 891 | 966 | 'name' => $name, |
| 892 | 967 | 'description' => $spec['description'], |
| 893 | 968 | 'inputSchema' => $spec['inputSchema'], |
| @@ -926,8 +1001,39 @@ | ||
| 926 | 1001 | |
| 927 | 1002 | return $error; |
| 928 | 1003 | } |
| 929 | 1004 | |
| 1005 | + // Hidden tools are private broker stages, not undiscoverable public MCP | |
| 1006 | + // tools. Omitting one from tools/list is only presentation, so this | |
| 1007 | + // closes the JSON-RPC channel to them as well, returning the same 404 a | |
| 1008 | + // nonexistent name returns. Both entry paths set the channel before | |
| 1009 | + // every invoke, so a previous broker call cannot widen a later one. | |
| 1010 | + // | |
| 1011 | + // What this is NOT: a permission boundary. The broker REST route | |
| 1012 | + // authenticates with the SAME pairing token as the JSON-RPC route | |
| 1013 | + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against | |
| 1014 | + // Mcp_Pairing::site_token()), so anyone holding that token — every | |
| 1015 | + // client the dashboard's connection recipes are written for — can call | |
| 1016 | + // a hidden tool by name on the broker route, and tell it from a | |
| 1017 | + // nonexistent one by the status. `hidden` keeps a tool off the | |
| 1018 | + // advertised surface and out of an OAuth grant's reach; it does not | |
| 1019 | + // make it safe for a pairing-token holder to run. Anything gated only | |
| 1020 | + // by `hidden` must be something that holder may already do. | |
| 1021 | + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) { | |
| 1022 | + $error = new \WP_Error( | |
| 1023 | + 'xspeed_mcp_unknown_tool', | |
| 1024 | + sprintf( | |
| 1025 | + /* translators: %s: tool name. */ | |
| 1026 | + __( 'Unknown tool: %s', 'xspeed' ), | |
| 1027 | + $name | |
| 1028 | + ), | |
| 1029 | + array( 'status' => 404 ) | |
| 1030 | + ); | |
| 1031 | + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel ); | |
| 1032 | + | |
| 1033 | + return $error; | |
| 1034 | + } | |
| 1035 | + | |
| 930 | 1036 | // Scope enforcement: a read-only connection cannot invoke a tool that |
| 931 | 1037 | // mutates state. run_command is a gateway to the full CLI surface, so |
| 932 | 1038 | // it's treated as write regardless of the wrapped command. The active |
| 933 | 1039 | // credential's scope (pairing token OR OAuth access token) is carried |
| @@ -1048,8 +1154,13 @@ | ||
| 1048 | 1154 | |
| 1049 | 1155 | /** |
| 1050 | 1156 | * Cache status, stats, and detected server. |
| 1051 | 1157 | * |
| 1158 | + * `site_icon` is the Site Icon set under Appearance, or '' when none is | |
| 1159 | + * set. The Hub shows it beside the site's name. Asking the site for | |
| 1160 | + * /favicon.ico instead failed on nginx hosts, which answer .ico | |
| 1161 | + * requests as static files and never reach WordPress. | |
| 1162 | + * | |
| 1052 | 1163 | * @param array $args Unused. |
| 1053 | 1164 | * @return array |
| 1054 | 1165 | */ |
| 1055 | 1166 | public static function get_cache_status( array $args ) { |
| @@ -1058,8 +1169,9 @@ | ||
| 1058 | 1169 | return array( |
| 1059 | 1170 | 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ), |
| 1060 | 1171 | 'stats' => Cache::get_stats(), |
| 1061 | 1172 | 'server' => Server::type(), |
| 1173 | + 'site_icon' => (string) get_site_icon_url( 64 ), | |
| 1062 | 1174 | ); |
| 1063 | 1175 | } |
| 1064 | 1176 | |
| 1065 | 1177 | /** |
| @@ -1157,14 +1269,29 @@ | ||
| 1157 | 1269 | * @param array<string,mixed> $args Tool arguments. |
| 1158 | 1270 | * @return array<string,mixed>|\WP_Error |
| 1159 | 1271 | */ |
| 1160 | 1272 | public static function optimize_site( array $args = array() ) { |
| 1161 | - return \XSpeed\Optimize_Runner::run( | |
| 1162 | - array( | |
| 1163 | - 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ), | |
| 1164 | - 'dry_run' => (bool) ( $args['dry_run'] ?? false ), | |
| 1165 | - ) | |
| 1273 | + $run = array( | |
| 1274 | + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ), | |
| 1275 | + 'dry_run' => (bool) ( $args['dry_run'] ?? false ), | |
| 1276 | + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ), | |
| 1166 | 1277 | ); |
| 1278 | + | |
| 1279 | + // Tuning arguments are forwarded ONLY when present, and are not | |
| 1280 | + // understood by Free — a listener on `xspeed_optimize_report` reads | |
| 1281 | + // them from that filter's `$context`. Passing them through rather | |
| 1282 | + // than naming them in the array above is deliberate: this handler | |
| 1283 | + // builds an explicit whitelist, so an argument it does not list is | |
| 1284 | + // silently dropped. A caller asking to reach a score would have got a | |
| 1285 | + // single pass and a success response — wrong behaviour with no error, | |
| 1286 | + // which is the expensive kind to diagnose. | |
| 1287 | + foreach ( array( 'target_score', 'max_rounds' ) as $key ) { | |
| 1288 | + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) { | |
| 1289 | + $run[ $key ] = (int) $args[ $key ]; | |
| 1290 | + } | |
| 1291 | + } | |
| 1292 | + | |
| 1293 | + return \XSpeed\Optimize_Runner::run( $run ); | |
| 1167 | 1294 | } |
| 1168 | 1295 | |
| 1169 | 1296 | /** |
| 1170 | 1297 | * Before/after cache benchmark timings. |
| @@ -1212,12 +1339,43 @@ | ||
| 1212 | 1339 | } |
| 1213 | 1340 | // Named source, not the default "manual": the purge log's whole job |
| 1214 | 1341 | // is to let an admin see that the cache cleared because an assistant |
| 1215 | 1342 | // asked, not because someone clicked. |
| 1216 | - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) ); | |
| 1343 | + $cause = __( 'AI assistant', 'xspeed' ); | |
| 1344 | + | |
| 1345 | + /* | |
| 1346 | + * `page`, `assets` and `rest` are fine-grained slices of the local | |
| 1347 | + * sweep with no target of their own, and they predate this tool's | |
| 1348 | + * per-store report — an assistant asking for `page` means the HTML, | |
| 1349 | + * not the HTML plus the minified bundles plus every purge listener. | |
| 1350 | + * They stay on purge_type() so their meaning does not change under | |
| 1351 | + * callers already relying on it. | |
| 1352 | + * | |
| 1353 | + * Everything else routes through the runner — the same core function | |
| 1354 | + * the CLI and the REST callback use — so an assistant told "cache | |
| 1355 | + * cleared" is reading the same per-store verdict a human would get, | |
| 1356 | + * including a Cloudflare zone that refused the purge. | |
| 1357 | + */ | |
| 1358 | + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) { | |
| 1359 | + return array( | |
| 1360 | + 'purged' => $type, | |
| 1361 | + 'count' => Cache::purge_type( $type, $cause ), | |
| 1362 | + 'ok' => true, | |
| 1363 | + 'stats' => Cache::get_stats(), | |
| 1364 | + ); | |
| 1365 | + } | |
| 1366 | + | |
| 1367 | + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause ); | |
| 1368 | + $count = 0; | |
| 1369 | + foreach ( $report['types'] as $row ) { | |
| 1370 | + $count += (int) $row['entries']; | |
| 1371 | + } | |
| 1372 | + | |
| 1217 | 1373 | return array( |
| 1218 | 1374 | 'purged' => $type, |
| 1219 | 1375 | 'count' => $count, |
| 1376 | + 'ok' => $report['ok'], | |
| 1377 | + 'report' => $report['types'], | |
| 1220 | 1378 | 'stats' => Cache::get_stats(), |
| 1221 | 1379 | ); |
| 1222 | 1380 | } |
| 1223 | 1381 | |
| @@ -1497,13 +1655,26 @@ | ||
| 1497 | 1655 | * @return true|\WP_Error True when every key would be applied. |
| 1498 | 1656 | */ |
| 1499 | 1657 | private static function inspect_or_error( string $module, array $values ) { |
| 1500 | 1658 | $report = Settings_Manager::inspect_input( $module, $values ); |
| 1501 | - if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) ) { | |
| 1659 | + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) { | |
| 1502 | 1660 | return true; |
| 1503 | 1661 | } |
| 1504 | 1662 | |
| 1505 | 1663 | $parts = array(); |
| 1664 | + // A field pinned by a wp-config.php constant cannot be written. Say so | |
| 1665 | + // rather than returning a success the agent relays as "changed" over a | |
| 1666 | + // write that update() would silently drop. (#398) | |
| 1667 | + foreach ( $report['locked'] as $key ) { | |
| 1668 | + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() ); | |
| 1669 | + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() ); | |
| 1670 | + $parts[] = sprintf( | |
| 1671 | + /* translators: 1: setting key, 2: wp-config.php constant name. */ | |
| 1672 | + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ), | |
| 1673 | + $key, | |
| 1674 | + (string) $constant | |
| 1675 | + ); | |
| 1676 | + } | |
| 1506 | 1677 | foreach ( $report['unknown'] as $key ) { |
| 1507 | 1678 | $detail = sprintf( |
| 1508 | 1679 | /* translators: 1: setting key, 2: module slug. */ |
| 1509 | 1680 | __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ), |
| @@ -1544,8 +1715,9 @@ | ||
| 1544 | 1715 | array( |
| 1545 | 1716 | 'status' => 400, |
| 1546 | 1717 | 'refused_unknown' => $report['unknown'], |
| 1547 | 1718 | 'refused_invalid' => $report['invalid'], |
| 1719 | + 'refused_locked' => $report['locked'], | |
| 1548 | 1720 | 'would_apply' => $report['applied'], |
| 1549 | 1721 | ) |
| 1550 | 1722 | ); |
| 1551 | 1723 | } |
| @@ -2290,14 +2462,41 @@ | ||
| 2290 | 2462 | |
| 2291 | 2463 | /** |
| 2292 | 2464 | * Generate Critical CSS (Pro). |
| 2293 | 2465 | * |
| 2294 | - * @param array $args Unused. | |
| 2466 | + * The tool took no arguments, so it could only build the home page's | |
| 2467 | + * blob, while `wp xspeed ccss generate --page-url` could target any page. | |
| 2468 | + * Passed as `page-url`: `url` is a WP-CLI global the command never sees | |
| 2469 | + * from a real command line. (#559) | |
| 2470 | + * | |
| 2471 | + * @param array $args { url?:string } Full URL or site path. | |
| 2295 | 2472 | * @return array|\WP_Error |
| 2296 | 2473 | */ |
| 2297 | 2474 | public static function generate_critical_css( array $args ) { |
| 2298 | - unset( $args ); | |
| 2299 | - return Cli_Bridge::run( 'ccss', array( 'generate' ) ); | |
| 2475 | + $options = array(); | |
| 2476 | + if ( ! empty( $args['url'] ) ) { | |
| 2477 | + $url = trim( (string) $args['url'] ); | |
| 2478 | + // A site path is a page on this site, as it is for run_pagespeed. | |
| 2479 | + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) { | |
| 2480 | + $url = home_url( $url ); | |
| 2481 | + } elseif ( ! preg_match( '#^https?://#i', $url ) ) { | |
| 2482 | + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL. | |
| 2483 | + } | |
| 2484 | + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) ); | |
| 2485 | + // Only pages of this site. A render spends the site's quota, and | |
| 2486 | + // another host is not a page this site's Critical CSS can serve. | |
| 2487 | + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); | |
| 2488 | + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) { | |
| 2489 | + $url = ''; | |
| 2490 | + } | |
| 2491 | + // Refused rather than dropped: an empty value would quietly | |
| 2492 | + // build the home page and report success for the wrong page. | |
| 2493 | + if ( '' === $url ) { | |
| 2494 | + return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) ); | |
| 2495 | + } | |
| 2496 | + $options['page-url'] = $url; | |
| 2497 | + } | |
| 2498 | + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options ); | |
| 2300 | 2499 | } |
| 2301 | 2500 | |
| 2302 | 2501 | /** |
| 2303 | 2502 | * Build a JSON Schema object node. |