Passed
Push — develop ( 9d2a29...f072e9 )
by Andrew
02:59
created

Manifest::getFileFromUri()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 9
nc 6
nop 2
dl 0
loc 17
rs 9.6111
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
    // Public Static Methods
47
    // =========================================================================
48
49
    /**
50
     * @param array  $config
51
     * @param string $moduleName
52
     * @param bool   $async
53
     *
54
     * @return null|string
55
     * @throws NotFoundHttpException
56
     */
57
    public static function getCssModuleTags(array $config, string $moduleName, bool $async)
58
    {
59
        $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
60
        if ($legacyModule === null) {
61
            return '';
62
        }
63
        $lines = [];
64
        if ($async) {
65
            $lines[] = "<link rel=\"preload\" href=\"{$legacyModule}\" as=\"style\" onload=\"this.onload=null;this.rel='stylesheet'\" />";
66
            $lines[] = "<noscript><link rel=\"stylesheet\" href=\"{$legacyModule}\"></noscript>";
67
        } else {
68
            $lines[] = "<link rel=\"stylesheet\" href=\"{$legacyModule}\" />";
69
        }
70
71
        return implode("\r\n", $lines);
72
    }
73
74
    /**
75
     * @param $path
76
     *
77
     * @return mixed|string
78
     */
79
    public static function getCssInlineTags($path)
80
    {
81
        $result = self::getFile($path);
82
        if ($result) {
83
            $result = "<style>\r\n" . $result . "</style>\r\n";
84
        }
85
86
        return $result;
87
    }
88
89
    /**
90
     * @param array $config
91
     * @param       $name
92
     *
93
     * @return mixed|string
94
     */
95
    public static function getCriticalCssTags(array $config, $name = null)
96
    {
97
        // Resolve the template name
98
        $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName);
99
        if ($template) {
100
            $name = pathinfo($template, PATHINFO_FILENAME);
101
            $path = self::combinePaths($config['critical']['basePath'], $name).$config['critical']['suffix'];
102
103
            return self::getCssInlineTags($path);
104
        }
105
106
        return '';
107
    }
108
109
    /**
110
     * Returns the uglified loadCSS rel=preload Polyfill as per:
111
     * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
112
     *
113
     * @return string
114
     */
115
    public static function getCssRelPreloadPolyfill(): string
116
    {
117
        return <<<EOT
118
<script>
119
/*! loadCSS. [c]2017 Filament Group, Inc. MIT License */
120
!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);
121
</script>
122
EOT;
123
    }
124
125
    /**
126
     * @param array  $config
127
     * @param string $moduleName
128
     * @param bool   $async
129
     *
130
     * @return null|string
131
     * @throws NotFoundHttpException
132
     */
133
    public static function getJsModuleTags(array $config, string $moduleName, bool $async)
134
    {
135
        $legacyModule = self::getModule($config, $moduleName, 'legacy');
136
        if ($legacyModule === null) {
137
            return '';
138
        }
139
        if ($async) {
140
            $modernModule = self::getModule($config, $moduleName, 'modern');
141
            if ($modernModule === null) {
142
                return '';
143
            }
144
        }
145
        $lines = [];
146
        if ($async) {
147
            $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...
148
            $lines[] = "<script nomodule src=\"{$legacyModule}\"></script>";
149
        } else {
150
            $lines[] = "<script src=\"{$legacyModule}\"></script>";
151
        }
152
153
        return implode("\r\n", $lines);
154
    }
155
156
    /**
157
     * Safari 10.1 supports modules, but does not support the `nomodule`
158
     * attribute - it will load <script nomodule> anyway. This snippet solve
159
     * this problem, but only for script tags that load external code, e.g.:
160
     * <script nomodule src="nomodule.js"></script>
161
     *
162
     * Again: this will **not* # prevent inline script, e.g.:
163
     * <script nomodule>alert('no modules');</script>.
164
     *
165
     * This workaround is possible because Safari supports the non-standard
166
     * 'beforeload' event. This allows us to trap the module and nomodule load.
167
     *
168
     * Note also that `nomodule` is supported in later versions of Safari -
169
     * it's just 10.1 that omits this attribute.
170
     *
171
     * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
172
     *
173
     * @return string
174
     */
