Schema::getSchemaTree()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 32
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 0
Metric Value
eloc 21
c 0
b 0
f 0
dl 0
loc 32
ccs 0
cts 26
cp 0
rs 9.584
cc 3
nc 2
nop 0
crap 12
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 = preg_replace('/\s+/', ' ', $description);
135
                $description = Markdown::process((string)$description);
136
                $description = str_replace(['<p>', '</p>', '\n'], ['', '', ' '], $description);
137
                $result['schemaTypeDescription'] = $description;
138
            }
139
        }
140
141
        return $result;
142
    }
143
144
    /**
145
     * Get the decomposed schema type
146
     *
147
     * @param string $schemaType
148
     *
149
     * @return array
150
     */
151
    public static function getDecomposedSchemaType(string $schemaType): array
152
    {
153
        $result = [];
154
        while ($schemaType) {
155
            $className = 'nystudio107\\seomatic\\models\\jsonld\\' . $schemaType;
156
            if (class_exists($className)) {
157
                $classRef = new ReflectionClass($className);
158
                $staticProps = $classRef->getStaticProperties();
159
160
                foreach ($staticProps as $key => $value) {
161
                    if ($key[0] === '_') {
162
                        $newKey = ltrim($key, '_');
163
                        $staticProps[$newKey] = $value;
164
                        unset($staticProps[$key]);
165
                    }
166
                }
167
                $result[$schemaType] = $staticProps;
168
                $schemaType = $staticProps['schemaTypeExtends'];
169
                if ($schemaType === 'JsonLdType') {
170
                    $schemaType = null;
171
                }
172
            }
173
        }
174
175
        return $result;
176
    }
177
178
    /**
179
     * Return a flattened, indented menu of the given $path
180
     *
181
     * @param string $path
182
     *
183
     * @return array
184
     */
185
    public static function getTypeMenu($path = ''): array
186
    {
187
        try {
188
            $schemaTypes = self::getSchemaArray($path);
189
        } catch (Exception $e) {
190
            Craft::error($e->getMessage(), __METHOD__);
191
            return [];
192
        }
193
194
        return self::flattenSchemaArray($schemaTypes, 0);
195
    }
196
197
    /**
198
     * Return a single menu of schemas starting at $path
199
     *
200
     * @param string $path
201
     *
202
     * @return array
203
     */
204
    public static function getSingleTypeMenu($path = ''): array
205
    {
206
        $result = [];
207
        try {
208
            $schemaTypes = self::getSchemaArray($path);
209
        } catch (Exception $e) {
210
            Craft::error($e->getMessage(), __METHOD__);
211
            return [];
212
        }
213
        foreach ($schemaTypes as $key => $value) {
214
            $result[$key] = $key;
215
        }
216
217
        return $result;
218
    }
219
220
    /**
221
     * @return array
222
     */
223
    public static function getTypeTree(): array
224
    {
225
        try {
226
            $schemaTypes = self::getSchemaTree();
227
        } catch (Exception $e) {
228
            Craft::error($e->getMessage(), __METHOD__);
229
            return [];
230
        }
231
        $schemaTypes = self::pruneSchemaTree($schemaTypes, '');
232
233
        // Ignore the top level "Thing" base schema
234
        return $schemaTypes['children'] ?? [];
235
    }
236
237
    /**
238
     * Return a hierarchical array of schema types, starting at $path. The $path
239
     * is specified as SchemaType.SubSchemaType using SCHEMA_PATH_DELIMITER as
240
     * the delimiter.
241
     *
242
     * @param string $path
243
     *
244
     * @return array
245
     * @throws Exception
246
     */
247
    public static function getSchemaArray($path = ''): array
248
    {
249
        $dependency = new TagDependency([
250
            'tags' => [
251
                self::GLOBAL_SCHEMA_CACHE_TAG,
252
                self::SCHEMA_CACHE_TAG . 'schemaArray',
253
            ],
254
        ]);
255
        $cache = Craft::$app->getCache();
256
        $typesArray = $cache->getOrSet(
257
            self::CACHE_KEY . 'schemaArray',
258
            function() use ($path) {
259
                Craft::info(
260
                    'schemaArray cache miss' . $path,
261
                    __METHOD__
262
                );
263
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/tree.jsonld');
264
                $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

264
                $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

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

337
                $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

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

555
                $googleRichSnippetTypes = 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

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