Schema::getSpecificEntityType()   A
last analyzed

Complexity

Conditions 6
Paths 5

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 12
ccs 0
cts 6
cp 0
rs 9.2222
c 0
b 0
f 0
cc 6
nc 5
nop 2
crap 42
1
<?php
2
/**
3
 * SEOmatic plugin for Craft CMS
4
 *
5
 * A turnkey SEO implementation for Craft CMS that is comprehensive, powerful,
6
 * and flexible
7
 *
8
 * @link      https://nystudio107.com
9
 * @copyright Copyright (c) 2017 nystudio107
10
 */
11
12
namespace nystudio107\seomatic\helpers;
13
14
use Craft;
15
use craft\helpers\Json as JsonHelper;
16
use Exception;
17
use nystudio107\seomatic\models\MetaJsonLd;
18
use nystudio107\seomatic\Seomatic;
19
use ReflectionClass;
20
use ReflectionException;
21
use yii\caching\TagDependency;
22
use yii\helpers\Markdown;
23
use function get_class;
24
use function is_array;
25
use function is_string;
26
27
/**
28
 * @author    nystudio107
29
 * @package   Seomatic
30
 * @since     3.0.0
31
 */
32
class Schema
33
{
34
    // Constants
35
    // =========================================================================
36
37
    public const SCHEMA_PATH_DELIMITER = '.';
38
    public const MENU_INDENT_STEP = 4;
39
40
    public const SCHEMA_TYPES = [
41
        'siteSpecificType',
42
        'siteSubType',
43
        'siteType',
44
    ];
45
46
    public const GLOBAL_SCHEMA_CACHE_TAG = 'seomatic_schema';
47
    public const SCHEMA_CACHE_TAG = 'seomatic_schema_';
48
49
    public const CACHE_KEY = 'seomatic_schema_';
50
51
    // Static Methods
52
    // =========================================================================
53
54
    /**
55
     * Invalidate all of the schema caches
56
     */
57
    public static function invalidateCaches(): void
58
    {
59
        $cache = Craft::$app->getCache();
60
        TagDependency::invalidate($cache, self::GLOBAL_SCHEMA_CACHE_TAG);
61
        Craft::info(
62
            'All schema caches cleared',
63
            __METHOD__
64
        );
65
    }
66
67
    /**
68
     * Return the most specific schema.org type possible from the $settings
69
     *
70
     * @param $settings
71
     *
72
     * @param bool $allowEmpty
73
     * @return string
74
     */
75
    public static function getSpecificEntityType($settings, bool $allowEmpty = false): string
76
    {
77
        if (!empty($settings)) {
78
            // Go from most specific type to least specific type
79
            foreach (self::SCHEMA_TYPES as $schemaType) {
80
                if (!empty($settings[$schemaType]) && ($settings[$schemaType] !== 'none')) {
81
                    return $settings[$schemaType];
82
                }
83
            }
84
        }
85
86
        return $allowEmpty ? '' : 'WebPage';
87
    }
88
89
    /**
90
     * Return a period-delimited schema.org path from the $settings
91
     *
92
     * @param $settings
93
     *
94
     * @return string
95
     */
96
    public static function getEntityPath($settings): string
97
    {
98
        $result = '';
99
        if (!empty($settings)) {
100
            // Go from most specific type to least specific type
101
            foreach (self::SCHEMA_TYPES as $schemaType) {
102
                if (!empty($settings[$schemaType]) && ($settings[$schemaType] !== 'none')) {
103
                    $result = $settings[$schemaType] . self::SCHEMA_PATH_DELIMITER . $result;
104
                }
105
            }
106
        }
107
108
        return rtrim($result, self::SCHEMA_PATH_DELIMITER);
109
    }
110
111
    /**
112
     * Get the fully composed schema type
113
     *
114
     * @param $schemaType
115
     *
116
     * @return array
117
     */
118
    public static function getSchemaType(string $schemaType): array
119
    {
120
        $result = [];
121
        $jsonLdType = MetaJsonLd::create($schemaType);
122
123
        // Get the static properties
124
        try {
125
            $classRef = new ReflectionClass(get_class($jsonLdType));
126
        } catch (ReflectionException $e) {
127
            $classRef = null;
128
        }
129
        if ($classRef) {
130
            $result = $classRef->getStaticProperties();
131
            if (isset($result['schemaTypeDescription'])) {
132
                $description = $result['schemaTypeDescription'];
133
                $description = preg_replace("`\[\[([A-z]*)\]\]`", '[$1](https://schema.org/$1)', $description);
134
                $description = Markdown::process((string)$description);
135
                $description = str_replace(['<p>', '</p>', '\n'], ['', '', ' '], $description);
136
                $result['schemaTypeDescription'] = $description;
137
            }
138
        }
139
140
        return $result;
141
    }
142
143
    /**
144
     * Get the decomposed schema type
145
     *
146
     * @param string $schemaType
147
     *
148
     * @return array
149
     */
150
    public static function getDecomposedSchemaType(string $schemaType): array
151
    {
152
        $result = [];
153
        while ($schemaType) {
154
            $className = 'nystudio107\\seomatic\\models\\jsonld\\' . $schemaType;
155
            if (class_exists($className)) {
156
                $classRef = new ReflectionClass($className);
157
                $staticProps = $classRef->getStaticProperties();
158
159
                foreach ($staticProps as $key => $value) {
160
                    if ($key[0] === '_') {
161
                        $newKey = ltrim($key, '_');
162
                        $staticProps[$newKey] = $value;
163
                        unset($staticProps[$key]);
164
                    }
165
                }
166
                $result[$schemaType] = $staticProps;
167
                $schemaType = $staticProps['schemaTypeExtends'];
168
                if ($schemaType === 'JsonLdType') {
169
                    $schemaType = null;
170
                }
171
            }
172
        }
173
174
        return $result;
175
    }
176
177
    /**
178
     * Return a flattened, indented menu of the given $path
179
     *
180
     * @param string $path
181
     *
182
     * @return array
183
     */
184
    public static function getTypeMenu($path = ''): array
185
    {
186
        try {
187
            $schemaTypes = self::getSchemaArray($path);
188
        } catch (Exception $e) {
189
            Craft::error($e->getMessage(), __METHOD__);
190
            return [];
191
        }
192
193
        return self::flattenSchemaArray($schemaTypes, 0);
194
    }
195
196
    /**
197
     * Return a single menu of schemas starting at $path
198
     *
199
     * @param string $path
200
     *
201
     * @return array
202
     */
203
    public static function getSingleTypeMenu($path = ''): array
204
    {
205
        $result = [];
206
        try {
207
            $schemaTypes = self::getSchemaArray($path);
208
        } catch (Exception $e) {
209
            Craft::error($e->getMessage(), __METHOD__);
210
            return [];
211
        }
212
        foreach ($schemaTypes as $key => $value) {
213
            $result[$key] = $key;
214
        }
215
216
        return $result;
217
    }
218
219
    /**
220
     * @return array
221
     */
222
    public static function getTypeTree(): array
223
    {
224
        try {
225
            $schemaTypes = self::getSchemaTree();
226
        } catch (Exception $e) {
227
            Craft::error($e->getMessage(), __METHOD__);
228
            return [];
229
        }
230
        $schemaTypes = self::pruneSchemaTree($schemaTypes, '');
231
232
        // Ignore the top level "Thing" base schema
233
        return $schemaTypes['children'] ?? [];
234
    }
235
236
    /**
237
     * Return a hierarchical array of schema types, starting at $path. The $path
238
     * is specified as SchemaType.SubSchemaType using SCHEMA_PATH_DELIMITER as
239
     * the delimiter.
240
     *
241
     * @param string $path
242
     *
243
     * @return array
244
     * @throws Exception
245
     */
246
    public static function getSchemaArray($path = ''): array
247
    {
248
        $dependency = new TagDependency([
249
            'tags' => [
250
                self::GLOBAL_SCHEMA_CACHE_TAG,
251
                self::SCHEMA_CACHE_TAG . 'schemaArray',
252
            ],
253
        ]);
254
        $cache = Craft::$app->getCache();
255
        $typesArray = $cache->getOrSet(
256
            self::CACHE_KEY . 'schemaArray',
257
            function() use ($path) {
258
                Craft::info(
259
                    'schemaArray cache miss' . $path,
260
                    __METHOD__
261
                );
262
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/tree.jsonld');
263
                $schemaTypes = JsonHelper::decode(@file_get_contents($filePath));
0 ignored issues
show
Bug introduced by
It seems like @file_get_contents($filePath) can also be of type false; however, parameter $json of yii\helpers\BaseJson::decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

263
                $schemaTypes = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_get_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

263
                $schemaTypes = JsonHelper::decode(@file_get_contents(/** @scrutinizer ignore-type */ $filePath));
Loading history...
264
                if (empty($schemaTypes)) {
265
                    throw new Exception(Craft::t('seomatic', 'Schema tree file not found'));
266
                }
267
                $schemaTypes = self::makeSchemaAssociative($schemaTypes);
268
                $schemaTypes = self::orphanChildren($schemaTypes);
269
270
                return $schemaTypes;
271
            },
272
            Seomatic::$cacheDuration,
273
            $dependency
274
        );
275
        // Get just the appropriate sub-array
276
        if (!empty($path)) {
277
            $keys = explode(self::SCHEMA_PATH_DELIMITER, $path);
278
            foreach ($keys as $key) {
279
                if (!empty($typesArray[$key])) {
280
                    $typesArray = $typesArray[$key];
281
                }
282
            }
283
        }
284
        if (!is_array($typesArray)) {
285
            $typesArray = [];
286
        }
287
288
        return $typesArray;
289
    }
290
291
    /**
292
     * Return the schema layer, and Google Rich Snippet info
293
     *
294
     * @param string $schemaName
295
     * @return array
296
     * @throws Exception
297
     */
298
    public static function getTypeMetaInfo($schemaName): array
299
    {
300
        $metaInfo = [
301
            'schemaPending' => false,
302
            'schemaRichSnippetUrls' => [],
303
        ];
304
        $schemaTree = self::getSchemaTree();
305
        $schemaArray = self::pluckSchemaArray($schemaTree, $schemaName);
306
        if (!empty($schemaArray)) {
307
            $googleRichSnippetTypes = self::getGoogleRichSnippets();
308
            $metaInfo['schemaPending'] = $schemaArray['pending'] ?? false;
309
            $metaInfo['schemaRichSnippetUrls'] = $googleRichSnippetTypes[$schemaArray['name']] ?? [];
310
        }
311
312
        return $metaInfo;
313
    }
314
315
    /**
316
     * @return array
317
     * @throws Exception
318
     */
319
    public static function getSchemaTree()
320
    {
321
        $dependency = new TagDependency([
322
            'tags' => [
323
                self::GLOBAL_SCHEMA_CACHE_TAG,
324
                self::SCHEMA_CACHE_TAG . 'schemaTree',
325
            ],
326
        ]);
327
        $cache = Craft::$app->getCache();
328
        $typesArray = $cache->getOrSet(
329
            self::CACHE_KEY . 'schemaTree',
330
            function() {
331
                Craft::info(
332
                    'schemaArray cache miss',
333
                    __METHOD__
334
                );
335
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/tree.jsonld');
336
                $schemaTree = JsonHelper::decode(@file_get_contents($filePath));
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_get_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

336
                $schemaTree = JsonHelper::decode(@file_get_contents(/** @scrutinizer ignore-type */ $filePath));
Loading history...
Bug introduced by
It seems like @file_get_contents($filePath) can also be of type false; however, parameter $json of yii\helpers\BaseJson::decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

336
                $schemaTree = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
337
                if (empty($schemaTree)) {
338
                    throw new Exception(Craft::t('seomatic', 'Schema tree file not found'));
339
                }
340
341
                return $schemaTree;
342
            },
343
            Seomatic::$cacheDuration,
344
            $dependency
345
        );
346
        if (!is_array($typesArray)) {
347
            $typesArray = [];
348
        }
349
350
        return $typesArray;
351
    }
352
353
    /**
354
     * Traverse the schema tree and pluck a single type array from it
355
     *
356
     * @param $schemaTree
357
     * @param $schemaName
358
     * @return array
359
     */
360
    protected static function pluckSchemaArray($schemaTree, $schemaName): array
361
    {
362
        if (!empty($schemaTree['children']) && is_array($schemaTree['children'])) {
363
            foreach ($schemaTree['children'] as $key => $value) {
364
                if (!empty($value['name']) && $value['name'] === $schemaName) {
365
                    unset($value['children']);
366
                    return $value;
367
                }
368
                if (!empty($value['children'])) {
369
                    $result = self::pluckSchemaArray($value, $schemaName);
370
                    if (!empty($result)) {
371
                        unset($result['children']);
372
                        return $result;
373
                    }
374
                }
375
            }
376
        }
377
378
        return [];
379
    }
380
381
    /**
382
     * @param array $typesArray
383
     * @param       $indentLevel
384
     *
385
     * @return array
386
     */
387
    protected static function flattenSchemaArray(array $typesArray, int $indentLevel): array
388
    {
389
        $result = [];
390
        foreach ($typesArray as $key => $value) {
391
            $indent = html_entity_decode(str_repeat('&nbsp;', $indentLevel));
392
            if (is_array($value)) {
393
                $result[$key] = $indent . $key;
394
                $value = self::flattenSchemaArray($value, $indentLevel + self::MENU_INDENT_STEP);
395
                $result = array_merge($result, $value);
396
            } else {
397
                $result[$key] = $indent . $value;
398
            }
399
        }
400
401
        return $result;
402
    }
403
404
    /**
405
     * Reduce everything in the $schemaTypes array to a simple hierarchical
406
     * array as 'SchemaType' => 'SchemaType' if it has no children, and if it
407
     * has children, as 'SchemaType' = [] with an array of sub-types
408
     *
409
     * @param array $typesArray
410
     *
411
     * @return array
412
     */
413
    protected static function orphanChildren(array $typesArray): array
414
    {
415
        $result = [];
416
417
        if (!empty($typesArray['children']) && is_array($typesArray['children'])) {
418
            foreach ($typesArray['children'] as $key => $value) {
419
                $key = '';
420
                if (!empty($value['name'])) {
421
                    $key = $value['name'];
422
                }
423
                if (!empty($value['children'])) {
424
                    $value = self::orphanChildren($value);
425
                } else {
426
                    $value = $key;
427
                }
428
                if (!empty($key)) {
429
                    $result[$key] = $value;
430
                }
431
            }
432
        }
433
434
        return $result;
435
    }
436
437
    /**
438
     * Return a new array that has each type returned as an associative array
439
     * as 'SchemaType' => [] rather than the way the tree.jsonld file has it
440
     * stored as a non-associative array
441
     *
442
     * @param array $typesArray
443
     *
444
     * @return array
445
     */
446
    protected static function makeSchemaAssociative(array $typesArray): array
447
    {
448
        $result = [];
449
450
        foreach ($typesArray as $key => $value) {
451
            if (isset($value['name'])) {
452
                $key = $value['name'];
453
            }
454
            if (is_array($value)) {
455
                $value = self::makeSchemaAssociative($value);
456
            }
457
            if (isset($value['layer']) && is_string($value['layer'])) {
458
                if ($value['layer'] === 'core' || $value['layer'] === 'pending') {
459
                    $result[$key] = $value;
460
                }
461
            } else {
462
                $result[$key] = $value;
463
            }
464
        }
465
466
        return $result;
467
    }
468
469
    /**
470
     * Prune the schema tree by removing everything but `label`, `id`, and `children`
471
     * in preparation for use by the treeselect component. Also make the id a namespaced
472
     * path to the schema, with the first two higher level schema types, and then the
473
     * third final type (skipping any in between)
474
     *
475
     * @param array $typesArray
476
     * @param string $path
477
     * @return array
478
     */
479
    protected static function pruneSchemaTree(array $typesArray, string $path): array
480
    {
481
        $result = [];
482
483
        // Don't include any "attic" (deprecated) schemas
484
        if (isset($typesArray['attic']) && $typesArray['attic']) {
485
            return [];
486
        }
487
        if (isset($typesArray['name']) && is_string($typesArray['name'])) {
488
            $children = [];
489
            $name = $typesArray['name'];
490
            // Construct a path-based $id, excluding the top-level `Thing` schema
491
            $id = $name === 'Thing' ? '' : $path . self::SCHEMA_PATH_DELIMITER . $name;
492
            $id = ltrim($id, self::SCHEMA_PATH_DELIMITER);
493
            // Make sure we have at most 3 specifiers in the schema path
494
            $parts = explode(self::SCHEMA_PATH_DELIMITER, $id);
495
            if (count($parts) > 3) {
496
                $id = implode(self::SCHEMA_PATH_DELIMITER, [
497
                    $parts[0],
498
                    $parts[1],
499
                    end($parts),
500
                ]);
501
            }
502
            if (!empty($typesArray['children'])) {
503
                foreach ($typesArray['children'] as $child) {
504
                    $childResult = self::pruneSchemaTree($child, $id);
505
                    if (!empty($childResult)) {
506
                        $children[] = $childResult;
507
                    }
508
                }
509
                if (!empty($children)) {
510
                    $result['children'] = $children;
511
                }
512
            }
513
            // Check to see if this is a Google Rich Snippet schema
514
            $googleRichSnippetTypes = self::getGoogleRichSnippets();
515
            $schemaPath = explode(self::SCHEMA_PATH_DELIMITER, $id);
516
            // Use only the specific (last) type for now, rather than the complete path of types
517
            $schemaPath = [end($schemaPath)];
518
            if ((bool)array_intersect($schemaPath, array_keys($googleRichSnippetTypes))) {
519
                $name .= ' (' . Craft::t('seomatic', 'Google rich result') . ')';
520
            }
521
            // Mark it as pending, if applicable
522
            if (isset($typesArray['pending']) && $typesArray['pending']) {
523
                $name .= ' (' . Craft::t('seomatic', 'pending') . ')';
524
            }
525
            $result['label'] = $name;
526
            $result['id'] = $id;
527
        }
528
529
        return $result;
530
    }
531
532
    /**
533
     * Get the Google Rich Snippets types & URLs
534
     *
535
     * @return array
536
     */
537
    protected static function getGoogleRichSnippets(): array
538
    {
539
        $dependency = new TagDependency([
540
            'tags' => [
541
                self::GLOBAL_SCHEMA_CACHE_TAG,
542
                self::SCHEMA_CACHE_TAG . 'googleRichSnippets',
543
            ],
544
        ]);
545
        $cache = Craft::$app->getCache();
546
        return $cache->getOrSet(
547
            self::CACHE_KEY . 'googleRichSnippets',
548
            function() {
549
                Craft::info(
550
                    'googleRichSnippets cache miss',
551
                    __METHOD__
552
                );
553
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/google-rich-snippets.json');
554
                $googleRichSnippetTypes = JsonHelper::decode(@file_get_contents($filePath));
0 ignored issues
show
Bug introduced by
It seems like $filePath can also be of type false; however, parameter $filename of file_get_contents() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

554
                $googleRichSnippetTypes = JsonHelper::decode(@file_get_contents(/** @scrutinizer ignore-type */ $filePath));
Loading history...
Bug introduced by
It seems like @file_get_contents($filePath) can also be of type false; however, parameter $json of yii\helpers\BaseJson::decode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

554
                $googleRichSnippetTypes = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
555
                if (empty($googleRichSnippetTypes)) {
556
                    throw new Exception(Craft::t('seomatic', 'Google rich snippets file not found'));
557
                }
558
559
                return $googleRichSnippetTypes;
560
            },
561
            Seomatic::$cacheDuration,
562
            $dependency
563
        );
564
    }
565
}
566