Passed
Push — v1 ( 3bfaf6...d06392 )
by Andrew
10:02 queued 04:19
created

Manifest::jsonFileDecode()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * Twigpack plugin for Craft CMS 3.x
4
 *
5
 * Twigpack is the conduit between Twig and webpack, with manifest.json &
6
 * webpack-dev-server HMR support
7
 *
8
 * @link      https://nystudio107.com/
9
 * @copyright Copyright (c) 2018 nystudio107
10
 */
11
12
namespace nystudio107\twigpack\helpers;
13
14
use Craft;
15
use craft\helpers\Json as JsonHelper;
16
use craft\helpers\UrlHelper;
17
18
use nystudio107\twigpack\Twigpack;
19
use yii\base\Exception;
20
use yii\caching\TagDependency;
21
use yii\web\NotFoundHttpException;
22
23
/**
24
 * @author    nystudio107
25
 * @package   Twigpack
26
 * @since     1.0.0
27
 */
28
class Manifest
29
{
30
    // Constants
31
    // =========================================================================
32
33
    const CACHE_KEY = 'twigpack';
34
    const CACHE_TAG = 'twigpack';
35
36
    const DEVMODE_CACHE_DURATION = 1;
37
38
    // Protected Static Properties
39
    // =========================================================================
40
41
    /**
42
     * @var array
43
     */
44
    protected static $files;
45
46
    /**
47
     * @var bool
48
     */
49
    protected static $isHot = false;
50
51
    // Public Static Methods
52
    // =========================================================================
53
54
    /**
55
     * @param array  $config
56
     * @param string $moduleName
57
     * @param bool   $async
58
     *
59
     * @return string
60
     * @throws NotFoundHttpException
61
     */
62
    public static function getCssModuleTags(array $config, string $moduleName, bool $async): string
63
    {
64
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
65
        if ($legacyModule === null) {
66
            return '';
67
        }
68
        $lines = [];
69
        if ($async) {
70
            $lines[] = "<link rel=\"preload\" href=\"{$legacyModule}\" as=\"style\" onload=\"this.onload=null;this.rel='stylesheet'\" />";
71
            $lines[] = "<noscript><link rel=\"stylesheet\" href=\"{$legacyModule}\"></noscript>";
72
        } else {
73
            $lines[] = "<link rel=\"stylesheet\" href=\"{$legacyModule}\" />";
74
        }
75
76
        return implode("\r\n", $lines);
77
    }
78
79
    /**
80
     * @param string $path
81
     *
82
     * @return string
83
     */
84
    public static function getCssInlineTags(string $path): string
85
    {
86
        $result = self::getFile($path);
87
        if ($result) {
88
            $result = "<style>\r\n".$result."</style>\r\n";
89
            return $result;
90
        }
91
92
        return '';
93
    }
94
95
    /**
96
     * @param array       $config
97
     * @param null|string $name
98
     *
99
     * @return string
100
     */
101
    public static function getCriticalCssTags(array $config, $name = null): string
102
    {
103
        // Resolve the template name
104
        $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName);
105
        if ($template) {
106
            $name = self::combinePaths(
107
                pathinfo($template, PATHINFO_DIRNAME),
108
                pathinfo($template, PATHINFO_FILENAME)
109
            );
110
            $dirPrefix = 'templates/';
111
            if (\defined('CRAFT_TEMPLATES_PATH')) {
112
                $dirPrefix = CRAFT_TEMPLATES_PATH;
113
            }
114
            $name = strstr($name, $dirPrefix);
115
            $name = str_replace($dirPrefix, '', $name);
116
            $path = self::combinePaths(
117
                $config['localFiles']['basePath'],
118
                $config['localFiles']['criticalPrefix'],
119
                $name
120
            ).$config['localFiles']['criticalSuffix'];
121
122
            return self::getCssInlineTags($path);
123
        }
124
125
        return '';
126
    }
127
128
    /**
129
     * Returns the uglified loadCSS rel=preload Polyfill as per:
130
     * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
131
     *
132
     * @return string
133
     */
134
    public static function getCssRelPreloadPolyfill(): string
