Passed
Push — develop ( ff37e3...53598d )
by Andrew
08:55
created

Schema::getDecomposedSchemaType()   B

Complexity

Conditions 8
Paths 16

Size

Total Lines 31
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

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

261
                $schemaTypes = 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

261
                $schemaTypes = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
262
                if (empty($schemaTypes)) {
263
                    throw new \Exception(Craft::t('seomatic', 'Schema tree file not found'));
264
                }
265
                $schemaTypes = self::makeSchemaAssociative($schemaTypes);
266
                $schemaTypes = self::orphanChildren($schemaTypes);
267
268
                return $schemaTypes;
269
            },
270
            Seomatic::$cacheDuration,
271
            $dependency
272
        );
273
        // Get just the appropriate sub-array
274
        if (!empty($path)) {
275
            $keys = explode(self::SCHEMA_PATH_DELIMITER, $path);
276
            foreach ($keys as $key) {
277
                if (!empty($typesArray[$key])) {
278
                    $typesArray = $typesArray[$key];
279
                }
280
            }
281
        }
282
        if (!\is_array($typesArray)) {
283
            $typesArray = [];
284
        }
285
286
        return $typesArray;
287
    }
288
289
    /**
290
     * @return array
291
     * @throws \Exception
292
     */
293
    public static function getSchemaTree()
294
    {
295
        $dependency = new TagDependency([
296
            'tags' => [
297
                self::GLOBAL_SCHEMA_CACHE_TAG,
298
                self::SCHEMA_CACHE_TAG . 'schemaTree',
299
            ],
300
        ]);
301
        $cache = Craft::$app->getCache();
302
        $typesArray = $cache->getOrSet(
303
            self::CACHE_KEY . 'schemaArray',
304
            function () {
305
                Craft::info(
306
                    'schemaTree cache miss',
307
                    __METHOD__
308
                );
309
                $filePath = Craft::getAlias('@nystudio107/seomatic/resources/schema/tree.jsonld');
310
                $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

310
                $schemaTree = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
Bug introduced by
It seems like $filePath can also be of type boolean; 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

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

501
                $googleRichSnippetTypes = JsonHelper::decode(/** @scrutinizer ignore-type */ @file_get_contents($filePath));
Loading history...
Bug introduced by
It seems like $filePath can also be of type boolean; 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

501
                $googleRichSnippetTypes = JsonHelper::decode(@file_get_contents(/** @scrutinizer ignore-type */ $filePath));
Loading history...
502
                if (empty($googleRichSnippetTypes)) {
503
                    throw new \Exception(Craft::t('seomatic', 'Google rich snippets file not found'));
504
                }
505
506
                return $googleRichSnippetTypes;
507
            },
508
            Seomatic::$cacheDuration,
509
            $dependency
510
        );
511
    }
512
}
513