├── README.md
├── .gitignore
├── composer.json
├── LICENSE.md
├── CHANGELOG.md
└── src
├── variables
└── ManifestVariable.php
└── services
└── ManifestService.php
/README.md:
--------------------------------------------------------------------------------
1 | # Plugin Manifest
2 |
3 | ## Requirements
4 |
5 | * Craft CMS 3.0.0 or later
6 |
7 | ## Installation
8 |
9 | ```
10 | composer require nystudio107/craft-plugin-manifest
11 | ```
12 |
13 | ## Plugin Manifest Overview
14 |
15 | Plugin Manifest is the conduit between Craft CMS plugins and webpack, with manifest.json & webpack-dev-server HMR support
16 |
17 | You shouldn't need to install this yourself normally; it's meant to be included by nystudio107 plugins.
18 |
19 | Brought to you by [nystudio107](https://nystudio107.com)
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # CRAFT ENVIRONMENT
2 | .env.php
3 | .env.sh
4 | .env
5 |
6 | # COMPOSER
7 | /vendor
8 |
9 | # BUILD FILES
10 | /bower_components/*
11 | /node_modules/*
12 | /build/*
13 | /yarn-error.log
14 |
15 | # MISC FILES
16 | .cache
17 | .DS_Store
18 | .idea
19 | .project
20 | .settings
21 | *.esproj
22 | *.sublime-workspace
23 | *.sublime-project
24 | *.tmproj
25 | *.tmproject
26 | .vscode/*
27 | !.vscode/settings.json
28 | !.vscode/tasks.json
29 | !.vscode/launch.json
30 | !.vscode/extensions.json
31 | config.codekit3
32 | prepros-6.config
33 |
34 | # BUILD FILES
35 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nystudio107/craft-plugin-manifest",
3 | "description": "Plugin Manifest is the conduit between Craft CMS plugins and webpack, with manifest.json & webpack-dev-server HMR support",
4 | "version": "1.0.10",
5 | "keywords": [
6 | "craftcms",
7 | "plugin",
8 | "manifest"
9 | ],
10 | "support": {
11 | "docs": "https://github.com/nystudio107/craft-plugin-manifest/blob/v1/README.md",
12 | "issues": "https://github.com/nystudio107/craft-plugin-manifest/issues"
13 | },
14 | "license": "MIT",
15 | "authors": [
16 | {
17 | "name": "nystudio107",
18 | "homepage": "https://nystudio107.com"
19 | }
20 | ],
21 | "minimum-stability": "dev",
22 | "prefer-stable": true,
23 | "require": {
24 | "craftcms/cms": "^3.0.0"
25 | },
26 | "autoload": {
27 | "psr-4": {
28 | "nystudio107\\pluginmanifest\\": "src/"
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 nystudio107
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Plugin Manifest Changelog
2 |
3 | ## 1.0.10 - 2022.04.26
4 | ### Changed
5 | * Don't log the full exception on a Guzzle error, just log the message
6 |
7 | ## 1.0.9 - 2022.04.17
8 | ### Fixed
9 | * Fix incorrect `User-Agent` header that could cause an error to be thrown
10 |
11 | ## 1.0.8 - 2021.10.11
12 | ### Changed
13 | * Handle catching all exceptions, to cover misconfigured installations ([#2](https://github.com/nystudio107/craft-plugin-manifest/issues/2))
14 | *
15 | ## 1.0.7 - 2021.09.22
16 | ### Changed
17 | * Remove `Craft::t()` that depended on a translation method that may or may not exist
18 |
19 | ## 1.0.6 - 2021.08.19
20 | ### Changed
21 | * Only do setup in the `ManifestServce::init()` method for CP requests
22 |
23 | ## 1.0.5 - 2021.07.13
24 | ### Changed
25 | * Wrap calls to `is_file()` with try/catch, to handle open_basedir restrictions that cause exceptions to be thrown (https://github.com/nystudio107/craft-seomatic/issues/924)
26 |
27 | ## 1.0.4 - 2021.05.06
28 | ### Fixed
29 | * Don't call any AssetManager methods in the component `init()` method during console requests
30 |
31 | ## 1.0.3 - 2021.04.07
32 | ### Fixed
33 | * Add a `100ms` delay when requesting the manifest file if using it in hot mode, as a hack to avoid a `webpack-dev-server` / Tailwind CSS JIT race condition (https://github.com/nystudio107/craft/issues/55)
34 |
35 | ## 1.0.2 - 2021.03.21
36 | ### Added
37 | * Added a `FileDependency` cache dependency for files loaded from a local path, so things like the `manifest.json` will auto-cache bust if the file changes
38 |
39 | ### Changed
40 | * Use Guzzle for remote file fetches rather than `curl`, for improved performance
41 |
42 | ## 1.0.1 - 2021.03.05
43 | ### Added
44 | * Added the ability to pass in the environment variable to check for enabling the devServer, so it's not hard-coded at `NYS_PLUGIN_DEVSERVER`
45 |
46 | ## 1.0.0 - 2021.03.02
47 | ### Added
48 | - Initial release
49 |
--------------------------------------------------------------------------------
/src/variables/ManifestVariable.php:
--------------------------------------------------------------------------------
1 | manifestService->registerJsModules($modules);
40 | }
41 |
42 | /**
43 | * Get the passed in CS modules from the manifest, and register them in the current Craft view
44 | *
45 | * @param array $modules
46 | *
47 | * @throws InvalidConfigException
48 | * @throws NotFoundHttpException
49 | */
50 | public function registerCssModules(array $modules)
51 | {
52 | $this->manifestService->registerCssModules($modules);
53 | }
54 |
55 |
56 | /**
57 | * Get the passed in JS module from the manifest, then output a `";
204 | $lines[] = "";
205 | } else {
206 | $lines[] = "";
207 | }
208 |
209 | return implode("\r\n", $lines);
210 | }
211 |
212 | /**
213 | * Get the passed in CS module from the manifest, then output a `` tag for it in the HTML
214 | *
215 | * @param string $moduleName
216 | * @param bool $async
217 | *
218 | * @return string
219 | * @throws NotFoundHttpException
220 | */
221 | public function includeCssModule(string $moduleName, bool $async): string
222 | {
223 | $legacyModule = $this->getModule($moduleName, 'legacy', true);
224 | if ($legacyModule === null) {
225 | return '';
226 | }
227 | $lines = [];
228 | if ($async) {
229 | $lines[] = "";
230 | $lines[] = "";
231 | } else {
232 | $lines[] = "";
233 | }
234 |
235 | return implode("\r\n", $lines);
236 | }
237 |
238 | /**
239 | * Invalidate all of the manifest caches
240 | */
241 | public function invalidateCaches()
242 | {
243 | $cache = Craft::$app->getCache();
244 | TagDependency::invalidate($cache, self::CACHE_TAG . $this->assetClass);
245 | Craft::info('All manifest caches cleared', __METHOD__);
246 | }
247 |
248 | // Protected Static Methods
249 | // =========================================================================
250 |
251 | /**
252 | * Return the URI to a module
253 | *
254 | * @param string $moduleName
255 | * @param string $type
256 | * @param bool $soft
257 | *
258 | * @return null|string
259 | * @throws NotFoundHttpException
260 | */
261 | protected function getModule(string $moduleName, string $type = 'modern', bool $soft = false)
262 | {
263 | // Get the module entry
264 | $module = $this->getModuleEntry($moduleName, $type, $soft);
265 | if ($module !== null) {
266 | $prefix = $this->isHot
267 | ? $this->devServerPublicPath
268 | : $this->serverPublicPath;
269 | // If the module isn't a full URL, prefix it
270 | if (!UrlHelper::isAbsoluteUrl($module)) {
271 | $module = $this->combinePaths($prefix, $module);
272 | }
273 | // Resolve any aliases
274 | $alias = Craft::getAlias($module, false);
275 | if ($alias) {
276 | $module = $alias;
277 | }
278 | // Make sure it's a full URL
279 | try {
280 | if (!UrlHelper::isAbsoluteUrl($module) && !is_file($module)) {
281 | $module = UrlHelper::siteUrl($module);
282 | }
283 | } catch (Throwable $e) {
284 | Craft::error($e->getMessage(), __METHOD__);
285 | }
286 | }
287 |
288 | return $module;
289 | }
290 |
291 | /**
292 | * Return a module's raw entry from the manifest
293 | *
294 | * @param string $moduleName
295 | * @param string $type
296 | * @param bool $soft
297 | *
298 | * @return null|string
299 | * @throws NotFoundHttpException
300 | */
301 | protected function getModuleEntry(string $moduleName, string $type = 'modern', bool $soft = false)
302 | {
303 | $module = null;
304 | // Get the manifest file
305 | $manifest = $this->getManifestFile($type);
306 | if ($manifest !== null) {
307 | // Make sure it exists in the manifest
308 | if (empty($manifest[$moduleName])) {
309 | // Don't report errors for any files in SUPPRESS_ERRORS_FOR_MODULES
310 | if (!in_array($moduleName, self::SUPPRESS_ERRORS_FOR_MODULES)) {
311 | $this->reportError("Module does not exist in the manifest: {$moduleName}", $soft);
312 | }
313 |
314 | return null;
315 | }
316 | $module = $manifest[$moduleName];
317 | }
318 |
319 | return $module;
320 | }
321 |
322 | /**
323 | * Return a JSON-decoded manifest file
324 | *
325 | * @param string $type
326 | *
327 | * @return null|array
328 | * @throws NotFoundHttpException
329 | */
330 | protected function getManifestFile(string $type = 'modern')
331 | {
332 | $manifest = null;
333 | // Determine whether we should use the devServer for HMR or not
334 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
335 | $this->isHot = ($devMode && $this->useDevServer);
336 | // Try to get the manifest
337 | while ($manifest === null) {
338 | $manifestPath = $this->isHot
339 | ? $this->devServerManifestPath
340 | : $this->serverManifestPath;
341 | // Normalize the path
342 | $manifestType = 'manifest' . ucfirst($type);
343 | $path = $this->combinePaths($manifestPath, $this->$manifestType);
344 | $manifest = $this->getJsonFile($path);
345 | // If the manifest isn't found, and it was hot, fall back on non-hot
346 | if ($manifest === null) {
347 | // We couldn't find a manifest; throw an error
348 | $this->reportError("Manifest file not found at: {$manifestPath}", true);
349 | if ($this->isHot) {
350 | // Try again, but not with home module replacement
351 | $this->isHot = false;
352 | } else {
353 | // Give up and return null
354 | return null;
355 | }
356 | }
357 | }
358 |
359 | return $manifest;
360 | }
361 |
362 | /**
363 | * Return the contents of a JSON file from a URI path
364 | *
365 | * @param string $path
366 | *
367 | * @return null|array
368 | */
369 | protected function getJsonFile(string $path)
370 | {
371 | return $this->getFileFromUri($path, [JsonHelper::class, 'decodeIfJson']);
372 | }
373 |
374 | /**
375 | * Return the contents of a file from a URI path
376 | *
377 | * @param string $path
378 | * @param callable|null $callback
379 | *
380 | * @return null|mixed
381 | */
382 | protected function getFileFromUri(string $path, callable $callback = null)
383 | {
384 | // Resolve any aliases
385 | $alias = Craft::getAlias($path, false);
386 | if ($alias) {
387 | $path = (string)$alias;
388 | }
389 | // Make sure it's a full URL
390 | try {
391 | if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
392 | $path = UrlHelper::siteUrl($path);
393 | }
394 | } catch (Throwable $e) {
395 | Craft::error($e->getMessage(), __METHOD__);
396 | }
397 |
398 | return $this->getFileContents($path, $callback);
399 | }
400 |
401 | /**
402 | * Return the contents of a file from the passed in path
403 | *
404 | * @param string $path
405 | * @param callable $callback
406 | *
407 | * @return null|mixed
408 | */
409 | protected function getFileContents(string $path, callable $callback = null)
410 | {
411 | // Return the memoized manifest if it exists
412 | if (!empty($this->files[$path])) {
413 | return $this->files[$path];
414 | }
415 | // Create the dependency tags
416 | $dependency = new TagDependency([
417 | 'tags' => [
418 | self::CACHE_TAG . $this->assetClass,
419 | self::CACHE_TAG . $this->assetClass . $path,
420 | ],
421 | ]);
422 | // If this is a file path such as for the `manifest.json`, add a FileDependency so it's cache bust if the file changes
423 | if (!UrlHelper::isAbsoluteUrl($path)) {
424 | $dependency = new ChainedDependency([
425 | 'dependencies' => [
426 | new FileDependency([
427 | 'fileName' => $path
428 | ]),
429 | $dependency
430 | ]
431 | ]);
432 | }
433 | // Set the cache duration based on devMode
434 | $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
435 | ? self::DEVMODE_CACHE_DURATION
436 | : null;
437 | // Get the result from the cache, or parse the file
438 | $cache = Craft::$app->getCache();
439 | $file = $cache->getOrSet(
440 | self::CACHE_KEY . $this->assetClass . $path,
441 | function () use ($path, $callback) {
442 | $result = null;
443 | $contents = null;
444 | if (UrlHelper::isAbsoluteUrl($path)) {
445 | $clientOptions = [
446 | RequestOptions::HTTP_ERRORS => false,
447 | RequestOptions::CONNECT_TIMEOUT => 3,
448 | RequestOptions::VERIFY => false,
449 | RequestOptions::TIMEOUT => 5,
450 | ];
451 | // If we're hot, insert a short 50ms delay in fetching remove files, to handle a webpack-dev-server/
452 | // Tailwind CSS JIT race condition
453 | if ($this->isHot) {
454 | $clientOptions = array_merge($clientOptions, [
455 | RequestOptions::DELAY => 100,
456 | ]);
457 | }
458 | $client = new Client($clientOptions);
459 | try {
460 | $response = $client->request('GET', $path, [
461 | RequestOptions::HEADERS => [
462 | '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",
463 | 'Accept' => '*/*',
464 | ],
465 | ]);
466 | if ($response->getStatusCode() === 200) {
467 | $contents = $response->getBody()->getContents();
468 | }
469 | } catch (Throwable $e) {
470 | Craft::error($e->getMessage(), __METHOD__);
471 | }
472 | } else {
473 | $contents = @file_get_contents($path);
474 | }
475 | if ($contents) {
476 | $result = $contents;
477 | if ($callback) {
478 | $result = $callback($result);
479 | }
480 | }
481 |
482 | return $result;
483 | },
484 | $cacheDuration,
485 | $dependency
486 | );
487 | $this->files[$path] = $file;
488 |
489 | return $file;
490 | }
491 |
492 | /**
493 | * Combined the passed in paths, whether file system or URL
494 | *
495 | * @param string ...$paths
496 | *
497 | * @return string
498 | */
499 | protected function combinePaths(string ...$paths): string
500 | {
501 | $lastKey = count($paths) - 1;
502 | array_walk($paths, static function (&$val, $key) use ($lastKey) {
503 | switch ($key) {
504 | case 0:
505 | $val = rtrim($val, '/ ');
506 | break;
507 | case $lastKey:
508 | $val = ltrim($val, '/ ');
509 | break;
510 | default:
511 | $val = trim($val, '/ ');
512 | break;
513 | }
514 | });
515 |
516 | $first = array_shift($paths);
517 | $last = array_pop($paths);
518 | $paths = array_filter($paths);
519 | array_unshift($paths, $first);
520 | $paths[] = $last;
521 |
522 | return implode('/', $paths);
523 | }
524 |
525 | /**
526 | * @param string $error
527 | * @param bool $soft
528 | *
529 | * @throws NotFoundHttpException
530 | */
531 | protected function reportError(string $error, $soft = false)
532 | {
533 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
534 | if ($devMode && !$soft) {
535 | throw new NotFoundHttpException($error);
536 | }
537 | Craft::error($error, __METHOD__);
538 | }
539 | }
540 |
--------------------------------------------------------------------------------