├── 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 | --------------------------------------------------------------------------------