PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.3
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.3
2.14.3 2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 All 58 releases
← All changes | includes/seo/class-schema-input-validator.php +105 -7 2.1.1 → 2.14.3 View file →
@@ -126,8 +126,40 @@
126 126 'Review' => [
127 127 'required_fields' => ['@type', 'itemReviewed', 'reviewRating', 'author'],
128 128 'optional_fields' => ['reviewBody', 'datePublished', 'publisher', 'name', 'url'],
129 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]
130 162 ]
131 163 ];
132 164
133 165 /**
@@ -197,8 +229,16 @@
197 229 'errors' => [],
198 230 'warnings' => []
199 231 ];
200 232
233 + // The deploy routes read the type from the node's @type, and a
234 + // LocalBusiness is published under the business type chosen in Local
235 + // SEO ("Restaurant", #1005). Validate it as the LocalBusiness it is;
236 + // the @type match below accepts the subtype.
237 + if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($schema_type)) {
238 + $schema_type = 'LocalBusiness';
239 + }
240 +
201 241 try {
202 242 // 1. Validate payload size to prevent DoS attacks
203 243 $size_validation = $this->validate_payload_size($schema_data);
204 244 if (!$size_validation['valid']) {
@@ -234,9 +274,9 @@
234 274 $result['errors'] = array_merge($result['errors'], $field_validation['errors']);
235 275 }
236 276
237 277 // 6. Validate data types and formats
238 - $format_validation = $this->validate_data_formats($sanitized_data, $schema_type);
278 + $format_validation = $this->validate_data_formats($sanitized_data, $schema_type, $schema_data);
239 279 if (!$format_validation['valid']) {
240 280 $result['errors'] = array_merge($result['errors'], $format_validation['errors']);
241 281 }
242 282 $result['warnings'] = array_merge($result['warnings'], $format_validation['warnings']);
@@ -384,9 +424,14 @@
384 424 $declared_types = is_array($schema_data['@type'])
385 425 ? array_map('strval', $schema_data['@type'])
386 426 : [(string) $schema_data['@type']];
387 427
388 - if (!in_array($schema_type, $declared_types, true)) {
428 + // A LocalBusiness is deployed under the business subtype the site
429 + // chose (#1005).
430 + $is_local_business_subtype = 'LocalBusiness' === $schema_type
431 + && [] !== array_filter($declared_types, [\ThinkRank\Config\Local_Business_Types_Config::class, 'is_local_business']);
432 +
433 + if (!$is_local_business_subtype && !in_array($schema_type, $declared_types, true)) {
389 434 $result['errors'][] = sprintf(
390 435 "Schema @type '%s' does not match expected type '%s'",
391 436 implode(', ', $declared_types),
392 437 $schema_type
@@ -550,11 +595,12 @@
550 595 * @since 1.0.0
551 596 *
552 597 * @param array $schema_data Schema data
553 598 * @param string $schema_type Schema type
599 + * @param array $raw_data Schema data as submitted, before sanitization.
554 600 * @return array Validation result
555 601 */
556 - private function validate_data_formats(array $schema_data, string $schema_type): array {
602 + private function validate_data_formats(array $schema_data, string $schema_type, array $raw_data = []): array {
557 603 $result = ['valid' => true, 'errors' => [], 'warnings' => []];
558 604
559 605 foreach ($schema_data as $field => $value) {
560 606 // sameAs is a list, so its members never reached the string branch
@@ -560,15 +606,27 @@
560 606 // sameAs is a list, so its members never reached the string branch
561 607 // below and free text entered in a social-profile field saved
562 608 // cleanly, then shipped as invalid structured data (#480).
563 609 if (is_array($value) && in_array($field, ['url', 'sameAs', 'logo', 'image'], true)) {
564 - foreach ($value as $item) {
565 - if (!is_string($item) || '' === trim($item)) {
610 + // Validated after sanitization, but reported as entered:
611 + // esc_url_raw() turns "not a url" into "http://not%20a%20url",
612 + // which the user never typed (#949 review).
613 + $items = $this->url_candidates($value);
614 + $raw_items = is_array($raw_data[$field] ?? null) ? $this->url_candidates($raw_data[$field]) : [];
615 + if (count($raw_items) !== count($items)) {
616 + // A non-string member sanitized into a string would shift
617 + // the positions; fall back rather than name the wrong one.
618 + $raw_items = [];
619 + }
620 +
621 + foreach ($items as $index => $item) {
622 + if ('' === trim($item)) {
566 623 continue;
567 624 }
568 625
569 626 if (!$this->is_valid_url($item)) {
570 - $result['errors'][] = "Invalid URL format for field: {$field} ({$item})";
627 + $shown = $raw_items[$index] ?? $item;
628 + $result['errors'][] = "Invalid URL format for field: {$field} ({$shown})";
571 629 $result['valid'] = false;
572 630 }
573 631 }
574 632
@@ -604,8 +662,47 @@
604 662 return $result;
605 663 }
606 664
607 665 /**
666 + * The URL strings inside an array-valued URL field.
667 + *
668 + * The field is either a list (`sameAs`, several `image` URLs) or a single
669 + * node such as the `ImageObject` Schema_Builder emits for a configured
670 + * logo. Checking every member of a node URL-checked its `@type` and
671 + * `width`, so "ImageObject" failed as an invalid URL and the deploy route
672 + * skipped — then retired — the site's Organization (#949). Only a node's
673 + * `url` / `contentUrl` are URLs; a list may hold strings or such nodes.
674 + *
675 + * @since 2.14.1
676 + *
677 + * @param array $value Array-valued URL field.
678 + * @return string[] URL strings to validate.
679 + */
680 + private function url_candidates(array $value): array {
681 + $node_urls = static function (array $node): array {
682 + return array_values(array_filter(
683 + [$node['url'] ?? null, $node['contentUrl'] ?? null],
684 + 'is_string'
685 + ));
686 + };
687 +
688 + if (array_values($value) !== $value) {
689 + return $node_urls($value);
690 + }
691 +
692 + $urls = [];
693 + foreach ($value as $item) {
694 + if (is_string($item)) {
695 + $urls[] = $item;
696 + } elseif (is_array($item)) {
697 + $urls = array_merge($urls, $node_urls($item));
698 + }
699 + }
700 +
701 + return $urls;
702 + }
703 +
704 + /**
608 705 * Validate content lengths
609 706 *
610 707 * @since 1.0.0
611 708 *
@@ -640,9 +737,9 @@
640 737 * @return bool Validation result
641 738 */
642 739 private function is_valid_url(string $url): bool {
643 740 // Basic URL validation
644 - if (!filter_var($url, FILTER_VALIDATE_URL)) {
741 + if (!\ThinkRank\Core\Url_Validator::is_valid($url)) {
645 742 return false;
646 743 }
647 744
648 745 // Check allowed protocols
@@ -982,8 +1079,9 @@
982 1079 'enable_howto_schema' => 'boolean',
983 1080 'enable_product_schema' => 'boolean',
984 1081 'enable_local_business' => 'boolean',
985 1082 'enable_breadcrumbs_schema' => 'boolean',
1083 + 'enable_accordion_faq_schema' => 'boolean',
986 1084 ];
987 1085
988 1086 foreach ($options as $key => $value) {
989 1087 $sanitized_key = sanitize_key($key);