Passed
Push — develop ( afbb05...8d7af0 )
by Andrew
13:10 queued 09:21
created

Manifest::reportError()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 4
nc 2
nop 2
dl 0
loc 7
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
90
            return $result;
91
        }
92
93
        return '';
94
    }
95
96
    /**
97
     * @param array       $config
98
     * @param null|string $name
99
     *
100
     * @return string
101
     */
102
    public static function getCriticalCssTags(array $config, $name = null): string
103
    {
104
        // Resolve the template name
105
        $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName ?? '');
106
        if ($template) {
107
            $name = self::combinePaths(
108
                pathinfo($template, PATHINFO_DIRNAME),
109
                pathinfo($template, PATHINFO_FILENAME)
110
            );
111
            $dirPrefix = 'templates/';
112
            if (defined('CRAFT_TEMPLATES_PATH')) {
113
                $dirPrefix = CRAFT_TEMPLATES_PATH;
114
            }
115
            $name = strstr($name, $dirPrefix);
116
            $name = (string)str_replace($dirPrefix, '', $name);
117
            $path = self::combinePaths(
118
                    $config['localFiles']['basePath'],
119
                    $config['localFiles']['criticalPrefix'],
120
                    $name
121
                ).$config['localFiles']['criticalSuffix'];
122
123
            return self::getCssInlineTags($path);
124
        }
125
126
        return '';
127
    }
128
129
    /**
130
     * Returns the uglified loadCSS rel=preload Polyfill as per:
131
     * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
132
     *
133
     * @return string
134
     */
135
    public static function getCssRelPreloadPolyfill(): string
136
    {
137
        return <<<EOT
138
<script>
139
/*! loadCSS. [c]2017 Filament Group, Inc. MIT License */
140
!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);
141
</script>
142
EOT;
143
    }
144
145
    /**
146
     * @param array  $config
147
     * @param string $moduleName
148
     * @param bool   $async
149
     *
150
     * @return null|string
151
     * @throws NotFoundHttpException
152
     */
153
    public static function getJsModuleTags(array $config, string $moduleName, bool $async)
154
    {
155
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
156
        if ($legacyModule === null) {
157
            return '';
158
        }
159
        $modernModule = '';
160
        if ($async) {
161
            $modernModule = self::getModule($config, $moduleName, 'modern', true);
162
            if ($modernModule === null) {
163
                return '';
164
            }
165
        }
166
        $lines = [];
167
        if ($async) {
168
            $lines[] = "<script type=\"module\" src=\"{$modernModule}\"></script>";
169
            $lines[] = "<script nomodule src=\"{$legacyModule}\"></script>";
170
        } else {
171
            $lines[] = "<script src=\"{$legacyModule}\"></script>";
172
        }
173
174
        return implode("\r\n", $lines);
175
    }
176
177
    /**
178
     * Safari 10.1 supports modules, but does not support the `nomodule`
179
     * attribute - it will load <script nomodule> anyway. This snippet solve
180
     * this problem, but only for script tags that load external code, e.g.:
181
     * <script nomodule src="nomodule.js"></script>
182
     *
183
     * Again: this will **not* # prevent inline script, e.g.:
184
     * <script nomodule>alert('no modules');</script>.
185
     *
186
     * This workaround is possible because Safari supports the non-standard
187
     * 'beforeload' event. This allows us to trap the module and nomodule load.
188
     *
189
     * Note also that `nomodule` is supported in later versions of Safari -
190
     * it's just 10.1 that omits this attribute.
191
     *
192
     * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
193
     *
194
     * @return string
195
     */
196
    public static function getSafariNomoduleFix(): string
197
    {
198
        return <<<EOT
199
<script>
200
!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()}}();
201
</script>
202
EOT;
203
    }
204
205
    /**
206
     * Return the URI to a module
207
     *
208
     * @param array  $config
209
     * @param string $moduleName
210
     * @param string $type
211
     * @param bool   $soft
212
     *
213
     * @return null|string
214
     * @throws NotFoundHttpException
215
     */
