Schema::orphanChildren()   B
last analyzed

Complexity

Conditions 7
Paths 2

Size

Total Lines 22
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

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

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

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

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

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

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

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