Passed
Branch v4 (7a8bf7)
by Andrew
20:17 queued 07:27
created

Schema::getTypeMetaInfo()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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

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

269
                $schemaTypes = JsonHelper::decode(@file_get_contents(/** @scrutinizer ignore-type */ $filePath));
Loading history...
270
                if (empty($schemaTypes)) {
271
                    throw new \Exception(Craft::t('seomatic', 'Schema tree file not found'));
272
                }
273
                $schemaTypes = self::makeSchemaAssociative($schemaTypes);
274
                $schemaTypes = self::orphanChildren($schemaTypes);
275
276
                return $schemaTypes;
277
            },
278
            Seomatic::$cacheDuration,
279
            $dependency
280
        );
281
        // Get just the appropriate sub-array
282
        if (!empty($path)) {
283
            $keys = explode(self::SCHEMA_PATH_DELIMITER, $path);
284
            foreach ($keys as $key) {
285
                if (!empty($typesArray[$key])) {
286
                    $typesArray = $typesArray[$key];
287
                }
288
            }
289
        }
290
        if (!\is_array($typesArray)) {
291
            $typesArray = [];
292
        }
293
294
        return $typesArray;
295
    }
296
297
    /**
298
     * Return the schema layer, and Google Rich Snippet info
299
     *
300
     * @param string $schemaName
301
     * @return array
302
     * @throws \Exception
303
     */
304
    public static function getTypeMetaInfo($schemaName): array
305
    {
306
        $metaInfo = [
307
            'schemaPending' => false,
308
            'schemaRichSnippetUrls' => [],
309
        ];
310
        $schemaTree = self::getSchemaTree();
311
        $schemaArray = self::pluckSchemaArray($schemaTree, $schemaName);
312
        if (!empty($schemaArray)) {
313
            $googleRichSnippetTypes = self::getGoogleRichSnippets();
314
            $metaInfo['schemaPending'] = $schemaArray['pending'] ?? false;
315
            $metaInfo['schemaRichSnippetUrls'] = $googleRichSnippetTypes[$schemaArray['name']] ?? [];
316
        }
317
318
        return $metaInfo;
319
    }
320
321
    /**
322
     * Traverse the schema tree and pluck a single type array from it
323
     *
324
     * @param $schemaTree
325
     * @param $schemaName
326
     * @return array
327
     */
328
    protected static function pluckSchemaArray($schemaTree, $schemaName): array
329
    {
330
        if (!empty($schemaTree['children']) && \is_array($schemaTree['children'])) {
331
            foreach ($schemaTree['children'] as $key => $value) {
332
                if (!empty($value['name']) && $value['name'] === $schemaName) {
333
                    unset($value['children']);
334
                    return $value;
335
                }
336
                if (!empty($value['children'])) {
337
                    $result = self::pluckSchemaArray($value, $schemaName);
338
                    if (!empty($result)) {
339
                        unset($result['children']);
340
                        return $result;
341
                    }
342
                }
343
            }
344
        }
345
346
        return [];
347
    }
348
349
    /**
350
     * @return array
351
     * @throws \Exception
352
     */
353
    public static function getSchemaTree()
354
    {
355
        $dependency = new TagDependency([
356
            'tags' => [
357
                self::GLOBAL_SCHEMA_CACHE_TAG,
358
                self::SCHEMA_CACHE_TAG . 'schemaTree',
359
            ],
360
        ]);
361
        $cache = Craft::$app->getCache();
362
        $typesArray = $cache->getOrSet(
363
            self::CACHE_KEY . 'schemaTree',
364
            function () {
365
                Craft::info(
366
                    'schemaArray cache miss',
367
                    __METHOD__
368
                );
369
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/tree.jsonld');
370
                $schemaTree = 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

370
                $schemaTree = 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

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

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

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