216
    public static function getModule(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
217
    {
218
        // Get the module entry
219
        $module = self::getModuleEntry($config, $moduleName, $type, $soft);
220
        // Determine whether we should use the devServer for HMR or not
221
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
222
        self::$isHot = ($devMode && $config['useDevServer']);
223
        if ($module !== null) {
224
            $prefix = self::$isHot
225
                ? $config['devServer']['publicPath']
226
                : $config['server']['publicPath'];
227
            // If the module isn't a full URL, prefix it
228
            if (!UrlHelper::isAbsoluteUrl($module)) {
229
                $module = self::combinePaths($prefix, $module);
230
            }
231
            // Resolve any aliases
232
            $alias = Craft::getAlias($module, false);
233
            if ($alias) {
234
                $module = $alias;
235
            }
236
            // Make sure it's a full URL
237
            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

237
            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

237
            if (!UrlHelper::isAbsoluteUrl($module) && !is_file(/** @scrutinizer ignore-type */ $module)) {
Loading history...
238
                try {
239
                    $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

239
                    $module = UrlHelper::siteUrl(/** @scrutinizer ignore-type */ $module);
Loading history...
240
                } catch (Exception $e) {
241
                    Craft::error($e->getMessage(), __METHOD__);
242
                }
243
            }
244
        }
245
246
        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...
247
    }
248
249
	/**
250
	 * Return the HASH value from to module
251
	 *
252
	 * @param array  $config
253
	 * @param string $moduleName
254
	 * @param string $type
255
	 * @param bool   $soft
256
	 *
257
	 * @return null|string
258
	 * @throws NotFoundHttpException
259
	 */
260
	public static function getModuleHash(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
261
	{
262
263
		try {
264
			// Get the module entry
265
			$module = self::getModuleEntry($config, $moduleName, $type, $soft);
266
			if ($module !== null) {
267
				$prefix = self::$isHot
0 ignored issues
show
Unused Code introduced by
The assignment to $prefix is dead and can be removed.
Loading history...
268
					? $config['devServer']['publicPath']
269
					: $config['server']['publicPath'];
270
				// Extract only the Hash Value
271
				$modulePath = pathinfo($module);
272
				$moduleFilename = $modulePath['filename'];
273
				$moduleHash = substr($moduleFilename, strpos($moduleFilename, ".") + 1);
274
			}
275
		} catch (Exception $e) {
276
			// return emtpt string if no module is found
277
			return '';
278
		}
279
280
		return $moduleHash;
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $moduleHash does not seem to be defined for all execution paths leading up to this point.
Loading history...
281
	}
282
283
    /**
284
     * Return a module's raw entry from the manifest
285
     *
286
     * @param array  $config
287
     * @param string $moduleName
288
     * @param string $type
289
     * @param bool   $soft
290
     *
291
     * @return null|string
292
     * @throws NotFoundHttpException
293
     */
294
    public static function getModuleEntry(
295
        array $config,
296
        string $moduleName,
297
        string $type = 'modern',
298
        bool $soft = false
299
    ) {
300
        $module = null;
301
        // Get the manifest file
302
        $manifest = self::getManifestFile($config, $type);
303
        if ($manifest !== null) {
304
            // Make sure it exists in the manifest
305
            if (empty($manifest[$moduleName])) {
306
                self::reportError(Craft::t(
307
                    'twigpack',
308
                    'Module does not exist in the manifest: {moduleName}',
309
                    ['moduleName' => $moduleName]
310
                ), $soft);
311
312
                return null;
313
            }
314
            $module = $manifest[$moduleName];
315
        }
316
317
        return $module;
318
    }
319
320
    /**
321
     * Return a JSON-decoded manifest file
322
     *
323
     * @param array  $config
324
     * @param string $type
325
     *
326
     * @return null|array
327
     * @throws NotFoundHttpException
328
     */
329
    public static function getManifestFile(array $config, string $type = 'modern')
330
    {
331
        $manifest = null;
332
        // Determine whether we should use the devServer for HMR or not
333
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
334
        self::$isHot = ($devMode && $config['useDevServer']);
335
        // Try to get the manifest
336
        while ($manifest === null) {
337
            $manifestPath = self::$isHot
338
                ? $config['devServer']['manifestPath']
339
                : $config['server']['manifestPath'];
340
            // Normalize the path
341
            $path = self::combinePaths($manifestPath, $config['manifest'][$type]);
342
            $manifest = self::getJsonFile($path);
343
            // If the manifest isn't found, and it was hot, fall back on non-hot
344
            if ($manifest === null) {
345
                // We couldn't find a manifest; throw an error
346
                self::reportError(Craft::t(
347
                    'twigpack',
348
                    'Manifest file not found at: {manifestPath}',
349
                    ['manifestPath' => $manifestPath]
350
                ), true);
351
                if (self::$isHot) {
352
                    // Try again, but not with home module replacement
353
                    self::$isHot = false;
354
                } else {
355
                    // Give up and return null
356
                    return null;
357
                }
358
            }
359
        }
360
361
        return $manifest;
362
    }
363
364
    /**
365
     * Returns the contents of a file from a URI path
366
     *
367
     * @param string $path
368
     *
369
     * @return string
370
     */
371
    public static function getFile(string $path): string
372
    {
373
        return self::getFileFromUri($path, null, true) ?? '';
374
    }
375
376
    /**
377
     * @param array  $config
378
     * @param string $fileName
379
     * @param string $type
380
     *
381
     * @return string
382
     */
383
    public static function getFileFromManifest(array $config, string $fileName, string $type = 'legacy'): string
384
    {
385
        $path = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $path is dead and can be removed.
Loading history...
386
        try {
387
            $path = self::getModuleEntry($config, $fileName, $type, true);
388
        } catch (NotFoundHttpException $e) {
389
            Craft::error($e->getMessage(), __METHOD__);
390
        }
391
        if ($path !== null) {
392
            // Determine whether we should use the devServer for HMR or not
393
            $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
394
            if ($devMode) {
395
                $devServerPrefix = $config['devServer']['publicPath'];
396
                $devServerPath = self::combinePaths(
397
                    $devServerPrefix,
398
                    $path
399
                );
400
                $devServerFile = self::getFileFromUri($devServerPath, null);
401
                if ($devServerFile) {
402
                    return $devServerFile;
403
                }
404
            }
405
            // Otherwise, try not-hot files
406
            $localPrefix = $config['localFiles']['basePath'] . $config['localFiles']['criticalPrefix'];
407
            $localPath = self::combinePaths(
408
                $localPrefix,
409
                $path
410
            );
411
            $alias = Craft::getAlias($localPath, false);
412
            if ($alias && is_string($alias)) {
413
                $localPath = $alias;
414
            }
415
            if (is_file($localPath)) {
416
                return self::getFile($localPath) ?? '';
417
            }
418
        }
419
        return '';
420
    }
421
422
    /**
423
     * Invalidate all of the manifest caches
424
     */
425
    public static function invalidateCaches()
426
    {
427
        $cache = Craft::$app->getCache();
428
        TagDependency::invalidate($cache, self::CACHE_TAG);
429
        Craft::info('All manifest caches cleared', __METHOD__);
430
    }
431
432
    /**
433
     * Return the contents of a JSON file from a URI path
434
     *
435
     * @param string $path
436
     *
437
     * @return null|array
438
     */
439
    protected static function getJsonFile(string $path)
440
    {
441
        return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
442
    }
443
444
    // Protected Static Methods
445
    // =========================================================================
446
447
    /**
448
     * Return the contents of a file from a URI path
449
     *
450
     * @param string        $path
451
     * @param callable|null $callback
452
     * @param bool          $pathOnly
453
     *
454
     * @return null|mixed
455
     */
456
    protected static function getFileFromUri(string $path, callable $callback = null, bool $pathOnly = false)
457
    {
458
        // Resolve any aliases
459
        $alias = Craft::getAlias($path, false);
460
        if ($alias && is_string($alias)) {
461
            $path = $alias;
462
        }
463
        // If we only want the file via path, make sure it exists
464
        if ($pathOnly && !is_file($path)) {
465
            Craft::warning(Craft::t(
466
                'twigpack',
467
                'File does not exist: {path}',
468
                ['path' => $path]
469
            ), __METHOD__);
470
471
            return '';
472
        }
473
        // Make sure it's a full URL
474
        if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
475
            try {
476
                $path = UrlHelper::siteUrl($path);
477
            } catch (Exception $e) {
478
                Craft::error($e->getMessage(), __METHOD__);
479
            }
480
        }
481
482
        return self::getFileContents($path, $callback);
483
    }
484
485
    /**
486
     * Return the contents of a file from the passed in path
487
     *
488
     * @param string   $path
489
     * @param callable $callback
490
     *
491
     * @return null|mixed
492
     */
493
    protected static function getFileContents(string $path, callable $callback = null)
494
    {
495
        // Return the memoized manifest if it exists
496
        if (!empty(self::$files[$path])) {
497
            return self::$files[$path];
498
        }
499
        // Create the dependency tags
500
        $dependency = new TagDependency([
501
            'tags' => [
502
                self::CACHE_TAG,
503
                self::CACHE_TAG.$path,
504
            ],
505
        ]);
506
        // Set the cache duration based on devMode
507
        $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
508
            ? self::DEVMODE_CACHE_DURATION
509
            : null;
510
        // Get the result from the cache, or parse the file
511
        $cache = Craft::$app->getCache();
512
        $settings = Twigpack::$plugin->getSettings();
513
        $cacheKeySuffix = $settings->cacheKeySuffix ?? '';
514
        $file = $cache->getOrSet(
515
            self::CACHE_KEY.$cacheKeySuffix.$path,
516
            function () use ($path, $callback) {
517
                $result = null;
518
                if (UrlHelper::isAbsoluteUrl($path)) {
519
                    /**
520
                     * Silly work-around for what appears to be a file_get_contents bug with https
521
                     * http://stackoverflow.com/questions/10524748/why-im-getting-500-error-when-using-file-get-contents-but-works-in-a-browser
522
                     */
523
                    $opts = [
524
                        'ssl' => [
525
                            'verify_peer' => false,
526
                            'verify_peer_name' => false,
527
                        ],
528
                        'http' => [
529
                            'ignore_errors' => true,
530
                            'header' => "User-Agent:Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13\r\n",
531
                        ],
532
                    ];
533
                    $context = stream_context_create($opts);
534
                    $contents = @file_get_contents($path, false, $context);
535
                } else {
536
                    $contents = @file_get_contents($path);
537
                }
538
                if ($contents) {
539
                    $result = $contents;
540
                    if ($callback) {
541
                        $result = $callback($result);
542
                    }
543
                }
544
545
                return $result;
546
            },
547
            $cacheDuration,
548
            $dependency
549
        );
550
        self::$files[$path] = $file;
551
552
        return $file;
553
    }
554
555
    /**
556
     * Combined the passed in paths, whether file system or URL
557
     *
558
     * @param string ...$paths
559
     *
560
     * @return string
561
     */
562
    protected static function combinePaths(string ...$paths): string
563
    {
564
        $last_key = count($paths) - 1;
565
        array_walk($paths, function (&$val, $key) use ($last_key) {
566
            switch ($key) {
567
                case 0:
568
                    $val = rtrim($val, '/ ');
569
                    break;
570
                case $last_key:
571
                    $val = ltrim($val, '/ ');
572
                    break;
573
                default:
574
                    $val = trim($val, '/ ');
575
                    break;
576
            }
577
        });
578
579
        $first = array_shift($paths);
580
        $last = array_pop($paths);
581
        $paths = array_filter($paths);
582
        array_unshift($paths, $first);
583
        $paths[] = $last;
584
585
        return implode('/', $paths);
586
    }
587
588
    /**
589
     * @param string $error
590
     * @param bool   $soft
591
     *
592
     * @throws NotFoundHttpException
593
     */
594
    protected static function reportError(string $error, $soft = false)
595
    {
596
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
597
        if ($devMode && !$soft) {
598
            throw new NotFoundHttpException($error);
599
        }
600
        Craft::error($error, __METHOD__);
601
    }
602
603
    // Private Static Methods
604
    // =========================================================================
605
606
    /**
607
     * @param $string
608
     *
609
     * @return null|array
610
     */
611
    private static function jsonFileDecode($string)
612
    {
613
        $json = JsonHelper::decodeIfJson($string);
614
        if (is_string($json)) {
615
            Craft::error('Error decoding JSON file: '.$json, __METHOD__);
616
            $json = null;
617
        }
618
619
        return $json;
620
    }
621
}
622