175
    public static function getSafariNomoduleFix(): string
176
    {
177
        return <<<EOT
178
<script>
179
!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()}}();
180
</script>
181
EOT;
182
    }
183
184
    /**
185
     * Return the URI to a module
186
     *
187
     * @param array  $config
188
     * @param string $moduleName
189
     * @param string $type
190
     * @param bool   $soft
191
     *
192
     * @return null|string
193
     * @throws NotFoundHttpException
194
     */
195
    public static function getModule(array $config, string $moduleName, string $type = 'modern', bool $soft = false)
196
    {
197
        $module = null;
198
        // Determine whether we should use the devServer for HMR or not
199
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
200
        $isHot = ($devMode && $config['useDevServer']);
201
        // Get the manifest file
202
        $manifest = self::getManifestFile($config, $isHot, $type);
203
        if ($manifest !== null) {
204
            // Make sure it exists in the manifest
205
            if (empty($manifest[$moduleName])) {
206
                self::reportError(Craft::t(
207
                    'twigpack',
208
                    'Module does not exist in the manifest: {moduleName}',
209
                    ['moduleName' => $moduleName]
210
                ), $soft);
211
212
                return null;
213
            }
214
            $module = $manifest[$moduleName];
215
            $prefix = $isHot
216
                ? $config['devServer']['publicPath']
217
                : $config['server']['publicPath'];
218
            // If the module isn't a full URL, prefix it
219
            if (!UrlHelper::isAbsoluteUrl($module)) {
220
                $module = self::combinePaths($prefix, $module);
221
            }
222
            // Make sure it's a full URL
223
            if (!UrlHelper::isAbsoluteUrl($module)) {
224
                try {
225
                    $module = UrlHelper::siteUrl($module);
226
                } catch (Exception $e) {
227
                    Craft::error($e->getMessage(), __METHOD__);
228
                }
229
            }
230
        }
231
232
        return $module;
233
    }
234
235
    /**
236
     * Return a JSON-decoded manifest file
237
     *
238
     * @param array  $config
239
     * @param bool   $isHot
240
     * @param string $type
241
     *
242
     * @return null|array
243
     * @throws NotFoundHttpException
244
     */
245
    public static function getManifestFile(array $config, bool &$isHot, string $type = 'modern')
246
    {
247
        $manifest = null;
248
        // Try to get the manifest
249
        while ($manifest === null) {
250
            $manifestPath = $isHot
251
                ? $config['devServer']['manifestPath']
252
                : $config['server']['manifestPath'];
253
            // Normalize the path
254
            $path = self::combinePaths($manifestPath, $config['manifest'][$type]);
255
            $manifest = self::getJsonFile($path);
256
            // If the manifest isn't found, and it was hot, fall back on non-hot
257
            if ($manifest === null) {
258
                // We couldn't find a manifest; throw an error
259
                self::reportError(Craft::t(
260
                    'twigpack',
261
                    'Manifest file not found at: {manifestPath}',
262
                    ['manifestPath' => $manifestPath]
263
                ), true);
264
                if ($isHot) {
265
                    // Try again, but not with home module replacement
266
                    $isHot = false;
267
                } else {
268
                    // Give up and return null
269
                    return null;
270
                }
271
            }
272
        }
273
274
        return $manifest;
275
    }
276
277
    /**
278
     * Returns the contents of a file from a URI path
279
     *
280
     * @param string $path
281
     *
282
     * @return mixed
283
     */
284
    public static function getFile($path)
285
    {
286
        return self::getFileFromUri($path, null);
287
    }
288
289
    /**
290
     * Return the contents of a JSON file from a URI path
291
     *
292
     * @param string $path
293
     *
294
     * @return mixed
295
     */
296
    protected static function getJsonFile(string $path)
297
    {
298
        return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
299
    }
300
301
    /**
302
     * Invalidate all of the manifest caches
303
     */
304
    public static function invalidateCaches()
