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

443 lines 17.3 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 }
160
161 return $validation;
162 }
163
164 /**
165 * Validate Article schema specific requirements
166 * PRESERVED: Exact same method logic from original Schema_Generator
167 *
168 * @since 1.0.0
169 *
170 * @param array $schema Schema markup
171 * @param array $validation Current validation results
172 * @return array Updated validation results
173 */
174 private function validate_article_schema(array $schema, array $validation): array {
175 // Check headline length (Google recommends under 110 characters)
176 if (isset($schema['headline']) && strlen($schema['headline']) > 110) {
177 $validation['warnings'][] = 'Headline is longer than 110 characters, may be truncated in search results';
178 }
179
180 // Check for image
181 if (!isset($schema['image'])) {
182 $validation['suggestions'][] = 'Add an image to improve rich snippet appearance';
183 }
184
185 // Check for author structure
186 if (isset($schema['author']) && is_array($schema['author'])) {
187 if (!isset($schema['author']['@type']) || $schema['author']['@type'] !== 'Person') {
188 $validation['warnings'][] = 'Author should be structured as a Person entity';
189 }
190 }
191
192 // Check for publisher
193 if (!isset($schema['publisher'])) {
194 $validation['suggestions'][] = 'Add publisher information for better credibility';
195 }
196
197 return $validation;
198 }
199
200 /**
201 * Validate Product schema specific requirements
202 * PRESERVED: Exact same method logic from original Schema_Generator
203 *
204 * @since 1.0.0
205 *
206 * @param array $schema Schema markup
207 * @param array $validation Current validation results
208 * @return array Updated validation results
209 */
210 private function validate_product_schema(array $schema, array $validation): array {
211 // Check for offers
212 if (!isset($schema['offers'])) {
213 $validation['suggestions'][] = 'Add price information with offers property';
214 } else {
215 // Validate offers structure
216 $offers = $schema['offers'];
217 if (!isset($offers['@type']) || $offers['@type'] !== 'Offer') {
218 $validation['warnings'][] = 'Offers should be structured as an Offer entity';
219 }
220 if (!isset($offers['price']) || !isset($offers['priceCurrency'])) {
221 $validation['warnings'][] = 'Offers should include price and priceCurrency';
222 }
223 }
224
225 // Check for brand
226 if (!isset($schema['brand'])) {
227 $validation['suggestions'][] = 'Add brand information to improve product visibility';
228 }
229
230 // Check for reviews or ratings
231 if (!isset($schema['review']) && !isset($schema['aggregateRating'])) {
232 $validation['suggestions'][] = 'Add reviews or ratings to improve product credibility';
233 }
234
235 return $validation;
236 }
237
238 /**
239 * Validate Organization schema specific requirements
240 * PRESERVED: Exact same method logic from original Schema_Generator
241 *
242 * @since 1.0.0
243 *
244 * @param array $schema Schema markup
245 * @param array $validation Current validation results
246 * @return array Updated validation results
247 */
248 private function validate_organization_schema(array $schema, array $validation): array {
249 // Check for logo - properly check if it exists and has URL
250 if (!isset($schema['logo']) || empty($schema['logo']) ||
251 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
252 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
253 }
254
255 // Check for contact information - properly validate the structure
256 if (isset($schema['contactPoint']) && !empty($schema['contactPoint']) && is_array($schema['contactPoint'])) {
257 $contact = $schema['contactPoint'];
258
259 // Only suggest missing fields if they're actually missing
260 if (!isset($contact['telephone']) || empty($contact['telephone'])) {
261 $validation['suggestions'][] = 'Contact phone number is recommended for organization schema.';
262 }
263 if (!isset($contact['email']) || empty($contact['email'])) {
264 $validation['suggestions'][] = 'Contact email is recommended for organization schema.';
265 }
266 if (!isset($contact['contactType']) || empty($contact['contactType'])) {
267 $validation['suggestions'][] = 'Contact type is recommended for organization schema.';
268 }
269 } else {
270 // Only suggest if contactPoint is completely missing or invalid
271 $validation['suggestions'][] = 'Contact information is recommended for organization schema.';
272 }
273
274 // Check for address (especially for LocalBusiness)
275 if ($schema['@type'] === 'LocalBusiness' && (!isset($schema['address']) || empty($schema['address']))) {
276 $validation['errors'][] = 'LocalBusiness requires an address';
277 $validation['valid'] = false;
278 }
279
280 // Check for social media profiles - properly validate array
281 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
282 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
283 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
284 })) === 0)) {
285 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for organization schema - add Facebook, Twitter, LinkedIn, Instagram, or YouTube URLs.';
286 }
287
288 return $validation;
289 }
290
291 /**
292 * Validate LocalBusiness schema specific requirements
293 *
294 * @since 1.0.0
295 *
296 * @param array $schema Schema markup
297 * @param array $validation Current validation results
298 * @return array Updated validation results
299 */
300 private function validate_local_business_schema(array $schema, array $validation): array {
301 // Check for logo - properly check if it exists and has URL
302 if (!isset($schema['logo']) || empty($schema['logo']) ||
303 (is_array($schema['logo']) && empty($schema['logo']['url']))) {
304 $validation['suggestions'][] = 'Add a logo to improve brand recognition';
305 }
306
307 // LocalBusiness uses direct telephone property, not contactPoint
308 if (!isset($schema['telephone']) || empty($schema['telephone'])) {
309 $validation['suggestions'][] = 'Contact phone number is recommended for local business schema.';
310 }
311
312 // Check for address (required for LocalBusiness)
313 if (!isset($schema['address']) || empty($schema['address'])) {
314 $validation['errors'][] = 'LocalBusiness requires an address';
315 $validation['valid'] = false;
316 }
317
318 // Check for social media profiles - properly validate array
319 if (!isset($schema['sameAs']) || empty($schema['sameAs']) ||
320 (is_array($schema['sameAs']) && count(array_filter($schema['sameAs'], function($url) {
321 return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
322 })) === 0)) {
323 $validation['suggestions'][] = 'Social media profiles (sameAs) are recommended for local business schema.';
324 }
325
326 return $validation;
327 }
328
329 /**
330 * Validate Website schema specific requirements
331 *
332 * @since 1.0.0
333 *
334 * @param array $schema Schema markup
335 * @param array $validation Current validation results
336 * @return array Updated validation results
337 */
338 private function validate_website_schema(array $schema, array $validation): array {
339 // Check for logo - properly check both direct logo and publisher logo
340 $has_logo = false;
341 if (isset($schema['logo']) && !empty($schema['logo']) &&
342 (is_string($schema['logo']) || (is_array($schema['logo']) && !empty($schema['logo']['url'])))) {
343 $has_logo = true;
344 } elseif (isset($schema['publisher']['logo']) && !empty($schema['publisher']['logo']) &&
345 (is_string($schema['publisher']['logo']) || (is_array($schema['publisher']['logo']) && !empty($schema['publisher']['logo']['url'])))) {
346 $has_logo = true;
347 }
348
349 if (!$has_logo) {
350 $validation['suggestions'][] = 'No logo configured. Add a logo in Site Identity > Site Assets for better brand recognition.';
351 }
352
353 // Check for search action (potentialAction) - properly validate structure
354 if (!isset($schema['potentialAction']) || empty($schema['potentialAction']) ||
355 (is_array($schema['potentialAction']) && empty($schema['potentialAction']['@type']))) {
356 $validation['suggestions'][] = 'Add search functionality with potentialAction for enhanced search box appearance.';
357 }
358
359 // Check for publisher information - properly validate structure
360 if (!isset($schema['publisher']) || empty($schema['publisher']) ||
361 (is_array($schema['publisher']) && empty($schema['publisher']['name']))) {
362 $validation['suggestions'][] = 'Add publisher information for better website credibility.';
363 }
364
365 return $validation;
366 }
367
368 /**
369 * Validate FAQ schema specific requirements
370 * PRESERVED: Exact same method logic from original Schema_Generator
371 *
372 * @since 1.0.0
373 *
374 * @param array $schema Schema markup
375 * @param array $validation Current validation results
376 * @return array Updated validation results
377 */
378 private function validate_faq_schema(array $schema, array $validation): array {
379 // Check minimum number of questions
380 if (isset($schema['mainEntity']) && count($schema['mainEntity']) < 2) {
381 $validation['warnings'][] = 'FAQ pages should have at least 2 questions for optimal SEO';
382 }
383
384 // Validate question structure
385 if (isset($schema['mainEntity']) && is_array($schema['mainEntity'])) {
386 foreach ($schema['mainEntity'] as $index => $question) {
387 if (!isset($question['@type']) || $question['@type'] !== 'Question') {
388 $validation['warnings'][] = "FAQ item {$index} should be structured as a Question entity";
389 }
390 if (!isset($question['acceptedAnswer'])) {
391 $validation['errors'][] = "FAQ question {$index} is missing acceptedAnswer";
392 $validation['valid'] = false;
393 }
394 }
395 }
396
397 return $validation;
398 }
399
400 /**
401 * Add SEO-specific suggestions
402 * PRESERVED: Exact same method logic from original Schema_Generator
403 *
404 * @since 1.0.0
405 *
406 * @param array $schema Schema markup
407 * @param string $schema_type Schema type
408 * @param array $validation Current validation results
409 * @return array Updated validation results
410 */
411 private function add_seo_suggestions(array $schema, string $schema_type, array $validation): array {
412 // General SEO suggestions
413 if (!isset($schema['description'])) {
414 $validation['suggestions'][] = 'Add a description to improve search result snippets';
415 }
416
417 if (!isset($schema['url'])) {
418 $validation['suggestions'][] = 'Add a URL to help search engines understand the content location';
419 }
420
421 // Type-specific SEO suggestions
422 switch ($schema_type) {
423 case 'Article':
424 case 'BlogPosting':
425 if (!isset($schema['dateModified'])) {
426 $validation['suggestions'][] = 'Add dateModified to show content freshness';
427 }
428 if (!isset($schema['wordCount'])) {
429 $validation['suggestions'][] = 'Add wordCount for better content analysis';
430 }
431 break;
432
433 case 'Product':
434 if (!isset($schema['sku'])) {
435 $validation['suggestions'][] = 'Add SKU for better product identification';
436 }
437 break;
438 }
439
440 return $validation;
441 }
442 }
443