| @@ -108,8 +108,58 @@ | ||
| 108 | 108 | 'HowTo' => [ |
| 109 | 109 | 'required_fields' => ['@type', 'name'], |
| 110 | 110 | 'optional_fields' => ['description', 'totalTime', 'prepTime', 'difficulty', 'estimatedCost', 'supply', 'tool', 'step', 'yield', 'image', 'video'], |
| 111 | 111 | 'max_length' => ['name' => 100, 'description' => 160] |
| 112 | + ], | |
| 113 | + 'BreadcrumbList' => [ | |
| 114 | + 'required_fields' => ['@type', 'itemListElement'], | |
| 115 | + 'optional_fields' => ['name', 'description', 'numberOfItems'], | |
| 116 | + 'max_length' => ['name' => 100, 'description' => 160] | |
| 117 | + ], | |
| 118 | + 'VideoObject' => [ | |
| 119 | + 'required_fields' => ['@type', 'name', 'thumbnailUrl', 'uploadDate'], | |
| 120 | + 'optional_fields' => ['description', 'contentUrl', 'embedUrl', 'duration', 'url'], | |
| 121 | + 'max_length' => ['name' => 110, 'description' => 160] | |
| 122 | + ], | |
| 123 | + // Offered by the metabox Schema Type dropdown and registered in | |
| 124 | + // Schema_Factory, but missing here — so a Review could be generated and | |
| 125 | + // never deployed (#462). Field list mirrors Schema_Factory::Review. | |
| 126 | + 'Review' => [ | |
| 127 | + 'required_fields' => ['@type', 'itemReviewed', 'reviewRating', 'author'], | |
| 128 | + 'optional_fields' => ['reviewBody', 'datePublished', 'publisher', 'name', 'url'], | |
| 129 | + 'max_length' => ['name' => 110, 'reviewBody' => 500] | |
| 130 | + ], | |
| 131 | + // The WebPage family. #624 made all four selectable in the metabox and | |
| 132 | + // taught the generate/validate/optimize/preview routes, Schema_Builder, | |
| 133 | + // Schema_Factory and Schema_Graph about them — but not this array, and | |
| 134 | + // deploy_schema_markup() gates every entry on it. So each one generated | |
| 135 | + // cleanly, validated at a score of 100, reported deployment_ready, then | |
| 136 | + // failed deployment with "Invalid schema type: AboutPage" and no row | |
| 137 | + // written. Exactly the #462 Review bug, one array and two releases | |
| 138 | + // later, which is why SchemaInputValidatorCoverageTest now scans the | |
| 139 | + // dropdown instead of trusting this list to be extended by hand. | |
| 140 | + // | |
| 141 | + // `name` and `url` are the two populate_webpage_schema() always sets; | |
| 142 | + // the rest are conditional, so they are optional here. | |
| 143 | + 'WebPage' => [ | |
| 144 | + 'required_fields' => ['@type', 'name', 'url'], | |
| 145 | + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'], | |
| 146 | + 'max_length' => ['name' => 110, 'description' => 160] | |
| 147 | + ], | |
| 148 | + 'AboutPage' => [ | |
| 149 | + 'required_fields' => ['@type', 'name', 'url'], | |
| 150 | + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'], | |
| 151 | + 'max_length' => ['name' => 110, 'description' => 160] | |
| 152 | + ], | |
| 153 | + 'ContactPage' => [ | |
| 154 | + 'required_fields' => ['@type', 'name', 'url'], | |
| 155 | + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'], | |
| 156 | + 'max_length' => ['name' => 110, 'description' => 160] | |
| 157 | + ], | |
| 158 | + 'ProfilePage' => [ | |
| 159 | + 'required_fields' => ['@type', 'name', 'url'], | |
| 160 | + 'optional_fields' => ['description', 'datePublished', 'dateModified', 'isPartOf', 'breadcrumb', 'primaryImageOfPage', 'inLanguage', 'mainEntity'], | |
| 161 | + 'max_length' => ['name' => 110, 'description' => 160] | |
| 112 | 162 | ] |
| 113 | 163 | ]; |
| 114 | 164 | |
| 115 | 165 | /** |
| @@ -131,16 +181,8 @@ | ||
| 131 | 181 | */ |
| 132 | 182 | private array $allowed_protocols = ['http', 'https', 'mailto', 'tel']; |
| 133 | 183 | |
| 134 | 184 | /** |
| 135 | - * Rate limiting storage | |
| 136 | - * | |
| 137 | - * @since 1.0.0 | |
| 138 | - * @var array | |
| 139 | - */ | |
| 140 | - private static array $rate_limits = []; | |
| 141 | - | |
| 142 | - /** | |
| 143 | 185 | * Maximum allowed JSON depth to prevent JSON bomb attacks |
| 144 | 186 | * |
| 145 | 187 | * @since 1.0.0 |
| 146 | 188 | * @var int |
| @@ -361,15 +403,29 @@ | ||
| 361 | 403 | $result['errors'][] = 'Invalid @context value. Must be "https://schema.org"'; |
| 362 | 404 | $result['valid'] = false; |
| 363 | 405 | } |
| 364 | 406 | |
| 365 | - // Check for required @type | |
| 407 | + // Check for required @type. | |
| 408 | + // `@type` may be an array — "@type": ["Product","Offer"] is valid | |
| 409 | + // JSON-LD. Comparing an array against a string emitted an "Array to | |
| 410 | + // string conversion" warning and always failed (#468), so match if the | |
| 411 | + // expected type appears anywhere in the list. | |
| 366 | 412 | if (!isset($schema_data['@type'])) { |
| 367 | 413 | $result['errors'][] = 'Missing required @type field'; |
| 368 | 414 | $result['valid'] = false; |
| 369 | - } elseif ($schema_data['@type'] !== $schema_type) { | |
| 370 | - $result['errors'][] = "Schema @type '{$schema_data['@type']}' does not match expected type '{$schema_type}'"; | |
| 371 | - $result['valid'] = false; | |
| 415 | + } else { | |
| 416 | + $declared_types = is_array($schema_data['@type']) | |
| 417 | + ? array_map('strval', $schema_data['@type']) | |
| 418 | + : [(string) $schema_data['@type']]; | |
| 419 | + | |
| 420 | + if (!in_array($schema_type, $declared_types, true)) { | |
| 421 | + $result['errors'][] = sprintf( | |
| 422 | + "Schema @type '%s' does not match expected type '%s'", | |
| 423 | + implode(', ', $declared_types), | |
| 424 | + $schema_type | |
| 425 | + ); | |
| 426 | + $result['valid'] = false; | |
| 427 | + } | |
| 372 | 428 | } |
| 373 | 429 | |
| 374 | 430 | return $result; |
| 375 | 431 | } |
| @@ -444,19 +500,19 @@ | ||
| 444 | 500 | * @return string Sanitized value |
| 445 | 501 | */ |
| 446 | 502 | private function sanitize_string_value(string $value, string $field_name = ''): string { |
| 447 | 503 | // Handle URLs differently to preserve valid URL structure |
| 448 | - if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'])) { | |
| 504 | + if (in_array($field_name, ['url', 'sameAs', 'logo', 'image', 'mainEntityOfPage'], true)) { | |
| 449 | 505 | return esc_url_raw($value); |
| 450 | 506 | } |
| 451 | 507 | |
| 452 | 508 | // Handle email fields |
| 453 | - if (in_array($field_name, ['email'])) { | |
| 509 | + if (in_array($field_name, ['email'], true)) { | |
| 454 | 510 | return sanitize_email($value); |
| 455 | 511 | } |
| 456 | 512 | |
| 457 | 513 | // Handle description fields that may contain basic HTML |
| 458 | - if (in_array($field_name, ['description', 'text', 'articleBody'])) { | |
| 514 | + if (in_array($field_name, ['description', 'text', 'articleBody'], true)) { | |
| 459 | 515 | // Allow basic HTML but strip dangerous tags |
| 460 | 516 | $allowed_html = [ |
| 461 | 517 | 'p' => [], |
| 462 | 518 | 'br' => [], |
| @@ -470,14 +526,16 @@ | ||
| 470 | 526 | // For other fields, remove all HTML tags |
| 471 | 527 | $value = wp_strip_all_tags($value); |
| 472 | 528 | } |
| 473 | 529 | |
| 474 | - // Sanitize for database storage | |
| 530 | + // Sanitize for database storage. Note: escaping is intentionally NOT done | |
| 531 | + // here. This value is stored and later emitted as JSON-LD inside a | |
| 532 | + // <script type="application/ld+json"> block, where wp_json_encode() is the | |
| 533 | + // correct encoder. Running esc_html() on input would persist HTML entities | |
| 534 | + // (e.g. "Ben & Jerry's" -> "Ben & Jerry's") into the structured | |
| 535 | + // data. Escape at the output boundary, not at storage. | |
| 475 | 536 | $value = sanitize_text_field($value); |
| 476 | 537 | |
| 477 | - // Additional XSS protection for output | |
| 478 | - $value = esc_html($value); | |
| 479 | - | |
| 480 | 538 | return trim($value); |
| 481 | 539 | } |
| 482 | 540 | |
| 483 | 541 | /** |
| @@ -530,11 +588,29 @@ | ||
| 530 | 588 | private function validate_data_formats(array $schema_data, string $schema_type): array { |
| 531 | 589 | $result = ['valid' => true, 'errors' => [], 'warnings' => []]; |
| 532 | 590 | |
| 533 | 591 | foreach ($schema_data as $field => $value) { |
| 592 | + // sameAs is a list, so its members never reached the string branch | |
| 593 | + // below and free text entered in a social-profile field saved | |
| 594 | + // cleanly, then shipped as invalid structured data (#480). | |
| 595 | + if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) { | |
| 596 | + foreach ($value as $item) { | |
| 597 | + if (!is_string($item) || '' === trim($item)) { | |
| 598 | + continue; | |
| 599 | + } | |
| 600 | + | |
| 601 | + if (!$this->is_valid_url($item)) { | |
| 602 | + $result['errors'][] = "Invalid URL format for field: {$field} ({$item})"; | |
| 603 | + $result['valid'] = false; | |
| 604 | + } | |
| 605 | + } | |
| 606 | + | |
| 607 | + continue; | |
| 608 | + } | |
| 609 | + | |
| 534 | 610 | if (is_string($value)) { |
| 535 | 611 | // Validate URLs |
| 536 | - if (in_array($field, ['url', 'sameAs', 'logo', 'image']) && !empty($value)) { | |
| 612 | + if (in_array($field, ['url', 'sameAs', 'logo', 'image'], true) && !empty($value)) { | |
| 537 | 613 | if (!$this->is_valid_url($value)) { |
| 538 | 614 | $result['errors'][] = "Invalid URL format for field: {$field}"; |
| 539 | 615 | $result['valid'] = false; |
| 540 | 616 | } |
| @@ -540,9 +616,9 @@ | ||
| 540 | 616 | } |
| 541 | 617 | } |
| 542 | 618 | |
| 543 | 619 | // Validate email addresses |
| 544 | - if (in_array($field, ['email']) && !empty($value)) { | |
| 620 | + if (in_array($field, ['email'], true) && !empty($value)) { | |
| 545 | 621 | if (!is_email($value)) { |
| 546 | 622 | $result['errors'][] = "Invalid email format for field: {$field}"; |
| 547 | 623 | $result['valid'] = false; |
| 548 | 624 | } |
| @@ -548,9 +624,9 @@ | ||
| 548 | 624 | } |
| 549 | 625 | } |
| 550 | 626 | |
| 551 | 627 | // Validate dates |
| 552 | - if (in_array($field, ['datePublished', 'dateModified']) && !empty($value)) { | |
| 628 | + if (in_array($field, ['datePublished', 'dateModified'], true) && !empty($value)) { | |
| 553 | 629 | if (!$this->is_valid_date($value)) { |
| 554 | 630 | $result['warnings'][] = "Invalid date format for field: {$field}. Use ISO 8601 format."; |
| 555 | 631 | } |
| 556 | 632 | } |
| @@ -602,9 +678,9 @@ | ||
| 602 | 678 | } |
| 603 | 679 | |
| 604 | 680 | // Check allowed protocols |
| 605 | 681 | $parsed = wp_parse_url($url); |
| 606 | - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols)) { | |
| 682 | + if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], $this->allowed_protocols, true)) { | |
| 607 | 683 | return false; |
| 608 | 684 | } |
| 609 | 685 | |
| 610 | 686 | return true; |
| @@ -647,34 +723,58 @@ | ||
| 647 | 723 | * @param int $limit Rate limit (requests per hour) |
| 648 | 724 | * @return bool Whether request is allowed |
| 649 | 725 | */ |
| 650 | 726 | public function check_rate_limit(int $user_id, string $action, int $limit = 100): bool { |
| 651 | - $key = "rate_limit_{$user_id}_{$action}"; | |
| 652 | - $current_time = time(); | |
| 653 | - $window_start = $current_time - 3600; // 1 hour window | |
| 727 | + // Persist the window in a transient (object cache / options) so the limit | |
| 728 | + // is enforced ACROSS requests. A per-request static array — as used | |
| 729 | + // previously — always starts empty on a fresh PHP process and therefore | |
| 730 | + // never throttled anything. | |
| 731 | + $key = 'thinkrank_schema_rl_' . $user_id . '_' . sanitize_key($action); | |
| 654 | 732 | |
| 655 | - // Initialize if not exists | |
| 656 | - if (!isset(self::$rate_limits[$key])) { | |
| 657 | - self::$rate_limits[$key] = []; | |
| 658 | - } | |
| 733 | + // Serialize the read-modify-write with a MySQL named lock so concurrent | |
| 734 | + // requests can't each read the same timestamp list, individually pass the | |
| 735 | + // limit check, and overwrite one another — which would let bursts slip | |
| 736 | + // past the configured limit. GET_LOCK is DB-level, so it serializes the | |
| 737 | + // critical section regardless of where the transient is stored. | |
| 738 | + global $wpdb; | |
| 739 | + $lock_name = substr('tr_schema_rl_' . md5($key), 0, 64); | |
| 740 | + $have_lock = ($wpdb instanceof \wpdb) | |
| 741 | + ? (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, %d)', $lock_name, 3)) === 1 | |
| 742 | + : false; | |
| 659 | 743 | |
| 660 | - // Clean old entries | |
| 661 | - self::$rate_limits[$key] = array_filter( | |
| 662 | - self::$rate_limits[$key], | |
| 663 | - function($timestamp) use ($window_start) { | |
| 664 | - return $timestamp > $window_start; | |
| 744 | + try { | |
| 745 | + $current_time = time(); | |
| 746 | + $window_start = $current_time - HOUR_IN_SECONDS; // 1 hour window | |
| 747 | + | |
| 748 | + $timestamps = get_transient($key); | |
| 749 | + if (!is_array($timestamps)) { | |
| 750 | + $timestamps = []; | |
| 665 | 751 | } |
| 666 | - ); | |
| 667 | 752 | |
| 668 | - // Check if limit exceeded | |
| 669 | - if (count(self::$rate_limits[$key]) >= $limit) { | |
| 670 | - return false; | |
| 671 | - } | |
| 753 | + // Drop entries outside the window. | |
| 754 | + $timestamps = array_values(array_filter( | |
| 755 | + $timestamps, | |
| 756 | + static function ($timestamp) use ($window_start) { | |
| 757 | + return (int) $timestamp > $window_start; | |
| 758 | + } | |
| 759 | + )); | |
| 672 | 760 | |
| 673 | - // Add current request | |
| 674 | - self::$rate_limits[$key][] = $current_time; | |
| 761 | + // Check if limit exceeded. | |
| 762 | + if (count($timestamps) >= $limit) { | |
| 763 | + set_transient($key, $timestamps, HOUR_IN_SECONDS); | |
| 764 | + return false; | |
| 765 | + } | |
| 675 | 766 | |
| 676 | - return true; | |
| 767 | + // Record this request. | |
| 768 | + $timestamps[] = $current_time; | |
| 769 | + set_transient($key, $timestamps, HOUR_IN_SECONDS); | |
| 770 | + | |
| 771 | + return true; | |
| 772 | + } finally { | |
| 773 | + if ($have_lock) { | |
| 774 | + $wpdb->query($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name)); | |
| 775 | + } | |
| 776 | + } | |
| 677 | 777 | } |
| 678 | 778 | |
| 679 | 779 | /** |
| 680 | 780 | * Validate user permissions for schema operations |
| @@ -693,14 +793,25 @@ | ||
| 693 | 793 | $result['errors'][] = 'Invalid user or user not logged in'; |
| 694 | 794 | return $result; |
| 695 | 795 | } |
| 696 | 796 | |
| 697 | - // Check operation-specific permissions | |
| 797 | + // Check operation-specific permissions. | |
| 798 | + // | |
| 799 | + // #457 loosened the route permission_callbacks to the delegable | |
| 800 | + // `thinkrank_schema` capability, but these handler-level checks still | |
| 801 | + // demanded edit_posts / publish_posts / manage_options — so a role | |
| 802 | + // granted Schema access could generate and validate but was denied on | |
| 803 | + // deploy, bulk operations and everything site-context. That is exactly | |
| 804 | + // the symptom #457 set out to fix (#470). A holder of thinkrank_schema | |
| 805 | + // satisfies any schema operation; the built-in caps remain as the | |
| 806 | + // fallback for roles that never went through the Role Manager. | |
| 807 | + $has_schema_cap = user_can($user_id, 'thinkrank_schema'); | |
| 808 | + | |
| 698 | 809 | switch ($operation) { |
| 699 | 810 | case 'generate': |
| 700 | 811 | case 'validate': |
| 701 | 812 | case 'optimize': |
| 702 | - if (!user_can($user_id, 'edit_posts')) { | |
| 813 | + if (!$has_schema_cap && !user_can($user_id, 'edit_posts')) { | |
| 703 | 814 | $result['errors'][] = 'Insufficient permissions for schema generation/validation'; |
| 704 | 815 | return $result; |
| 705 | 816 | } |
| 706 | 817 | break; |
| @@ -705,9 +816,9 @@ | ||
| 705 | 816 | } |
| 706 | 817 | break; |
| 707 | 818 | |
| 708 | 819 | case 'deploy': |
| 709 | - if (!user_can($user_id, 'publish_posts')) { | |
| 820 | + if (!$has_schema_cap && !user_can($user_id, 'publish_posts')) { | |
| 710 | 821 | $result['errors'][] = 'Insufficient permissions for schema deployment'; |
| 711 | 822 | return $result; |
| 712 | 823 | } |
| 713 | 824 | break; |
| @@ -713,9 +824,9 @@ | ||
| 713 | 824 | break; |
| 714 | 825 | |
| 715 | 826 | case 'manage_settings': |
| 716 | 827 | case 'bulk_operations': |
| 717 | - if (!user_can($user_id, 'manage_options')) { | |
| 828 | + if (!$has_schema_cap && !user_can($user_id, 'manage_options')) { | |
| 718 | 829 | $result['errors'][] = 'Insufficient permissions for schema management'; |
| 719 | 830 | return $result; |
| 720 | 831 | } |
| 721 | 832 | break; |
| @@ -780,10 +891,14 @@ | ||
| 780 | 891 | $result['errors'][] = "Invalid context ID: {$context_id}"; |
| 781 | 892 | return $result; |
| 782 | 893 | } |
| 783 | 894 | |
| 784 | - // SECURITY: Check context ownership | |
| 785 | - if ($user_id && !$this->validate_context_ownership($post, $user_id)) { | |
| 895 | + // SECURITY: Check context ownership. | |
| 896 | + // Fails closed on a missing user — a security helper that waves the | |
| 897 | + // check through when it cannot identify the caller is the wrong way | |
| 898 | + // round. Every caller passes a real ID, so this only tightens an | |
| 899 | + // unreachable path. | |
| 900 | + if (!$user_id || !$this->validate_context_ownership($post, $user_id)) { | |
| 786 | 901 | $result['errors'][] = "Access denied: You don't have permission to modify this {$context_type}"; |
| 787 | 902 | return $result; |
| 788 | 903 | } |
| 789 | 904 | } else { |
| @@ -788,10 +903,18 @@ | ||
| 788 | 903 | } |
| 789 | 904 | } else { |
| 790 | 905 | $context_id = null; // Site context doesn't use ID |
| 791 | 906 | |
| 792 | - // SECURITY: Check site-level permissions for site context | |
| 793 | - if ($user_id && !current_user_can('manage_options')) { | |
| 907 | + // SECURITY: Check site-level permissions for site context. | |
| 908 | + // user_can($user_id, …) rather than current_user_can() so this | |
| 909 | + // agrees with the rest of the validator outside a REST request, | |
| 910 | + // where the current user and $user_id can differ (cron, CLI). | |
| 911 | + // | |
| 912 | + // Accepts the delegable `thinkrank_schema` capability as well as | |
| 913 | + // manage_options: /schema/settings already lets a delegated role | |
| 914 | + // edit site schema settings, so blocking site-context generate and | |
| 915 | + // deploy for the same role was inconsistent (#470). | |
| 916 | + if (!$user_id || (!user_can($user_id, 'thinkrank_schema') && !user_can($user_id, 'manage_options'))) { | |
| 794 | 917 | $result['errors'][] = 'Access denied: You need administrator privileges for site-level schema operations'; |
| 795 | 918 | return $result; |
| 796 | 919 | } |
| 797 | 920 | } |
| @@ -814,25 +937,23 @@ | ||
| 814 | 937 | * @param int $user_id User ID |
| 815 | 938 | * @return bool Whether user has permission |
| 816 | 939 | */ |
| 817 | 940 | private function validate_context_ownership(\WP_Post $post, int $user_id): bool { |
| 818 | - // Check if user can edit this specific post | |
| 819 | - if (current_user_can('edit_post', $post->ID)) { | |
| 820 | - return true; | |
| 821 | - } | |
| 822 | - | |
| 823 | - // Check if user is the post author | |
| 824 | - if ($post->post_author == $user_id) { | |
| 825 | - return true; | |
| 826 | - } | |
| 827 | - | |
| 828 | - // Check if user has general edit capabilities for this post type | |
| 829 | - $post_type_object = get_post_type_object($post->post_type); | |
| 830 | - if ($post_type_object && current_user_can($post_type_object->cap->edit_posts)) { | |
| 831 | - return true; | |
| 832 | - } | |
| 833 | - | |
| 834 | - return false; | |
| 941 | + // `edit_post` is a meta capability: map_meta_cap() already resolves | |
| 942 | + // authorship, published state, and edit_others_posts for this specific | |
| 943 | + // post. It is the whole check. | |
| 944 | + // | |
| 945 | + // Two fallbacks used to sit under it and between them defeated the | |
| 946 | + // function. One granted access on authorship alone, which hands a | |
| 947 | + // Contributor back a post they lost edit rights to once it published. | |
| 948 | + // The other granted access to anyone holding the post type's *general* | |
| 949 | + // edit_posts capability — a cap every Author and Contributor has, that | |
| 950 | + // says nothing about this post — so ownership validation returned true | |
| 951 | + // for every post on the site (#326). | |
| 952 | + // | |
| 953 | + // user_can() rather than current_user_can() so the method honours the | |
| 954 | + // $user_id it was handed, matching validate_user_permissions(). | |
| 955 | + return user_can($user_id, 'edit_post', $post->ID); | |
| 835 | 956 | } |
| 836 | 957 | |
| 837 | 958 | /** |
| 838 | 959 | * Validate JSON depth to prevent JSON bomb attacks |
| @@ -868,14 +989,33 @@ | ||
| 868 | 989 | * @return array Sanitized options |
| 869 | 990 | */ |
| 870 | 991 | public function sanitize_options(array $options): array { |
| 871 | 992 | $sanitized = []; |
| 993 | + // Anything omitted here is dropped before the manager sees it, which is | |
| 994 | + // why apply_content_schema_settings_from_options() and the per-request | |
| 995 | + // schema-type opt-in were unreachable from REST (#470). The list now | |
| 996 | + // covers every option the generate path actually reads. | |
| 997 | + // | |
| 998 | + // `validation_level` previously allowed 'basic' and rejected 'lenient', | |
| 999 | + // disagreeing with Schema_Settings_Config, validate_settings() and the | |
| 1000 | + // update-settings ability, which all use 'lenient'. | |
| 1001 | + // `deployment_method` no longer advertises microdata/rdfa, which | |
| 1002 | + // determine_deployment_method() hardcodes away to json_ld anyway. | |
| 872 | 1003 | $allowed_options = [ |
| 873 | - 'deployment_method' => ['json_ld', 'microdata', 'rdfa'], | |
| 874 | - 'validation_level' => ['strict', 'moderate', 'basic'], | |
| 1004 | + 'deployment_method' => ['json_ld'], | |
| 1005 | + 'validation_level' => ['strict', 'moderate', 'lenient'], | |
| 875 | 1006 | 'include_meta' => 'boolean', |
| 876 | 1007 | 'minify_output' => 'boolean', |
| 877 | - 'cache_duration' => 'integer' | |
| 1008 | + 'cache_duration' => 'integer', | |
| 1009 | + 'rich_snippets_optimization' => 'boolean', | |
| 1010 | + 'knowledge_graph' => 'boolean', | |
| 1011 | + 'auto_generate_schema' => 'boolean', | |
| 1012 | + 'enable_article_schema' => 'boolean', | |
| 1013 | + 'enable_faq_schema' => 'boolean', | |
| 1014 | + 'enable_howto_schema' => 'boolean', | |
| 1015 | + 'enable_product_schema' => 'boolean', | |
| 1016 | + 'enable_local_business' => 'boolean', | |
| 1017 | + 'enable_breadcrumbs_schema' => 'boolean', | |
| 878 | 1018 | ]; |
| 879 | 1019 | |
| 880 | 1020 | foreach ($options as $key => $value) { |
| 881 | 1021 | $sanitized_key = sanitize_key($key); |