Schema   F
last analyzed

Complexity

Total Complexity 80

Size/Duplication

Total Lines 526
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 5
Bugs 1 Features 0
Metric Value
wmc 80
eloc 227
dl 0
loc 526
ccs 0
cts 246
cp 0
rs 2
c 5
b 1
f 0

17 Methods

Rating   Name   Duplication   Size   Complexity  
A invalidateCaches() 0 7 1
A getEntityPath() 0 13 5
A getTypeMenu() 0 10 2
C pruneSchemaTree() 0 51 14
B getSchemaArray() 0 43 6
B pluckSchemaArray() 0 19 8
B makeSchemaAssociative() 0 21 8
A getDecomposedSchemaType() 0 25 6
A getGoogleRichSnippets() 0 26 2
A getTypeMetaInfo() 0 15 2
A getSchemaType() 0 16 2
A getSingleTypeMenu() 0 14 3
A flattenSchemaArray() 0 15 3
A getSpecificEntityType() 0 12 6
B orphanChildren() 0 22 7
A getSchemaTree() 0 32 3
A getTypeTree() 0 12 2

How to fix   Complexity   

Complex Class

Complex classes like Schema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Schema, and based on these observations, apply Extract Interface, too.

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

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

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

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

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

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

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