305
    {
306
        $cache = Craft::$app->getCache();
307
        TagDependency::invalidate($cache, self::CACHE_TAG);
308
        Craft::info('All manifest caches cleared', __METHOD__);
309
    }
310
311
    // Protected Static Methods
312
    // =========================================================================
313
314
    /**
315
     * Return the contents of a file from a URI path
316
     *
317
     * @param string        $path
318
     * @param callable|null $callback
319
     *
320
     * @return mixed
321
     */
322
    protected static function getFileFromUri(string $path, callable $callback = null)
323
    {
324
        // Resolve any aliases
325
        $alias = Craft::getAlias($path, false);
326
        if ($alias) {
327
            $path = $alias;
328
        }
329
        // Make sure it's a full URL
330
        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 $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

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

330
        if (!UrlHelper::isAbsoluteUrl(/** @scrutinizer ignore-type */ $path) && !is_file($path)) {
Loading history...
331
            try {
332
                $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

332
                $path = UrlHelper::siteUrl(/** @scrutinizer ignore-type */ $path);
Loading history...
333
            } catch (Exception $e) {
334
                Craft::error($e->getMessage(), __METHOD__);
335
            }
336
        }
337
338
        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

338
        return self::getFileContents(/** @scrutinizer ignore-type */ $path, $callback);
Loading history...
339
    }
340
341
    /**
342
     * Return the contents of a file from the passed in path
343
     *
344
     * @param string   $path
345
     * @param callable $callback
346
     *
347
     * @return mixed
348
     */
349
    protected static function getFileContents(string $path, callable $callback = null)
350
    {
351
        // Return the memoized manifest if it exists
352
        if (!empty(self::$files[$path])) {
353
            return self::$files[$path];
354
        }
355
        // Create the dependency tags
356
        $dependency = new TagDependency([
357
            'tags' => [
358
                self::CACHE_TAG,
359
                self::CACHE_TAG.$path,
360
            ],
361
        ]);
362
        // Set the cache duration based on devMode
363
        $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
364
            ? self::DEVMODE_CACHE_DURATION
365
            : null;
366
        // Get the result from the cache, or parse the file
367
        $cache = Craft::$app->getCache();
368
        $file = $cache->getOrSet(
369
            self::CACHE_KEY.$path,
370
            function () use ($path, $callback) {
371
                $result = @file_get_contents($path);
372
                if ($result && $callback) {
373
                    $result = $callback($result);
374
                }
375
376
                return $result;
377
            },
378
            $cacheDuration,
379
            $dependency
380
        );
381
        self::$files[$path] = $file;
382
383
        return $file;
384
    }
385
386
    /**
387
     * Combined the passed in paths, whether file system or URL
388
     *
389
     * @param string ...$paths
390
     *
391
     * @return string
392
     */
393
    protected static function combinePaths(string ...$paths): string
394
    {
395
        $last_key = \count($paths) - 1;
396
        array_walk($paths, function (&$val, $key) use ($last_key) {
397
            switch ($key) {
398
                case 0:
399
                    $val = rtrim($val, '/ ');
400
                    break;
401
                case $last_key:
402
                    $val = ltrim($val, '/ ');
403
                    break;
404
                default:
405
                    $val = trim($val, '/ ');
406
                    break;
407
            }
408
        });
409
410
        $first = array_shift($paths);
411
        $last = array_pop($paths);
412
        $paths = array_filter($paths);
413
        array_unshift($paths, $first);
414
        $paths[] = $last;
415
416
        return implode('/', $paths);
417
    }
418
419
    /**
420
     * @param string $error
421
     * @param bool   $soft
422
     *
423
     * @throws NotFoundHttpException
424
     */
425
    protected static function reportError(string $error, $soft = false)
426
    {
427
        $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
428
        if ($devMode && !$soft) {
429
            throw new NotFoundHttpException($error);
430
        }
431
        Craft::error($error, __METHOD__);
432
    }
433
434
    // Private Static Methods
435
    // =========================================================================
436
437
    /**
438
     * @param $string
439
     *
440
     * @return mixed
441
     */
442
    private static function jsonFileDecode($string)
443
    {
444
        return JsonHelper::decodeIfJson($string);
445
    }
446
}
447