PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / seo / class-schema-validator.php

class-schema-validator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/seo/class-schema-validator.php

482 lines 19.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Schema Validator Class
4 *
5 * Handles Schema.org compliance validation and SEO optimization checks.
6 * Extracted from Schema_Generator to follow Single Responsibility Principle.
7 * Maintains exact same validation logic and return formats as original implementation.
8 *
9 * @package ThinkRank\SEO
10 * @since 1.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\SEO;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * Schema Validator Class
24 *
25 * Validates schema markup against Schema.org specifications and provides SEO suggestions.
26 * Preserves all existing validation logic and return formats.
27 *
28 * @since 1.0.0
29 */
30 class Schema_Validator {
31
32 /**
33 * Schema Factory instance for type specifications
34 *
35 * @since 1.0.0
36 * @var Schema_Factory
37 */
38 private Schema_Factory $schema_factory;
39
40 /**
41 * Constructor
42 *
43 * @since 1.0.0
44 */
45 public function __construct() {
46 // Ensure Schema_Factory is loaded
47 if (!class_exists('ThinkRank\\SEO\\Schema_Factory')) {
48 require_once THINKRANK_PLUGIN_DIR . 'includes/seo/class-schema-factory.php';
49 }
50 $this->schema_factory = new Schema_Factory();
51 }
52
53 /**
54 * Validate schema markup against Schema.org specifications
55 * PRESERVED: Exact same method signature and return format from original Schema_Generator
56 *
57 * @since 1.0.0
58 *
59 * @param array $schema Schema markup to validate
60 * @return array Validation results with errors, warnings, and suggestions
61 */
62 public function validate_schema(array $schema): array {
63 $validation = [
64 'valid' => true,
65 'errors' => [],
66 'warnings' => [],
67 'suggestions' => [],
68 'score' => 100
69 ];
70
71 // Check basic structure
72 if (!isset($schema['@context']) || $schema['@context'] !== $this->schema_factory->get_schema_context()) {
73 $validation['errors'][] = 'Missing or invalid @context. Should be: ' . $this->schema_factory->get_schema_context();
74 $validation['valid'] = false;
75 }
76
77 if (!isset($schema['@type'])) {
78 $validation['errors'][] = 'Missing @type property';
79 $validation['valid'] = false;
80 return $validation;
81 }
82
83 $schema_type = $schema['@type'];
84
85 // A LocalBusiness node carries the subtype the site chose ("Dentist"),
86 // and none of the ~150 subtypes has a spec of its own. They all share
87 // LocalBusiness's requirements, so validate against that rather than
88 // reporting the site's own business type as unsupported.
89 if (\ThinkRank\Config\Local_Business_Types_Config::is_local_business($schema_type)) {
90 $schema_type = 'LocalBusiness';
91 }
92
93 $spec =$this->schema_factory->get_schema_specification($schema_type);
94
95 if (empty($spec)) {
96 $validation['errors'][] = "Unsupported schema type: {$schema_type}";
97 $validation['valid'] = false;
98 return $validation;
99 }
100
101 // Check required properties
102 foreach ($spec['required'] as $required_prop) {
103 if (!isset($schema[$required_prop]) || empty($schema[$required_prop])) {
104 $validation['errors'][] = "Missing required property: {$required_prop}";
105 $validation['valid'] = false;
106 }
107 }
108
109 // Check recommended properties
110 $missing_recommended = 0;
111 foreach ($spec['recommended'] as $recommended_prop) {
112 if (!isset($schema[$recommended_prop]) || empty($schema[$recommended_prop])) {
113 $validation['warnings'][] = "Missing recommended property: {$recommended_prop}";
114 $missing_recommended++;
115 }
116 }
117
118 // Calculate score based on completeness
119 $total_props = count($spec['required']) + count($spec['recommended']);
120 $missing_props = count($validation['errors']) + $missing_recommended;
121 $validation['score'] = $total_props > 0 ? (int) round(max(0, 100 - (($missing_props / $total_props) * 100))) : 100;
122
123 // Type-specific validation
124 $validation = $this->validate_schema_type_specific($schema, $schema_type, $validation);
125
126 // SEO-specific suggestions
127 $validation = $this->add_seo_suggestions($schema, $schema_type, $validation);
128
129 return $validation;
130 }
131
132 /**
133 * Validate schema type-specific requirements
134 * PRESERVED: Exact same method logic from original Schema_Generator
135 *
136 * @since 1.0.0
137 *
138 * @param array $schema Schema markup
139 * @param string $schema_type Schema type
140 * @param array $validation Current validation results
141 * @return array Updated validation results
142 */
143 private function validate_schema_type_specific(array $schema, string $schema_type, array $validation): array {
144 switch ($schema_type) {
145 case 'Article':
146 case 'BlogPosting':
147 case 'TechnicalArticle':
148 case 'NewsArticle':
149 case 'ScholarlyArticle':
150 case 'Report':
151 $validation = $this->validate_article_schema($schema, $validation);
152 break;
153 case 'Product':
154 $validation = $this->validate_product_schema($schema, $validation);
155 break;
156 case 'Organization':
157 $validation = $this->validate_organization_schema($schema, $validation);
158 break;
159 case 'LocalBusiness':
160 $validation = $this->validate_local_business_schema($schema, $validation);
161 break;
162 case 'WebSite':
163 $validation = $this->validate_website_schema($schema, $validation);
164 break;
165 case 'FAQPage':
166 $validation = $this->validate_faq_schema($schema, $validation);
167 break;
168 case 'VideoObject':
169 $validation = $this->validate_video_object_schema($schema, $validation);
170 break;
171 }
172
173 return $validation;
174 }
175
176 /**
177 * Validate VideoObject schema specific requirements.
178 *
179 * Google requires name, description, thumbnailUrl and uploadDate. A video is
180 * only playable with a contentUrl or embedUrl, so warn when both are missing.
181 *
182 * @since 1.0.0
183 *
184 * @param array $schema Schema markup
185 * @param array $validation Current validation results
186 * @return array Updated validation results
187 */
188 private function validate_video_object_schema(array $schema, array $validation): array {
189 foreach (['name', 'description', 'thumbnailUrl', 'uploadDate'] as $field) {
190 if (empty($schema[$field])) {
191 $validation['errors'][] = sprintf('VideoObject schema requires a %s', $field);
192 $validation['valid'] = false;
193 }
194 }
195
196 if (empty($schema['contentUrl']) && empty($schema['embedUrl'])) {
197 $validation['warnings'][] = 'VideoObject should include a contentUrl or embedUrl so the video is playable';
198 }
199
200 return $validation;
201 }
202
203 /**
204 * Validate Article schema specific requirements
205 * PRESERVED: Exact same method logic from original Schema_Generator
206 *
207 * @since 1.0.0
208 *
209 * @param array $schema Schema markup
210 * @param array $validation Current validation results
211 * @return array Updated validation results
212 */
213 private function validate_article_schema(array $schema, array $validation): array {
214 // Check headline length (Google recommends under 110 characters)
215 if (isset($schema['headline']) && is_string($schema['headline']) && strlen($schema['headline']) > 110) {
216 $validation['warnings'][] = 'Headline is longer than 110 characters, may be truncated in search results';
217 }
218
219 // Check for image
220 if (!isset($schema['image'])) {
221 $validation['suggestions'][] = 'Add an image to improve rich snippet appearance';
222 }
223
224 // Check for author structure
225 if (isset($schema['author']) && is_array($schema['author'])) {
226 if (!isset($schema['author']['@type']) || $schema['author']['@type'] !== 'Person') {
227 $validation['warnings'][] = 'Author should be structured as a Person entity';
228 }
229 }
230
231 // Check for publisher
232 if (!isset($schema['publisher'])) {
233 $validation['suggestions'][] = 'Add publisher information for better credibility';
234 }
235
236 return $validation;
237 }
238
239 /**
240 * Validate Product schema specific requirements
241 * PRESERVED: Exact same method logic from original Schema_Generator
242 *
243 * @since 1.0.0
244 *
245 * @param array $schema Schema markup
246 * @param array $validation Current validation results
247 * @return array Updated validation results
248 */
249 private function validate_product_schema(array $schema, array $validation): array {
250 // Check for offers
251 if (!isset($schema['offers'])) {
252 $validation['suggestions'][] = 'Add price information with offers property';
253 } else {
254 // Validate offers structure
255 $offers = $schema['offers'];
256 if (!isset($offers['@type']) || $offers['@type'] !== 'Offer') {
257 $validation['warnings'][] = 'Offers should be structured as an Offer entity';
258 }
259 if (!isset($offers['price']) || !isset($offers['priceCurrency'])) {
260 $validation['warnings'][] = 'Offers should include price and priceCurrency';
261 }
262 }
263
264 // Check for brand
265 if (!isset($schema['brand'])) {
266 $validation['suggestions'][] = 'Add brand information to improve product visibility';
267 }
268
269 // Check for reviews or ratings
270 if (!isset($schema['review']) && !isset($schema['aggregateRating'])) {
271 $validation['suggestions'][] = 'Add reviews or ratings to improve product credibility';
272 }
273
274 return $validation;
275 }
276
277 /**
278 * Validate Organization schema specific requirements
279 * PRESERVED: Exact same method logic from original Schema_Generator
280 *
281 * @since 1.0.0
282 *
283 * @param array $schema Schema markup
284 * @param array $validation Current validation results
285 * @return array Updated validation results
286 */
287 private function validate_organization_schema(array $schema, array $validation): array {
288 // Check for logo - properly check if it exists and has URL
289 if (!isset($schema['logo']) || empty($schema['logo']) ||
290 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
291 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
292 }
293
294 // Check for contact information - properly validate the structure
295 if (isset($schema['contactPoint']) && !empty($schema['contactPoint']) && is_array($schema['contactPoint'])) {
296 $contact = $schema['contactPoint'];
297
298 // Only suggest missing fields if they're actually missing
299 if (!isset($contact['telephone']) || empty($contact['telephone'])) {
300 $validation['suggestions'][] = 'Contact phone number is recommended for organization schema.';
301 }
302 if (!isset($contact['email']) || empty($contact['email'])) {
303 $validation['suggestions'][] = 'Contact email is recommended for organization schema.';
304 }
305 if (!isset($contact['contactType']) || empty($contact['contactType'])) {
306 $validation['suggestions'][] = 'Contact type is recommended for organization schema.';
307 }
308 } else {
309 // Only suggest if contactPoint is completely missing or invalid
310 $validation['suggestions'][] = 'Contact information is recommended for organization schema.';
311 }
312
313 // Check for address (especially for LocalBusiness)
314 if ($schema['@type'] === 'LocalBusiness' && (!isset($schema['address']) || empty($schema['address']))) {
315 $validation['errors'][] = 'LocalBusiness requires an address';
316 $validation['valid'] = false;
317 }
318
319 // Check for social media profiles - properly validate array
320 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
321 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
322 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
323 })) === 0)) {
324 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for organization schema - add Facebook, Twitter, LinkedIn, Instagram, or YouTube URLs.';
325 }
326
327 return $validation;
328 }
329
330 /**
331 * Validate LocalBusiness schema specific requirements
332 *
333 * @since 1.0.0
334 *
335 * @param array $schema Schema markup
336 * @param array $validation Current validation results
337 * @return array Updated validation results
338 */
339 private function validate_local_business_schema(array $schema, array $validation): array {
340 // Check for logo - properly check if it exists and has URL
341 if (!isset($schema['logo']) || empty($schema['logo']) ||
342 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
343 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
344 }
345
346 // LocalBusiness uses direct telephone property, not contactPoint
347 if (!isset($schema['telephone']) || empty($schema['telephone'])) {
348 $validation['suggestions'][] = 'Contact phone number is recommended for local business schema.';
349 }
350
351 // Check for address (required for LocalBusiness)
352 if (!isset($schema['address']) || empty($schema['address'])) {
353 $validation['errors'][] = 'LocalBusiness requires an address';
354 $validation['valid'] = false;
355 }
356
357 // Check for social media profiles - properly validate array
358 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
359 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
360 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
361 })) === 0)) {
362 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for local business schema.';
363 }
364
365 return $validation;
366 }
367
368 /**
369 * Validate Website schema specific requirements
370 *
371 * @since 1.0.0
372 *
373 * @param array $schema Schema markup
374 * @param array $validation Current validation results
375 * @return array Updated validation results
376 */
377 private function validate_website_schema(array $schema, array $validation): array {
378 // Check for logo - properly check both direct logo and publisher logo
379 $has_logo = false;
380 if (isset($schema['logo']) && !empty($schema['logo']) &&
381 (is_string($schema['logo']) || (is_array($schema['logo']) && !empty($schema['logo']['url'])))) {
382 $has_logo = true;
383 } elseif (isset($schema['publisher']['logo']) && !empty($schema['publisher']['logo']) &&
384 (is_string($schema['publisher']['logo']) || (is_array($schema['publisher']['logo']) && !empty($schema['publisher']['logo']['url'])))) {
385 $has_logo = true;
386 }
387
388 if (!$has_logo) {
389 $validation['suggestions'][] = 'No logo configured. Add a logo in Site Identity > Site Assets for better brand recognition.';
390 }
391
392 // Check for search action (potentialAction) - properly validate structure
393 if (!isset($schema['potentialAction']) || empty($schema['potentialAction']) ||
394 (is_array($schema['potentialAction']) && empty($schema['potentialAction']['@type']))) {
395 $validation['suggestions'][] = 'Add search functionality with potentialAction for enhanced search box appearance.';
396 }
397
398 // Check for publisher information - properly validate structure
399 if (!isset($schema['publisher']) || empty($schema['publisher']) ||
400 (is_array($schema['publisher']) && empty($schema['publisher']['name']))) {
401 $validation['suggestions'][] = 'Add publisher information for better website credibility.';
402 }
403
404 return $validation;
405 }
406
407 /**
408 * Validate FAQ schema specific requirements
409 * PRESERVED: Exact same method logic from original Schema_Generator
410 *
411 * @since 1.0.0
412 *
413 * @param array $schema Schema markup
414 * @param array $validation Current validation results
415 * @return array Updated validation results
416 */
417 private function validate_faq_schema(array $schema, array $validation): array {
418 // Check minimum number of questions
419 if (isset($schema['mainEntity']) && is_array($schema['mainEntity']) && count($schema['mainEntity']) < 2) {
420 $validation['warnings'][] = 'FAQ pages should have at least 2 questions for optimal SEO';
421 }
422
423 // Validate question structure
424 if (isset($schema['mainEntity']) && is_array($schema['mainEntity'])) {
425 foreach ($schema['mainEntity'] as $index => $question) {
426 if (!isset($question['@type']) || $question['@type'] !== 'Question') {
427 $validation['warnings'][] = "FAQ item {$index} should be structured as a Question entity";
428 }
429 if (!isset($question['acceptedAnswer'])) {
430 $validation['errors'][] = "FAQ question {$index} is missing acceptedAnswer";
431 $validation['valid'] = false;
432 }
433 }
434 }
435
436 return $validation;
437 }
438
439 /**
440 * Add SEO-specific suggestions
441 * PRESERVED: Exact same method logic from original Schema_Generator
442 *
443 * @since 1.0.0
444 *
445 * @param array $schema Schema markup
446 * @param string $schema_type Schema type
447 * @param array $validation Current validation results
448 * @return array Updated validation results
449 */
450 private function add_seo_suggestions(array $schema, string $schema_type, array $validation): array {
451 // General SEO suggestions
452 if (!isset($schema['description'])) {
453 $validation['suggestions'][] = 'Add a description to improve search result snippets';
454 }
455
456 if (!isset($schema['url'])) {
457 $validation['suggestions'][] = 'Add a URL to help search engines understand the content location';
458 }
459
460 // Type-specific SEO suggestions
461 switch ($schema_type) {
462 case 'Article':
463 case 'BlogPosting':
464 if (!isset($schema['dateModified'])) {
465 $validation['suggestions'][] = 'Add dateModified to show content freshness';
466 }
467 if (!isset($schema['wordCount'])) {
468 $validation['suggestions'][] = 'Add wordCount for better content analysis';
469 }
470 break;
471
472 case 'Product':
473 if (!isset($schema['sku'])) {
474 $validation['suggestions'][] = 'Add SKU for better product identification';
475 }
476 break;
477 }
478
479 return $validation;
480 }
481 }
482