← All changes
|
wp-mcp-server/tools/class-mcp-tool-post-meta.php
+134
-87
2.6.21
→
2.7.1
View file →
| @@ -81,9 +81,10 @@ | ||
| 81 | 81 | |
| 82 | 82 | // hreflang / language alternates: value must be a JSON array of |
| 83 | 83 | // {lang, url} objects (with optional region). Parse from the raw |
| 84 | 84 | // param to avoid textarea sanitization mangling the JSON, validate |
| 85 | - // shape, and re-encode to canonical form before storing. | |
| 85 | + // shape AND values (ISO language codes, absolute http(s) URLs), | |
| 86 | + // normalise, and re-encode to canonical form before storing. | |
| 86 | 87 | if ($meta_key === '_metasync_hreflang') { |
| 87 | 88 | $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : ''; |
| 88 | 89 | $decoded = json_decode($raw_value, true); |
| 89 | 90 | if (!is_array($decoded)) { |
| @@ -88,19 +89,40 @@ | ||
| 88 | 89 | $decoded = json_decode($raw_value, true); |
| 89 | 90 | if (!is_array($decoded)) { |
| 90 | 91 | throw new Exception("_metasync_hreflang must be a JSON array"); |
| 91 | 92 | } |
| 93 | + $clean = []; | |
| 92 | 94 | foreach ($decoded as $entry) { |
| 93 | 95 | if (!is_array($entry) || !isset($entry['lang']) || !isset($entry['url'])) { |
| 94 | 96 | throw new Exception("Each hreflang entry must have 'lang' and 'url' keys"); |
| 95 | 97 | } |
| 98 | + $normalized = Metasync_Hreflang_Output::normalize_entry($entry); | |
| 99 | + if (!$normalized['valid']) { | |
| 100 | + throw new Exception(sprintf( | |
| 101 | + "Invalid hreflang language code '%s' — use an ISO code like 'en', 'en-US' (hyphen, not underscore) or 'x-default'", | |
| 102 | + is_scalar($entry['lang']) ? (string) $entry['lang'] : gettype($entry['lang']) | |
| 103 | + )); | |
| 104 | + } | |
| 105 | + // Sanitize each URL to strip javascript: and other unsafe protocols. | |
| 106 | + $url = esc_url_raw(trim((string) $entry['url'])); | |
| 107 | + if (!Metasync_Hreflang_Output::is_absolute_http_url($url)) { | |
| 108 | + throw new Exception(sprintf( | |
| 109 | + "hreflang URL '%s' must be an absolute http(s) URL (relative paths are not valid hreflang hrefs)", | |
| 110 | + (string) $entry['url'] | |
| 111 | + )); | |
| 112 | + } | |
| 113 | + $clean[] = [ | |
| 114 | + 'lang' => $normalized['lang'], | |
| 115 | + 'region' => $normalized['region'], | |
| 116 | + 'url' => $url, | |
| 117 | + ]; | |
| 96 | 118 | } |
| 97 | - // Sanitize each URL to strip javascript: and other unsafe protocols. | |
| 98 | - foreach ($decoded as &$entry) { | |
| 99 | - $entry['url'] = esc_url_raw($entry['url']); | |
| 100 | - } | |
| 101 | - unset($entry); | |
| 102 | - $meta_value = wp_json_encode($decoded); | |
| 119 | + // update_metadata() runs wp_unslash() on the value; combined with | |
| 120 | + // the default \uXXXX escaping this corrupted non-ASCII URLs | |
| 121 | + // (…/über-uns/ stored as …/u00fcber-uns/). Encoding without the | |
| 122 | + // escapes and passing slashed data — the same contract the REST | |
| 123 | + // path uses — keeps the stored JSON byte-faithful. | |
| 124 | + $meta_value = wp_slash(wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); | |
| 103 | 125 | } |
| 104 | 126 | |
| 105 | 127 | // Validate _metasync_robots_advanced JSON |
| 106 | 128 | if ($meta_key === '_metasync_robots_advanced') { |
| @@ -167,9 +189,15 @@ | ||
| 167 | 189 | // so cast for type-safe comparison against the integer $meta_value above. |
| 168 | 190 | if ($meta_key === '_metasync_primary_category') { |
| 169 | 191 | $current_value = (int) $current_value; |
| 170 | 192 | } |
| 171 | - if ($current_value === $meta_value) { | |
| 193 | + // The hreflang payload is stored slashed (wp_slash) so it survives | |
| 194 | + // update_metadata()'s wp_unslash(); the stored value is therefore the | |
| 195 | + // UNSLASHED form. Compare against that, or an identical re-write would | |
| 196 | + // look like a change, run update_post_meta(), come back false ("value | |
| 197 | + // unchanged") and wrongly throw below. | |
| 198 | + $compare_value = $meta_key === '_metasync_hreflang' ? wp_unslash($meta_value) : $meta_value; | |
| 199 | + if ($current_value === $compare_value) { | |
| 172 | 200 | // Value already matches — return success without update |
| 173 | 201 | return $this->success([ |
| 174 | 202 | 'post_id' => $post_id, |
| 175 | 203 | 'meta_key' => $meta_key, |
| @@ -401,10 +429,11 @@ | ||
| 401 | 429 | /** |
| 402 | 430 | * Get Hreflang Links Tool |
| 403 | 431 | * |
| 404 | 432 | * Returns all hreflang entries for a post (manual + WPML auto-detected), |
| 405 | - * validates that each referenced URL returns HTTP 200, and flags a missing | |
| 406 | - * x-default entry. | |
| 433 | + * validates that each referenced URL returns HTTP 200 and links back to | |
| 434 | + * this post (hreflang reciprocity), and flags a missing x-default entry | |
| 435 | + * and a missing self-reference. | |
| 407 | 436 | */ |
| 408 | 437 | class MCP_Tool_Get_Hreflang_Links extends MCP_Tool_Base { |
| 409 | 438 | |
| 410 | 439 | public function get_name() { |
| @@ -411,9 +440,9 @@ | ||
| 411 | 440 | return 'wordpress_get_hreflang_links'; |
| 412 | 441 | } |
| 413 | 442 | |
| 414 | 443 | public function get_description() { |
| 415 | - return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200, and flag a missing x-default entry'; | |
| 444 | + return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200 and links back to this post (reciprocity), and flag missing x-default / self-reference entries'; | |
| 416 | 445 | } |
| 417 | 446 | |
| 418 | 447 | public function get_input_schema() { |
| 419 | 448 | return [ |
| @@ -439,26 +468,44 @@ | ||
| 439 | 468 | |
| 440 | 469 | $manual_entries = $this->get_manual_entries($post_id); |
| 441 | 470 | $wpml_entries = $this->get_wpml_entries($post_id, $post); |
| 442 | 471 | |
| 443 | - // Merge: auto-detected first, then manual entries override by | |
| 444 | - // lang+region collision key. | |
| 472 | + // Merge: auto-detected first, then manual entries override by the | |
| 473 | + // normalised language-code collision key. Rows with an invalid code | |
| 474 | + // (empty key) are kept verbatim instead of collapsing into one — | |
| 475 | + // the audit report is where they become visible. | |
| 445 | 476 | $by_key = []; |
| 477 | + $invalid = []; | |
| 446 | 478 | foreach ($wpml_entries as $entry) { |
| 447 | - $by_key[$this->collision_key($entry)] = $entry; | |
| 479 | + $key = Metasync_Hreflang_Output::lang_code($entry); | |
| 480 | + if ($key === '') { | |
| 481 | + $invalid[] = $entry; | |
| 482 | + } else { | |
| 483 | + $by_key[$key] = $entry; | |
| 484 | + } | |
| 448 | 485 | } |
| 449 | 486 | foreach ($manual_entries as $entry) { |
| 450 | - $by_key[$this->collision_key($entry)] = $entry; | |
| 487 | + $key = Metasync_Hreflang_Output::lang_code($entry); | |
| 488 | + if ($key === '') { | |
| 489 | + $invalid[] = $entry; | |
| 490 | + } else { | |
| 491 | + $by_key[$key] = $entry; | |
| 492 | + } | |
| 451 | 493 | } |
| 452 | - $entries = array_values($by_key); | |
| 494 | + $entries = array_merge(array_values($by_key), $invalid); | |
| 453 | 495 | |
| 454 | - // Validate each URL returns HTTP 200 (try HEAD first, fall back to | |
| 455 | - // GET on 405 Method Not Allowed). | |
| 496 | + $self_url = get_permalink($post_id); | |
| 497 | + | |
| 498 | + // Validate each URL: HTTP 200 (HEAD first, GET on 405) and — when | |
| 499 | + // the target is an external alternate — a return link to this post | |
| 500 | + // (hreflang must be bidirectional; Google ignores one-way clusters). | |
| 456 | 501 | $has_x_default = false; |
| 502 | + $has_self_reference = false; | |
| 457 | 503 | foreach ($entries as &$entry) { |
| 458 | 504 | $url = isset($entry['url']) ? $entry['url'] : ''; |
| 459 | 505 | $status = null; |
| 460 | 506 | $error = null; |
| 507 | + $return_link = null; | |
| 461 | 508 | |
| 462 | 509 | if (!empty($url)) { |
| 463 | 510 | $response = wp_remote_head($url, ['timeout' => 5, 'sslverify' => false]); |
| 464 | 511 | if (is_wp_error($response)) { |
| @@ -474,8 +521,18 @@ | ||
| 474 | 521 | $status = (int) wp_remote_retrieve_response_code($response); |
| 475 | 522 | } |
| 476 | 523 | } |
| 477 | 524 | } |
| 525 | + | |
| 526 | + // Reciprocity: fetch the alternate page and check whether | |
| 527 | + // its hreflang set contains this post's URL. | |
| 528 | + if ($error === null && $status === 200 && !empty($self_url) && $url !== $self_url) { | |
| 529 | + $response = wp_remote_get($url, ['timeout' => 5, 'sslverify' => false]); | |
| 530 | + if (!is_wp_error($response)) { | |
| 531 | + $body = (string) wp_remote_retrieve_body($response); | |
| 532 | + $return_link = self::body_links_back($body, $self_url); | |
| 533 | + } | |
| 534 | + } | |
| 478 | 535 | } |
| 479 | 536 | |
| 480 | 537 | $entry['http_status'] = $status; |
| 481 | 538 | $entry['http_ok'] = ($status === 200); |
| @@ -481,34 +538,67 @@ | ||
| 481 | 538 | $entry['http_ok'] = ($status === 200); |
| 482 | 539 | if ($error !== null) { |
| 483 | 540 | $entry['http_error'] = $error; |
| 484 | 541 | } |
| 542 | + if ($return_link !== null) { | |
| 543 | + $entry['return_link'] = $return_link; | |
| 544 | + } | |
| 485 | 545 | |
| 486 | - if (isset($entry['lang']) && $entry['lang'] === 'x-default') { | |
| 546 | + if (Metasync_Hreflang_Output::lang_code($entry) === 'x-default') { | |
| 487 | 547 | $has_x_default = true; |
| 488 | 548 | } |
| 549 | + if (!empty($self_url) && $url === $self_url) { | |
| 550 | + $has_self_reference = true; | |
| 551 | + } | |
| 489 | 552 | } |
| 490 | 553 | unset($entry); |
| 491 | 554 | |
| 492 | - $missing_x_default = !$has_x_default; | |
| 493 | - | |
| 494 | 555 | return $this->success([ |
| 495 | - 'post_id' => $post_id, | |
| 496 | - 'post_title' => $post->post_title, | |
| 497 | - 'entries' => $entries, | |
| 498 | - 'missing_x_default' => $missing_x_default, | |
| 556 | + 'post_id' => $post_id, | |
| 557 | + 'post_title' => $post->post_title, | |
| 558 | + 'self_url' => $self_url, | |
| 559 | + 'entries' => $entries, | |
| 560 | + 'missing_x_default' => !$has_x_default, | |
| 561 | + 'missing_self_reference' => !$has_self_reference, | |
| 499 | 562 | ]); |
| 500 | 563 | } |
| 501 | 564 | |
| 502 | 565 | /** |
| 566 | + * True when the page body contains a hreflang link pointing back at the | |
| 567 | + * given URL. | |
| 568 | + * | |
| 569 | + * @param string $body HTML body of the alternate page. | |
| 570 | + * @param string $self_url This post's permalink. | |
| 571 | + * @return bool | |
| 572 | + */ | |
| 573 | + private static function body_links_back($body, $self_url) { | |
| 574 | + if (!preg_match_all('/<link[^>]+hreflang\s*=\s*[\'"]([^\'"]*)[\'"][^>]*>/i', (string) $body, $links)) { | |
| 575 | + return false; | |
| 576 | + } | |
| 577 | + foreach ($links[0] as $tag) { | |
| 578 | + if (stripos($tag, 'rel="alternate"') === false && stripos($tag, "rel='alternate'") === false) { | |
| 579 | + continue; | |
| 580 | + } | |
| 581 | + if (strpos($tag, $self_url) !== false) { | |
| 582 | + return true; | |
| 583 | + } | |
| 584 | + } | |
| 585 | + return false; | |
| 586 | + } | |
| 587 | + | |
| 588 | + /** | |
| 503 | 589 | * Read manual hreflang entries from `_metasync_hreflang` post meta. |
| 504 | 590 | * |
| 591 | + * Rows are normalised and carry their final lang_code plus a valid flag, | |
| 592 | + * so an invalid stored row is visible in the report (the emitter skips | |
| 593 | + * it silently — this is the tool that surfaces it). | |
| 594 | + * | |
| 505 | 595 | * @param int $post_id Post ID. |
| 506 | 596 | * @return array |
| 507 | 597 | */ |
| 508 | 598 | private function get_manual_entries($post_id) { |
| 509 | 599 | $raw = get_post_meta($post_id, '_metasync_hreflang', true); |
| 510 | - if (empty($raw)) { | |
| 600 | + if (empty($raw) || !is_string($raw)) { | |
| 511 | 601 | return []; |
| 512 | 602 | } |
| 513 | 603 | $decoded = json_decode($raw, true); |
| 514 | 604 | if (!is_array($decoded)) { |
| @@ -518,13 +608,16 @@ | ||
| 518 | 608 | foreach ($decoded as $entry) { |
| 519 | 609 | if (!is_array($entry)) { |
| 520 | 610 | continue; |
| 521 | 611 | } |
| 612 | + $normalized = Metasync_Hreflang_Output::normalize_entry($entry); | |
| 522 | 613 | $entries[] = [ |
| 523 | - 'lang' => isset($entry['lang']) ? (string) $entry['lang'] : '', | |
| 524 | - 'region' => isset($entry['region']) ? (string) $entry['region'] : '', | |
| 525 | - 'url' => isset($entry['url']) ? (string) $entry['url'] : '', | |
| 526 | - 'source' => 'manual', | |
| 614 | + 'lang' => $normalized['lang'], | |
| 615 | + 'region' => $normalized['region'], | |
| 616 | + 'url' => $normalized['url'], | |
| 617 | + 'lang_code' => $normalized['lang_code'], | |
| 618 | + 'valid' => $normalized['valid'], | |
| 619 | + 'source' => 'manual', | |
| 527 | 620 | ]; |
| 528 | 621 | } |
| 529 | 622 | return $entries; |
| 530 | 623 | } |
| @@ -529,10 +622,12 @@ | ||
| 529 | 622 | return $entries; |
| 530 | 623 | } |
| 531 | 624 | |
| 532 | 625 | /** |
| 533 | - * Build WPML auto-detected entries by querying the icl_translations table. | |
| 534 | - * Mirrors the logic in Metasync_Hreflang_Output::get_wpml_entries. | |
| 626 | + * Build WPML auto-detected entries via the shared implementation in | |
| 627 | + * Metasync_Hreflang_Output::get_wpml_entries() (publish-only rows, | |
| 628 | + * x-default synthesis) so the audit reports exactly what the front end | |
| 629 | + * emits. | |
| 535 | 630 | * |
| 536 | 631 | * @param int $post_id Post ID. |
| 537 | 632 | * @param WP_Post $post Post object (already verified). |
| 538 | 633 | * @return array |
| @@ -540,64 +635,16 @@ | ||
| 540 | 635 | private function get_wpml_entries($post_id, $post) { |
| 541 | 636 | if (!defined('ICL_SITEPRESS_VERSION')) { |
| 542 | 637 | return []; |
| 543 | 638 | } |
| 544 | - global $wpdb; | |
| 545 | - $table = $wpdb->prefix . 'icl_translations'; | |
| 546 | - $element_type = 'post_' . $post->post_type; | |
| 547 | 639 | |
| 548 | - $trid = $wpdb->get_var($wpdb->prepare( | |
| 549 | - "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1", | |
| 550 | - $post_id, | |
| 551 | - $element_type | |
| 552 | - )); | |
| 553 | - if (empty($trid)) { | |
| 554 | - return []; | |
| 555 | - } | |
| 640 | + $entries = (new Metasync_Hreflang_Output())->get_wpml_entries($post_id); | |
| 556 | 641 | |
| 557 | - $rows = $wpdb->get_results($wpdb->prepare( | |
| 558 | - "SELECT language_code, element_id FROM {$table} WHERE trid = %d", | |
| 559 | - $trid | |
| 560 | - )); | |
| 561 | - if (empty($rows)) { | |
| 562 | - return []; | |
| 642 | + $out = []; | |
| 643 | + foreach ($entries as $entry) { | |
| 644 | + $normalized = Metasync_Hreflang_Output::normalize_entry($entry); | |
| 645 | + $normalized['source'] = 'wpml'; | |
| 646 | + $out[] = $normalized; | |
| 563 | 647 | } |
| 564 | - | |
| 565 | - $default_lang = apply_filters('wpml_default_language', null); | |
| 566 | - if (empty($default_lang)) { | |
| 567 | - $default_lang = get_option('wpml_default_language'); | |
| 568 | - } | |
| 569 | - | |
| 570 | - $entries = []; | |
| 571 | - foreach ($rows as $row) { | |
| 572 | - $permalink = get_permalink((int) $row->element_id); | |
| 573 | - if (empty($permalink)) { | |
| 574 | - continue; | |
| 575 | - } | |
| 576 | - $entries[] = [ | |
| 577 | - 'lang' => (string) $row->language_code, | |
| 578 | - 'region' => '', | |
| 579 | - 'url' => $permalink, | |
| 580 | - 'source' => 'wpml', | |
| 581 | - ]; | |
| 582 | - if (!empty($default_lang) && $row->language_code === $default_lang) { | |
| 583 | - $entries[] = [ | |
| 584 | - 'lang' => 'x-default', | |
| 585 | - 'region' => '', | |
| 586 | - 'url' => $permalink, | |
| 587 | - 'source' => 'wpml', | |
| 588 | - ]; | |
| 589 | - } | |
| 590 | - } | |
| 591 | - return $entries; | |
| 592 | - } | |
| 593 | - | |
| 594 | - /** | |
| 595 | - * Collision key in the form `lang-region` (or just `lang` when region | |
| 596 | - * is empty) for de-duplicating entries. | |
| 597 | - */ | |
| 598 | - private function collision_key(array $entry) { | |
| 599 | - $lang = isset($entry['lang']) ? (string) $entry['lang'] : ''; | |
| 600 | - $region = isset($entry['region']) ? (string) $entry['region'] : ''; | |
| 601 | - return $region !== '' ? $lang . '-' . $region : $lang; | |
| 648 | + return $out; | |
| 602 | 649 | } |
| 603 | 650 | } |