PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.0.2 1.1.0 1.10.0 1.11.0 All 47 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 trunk, at includes/seo/class-schema-validator.php

473 lines 18.5 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 $spec = $this->schema_factory->get_schema_specification($schema_type);
85
86 if (empty($spec)) {
87 $validation['errors'][] = "Unsupported schema type: {$schema_type}";
88 $validation['valid'] = false;
89 return $validation;
90 }
91
92 // Check required properties
93 foreach ($spec['required'] as $required_prop) {
94 if (!isset($schema[$required_prop]) || empty($schema[$required_prop])) {
95 $validation['errors'][] = "Missing required property: {$required_prop}";
96 $validation['valid'] = false;
97 }
98 }
99
100 // Check recommended properties
101 $missing_recommended = 0;
102 foreach ($spec['recommended'] as $recommended_prop) {
103 if (!isset($schema[$recommended_prop]) || empty($schema[$recommended_prop])) {
104 $validation['warnings'][] = "Missing recommended property: {$recommended_prop}";
105 $missing_recommended++;
106 }
107 }
108
109 // Calculate score based on completeness
110 $total_props = count($spec['required']) + count($spec['recommended']);
111 $missing_props = count($validation['errors']) + $missing_recommended;
112 $validation['score'] = $total_props > 0 ? (int) round(max(0, 100 - (($missing_props / $total_props) * 100))) : 100;
113
114 // Type-specific validation
115 $validation = $this->validate_schema_type_specific($schema, $schema_type, $validation);
116
117 // SEO-specific suggestions
118 $validation = $this->add_seo_suggestions($schema, $schema_type, $validation);
119
120 return $validation;
121 }
122
123 /**
124 * Validate schema type-specific requirements
125 * PRESERVED: Exact same method logic from original Schema_Generator
126 *
127 * @since 1.0.0
128 *
129 * @param array $schema Schema markup
130 * @param string $schema_type Schema type
131 * @param array $validation Current validation results
132 * @return array Updated validation results
133 */
134 private function validate_schema_type_specific(array $schema, string $schema_type, array $validation): array {
135 switch ($schema_type) {
136 case 'Article':
137 case 'BlogPosting':
138 case 'TechnicalArticle':
139 case 'NewsArticle':
140 case 'ScholarlyArticle':
141 case 'Report':
142 $validation = $this->validate_article_schema($schema, $validation);
143 break;
144 case 'Product':
145 $validation = $this->validate_product_schema($schema, $validation);
146 break;
147 case 'Organization':
148 $validation = $this->validate_organization_schema($schema, $validation);
149 break;
150 case 'LocalBusiness':
151 $validation = $this->validate_local_business_schema($schema, $validation);
152 break;
153 case 'WebSite':
154 $validation = $this->validate_website_schema($schema, $validation);
155 break;
156 case 'FAQPage':
157 $validation = $this->validate_faq_schema($schema, $validation);
158 break;
159 case 'VideoObject':
160 $validation = $this->validate_video_object_schema($schema, $validation);
161 break;
162 }
163
164 return $validation;
165 }
166
167 /**
168 * Validate VideoObject schema specific requirements.
169 *
170 * Google requires name, description, thumbnailUrl and uploadDate. A video is
171 * only playable with a contentUrl or embedUrl, so warn when both are missing.
172 *
173 * @since 1.0.0
174 *
175 * @param array $schema Schema markup
176 * @param array $validation Current validation results
177 * @return array Updated validation results
178 */
179 private function validate_video_object_schema(array $schema, array $validation): array {
180 foreach (['name', 'description', 'thumbnailUrl', 'uploadDate'] as $field) {
181 if (empty($schema[$field])) {
182 $validation['errors'][] = sprintf('VideoObject schema requires a %s', $field);
183 $validation['valid'] = false;
184 }
185 }
186
187 if (empty($schema['contentUrl']) && empty($schema['embedUrl'])) {
188 $validation['warnings'][] = 'VideoObject should include a contentUrl or embedUrl so the video is playable';
189 }
190
191 return $validation;
192 }
193
194 /**
195 * Validate Article schema specific requirements
196 * PRESERVED: Exact same method logic from original Schema_Generator
197 *
198 * @since 1.0.0
199 *
200 * @param array $schema Schema markup
201 * @param array $validation Current validation results
202 * @return array Updated validation results
203 */
204 private function validate_article_schema(array $schema, array $validation): array {
205 // Check headline length (Google recommends under 110 characters)
206 if (isset($schema['headline']) && is_string($schema['headline']) && strlen($schema['headline']) > 110) {
207 $validation['warnings'][] = 'Headline is longer than 110 characters, may be truncated in search results';
208 }
209
210 // Check for image
211 if (!isset($schema['image'])) {
212 $validation['suggestions'][] = 'Add an image to improve rich snippet appearance';
213 }
214
215 // Check for author structure
216 if (isset($schema['author']) && is_array($schema['author'])) {
217 if (!isset($schema['author']['@type']) || $schema['author']['@type'] !== 'Person') {
218 $validation['warnings'][] = 'Author should be structured as a Person entity';
219 }
220 }
221
222 // Check for publisher
223 if (!isset($schema['publisher'])) {
224 $validation['suggestions'][] = 'Add publisher information for better credibility';
225 }
226
227 return $validation;
228 }
229
230 /**
231 * Validate Product schema specific requirements
232 * PRESERVED: Exact same method logic from original Schema_Generator
233 *
234 * @since 1.0.0
235 *
236 * @param array $schema Schema markup
237 * @param array $validation Current validation results
238 * @return array Updated validation results
239 */
240 private function validate_product_schema(array $schema, array $validation): array {
241 // Check for offers
242 if (!isset($schema['offers'])) {
243 $validation['suggestions'][] = 'Add price information with offers property';
244 } else {
245 // Validate offers structure
246 $offers = $schema['offers'];
247 if (!isset($offers['@type']) || $offers['@type'] !== 'Offer') {
248 $validation['warnings'][] = 'Offers should be structured as an Offer entity';
249 }
250 if (!isset($offers['price']) || !isset($offers['priceCurrency'])) {
251 $validation['warnings'][] = 'Offers should include price and priceCurrency';
252 }
253 }
254
255 // Check for brand
256 if (!isset($schema['brand'])) {
257 $validation['suggestions'][] = 'Add brand information to improve product visibility';
258 }
259
260 // Check for reviews or ratings
261 if (!isset($schema['review']) && !isset($schema['aggregateRating'])) {
262 $validation['suggestions'][] = 'Add reviews or ratings to improve product credibility';
263 }
264
265 return $validation;
266 }
267
268 /**
269 * Validate Organization schema specific requirements
270 * PRESERVED: Exact same method logic from original Schema_Generator
271 *
272 * @since 1.0.0
273 *
274 * @param array $schema Schema markup
275 * @param array $validation Current validation results
276 * @return array Updated validation results
277 */
278 private function validate_organization_schema(array $schema, array $validation): array {
279 // Check for logo - properly check if it exists and has URL
280 if (!isset($schema['logo']) || empty($schema['logo']) ||
281 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
282 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
283 }
284
285 // Check for contact information - properly validate the structure
286 if (isset($schema['contactPoint']) && !empty($schema['contactPoint']) && is_array($schema['contactPoint'])) {
287 $contact = $schema['contactPoint'];
288
289 // Only suggest missing fields if they're actually missing
290 if (!isset($contact['telephone']) || empty($contact['telephone'])) {
291 $validation['suggestions'][] = 'Contact phone number is recommended for organization schema.';
292 }
293 if (!isset($contact['email']) || empty($contact['email'])) {
294 $validation['suggestions'][] = 'Contact email is recommended for organization schema.';
295 }
296 if (!isset($contact['contactType']) || empty($contact['contactType'])) {
297 $validation['suggestions'][] = 'Contact type is recommended for organization schema.';
298 }
299 } else {
300 // Only suggest if contactPoint is completely missing or invalid
301 $validation['suggestions'][] = 'Contact information is recommended for organization schema.';
302 }
303
304 // Check for address (especially for LocalBusiness)
305 if ($schema['@type'] === 'LocalBusiness' && (!isset($schema['address']) || empty($schema['address']))) {
306 $validation['errors'][] = 'LocalBusiness requires an address';
307 $validation['valid'] = false;
308 }
309
310 // Check for social media profiles - properly validate array
311 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
312 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
313 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
314 })) === 0)) {
315 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for organization schema - add Facebook, Twitter, LinkedIn, Instagram, or YouTube URLs.';
316 }
317
318 return $validation;
319 }
320
321 /**
322 * Validate LocalBusiness schema specific requirements
323 *
324 * @since 1.0.0
325 *
326 * @param array $schema Schema markup
327 * @param array $validation Current validation results
328 * @return array Updated validation results
329 */
330 private function validate_local_business_schema(array $schema, array $validation): array {
331 // Check for logo - properly check if it exists and has URL
332 if (!isset($schema['logo']) || empty($schema['logo']) ||
333 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
334 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
335 }
336
337 // LocalBusiness uses direct telephone property, not contactPoint
338 if (!isset($schema['telephone']) || empty($schema['telephone'])) {
339 $validation['suggestions'][] = 'Contact phone number is recommended for local business schema.';
340 }
341
342 // Check for address (required for LocalBusiness)
343 if (!isset($schema['address']) || empty($schema['address'])) {
344 $validation['errors'][] = 'LocalBusiness requires an address';
345 $validation['valid'] = false;
346 }
347
348 // Check for social media profiles - properly validate array
349 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
350 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
351 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
352 })) === 0)) {
353 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for local business schema.';
354 }
355
356 return $validation;
357 }
358
359 /**
360 * Validate Website schema specific requirements
361 *
362 * @since 1.0.0
363 *
364 * @param array $schema Schema markup
365 * @param array $validation Current validation results
366 * @return array Updated validation results
367 */
368 private function validate_website_schema(array $schema, array $validation): array {
369 // Check for logo - properly check both direct logo and publisher logo
370 $has_logo = false;
371 if (isset($schema['logo']) && !empty($schema['logo']) &&
372 (is_string($schema['logo']) || (is_array($schema['logo']) && !empty($schema['logo']['url'])))) {
373 $has_logo = true;
374 } elseif (isset($schema['publisher']['logo']) && !empty($schema['publisher']['logo']) &&
375 (is_string($schema['publisher']['logo']) || (is_array($schema['publisher']['logo']) && !empty($schema['publisher']['logo']['url'])))) {
376 $has_logo = true;
377 }
378
379 if (!$has_logo) {
380 $validation['suggestions'][] = 'No logo configured. Add a logo in Site Identity > Site Assets for better brand recognition.';
381 }
382
383 // Check for search action (potentialAction) - properly validate structure
384 if (!isset($schema['potentialAction']) || empty($schema['potentialAction']) ||
385 (is_array($schema['potentialAction']) && empty($schema['potentialAction']['@type']))) {
386 $validation['suggestions'][] = 'Add search functionality with potentialAction for enhanced search box appearance.';
387 }
388
389 // Check for publisher information - properly validate structure
390 if (!isset($schema['publisher']) || empty($schema['publisher']) ||
391 (is_array($schema['publisher']) && empty($schema['publisher']['name']))) {
392 $validation['suggestions'][] = 'Add publisher information for better website credibility.';
393 }
394
395 return $validation;
396 }
397
398 /**
399 * Validate FAQ schema specific requirements
400 * PRESERVED: Exact same method logic from original Schema_Generator
401 *
402 * @since 1.0.0
403 *
404 * @param array $schema Schema markup
405 * @param array $validation Current validation results
406 * @return array Updated validation results
407 */
408 private function validate_faq_schema(array $schema, array $validation): array {
409 // Check minimum number of questions
410 if (isset($schema['mainEntity']) && is_array($schema['mainEntity']) && count($schema['mainEntity']) < 2) {
411 $validation['warnings'][] = 'FAQ pages should have at least 2 questions for optimal SEO';
412 }
413
414 // Validate question structure
415 if (isset($schema['mainEntity']) && is_array($schema['mainEntity'])) {
416 foreach ($schema['mainEntity'] as $index => $question) {
417 if (!isset($question['@type']) || $question['@type'] !== 'Question') {
418 $validation['warnings'][] = "FAQ item {$index} should be structured as a Question entity";
419 }
420 if (!isset($question['acceptedAnswer'])) {
421 $validation['errors'][] = "FAQ question {$index} is missing acceptedAnswer";
422 $validation['valid'] = false;
423 }
424 }
425 }
426
427 return $validation;
428 }
429
430 /**
431 * Add SEO-specific suggestions
432 * PRESERVED: Exact same method logic from original Schema_Generator
433 *
434 * @since 1.0.0
435 *
436 * @param array $schema Schema markup
437 * @param string $schema_type Schema type
438 * @param array $validation Current validation results
439 * @return array Updated validation results
440 */
441 private function add_seo_suggestions(array $schema, string $schema_type, array $validation): array {
442 // General SEO suggestions
443 if (!isset($schema['description'])) {
444 $validation['suggestions'][] = 'Add a description to improve search result snippets';
445 }
446
447 if (!isset($schema['url'])) {
448 $validation['suggestions'][] = 'Add a URL to help search engines understand the content location';
449 }
450
451 // Type-specific SEO suggestions
452 switch ($schema_type) {
453 case 'Article':
454 case 'BlogPosting':
455 if (!isset($schema['dateModified'])) {
456 $validation['suggestions'][] = 'Add dateModified to show content freshness';
457 }
458 if (!isset($schema['wordCount'])) {
459 $validation['suggestions'][] = 'Add wordCount for better content analysis';
460 }
461 break;
462
463 case 'Product':
464 if (!isset($schema['sku'])) {
465 $validation['suggestions'][] = 'Add SKU for better product identification';
466 }
467 break;
468 }
469
470 return $validation;
471 }
472 }
473