135
    {
136
        return <<<EOT
137
<script>
138
/*! loadCSS. [c]2017 Filament Group, Inc. MIT License */
139
!function(t){"use strict";t.loadCSS||(t.loadCSS=function(){});var e=loadCSS.relpreload={};if(e.support=function(){var e;try{e=t.document.createElement("link").relList.supports("preload")}catch(t){e=!1}return function(){return e}}(),e.bindMediaToggle=function(t){var e=t.media||"all";function a(){t.media=e}t.addEventListener?t.addEventListener("load",a):t.attachEvent&&t.attachEvent("onload",a),setTimeout(function(){t.rel="stylesheet",t.media="only x"}),setTimeout(a,3e3)},e.poly=function(){if(!e.support())for(var a=t.document.getElementsByTagName("link"),n=0;n<a.length;n++){var o=a[n];"preload"!==o.rel||"style"!==o.getAttribute("as")||o.getAttribute("data-loadcss")||(o.setAttribute("data-loadcss",!0),e.bindMediaToggle(o))}},!e.support()){e.poly();var a=t.setInterval(e.poly,500);t.addEventListener?t.addEventListener("load",function(){e.poly(),t.clearInterval(a)}):t.attachEvent&&t.attachEvent("onload",function(){e.poly(),t.clearInterval(a)})}"undefined"!=typeof exports?exports.loadCSS=loadCSS:t.loadCSS=loadCSS}("undefined"!=typeof global?global:this);
140
</script>
141
EOT;
142
    }
143
144
    /**
145
     * @param array  $config
146
     * @param string $moduleName
147
     * @param bool   $async
148
     *
149
     * @return null|string
150
     * @throws NotFoundHttpException
151
     */
152
    public static function getJsModuleTags(array $config, string $moduleName, bool $async)
153
    {
154
        $legacyModule = self::getModule($config, $moduleName, 'legacy');
155
        if ($legacyModule === null) {
156
            return '';
157
        }
158
        if ($async) {
159
            $modernModule = self::getModule($config, $moduleName, 'modern');
160
            if ($modernModule === null) {
161
                return '';
162
            }
163
        }
164
        $lines = [];
165
        if ($async) {
166
            $lines[] = "<script type=\"module\" src=\"{$modernModule}\"></script>";
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $modernModule does not seem to be defined for all execution paths leading up to this point.
Loading history...
167
            $lines[] = "<script nomodule src=\"{$legacyModule}\"></script>";
168
        } else {
169
            $lines[] = "<script src=\"{$legacyModule}\"></script>";
170
        }
171
172
        return implode("\r\n", $lines);
173
    }
174
175
    /**
176
     * Safari 10.1 supports modules, but does not support the `nomodule`
177
     * attribute - it will load <script nomodule> anyway. This snippet solve
178
     * this problem, but only for script tags that load external code, e.g.:
179
     * <script nomodule src="nomodule.js"></script>
180
     *
181
     * Again: this will **not* # prevent inline script, e.g.:
182
     * <script nomodule>alert('no modules');</script>.
183
     *
184
     * This workaround is possible because Safari supports the non-standard
185
     * 'beforeload' event. This allows us to trap the module and nomodule load.
186
     *
187
     * Note also that `nomodule` is supported in later versions of Safari -
188
     * it's just 10.1 that omits this attribute.
189
     *
190
     * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
191
     *
192
     * @return string
193
     */
194
    public static function getSafariNomoduleFix(): string
195
    {
196
        return <<<EOT
197
<script>
198
!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();
199
</script>
200
EOT;
201
    }
202
203
    /**
204
     * Return the URI to a module
205
     *
206
     * @param array  $config
207
     * @param string $moduleName
208
     * @param string $type
209
     * @param bool   $soft
210
     *
211
     * @return null|string
212
     * @throws NotFoundHttpException
213
     */
