├── phpstan.neon
├── CHANGELOG.md
├── ecs.php
├── src
├── translations
│ └── en
│ │ └── twigpack.php
├── icon.svg
├── config.php
├── models
│ └── Settings.php
├── variables
│ └── ManifestVariable.php
├── services
│ └── Manifest.php
├── Twigpack.php
└── helpers
│ └── Manifest.php
├── LICENSE.md
├── composer.json
└── README.md
/phpstan.neon:
--------------------------------------------------------------------------------
1 | includes:
2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon
3 |
4 | parameters:
5 | level: 5
6 | paths:
7 | - src
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Twigpack Changelog
2 |
3 | ## 5.0.0 - 2024.04.16
4 | ### Added
5 | * Stable release for Craft CMS 5
6 |
7 | ## 5.0.0-beta.1 - 2024.04.05
8 | ### Added
9 | * Initial Craft CMS 5 compatibility
10 |
--------------------------------------------------------------------------------
/ecs.php:
--------------------------------------------------------------------------------
1 | paths([
8 | __DIR__ . '/src',
9 | __FILE__,
10 | ]);
11 | $ecsConfig->parallel();
12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]);
13 | };
14 |
--------------------------------------------------------------------------------
/src/translations/en/twigpack.php:
--------------------------------------------------------------------------------
1 | 'Twigpack Manifest Cache',
19 | '{name} plugin loaded' => '{name} plugin loaded',
20 | 'Manifest file not found at: {manifestPath}' => 'Manifest file not found at: {manifestPath}',
21 | 'Module does not exist in the manifest: {moduleName}' => 'Module does not exist in the manifest: {moduleName}',
22 | 'File does not exist: {path}' => 'File does not exist: {path}',
23 | ];
24 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 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 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nystudio107/craft-twigpack",
3 | "description": "Twigpack is a bridge between Twig and webpack, with manifest.json & webpack-dev-server HMR support",
4 | "type": "craft-plugin",
5 | "version": "5.0.0",
6 | "keywords": [
7 | "craftcms",
8 | "craft-plugin",
9 | "twig",
10 | "webpack",
11 | "webpack-dev-server",
12 | "hmr-support",
13 | "manifest-json"
14 | ],
15 | "support": {
16 | "docs": "https://nystudio107.com/docs/twigpack/",
17 | "issues": "https://nystudio107.com/plugins/twigpack/support",
18 | "source": "https://github.com/nystudio107/craft-twigpack"
19 | },
20 | "license": "MIT",
21 | "authors": [
22 | {
23 | "name": "nystudio107",
24 | "homepage": "https://nystudio107.com/"
25 | }
26 | ],
27 | "require": {
28 | "php": "^8.2.0",
29 | "craftcms/cms": "^5.0.0"
30 | },
31 | "require-dev": {
32 | "craftcms/cloud": "^2.0.0",
33 | "craftcms/ecs": "dev-main",
34 | "craftcms/phpstan": "dev-main",
35 | "craftcms/rector": "dev-main"
36 | },
37 | "scripts": {
38 | "phpstan": "phpstan --ansi --memory-limit=1G",
39 | "check-cs": "ecs check --ansi",
40 | "fix-cs": "ecs check --fix --ansi"
41 | },
42 | "config": {
43 | "allow-plugins": {
44 | "craftcms/plugin-installer": true,
45 | "yiisoft/yii2-composer": true
46 | },
47 | "optimize-autoloader": true,
48 | "sort-packages": true
49 | },
50 | "autoload": {
51 | "psr-4": {
52 | "nystudio107\\twigpack\\": "src/"
53 | }
54 | },
55 | "extra": {
56 | "class": "nystudio107\\twigpack\\Twigpack",
57 | "handle": "twigpack",
58 | "name": "Twigpack"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://scrutinizer-ci.com/g/nystudio107/craft-twigpack/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-twigpack/?branch=v5) [](https://scrutinizer-ci.com/g/nystudio107/craft-twigpack/build-status/v5) [](https://scrutinizer-ci.com/code-intelligence)
2 |
3 | # Twigpack plugin for Craft CMS 5.x
4 |
5 | Twigpack is a bridge between Twig and webpack, with manifest.json & webpack-dev-server HMR support
6 |
7 | 
8 |
9 | Related Article: [An Annotated webpack 4 Config for Frontend Web Development](https://nystudio107.com/blog/an-annotated-webpack-4-config-for-frontend-web-development)
10 |
11 | ## Requirements
12 |
13 | This plugin requires Craft CMS 5.0.0 or later.
14 |
15 | ## Installation
16 |
17 | To install the plugin, follow these instructions.
18 |
19 | 1. Open your terminal and go to your Craft project:
20 |
21 | cd /path/to/project
22 |
23 | 2. Then tell Composer to load the plugin:
24 |
25 | composer require nystudio107/craft-twigpack
26 |
27 | 3. Install the plugin via `./craft install/plugin twigpack` via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Twigpack.
28 |
29 | You can also install Twigpack via the **Plugin Store** in the Craft Control Panel.
30 |
31 | ## Documentation
32 |
33 | Click here -> [Twigpack Documentation](https://nystudio107.com/plugins/twigpack/documentation)
34 |
35 | ## Twigpack Roadmap
36 |
37 | Some things to do, and ideas for potential features:
38 |
39 | * Release it
40 |
41 | Brought to you by [nystudio107](https://nystudio107.com/)
42 |
--------------------------------------------------------------------------------
/src/config.php:
--------------------------------------------------------------------------------
1 | [
29 | // If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
30 | 'useDevServer' => false,
31 | // Enforce Absolute URLs on includes
32 | 'useAbsoluteUrl' => true,
33 | // The JavaScript entry from the manifest.json to inject on Twig error pages
34 | // This can be a string or an array of strings
35 | 'errorEntry' => '',
36 | // String to be appended to the cache key
37 | 'cacheKeySuffix' => '',
38 | // Manifest file names
39 | 'manifest' => [
40 | 'legacy' => 'manifest-legacy.json',
41 | 'modern' => 'manifest.json',
42 | ],
43 | // Public server config
44 | 'server' => [
45 | 'manifestPath' => '@webroot/',
46 | 'publicPath' => '/',
47 | ],
48 | // webpack-dev-server config
49 | 'devServer' => [
50 | 'manifestPath' => 'http://localhost:8080/',
51 | 'publicPath' => 'http://localhost:8080/',
52 | ],
53 | // Bundle to use with the webpack-dev-server
54 | 'devServerBuildType' => 'modern',
55 | // Whether to include a Content Security Policy "nonce" for inline
56 | // CSS or JavaScript. Valid values are 'header' or 'tag' for how the CSP
57 | // should be included. c.f.:
58 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script
59 | 'cspNonce' => '',
60 | // Local files config
61 | 'localFiles' => [
62 | 'basePath' => '@webroot/',
63 | 'criticalPrefix' => 'dist/criticalcss/',
64 | 'criticalSuffix' => '_critical.min.css',
65 | ],
66 | ],
67 | // Live (production) environment
68 | 'live' => [
69 | ],
70 | // Staging (pre-production) environment
71 | 'staging' => [
72 | ],
73 | // Development environment
74 | 'dev' => [
75 | // If `devMode` is on, use webpack-dev-server to all for HMR (hot module reloading)
76 | 'useDevServer' => true,
77 | ],
78 | ];
79 |
--------------------------------------------------------------------------------
/src/models/Settings.php:
--------------------------------------------------------------------------------
1 | 'manifest-legacy.json',
53 | 'modern' => 'manifest.json',
54 | ];
55 |
56 | /**
57 | * @var array Public server config
58 | */
59 | public array $server = [
60 | 'manifestPath' => '/',
61 | 'publicPath' => '/',
62 | ];
63 |
64 | /**
65 | * @var array webpack-dev-server config
66 | */
67 | public array $devServer = [
68 | 'manifestPath' => 'http://localhost:8080/',
69 | 'publicPath' => 'http://localhost:8080/',
70 | ];
71 |
72 | /**
73 | * @var string defines which bundle will be used from the webpack dev server.
74 | * Can be 'modern', 'legacy' or 'combined'. Defaults to 'modern'.
75 | */
76 | public string $devServerBuildType = 'modern';
77 |
78 | /**
79 | * @var string Whether to include a Content Security Policy "nonce" for inline
80 | * CSS or JavaScript. Valid values are 'header' or 'tag' for how the CSP
81 | * should be included. c.f.:
82 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script
83 | */
84 | public string $cspNonce = '';
85 |
86 | /**
87 | * @var array Local files config
88 | */
89 | public array $localFiles = [
90 | 'basePath' => '@webroot/',
91 | 'criticalPrefix' => 'dist/criticalcss/',
92 | 'criticalSuffix' => '_critical.min.css',
93 | ];
94 |
95 | // Public Methods
96 | // =========================================================================
97 |
98 | /**
99 | * @inheritdoc
100 | */
101 | public function rules(): array
102 | {
103 | return [
104 | ['useDevServer', 'boolean'],
105 | ['useDevServer', 'default', 'value' => true],
106 | ['errorEntry', 'string'],
107 | ['devServerBuildType', 'string'],
108 | ['cspNonce', 'string'],
109 | [
110 | [
111 | 'manifest',
112 | 'server',
113 | 'devServer',
114 | 'localFiles',
115 | ],
116 | 'each',
117 | 'rule' => ['string'],
118 | ],
119 | ];
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/variables/ManifestVariable.php:
--------------------------------------------------------------------------------
1 | manifest->getCssRelPreloadPolyfill()
40 | );
41 | }
42 |
43 | /**
44 | * @param string $moduleName
45 | * @param bool $async
46 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
47 | *
48 | * @return Markup
49 | * @throws NotFoundHttpException
50 | */
51 | public function includeCssModule(string $moduleName, bool $async = false, array $attributes = []): Markup
52 | {
53 | return Template::raw(
54 | Twigpack::$plugin->manifest->getCssModuleTags($moduleName, $async, null, $attributes)
55 | );
56 | }
57 |
58 | /**
59 | * Returns the CSS file in $path wrapped in tags
60 | *
61 | * @param string $path
62 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
63 | *
64 | * @return Markup
65 | */
66 | public function includeInlineCssTags(string $path, array $attributes = []): Markup
67 | {
68 | return Template::raw(
69 | Twigpack::$plugin->manifest->getCssInlineTags($path, $attributes)
70 | );
71 | }
72 |
73 | /**
74 | * Returns the Critical CSS file for $template wrapped in
75 | * tags
76 | *
77 | * @param null|string $name
78 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
79 | *
80 | * @return Markup
81 | * @throws LoaderError
82 | */
83 | public function includeCriticalCssTags(?string $name = null, array $attributes = []): Markup
84 | {
85 | return Template::raw(
86 | Twigpack::$plugin->manifest->getCriticalCssTags($name, null, $attributes)
87 | );
88 | }
89 |
90 | /**
91 | * @param string $moduleName
92 | * @param bool $async
93 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
94 | *
95 | * @return Markup
96 | * @throws NotFoundHttpException
97 | */
98 | public function includeJsModule(string $moduleName, bool $async = false, array $attributes = []): Markup
99 | {
100 | return Template::raw(
101 | Twigpack::$plugin->manifest->getJsModuleTags($moduleName, $async, null, $attributes)
102 | );
103 | }
104 |
105 | /**
106 | * Return the URI to a module
107 | *
108 | * @param string $moduleName
109 | * @param string $type
110 | * @param null $config
111 | *
112 | * @return Markup
113 | * @throws NotFoundHttpException
114 | */
115 | public function getModuleUri(string $moduleName, string $type = 'modern', $config = null): Markup
116 | {
117 | return Template::raw(
118 | Twigpack::$plugin->manifest->getModule($moduleName, $type, $config)
119 | );
120 | }
121 |
122 | /**
123 | * Return the HASH value from a module
124 | *
125 | * @param string $moduleName
126 | * @param string $type
127 | * @param null $config
128 | *
129 | * @return Markup
130 | * @throws NotFoundHttpException
131 | */
132 | public function getModuleHash(string $moduleName, string $type = 'modern', $config = null): Markup
133 | {
134 | return Template::raw(
135 | Twigpack::$plugin->manifest->getModuleHash($moduleName, $type, $config)
136 | );
137 | }
138 |
139 | /**
140 | * Include the Safari 10.1 nomodule fix JavaScript
141 | *
142 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
143 | *
144 | * @return Markup
145 | */
146 | public function includeSafariNomoduleFix(array $attributes = []): Markup
147 | {
148 | return Template::raw(
149 | Twigpack::$plugin->manifest->getSafariNomoduleFix($attributes)
150 | );
151 | }
152 |
153 | /**
154 | * Returns the contents of a file from a URI path
155 | *
156 | * @param string $path
157 | *
158 | * @return Markup
159 | */
160 | public function includeFile(string $path): Markup
161 | {
162 | return Template::raw(
163 | Twigpack::$plugin->manifest->getFile($path)
164 | );
165 | }
166 |
167 | /**
168 | * Returns the contents of a file from the $fileName in the manifest
169 | *
170 | * @param string $fileName
171 | * @param string $type
172 | * @param null $config
173 | *
174 | * @return Markup
175 | */
176 | public function includeFileFromManifest(string $fileName, string $type = 'legacy', $config = null): Markup
177 | {
178 | return Template::raw(
179 | Twigpack::$plugin->manifest->getFileFromManifest($fileName, $type, $config)
180 | );
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/src/services/Manifest.php:
--------------------------------------------------------------------------------
1 | getSettings();
46 | $config = $config ?? $settings->getAttributes();
47 |
48 | return ManifestHelper::getCssModuleTags($config, $moduleName, $async, $attributes);
49 | }
50 |
51 | /**
52 | * Returns the CSS file in $path wrapped in tags
53 | *
54 | * @param string $path
55 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
56 | *
57 | * @return string
58 | */
59 | public function getCssInlineTags(string $path, array $attributes = []): string
60 | {
61 | return ManifestHelper::getCssInlineTags($path, $attributes);
62 | }
63 |
64 | /**
65 | * @param array $config
66 | * @param null|string $name
67 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
68 | *
69 | * @return string
70 | * @throws LoaderError
71 | */
72 | public function getCriticalCssTags(?string $name = null, ?array $config = null, array $attributes = []): string
73 | {
74 | $settings = Twigpack::$plugin->getSettings();
75 | $config = $config ?? $settings->getAttributes();
76 |
77 | return ManifestHelper::getCriticalCssTags($config, $name, $attributes);
78 | }
79 |
80 | /**
81 | * Returns the uglified loadCSS rel=preload Polyfill as per:
82 | * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
83 | *
84 | * @return string
85 | */
86 | public function getCssRelPreloadPolyfill(): string
87 | {
88 | return ManifestHelper::getCssRelPreloadPolyfill();
89 | }
90 |
91 | /**
92 | * Return the HTML tags to include the JavaScript module
93 | *
94 | * @param string $moduleName
95 | * @param bool $async
96 | * @param null|array $config
97 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
98 | *
99 | * @return null|string
100 | * @throws NotFoundHttpException
101 | */
102 | public function getJsModuleTags(string $moduleName, bool $async = false, ?array $config = null, array $attributes = [])
103 | {
104 | $settings = Twigpack::$plugin->getSettings();
105 | $config = $config ?? $settings->getAttributes();
106 |
107 | return ManifestHelper::getJsModuleTags($config, $moduleName, $async, $attributes);
108 | }
109 |
110 | /**
111 | * Return the Safari 10.1 nomodule JavaScript fix
112 | *
113 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
114 | *
115 | * @return string
116 | */
117 | public function getSafariNomoduleFix(array $attributes = []): string
118 | {
119 | return ManifestHelper::getSafariNomoduleFix($attributes);
120 | }
121 |
122 | /**
123 | * Return the URI to a module
124 | *
125 | * @param string $moduleName
126 | * @param string $type
127 | * @param ?array $config
128 | *
129 | * @return null|string
130 | * @throws NotFoundHttpException
131 | */
132 | public function getModule(string $moduleName, string $type = 'modern', $config = null): ?string
133 | {
134 | $settings = Twigpack::$plugin->getSettings();
135 | $config = $config ?? $settings->getAttributes();
136 |
137 | return ManifestHelper::getModule($config, $moduleName, $type);
138 | }
139 |
140 | /**
141 | * Return the HASH value from a module
142 | *
143 | * @param string $moduleName
144 | * @param string $type
145 | * @param ?array $config
146 | *
147 | * @return null|string
148 | * @throws NotFoundHttpException
149 | */
150 | public function getModuleHash(string $moduleName, string $type = 'modern', $config = null): ?string
151 | {
152 | $settings = Twigpack::$plugin->getSettings();
153 | $config = $config ?? $settings->getAttributes();
154 |
155 | return ManifestHelper::getModuleHash($config, $moduleName, $type);
156 | }
157 |
158 | /**
159 | * Returns the contents of a file from a URI path
160 | *
161 | * @param string $path
162 | *
163 | * @return string
164 | */
165 | public function getFile(string $path): string
166 | {
167 | return ManifestHelper::getFile($path);
168 | }
169 |
170 | /**
171 | * Returns the contents of a file from the $fileName in the manifest
172 | *
173 | * @param string $fileName
174 | * @param string $type
175 | * @param ?array $config
176 | *
177 | * @return string
178 | */
179 | public function getFileFromManifest(string $fileName, string $type = 'legacy', $config = null): string
180 | {
181 | $settings = Twigpack::$plugin->getSettings();
182 | $config = $config ?? $settings->getAttributes();
183 |
184 | return ManifestHelper::getFileFromManifest($config, $fileName, $type);
185 | }
186 |
187 | /**
188 | * Invalidate the manifest cache
189 | */
190 | public function invalidateCaches()
191 | {
192 | ManifestHelper::invalidateCaches();
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/Twigpack.php:
--------------------------------------------------------------------------------
1 | ManifestService::class,
82 | ];
83 |
84 | parent::__construct($id, $parent, $config);
85 | }
86 |
87 | // Public Methods
88 | // =========================================================================
89 |
90 | /**
91 | * @inheritdoc
92 | */
93 | public function init(): void
94 | {
95 | parent::init();
96 | self::$plugin = $this;
97 | // Install our event listeners
98 | $this->installEventListeners();
99 | // Log that we've loaded
100 | Craft::info(
101 | Craft::t(
102 | 'twigpack',
103 | '{name} plugin loaded',
104 | ['name' => $this->name]
105 | ),
106 | __METHOD__
107 | );
108 | }
109 |
110 | /**
111 | * Clear all the caches!
112 | */
113 | public function clearAllCaches(): void
114 | {
115 | // Clear all of Twigpack's caches
116 | self::$plugin->manifest->invalidateCaches();
117 | }
118 |
119 | /**
120 | * Inject the error entry point JavaScript for auto-reloading of Twig error
121 | * pages
122 | */
123 | public function injectErrorEntry(): void
124 | {
125 | if (Craft::$app->getResponse()->isServerError || Craft::$app->getResponse()->isClientError) {
126 | /** @var ?Settings $settings */
127 | $settings = self::$plugin->getSettings();
128 | if ($settings && !empty($settings->errorEntry) && $settings->useDevServer) {
129 | try {
130 | $errorEntry = $settings->errorEntry;
131 | if (is_string($errorEntry)) {
132 | $errorEntry = [$errorEntry];
133 | }
134 | foreach ($errorEntry as $entry) {
135 | $tag = self::$plugin->manifest->getJsModuleTags($entry, false);
136 | if ($tag !== null) {
137 | echo $tag;
138 | }
139 | }
140 | } catch (NotFoundHttpException $e) {
141 | // That's okay, Twigpack will have already logged the error
142 | }
143 | }
144 | }
145 | }
146 |
147 | // Protected Methods
148 | // =========================================================================
149 |
150 | /**
151 | * Install our event listeners.
152 | */
153 | protected function installEventListeners(): void
154 | {
155 | // Remember the name of the currently rendering template
156 | // Handler: View::EVENT_BEFORE_RENDER_PAGE_TEMPLATE
157 | Event::on(
158 | View::class,
159 | View::EVENT_BEFORE_RENDER_PAGE_TEMPLATE,
160 | static function(TemplateEvent $event) {
161 | self::$templateName = $event->template;
162 | }
163 | );
164 | // Handler: CraftVariable::EVENT_INIT
165 | Event::on(
166 | CraftVariable::class,
167 | CraftVariable::EVENT_INIT,
168 | static function(Event $event) {
169 | /** @var CraftVariable $variable */
170 | $variable = $event->sender;
171 | $variable->set('twigpack', ManifestVariable::class);
172 | }
173 | );
174 | // Handler: Plugins::EVENT_AFTER_INSTALL_PLUGIN
175 | Event::on(
176 | Plugins::class,
177 | Plugins::EVENT_AFTER_INSTALL_PLUGIN,
178 | function(PluginEvent $event) {
179 | if ($event->plugin === $this) {
180 | // Invalidate our caches after we've been installed
181 | $this->clearAllCaches();
182 | }
183 | }
184 | );
185 | // Handler: ClearCaches::EVENT_REGISTER_CACHE_OPTIONS
186 | Event::on(
187 | ClearCaches::class,
188 | ClearCaches::EVENT_REGISTER_CACHE_OPTIONS,
189 | function(RegisterCacheOptionsEvent $event) {
190 | Craft::debug(
191 | 'ClearCaches::EVENT_REGISTER_CACHE_OPTIONS',
192 | __METHOD__
193 | );
194 | // Register our caches for the Clear Cache Utility
195 | $event->options = array_merge(
196 | $event->options,
197 | $this->customAdminCpCacheOptions()
198 | );
199 | }
200 | );
201 | // Clears cache after craft cloud/up is run, which Craft Cloud runs on deploy
202 | // Handler: UpController::EVENT_AFTER_UP
203 | if (class_exists(UpController::class)) {
204 | Event::on(
205 | UpController::class,
206 | UpController::EVENT_AFTER_UP,
207 | function (CancelableEvent $event) {
208 | $this->clearAllCaches();
209 | }
210 | );
211 | }
212 |
213 | // delay attaching event handler to the view component after it is fully configured
214 | $app = Craft::$app;
215 | if ($app->getConfig()->getGeneral()->devMode) {
216 | $app->on(Application::EVENT_BEFORE_REQUEST, function() use ($app) {
217 | $app->getView()->on(View::EVENT_END_BODY, [$this, 'injectErrorEntry']);
218 | });
219 | }
220 | }
221 |
222 | /**
223 | * Returns the custom Control Panel cache options.
224 | *
225 | * @return array
226 | */
227 | protected function customAdminCpCacheOptions(): array
228 | {
229 | return [
230 | // Manifest cache
231 | [
232 | 'key' => 'twigpack-manifest-cache',
233 | 'label' => Craft::t('twigpack', 'Twigpack Manifest Cache'),
234 | 'action' => [$this, 'clearAllCaches'],
235 | ],
236 | ];
237 | }
238 |
239 | /**
240 | * @inheritdoc
241 | */
242 | protected function createSettingsModel(): ?Model
243 | {
244 | return new Settings();
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/src/helpers/Manifest.php:
--------------------------------------------------------------------------------
1 | 'stylesheet',
91 | 'media' => 'print',
92 | 'onload' => "this.media='all'",
93 | ], $attributes));
94 | $lines[] = Html::cssFile($legacyModule, array_merge([
95 | 'rel' => 'stylesheet',
96 | 'noscript' => true,
97 | ], $attributes));
98 | } else {
99 | $lines[] = Html::cssFile($legacyModule, array_merge([
100 | 'rel' => 'stylesheet',
101 | ], $attributes));
102 | }
103 |
104 | return implode("\r\n", $lines);
105 | }
106 |
107 | /**
108 | * @param string $path
109 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
110 | *
111 | * @return string
112 | */
113 | public static function getCssInlineTags(string $path, array $attributes = []): string
114 | {
115 | $result = self::getFile($path);
116 | if ($result) {
117 | $config = [];
118 | $nonce = self::getNonce();
119 | if ($nonce !== null) {
120 | $config['nonce'] = $nonce;
121 | self::includeNonce($nonce, 'style-src');
122 | }
123 | $result = Html::style($result, array_merge($config, $attributes));
124 |
125 | return $result;
126 | }
127 |
128 | return '';
129 | }
130 |
131 | /**
132 | * @param array $config
133 | * @param string|null $name
134 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
135 | *
136 | * @return string
137 | * @throws LoaderError
138 | */
139 | public static function getCriticalCssTags(array $config, ?string $name = null, array $attributes = []): string
140 | {
141 | // Resolve the template name
142 | $template = Craft::$app->getView()->resolveTemplate($name ?? Twigpack::$templateName ?? '');
143 | if ($template) {
144 | $name = self::combinePaths(
145 | pathinfo($template, PATHINFO_DIRNAME),
146 | pathinfo($template, PATHINFO_FILENAME)
147 | );
148 | $dirPrefix = 'templates/';
149 | if (defined('CRAFT_TEMPLATES_PATH')) {
150 | $dirPrefix = CRAFT_TEMPLATES_PATH;
151 | }
152 | $name = strstr($name, $dirPrefix);
153 | $name = (string)str_replace($dirPrefix, '', $name);
154 | $path = self::combinePaths(
155 | $config['localFiles']['basePath'],
156 | $config['localFiles']['criticalPrefix'],
157 | $name
158 | ) . $config['localFiles']['criticalSuffix'];
159 |
160 | return self::getCssInlineTags($path, $attributes);
161 | }
162 |
163 | return '';
164 | }
165 |
166 | /**
167 | * Returns the uglified loadCSS rel=preload Polyfill as per:
168 | * https://github.com/filamentgroup/loadCSS#how-to-use-loadcss-recommended-example
169 | *
170 | * @return string
171 | * @throws DeprecationException
172 | * @deprecated in 1.2.0
173 | */
174 | public static function getCssRelPreloadPolyfill(): string
175 | {
176 | Craft::$app->getDeprecator()->log('craft.twigpack.includeCssRelPreloadPolyfill()', 'craft.twigpack.includeCssRelPreloadPolyfill() has been deprecated, this function now does nothing. You can safely remove craft.twigpack.includeCssRelPreloadPolyfill() from your templates.');
177 |
178 | return '';
179 | }
180 |
181 | /**
182 | * @param array $config
183 | * @param string $moduleName
184 | * @param bool $async
185 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
186 | *
187 | * @return null|string
188 | * @throws NotFoundHttpException
189 | */
190 | public static function getJsModuleTags(array $config, string $moduleName, bool $async, array $attributes = []): ?string
191 | {
192 | $legacyModule = self::getModule($config, $moduleName, 'legacy', true);
193 | if ($legacyModule === null) {
194 | return '';
195 | }
196 | $modernModule = '';
197 | if ($async) {
198 | $modernModule = self::getModule($config, $moduleName, 'modern', true);
199 | if ($modernModule === null) {
200 | return '';
201 | }
202 | }
203 | $lines = [];
204 | if ($async) {
205 | $lines[] = Html::jsFile($modernModule, array_merge([
206 | 'type' => 'module',
207 | ], $attributes));
208 | $lines[] = Html::jsFile($legacyModule, array_merge([
209 | 'nomodule' => true,
210 | ], $attributes));
211 | } else {
212 | $lines[] = Html::jsFile($legacyModule, array_merge([
213 | ], $attributes));
214 | }
215 |
216 | return implode("\r\n", $lines);
217 | }
218 |
219 | /**
220 | * Safari 10.1 supports modules, but does not support the `nomodule`
221 | * attribute - it will load
224 | *
225 | * Again: this will **not* # prevent inline script, e.g.:
226 | * .
227 | *
228 | * This workaround is possible because Safari supports the non-standard
229 | * 'beforeload' event. This allows us to trap the module and nomodule load.
230 | *
231 | * Note also that `nomodule` is supported in later versions of Safari -
232 | * it's just 10.1 that omits this attribute.
233 | *
234 | * c.f.: https://gist.github.com/samthor/64b114e4a4f539915a95b91ffd340acc
235 | *
236 | * @param array $attributes additional HTML key/value pair attributes to add to the resulting tag
237 | *
238 | * @return string
239 | */
240 | public static function getSafariNomoduleFix(array $attributes = []): string
241 | {
242 | $code = /** @lang JavaScript */
243 | <<getMessage(), __METHOD__);
292 | }
293 | }
294 |
295 | return $module;
296 | }
297 |
298 | /**
299 | * Return the HASH value from to module
300 | *
301 | * @param array $config
302 | * @param string $moduleName
303 | * @param string $type
304 | * @param bool $soft
305 | *
306 | * @return null|string
307 | */
308 | public static function getModuleHash(array $config, string $moduleName, string $type = 'modern', bool $soft = false): ?string
309 | {
310 | $moduleHash = '';
311 | try {
312 | // Get the module entry
313 | $module = self::getModuleEntry($config, $moduleName, $type, $soft);
314 | if ($module !== null) {
315 | // Extract only the Hash Value
316 | $modulePath = pathinfo($module);
317 | $moduleFilename = $modulePath['filename'];
318 | $moduleHash = substr($moduleFilename, strpos($moduleFilename, ".") + 1);
319 | }
320 | } catch (Exception $e) {
321 | // return empty string if no module is found
322 | return '';
323 | }
324 |
325 | return $moduleHash;
326 | }
327 |
328 | /**
329 | * Return a module's raw entry from the manifest
330 | *
331 | * @param array $config
332 | * @param string $moduleName
333 | * @param string $type
334 | * @param bool $soft
335 | *
336 | * @return null|string
337 | * @throws NotFoundHttpException
338 | */
339 | public static function getModuleEntry(
340 | array $config,
341 | string $moduleName,
342 | string $type = 'modern',
343 | bool $soft = false,
344 | ): ?string {
345 | $module = null;
346 | // Get the manifest file
347 | $manifest = self::getManifestFile($config, $type);
348 | if ($manifest !== null) {
349 | // Make sure it exists in the manifest
350 | if (empty($manifest[$moduleName])) {
351 | // Don't report errors for any files in SUPPRESS_ERRORS_FOR_MODULES
352 | if (!in_array($moduleName, self::SUPPRESS_ERRORS_FOR_MODULES)) {
353 | self::reportError(Craft::t(
354 | 'twigpack',
355 | 'Module does not exist in the manifest: {moduleName}',
356 | ['moduleName' => $moduleName]
357 | ), $soft);
358 | }
359 |
360 | return null;
361 | }
362 | $module = $manifest[$moduleName];
363 | }
364 |
365 | return $module;
366 | }
367 |
368 | /**
369 | * Return a JSON-decoded manifest file
370 | *
371 | * @param array $config
372 | * @param string $type
373 | *
374 | * @return null|array
375 | * @throws NotFoundHttpException
376 | */
377 | public static function getManifestFile(array $config, string $type = 'modern'): ?array
378 | {
379 | $manifest = null;
380 | // Determine whether we should use the devServer for HMR or not
381 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
382 | self::$isHot = ($devMode && $config['useDevServer']);
383 | // Try to get the manifest
384 | while ($manifest === null) {
385 | $manifestPath = self::$isHot
386 | ? $config['devServer']['manifestPath']
387 | : $config['server']['manifestPath'];
388 | // If this is a dev-server, use the defined build type
389 | $thisType = $type;
390 | if (self::$isHot) {
391 | $thisType = $config['devServerBuildType'] === 'combined'
392 | ? $thisType
393 | : $config['devServerBuildType'];
394 | }
395 | // Normalize the path
396 | $path = self::combinePaths($manifestPath, $config['manifest'][$thisType]);
397 | $manifest = self::getJsonFile($path);
398 | // If the manifest isn't found, and it was hot, fall back on non-hot
399 | if ($manifest === null) {
400 | // We couldn't find a manifest; throw an error
401 | self::reportError(Craft::t(
402 | 'twigpack',
403 | 'Manifest file not found at: {manifestPath}',
404 | ['manifestPath' => $manifestPath]
405 | ), true);
406 | if (self::$isHot) {
407 | // Try again, but not with home module replacement
408 | self::$isHot = false;
409 | } else {
410 | // Give up and return null
411 | return null;
412 | }
413 | }
414 | }
415 |
416 | return $manifest;
417 | }
418 |
419 | /**
420 | * Returns the contents of a file from a URI path
421 | *
422 | * @param string $path
423 | *
424 | * @return string
425 | */
426 | public static function getFile(string $path): string
427 | {
428 | return self::getFileFromUri($path, null, true) ?? '';
429 | }
430 |
431 | /**
432 | * @param array $config
433 | * @param string $fileName
434 | * @param string $type
435 | *
436 | * @return string
437 | */
438 | public static function getFileFromManifest(array $config, string $fileName, string $type = 'legacy'): string
439 | {
440 | $path = null;
441 | try {
442 | $path = self::getModuleEntry($config, $fileName, $type, true);
443 | } catch (NotFoundHttpException $e) {
444 | Craft::error($e->getMessage(), __METHOD__);
445 | }
446 | if ($path !== null) {
447 | // Determine whether we should use the devServer for HMR or not
448 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
449 | if ($devMode) {
450 | $devServerPrefix = $config['devServer']['publicPath'];
451 | // If we're using the devserver, swap in the deverserver path
452 | if (UrlHelper::isAbsoluteUrl($path) && self::$isHot) {
453 | $path = parse_url($path, PHP_URL_PATH);
454 | }
455 | $devServerPath = self::combinePaths(
456 | $devServerPrefix,
457 | $path
458 | );
459 | $devServerFile = self::getFileFromUri($devServerPath, null);
460 | if ($devServerFile) {
461 | return $devServerFile;
462 | }
463 | }
464 | // Otherwise, try not-hot files
465 | $localPrefix = $config['localFiles']['basePath'];
466 | $localPath = self::combinePaths(
467 | $localPrefix,
468 | $path
469 | );
470 | $alias = Craft::getAlias($localPath, false);
471 | if ($alias && is_string($alias)) {
472 | $localPath = $alias;
473 | }
474 | try {
475 | if (is_file($localPath)) {
476 | return self::getFile($localPath);
477 | }
478 | } catch (Exception $e) {
479 | Craft::error($e->getMessage(), __METHOD__);
480 | }
481 | }
482 |
483 | return '';
484 | }
485 |
486 | /**
487 | * Invalidate all of the manifest caches
488 | */
489 | public static function invalidateCaches(): void
490 | {
491 | $cache = Craft::$app->getCache();
492 | TagDependency::invalidate($cache, self::CACHE_TAG);
493 | Craft::info('All manifest caches cleared', __METHOD__);
494 | }
495 |
496 | /**
497 | * Return the contents of a JSON file from a URI path
498 | *
499 | * @param string $path
500 | *
501 | * @return null|array
502 | */
503 | protected static function getJsonFile(string $path): ?array
504 | {
505 | return self::getFileFromUri($path, [self::class, 'jsonFileDecode']);
506 | }
507 |
508 | // Protected Static Methods
509 | // =========================================================================
510 |
511 | /**
512 | * Return the contents of a file from a URI path
513 | *
514 | * @param string $path
515 | * @param callable|null $callback
516 | * @param bool $pathOnly
517 | *
518 | * @return null|mixed
519 | */
520 | protected static function getFileFromUri(string $path, callable $callback = null, bool $pathOnly = false): mixed
521 | {
522 | // Resolve any aliases
523 | $alias = Craft::getAlias($path, false);
524 | if ($alias && is_string($alias)) {
525 | $path = $alias;
526 | }
527 | // If we only want the file via path, make sure it exists
528 | try {
529 | if ($pathOnly && !is_file($path)) {
530 | Craft::warning(Craft::t(
531 | 'twigpack',
532 | 'File does not exist: {path}',
533 | ['path' => $path]
534 | ), __METHOD__);
535 |
536 | return '';
537 | }
538 | } catch (Exception $e) {
539 | Craft::error($e->getMessage(), __METHOD__);
540 | }
541 |
542 | // Make sure it's a full URL
543 | try {
544 | if (!UrlHelper::isAbsoluteUrl($path) && !is_file($path)) {
545 | $path = UrlHelper::siteUrl($path);
546 | }
547 | } catch (Exception $e) {
548 | Craft::error($e->getMessage(), __METHOD__);
549 | }
550 |
551 | return self::getFileContents($path, $callback);
552 | }
553 |
554 | /**
555 | * Return the contents of a file from the passed in path
556 | *
557 | * @param string $path
558 | * @param callable $callback
559 | *
560 | * @return null|mixed
561 | */
562 | protected static function getFileContents(string $path, callable $callback = null): mixed
563 | {
564 | // Return the memoized manifest if it exists
565 | if (!empty(self::$files[$path])) {
566 | return self::$files[$path];
567 | }
568 | // Create the dependency tags
569 | $dependency = new TagDependency([
570 | 'tags' => [
571 | self::CACHE_TAG,
572 | self::CACHE_TAG . $path,
573 | ],
574 | ]);
575 | // If this is a file path such as for the `manifest.json`, add a FileDependency so it's cache bust if the file changes
576 | if (!UrlHelper::isAbsoluteUrl($path)) {
577 | $dependency = new ChainedDependency([
578 | 'dependencies' => [
579 | new FileDependency([
580 | 'fileName' => $path,
581 | ]),
582 | $dependency,
583 | ],
584 | ]);
585 | }
586 | // Set the cache duration based on devMode
587 | $cacheDuration = Craft::$app->getConfig()->getGeneral()->devMode
588 | ? self::DEVMODE_CACHE_DURATION
589 | : null;
590 | // Get the result from the cache, or parse the file
591 | $cache = Craft::$app->getCache();
592 | $settings = Twigpack::$plugin->getSettings();
593 | $cacheKeySuffix = $settings->cacheKeySuffix ?? '';
594 | $file = $cache->getOrSet(
595 | self::CACHE_KEY . $cacheKeySuffix . $path,
596 | function() use ($path, $callback) {
597 | $result = null;
598 | $contents = null;
599 | if (UrlHelper::isAbsoluteUrl($path)) {
600 | $clientOptions = [
601 | RequestOptions::HTTP_ERRORS => false,
602 | RequestOptions::CONNECT_TIMEOUT => 3,
603 | RequestOptions::VERIFY => false,
604 | RequestOptions::TIMEOUT => 5,
605 | ];
606 | // If we're hot, insert a short 50ms delay in fetching remove files, to handle a webpack-dev-server/
607 | // Tailwind CSS JIT race condition
608 | if (self::$isHot) {
609 | $clientOptions = array_merge($clientOptions, [
610 | RequestOptions::DELAY => 100,
611 | ]);
612 | }
613 | $client = new Client($clientOptions);
614 | try {
615 | $response = $client->request('GET', $path, [
616 | RequestOptions::HEADERS => [
617 | '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",
618 | 'Accept' => '*/*',
619 | ],
620 | ]);
621 | if ($response->getStatusCode() === 200) {
622 | $contents = $response->getBody()->getContents();
623 | }
624 | } catch (Throwable $e) {
625 | Craft::error($e->getMessage(), __METHOD__);
626 | }
627 | } else {
628 | $contents = @file_get_contents($path);
629 | }
630 | if ($contents) {
631 | $result = $contents;
632 | if ($callback) {
633 | $result = $callback($result);
634 | }
635 | }
636 |
637 | return $result;
638 | },
639 | $cacheDuration,
640 | $dependency
641 | );
642 | self::$files[$path] = $file;
643 |
644 | return $file;
645 | }
646 |
647 | /**
648 | * Get the response code from a given $url
649 | *
650 | * @param $url
651 | * @param $context
652 | * @return false|string
653 | */
654 | protected static function getHttpResponseCode($url, $context): bool|string
655 | {
656 | $headers = @get_headers($url, false, $context);
657 | if (empty($headers)) {
658 | return '404';
659 | }
660 |
661 | return substr($headers[0], 9, 3);
662 | }
663 |
664 | /**
665 | * Combined the passed in paths, whether file system or URL
666 | *
667 | * @param string ...$paths
668 | *
669 | * @return string
670 | */
671 | protected static function combinePaths(?string ...$paths): string
672 | {
673 | $last_key = count($paths) - 1;
674 | array_walk($paths, function(&$val, $key) use ($last_key) {
675 | switch ($key) {
676 | case 0:
677 | $val = rtrim($val, '/ ');
678 | break;
679 | case $last_key:
680 | $val = ltrim($val, '/ ');
681 | break;
682 | default:
683 | $val = trim($val, '/ ');
684 | break;
685 | }
686 | });
687 |
688 | $first = array_shift($paths);
689 | $last = array_pop($paths);
690 | $paths = array_filter($paths);
691 | array_unshift($paths, $first);
692 | $paths[] = $last;
693 |
694 | return implode('/', $paths);
695 | }
696 |
697 | /**
698 | * @param string $error
699 | * @param bool $soft
700 | *
701 | * @throws NotFoundHttpException
702 | */
703 | protected static function reportError(string $error, $soft = false): void
704 | {
705 | $devMode = Craft::$app->getConfig()->getGeneral()->devMode;
706 | if ($devMode && !$soft) {
707 | throw new NotFoundHttpException($error);
708 | }
709 | if (self::$isHot) {
710 | Craft::warning($error, __METHOD__);
711 | } else {
712 | Craft::error($error, __METHOD__);
713 | }
714 | }
715 |
716 | // Private Static Methods
717 | // =========================================================================
718 |
719 | /**
720 | * @param string $nonce
721 | * @param string $cspDirective
722 | */
723 | private static function includeNonce(string $nonce, string $cspDirective): void
724 | {
725 | $cspNonceType = self::getCspNonceType();
726 | if ($cspNonceType) {
727 | $cspValue = "{$cspDirective} 'nonce-$nonce'";
728 | foreach (self::CSP_HEADERS as $cspHeader) {
729 | switch ($cspNonceType) {
730 | case 'tag':
731 | Craft::$app->getView()->registerMetaTag([
732 | 'httpEquiv' => $cspHeader,
733 | 'value' => $cspValue,
734 | ]);
735 | break;
736 | case 'header':
737 | Craft::$app->getResponse()->getHeaders()->add($cspHeader, $cspValue . ';');
738 | break;
739 | default:
740 | break;
741 | }
742 | }
743 | }
744 | }
745 |
746 | /**
747 | * @return string|null
748 | */
749 | private static function getCspNonceType(): ?string
750 | {
751 | /** @var Settings $settings */
752 | $settings = Twigpack::$plugin->getSettings();
753 | $cspNonceType = !empty($settings->cspNonce) ? strtolower($settings->cspNonce) : null;
754 |
755 | return $cspNonceType;
756 | }
757 |
758 | /**
759 | * @return string|null
760 | */
761 | private static function getNonce(): ?string
762 | {
763 | $result = null;
764 | if (self::getCspNonceType() !== null) {
765 | try {
766 | $result = bin2hex(random_bytes(22));
767 | } catch (\Exception $e) {
768 | // That's okay
769 | }
770 | }
771 |
772 | return $result;
773 | }
774 |
775 | /**
776 | * @param $string
777 | *
778 | * @return null|array
779 | */
780 | private static function jsonFileDecode($string): ?array
781 | {
782 | $json = JsonHelper::decodeIfJson($string);
783 | if (is_string($json)) {
784 | Craft::error('Error decoding JSON file: ' . $json, __METHOD__);
785 | $json = null;
786 | }
787 |
788 | return $json;
789 | }
790 | }
791 |
--------------------------------------------------------------------------------