214
    public static function getModule(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
215
    {
216
        // Get the module entry
217
        $module = self::getModuleEntry($config, $moduleName, $type, $soft);
218
        if ($module !== null) {
219
            $prefix = self::$isHot
220
                ? $config['devServer']['publicPath']
221
                : $config['server']['publicPath'];
222
            // If the module isn't a full URL, prefix it
223
            if (!UrlHelper::isAbsoluteUrl($module)) {
224
                $module = self::combinePaths($prefix, $module);
225
            }
226
            // Resolve any aliases
227
            $alias = Craft::getAlias($module, false);
228
            if ($alias) {
229
                $module = $alias;
230
            }
231
            // Make sure it's a full URL
232
            if (!UrlHelper::isAbsoluteUrl($module) && !is_file($module)) {
0 ignored issues
show
Bug introduced by
It seems like $module can also be of type true; however, parameter $url of craft\helpers\UrlHelper::isAbsoluteUrl() 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

232
            if (!UrlHelper::isAbsoluteUrl(/** @scrutinizer ignore-type */ $module) && !is_file($module)) {
Loading history...
Bug introduced by
It seems like $module can also be of type true; however, parameter $filename of is_file() 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

232
            if (!UrlHelper::isAbsoluteUrl($module) && !is_file(/** @scrutinizer ignore-type */ $module)) {
Loading history...
233
                try {
234
                    $module = UrlHelper::siteUrl($module);
0 ignored issues
show
Bug introduced by
It seems like $module can also be of type true; however, parameter $path of craft\helpers\UrlHelper::siteUrl() 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

234
                    $module = UrlHelper::siteUrl(/** @scrutinizer ignore-type */ $module);
Loading history...
235
                } catch (Exception $e) {
236
                    Craft::error($e->getMessage(), __METHOD__);
237
                }
238
            }
239
        }
240
241
        return $module;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $module also could return the type true which is incompatible with the documented return type null|string.
Loading history...
242
    }
243
244
    /**
245
     * Return a module's raw entry from the manifest
246
     *
247
     * @param array  $config
248
     * @param string $moduleName
249
     * @param string $type
250
     * @param bool   $soft
251
     *
252
     * @return null|string
253
     * @throws NotFoundHttpException
254
     */
255
    public static function getModuleEntry(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
256
    {
257
        $module = null;
258
        // Get the manifest file
259
        $manifest = self::getManifestFile($config, $type);
260
        if ($manifest !== null) {
261
            // Make sure it exists in the manifest
262
            if (empty($manifest[$moduleName])) {
263
                self::reportError(Craft::t(
264
                    'twigpack',
265
                    'Module does not exist in the manifest: {moduleName}',
266
                    ['moduleName' => $moduleName]
267
                ), $soft);
268
269
                return null;
270
            }
271
            $module = $manifest[$moduleName];
272
        }
273
274
        return $module;
275
    }
276
277
    /**
278
     * Return a JSON-decoded manifest file
279
     *
280
     * @param array  $config
281
     * @param string $type
282
     *
283
     * @return null|array
284
     * @throws NotFoundHttpException
285
     */
286
    public static function getManifestFile(array $config, string $type = 'modern')
287
    {
288
        $manifest = null;
289
        // Determine whether we should use the devServer for HMR or not
290
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
291
        self::$isHot = ($devMode && $config['useDevServer']);
292
        // Try to get the manifest
293
        while ($manifest === null) {
294
            $manifestPath = self::$isHot
295
                ? $config['devServer']['manifestPath']
296
                : $config['server']['manifestPath'];
297
            // Normalize the path
298
            $path = self::combinePaths($manifestPath, $config['manifest'][$type]);
299
            $manifest = self::getJsonFile($path);
300
            // If the manifest isn't found, and it was hot, fall back on non-hot
301
            if ($manifest === null) {
302
                // We couldn't find a manifest; throw an error
303
                self::reportError(Craft::t(
304
                    'twigpack',
305
                    'Manifest file not found at: {manifestPath}',
306
                    ['manifestPath' => $manifestPath]
307
                ), true);
308
                if (self::$isHot) {
309
                    // Try again, but not with home module replacement
310
                    self::$isHot = false;
311
                } else {
312
                    // Give up and return null
313
                    return null;
314
                }
315
            }
316
        }
317
318
        return $manifest;
319
    }
320
321
    /**
322
     * Returns the contents of a file from a URI path
323
     *
324
     * @param string $path
325
     *
326
     * @return string
327
     */
328
    public static function getFile(string $path): string
329
    {
330
        return self::getFileFromUri($path, null) ?? '';
331
    }
332
333
    /**
334
     * @param array  $config
335
     * @param string $fileName
336
     * @param string $type
337
     *
338
     * @return string
339
     */
340
    public static function getFileFromManifest(array $config, string $fileName, string $type = 'legacy'): string
341
    {
342
        try {
343
            $path = self::getModuleEntry($config, $fileName, $type, true);
344
        } catch (NotFoundHttpException $e) {
345
            Craft::error($e->getMessage(), __METHOD__);
346
        }
347
        if ($path !== null) {
348
            $path = self::combinePaths(
349
                    $config['localFiles']['basePath'],
350
                    $path
351
                );
352
353
            return self::getFileFromUri($path, null) ?? '';
354
        }
355
356
        return '';
357
    }
358
359
    /**
360
     * Return the contents of a JSON file from a URI path
361
     *
362
     * @param string $path
363
     *
364
     * @return null|array
365
     */
366
    protected static function getJsonFile(string $path)
367
    {
368
        return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
369
    }
370
371
    /**
372
     * Invalidate all of the manifest caches
373
     */
374
    public static function invalidateCaches()
375
    {
376
        $cache = Craft::$app->getCache();
377
        TagDependency::invalidate($cache, self::CACHE_TAG);
378
        Craft::info('All manifest caches cleared', __METHOD__);
379
    }
380
381
    // Protected Static Methods
382
    // =========================================================================
383
384
    /**
385
     * Return the contents of a file from a URI path
386
     *
387
     * @param string        $path
388
     * @param callable|null $callback
389
     *
390
     * @return null|mixed
391
     */
392
    protected static function getFileFromUri(string $path, callable $callback = null)
393
    {
394
        // Resolve any aliases
395
        $alias = Craft::getAlias($path, false);
396
        if ($alias) {
397
            $path = $alias;
398
        }
399
        // Make sure it's a full URL
400
        if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type true; however, parameter $url of craft\helpers\UrlHelper::isAbsoluteUrl() 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

400
        if (!UrlHelper::isAbsoluteUrl(/** @scrutinizer ignore-type */ $path) && !is_file($path)) {
Loading history...
Bug introduced by
It seems like $path can also be of type true; however, parameter $filename of is_file() 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

400
        if (!UrlHelper::isAbsoluteUrl($path) && !is_file(/** @scrutinizer ignore-type */ $path)) {
Loading history...
401
            try {
402
                $path = UrlHelper::siteUrl($path);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type true; however, parameter $path of craft\helpers\UrlHelper::siteUrl() 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

402
                $path = UrlHelper::siteUrl(/** @scrutinizer ignore-type */ $path);
Loading history...
403
            } catch (Exception $e) {
404
                Craft::error($e->getMessage(), __METHOD__);
405
            }
406
        }
407
408
        return self::getFileContents($path, $callback);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type true; however, parameter $path of nystudio107\twigpack\hel...fest::getFileContents() 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

408
        return self::getFileContents(/** @scrutinizer ignore-type */ $path, $callback);
Loading history...
409
    }
410
411
    /**
412
     * Return the contents of a file from the passed in path
413
     *
414
     * @param string   $path
415
     * @param callable $callback
416
     *
417
     * @return null|mixed
418
     */
419
    protected static function getFileContents(string $path, callable $callback = null)
420
    {
421
        // Return the memoized manifest if it exists
422
        if (!empty(self::$files[$path])) {
423
            return self::$files[$path];
424
        }
425
        // Create the dependency tags
426
        $dependency = new TagDependency([
427
            'tags' => [
428
                self::CACHE_TAG,
429
                self::CACHE_TAG.$path,
430
            ],
431
        ]);
432
        // Set the cache duration based on devMode
433
        $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
434
            ? self::DEVMODE_CACHE_DURATION
435
            : null;
436
        // Get the result from the cache, or parse the file
437
        $cache = Craft::$app->getCache();
438
        $file = $cache->getOrSet(
439
            self::CACHE_KEY.$path,
440
            function () use ($path, $callback) {
441
                $result = null;
442
                $contents = @file_get_contents($path);
443
                if ($contents) {
444
                    $result = $contents;
445
                    if ($callback) {
446
                        $result = $callback($result);
447
                    }
448
                }
449
450
                return $result;
451
            },
452
            $cacheDuration,
453
            $dependency
454
        );
455
        self::$files[$path] = $file;
456
457
        return $file;
458
    }
459
460
    /**
461
     * Combined the passed in paths, whether file system or URL
462
     *
463
     * @param string ...$paths
464
     *
465
     * @return string
466
     */
467
    protected static function combinePaths(string ...$paths): string
468
    {
469
        $last_key = \count($paths) - 1;
470
        array_walk($paths, function (&$val, $key) use ($last_key) {
471
            switch ($key) {
472
                case 0:
473
                    $val = rtrim($val, '/ ');
474
                    break;
475
                case $last_key:
476
                    $val = ltrim($val, '/ ');
477
                    break;
478
                default:
479
                    $val = trim($val, '/ ');
480
                    break;
481
            }
482
        });
483
484
        $first = array_shift($paths);
485
        $last = array_pop($paths);
486
        $paths = array_filter($paths);
487
        array_unshift($paths, $first);
488
        $paths[] = $last;
489
490
        return implode('/', $paths);
491
    }
492
493
    /**
494
     * @param string $error
495
     * @param bool   $soft
496
     *
497
     * @throws NotFoundHttpException
498
     */
499
    protected static function reportError(string $error, $soft = false)
500
    {
501
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
502
        if ($devMode && !$soft) {
503
            throw new NotFoundHttpException($error);
504
        }
505
        Craft::error($error, __METHOD__);
506
    }
507
508
    // Private Static Methods
509
    // =========================================================================
510
511
    /**
512
     * @param $string
513
     *
514
     * @return mixed
515
     */
516
    private static function jsonFileDecode($string)
517
    {
518
        return JsonHelper::decodeIfJson($string);
519
    }
520
}
521