├── .github ├── CODEOWNERS └── workflows │ ├── create-release.yml │ └── code-analysis.yaml ├── phpstan.neon ├── CHANGELOG.md ├── ecs.php ├── .gitignore ├── README.md ├── composer.json ├── src ├── templates │ └── settings │ │ └── image-transforms │ │ └── imgix.twig └── imagetransforms │ └── ImgixImageTransform.php └── LICENSE.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @khalwat 2 | -------------------------------------------------------------------------------- /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 | # ImageOptimize Imgix Image Transform Changelog 2 | 3 | ## 5.0.0 - 2024.04.15 4 | ### Added 5 | * Stable release for Craft CMS 5 6 | 7 | ## 5.0.0-beta.1 - 2024.04.02 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/create-release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | run-name: Create release for ${{ github.event.client_payload.version }} 3 | 4 | on: 5 | repository_dispatch: 6 | types: 7 | - craftcms/new-release 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: ncipollo/release-action@v1 16 | with: 17 | body: ${{ github.event.client_payload.notes }} 18 | makeLatest: ${{ github.event.client_payload.latest }} 19 | name: ${{ github.event.client_payload.version }} 20 | prerelease: ${{ github.event.client_payload.prerelease }} 21 | tag: ${{ github.event.client_payload.tag }} 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImageOptimize Imgix Image Transform 2 | 3 | ## Requirements 4 | 5 | * Craft CMS 3.0.0 or later 6 | * [ImageOptimize](https://github.com/nystudio107/craft-imageoptimize) plugin 1.6.0 or later 7 | 8 | ## Installation 9 | 10 | ``` 11 | composer require nystudio107/craft-imageoptimize-imgix 12 | ``` 13 | 14 | ## ImageOptimize Imgix Image Transform Overview 15 | 16 | This is an Image Transform for the [ImageOptimize](https://github.com/nystudio107/craft-imageoptimize) Craft CMS plugin that implements the [Imgix](https://www.imgix.com/) service. 17 | 18 | You shouldn't need to install this yourself normally; the ImageOptimize plugin will require it. 19 | 20 | Brought to you by [nystudio107](https://nystudio107.com) 21 | -------------------------------------------------------------------------------- /.github/workflows/code-analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Code Analysis 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - develop-v4 8 | workflow_dispatch: 9 | permissions: 10 | contents: read 11 | jobs: 12 | code_analysis: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | actions: 17 | - name: 'PHPStan' 18 | run: composer phpstan 19 | - name: 'Coding Standards' 20 | run: composer fix-cs 21 | name: ${{ matrix.actions.name }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Cache Composer dependencies 26 | uses: actions/cache@v4 27 | with: 28 | path: /tmp/composer-cache 29 | key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} 30 | - name: Setup PHP 31 | id: setup-php 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: 8.2 35 | extensions: 'ctype,curl,dom,iconv,imagick,intl,json,mbstring,openssl,pcre,pdo,reflection,spl,zip' 36 | ini-values: post_max_size=256M, max_execution_time=180, memory_limit=512M 37 | tools: composer:v2 38 | - name: Install Composer dependencies 39 | run: composer install --no-interaction --no-ansi --no-progress 40 | - run: ${{ matrix.actions.run }} 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-imageoptimize-imgix", 3 | "description": "Provides an Imgix image transform type for the ImageOptimize plugin.", 4 | "type": "image-transform", 5 | "version": "5.0.0", 6 | "keywords": [ 7 | "craftcms", 8 | "imgix", 9 | "image-optimize", 10 | "image-transform" 11 | ], 12 | "support": { 13 | "docs": "https://github.com/nystudio107/craft-imageoptimize-imgix/", 14 | "issues": "https://github.com/nystudio107/craft-imageoptimize-imgix/issues" 15 | }, 16 | "license": "proprietary", 17 | "authors": [ 18 | { 19 | "name": "nystudio107", 20 | "homepage": "https://nystudio107.com" 21 | } 22 | ], 23 | "minimum-stability": "dev", 24 | "prefer-stable": true, 25 | "require": { 26 | "php": "^8.2", 27 | "nystudio107/craft-imageoptimize": "^5.0.0", 28 | "imgix/imgix-php": "^3.0.0" 29 | }, 30 | "require-dev": { 31 | "craftcms/ecs": "dev-main", 32 | "craftcms/phpstan": "dev-main", 33 | "craftcms/rector": "dev-main" 34 | }, 35 | "scripts": { 36 | "phpstan": "phpstan --ansi --memory-limit=1G", 37 | "check-cs": "ecs check --ansi", 38 | "fix-cs": "ecs check --fix --ansi" 39 | }, 40 | "config": { 41 | "allow-plugins": { 42 | "craftcms/plugin-installer": true, 43 | "yiisoft/yii2-composer": true 44 | }, 45 | "optimize-autoloader": true, 46 | "sort-packages": true 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "nystudio107\\imageoptimizeimgix\\": "src/" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/templates/settings/image-transforms/imgix.twig: -------------------------------------------------------------------------------- 1 | {% from 'image-optimize/_includes/macros' import configWarning %} 2 | 3 | {% import "_includes/forms" as forms %} 4 | 5 | 6 | {{ forms.autosuggestField({ 7 | label: 'Imgix Source Domain', 8 | instructions: "The source domain to use for the Imgix transforms."|t('image-optimize'), 9 | suggestEnvVars: true, 10 | id: 'domain', 11 | name: 'domain', 12 | value: imageTransform.domain, 13 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 14 | }) }} 15 | 16 | {{ forms.autosuggestField({ 17 | label: 'Imgix API Key', 18 | instructions: "The API key to use for the Imgix transforms (needed for auto-purging changed assets)."|t('image-optimize'), 19 | suggestEnvVars: true, 20 | id: 'apiKey', 21 | name: 'apiKey', 22 | value: imageTransform.apiKey, 23 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 24 | }) }} 25 | 26 | {{ forms.autosuggestField({ 27 | label: 'Imgix Security Token', 28 | instructions: "The optional [security token](https://docs.imgix.com/setup/securing-images) used to sign image URLs from Imgix."|t('image-optimize'), 29 | suggestEnvVars: true, 30 | id: 'securityToken', 31 | name: 'securityToken', 32 | value: imageTransform.securityToken, 33 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 34 | }) }} 35 | 36 | {{ forms.textField({ 37 | label: 'Unsharp Mask (USM)', 38 | instructions: "The amount of sharpening that should be applied if an image is scaled down more than 50% via the [USM parameter](https://docs.imgix.com/apis/rendering/adjustment/usm)."|t('image-optimize'), 39 | id: 'unsharpMask', 40 | name: 'unsharpMask', 41 | value: imageTransform.unsharpMask, 42 | min: -100, 43 | max: 100, 44 | inputmode: 'numeric', 45 | size: 4, 46 | warning: configWarning('imageTransformTypeSettings', 'image-optimize'), 47 | }) }} 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © nystudio107 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /src/imagetransforms/ImgixImageTransform.php: -------------------------------------------------------------------------------- 1 | 'w', 41 | 'height' => 'h', 42 | 'quality' => 'q', 43 | 'format' => 'fm', 44 | ]; 45 | 46 | protected const IMGIX_PURGE_ENDPOINT = 'https://api.imgix.com/api/v1/purge'; 47 | 48 | // Public Properties 49 | // ========================================================================= 50 | 51 | /** 52 | * @var string 53 | */ 54 | public string $domain = ''; 55 | 56 | /** 57 | * @var string 58 | */ 59 | public string $apiKey = ''; 60 | 61 | /** 62 | * @var string 63 | */ 64 | public string $securityToken = ''; 65 | 66 | /** 67 | * @var int The amount that should be sent to the USM parameter 68 | */ 69 | public int $unsharpMask = 20; 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | public static function displayName(): string 75 | { 76 | return Craft::t('image-optimize', 'Imgix'); 77 | } 78 | 79 | // Public Methods 80 | // ========================================================================= 81 | 82 | public function init(): void 83 | { 84 | parent::init(); 85 | } 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function getTransformUrl(Asset $asset, CraftImageTransformModel|string|array|null $transform): ?string 91 | { 92 | $params = []; 93 | /** @var Settings $settings */ 94 | $settings = ImageOptimize::$plugin->getSettings(); 95 | 96 | $domain = $this->domain ?? 'demos.imgix.net'; 97 | $securityToken = $this->securityToken; 98 | $domain = App::parseEnv($domain); 99 | $securityToken = App::parseEnv($securityToken); 100 | $params['domain'] = $domain; 101 | $builder = new UrlBuilder($domain); 102 | $builder->setUseHttps(true); 103 | if ($transform) { 104 | // Map the transform properties 105 | foreach (self::TRANSFORM_ATTRIBUTES_MAP as $key => $value) { 106 | if (!empty($transform[$key])) { 107 | $params[$value] = $transform[$key]; 108 | } 109 | } 110 | // Remove any 'AUTO' settings 111 | ArrayHelper::removeValue($params, 'AUTO'); 112 | // Handle the Imgix auto setting for compression/format 113 | $autoParams = []; 114 | if (empty($params['q'])) { 115 | $autoParams[] = 'compress'; 116 | } 117 | if (empty($params['fm'])) { 118 | $autoParams[] = 'format'; 119 | } 120 | if (!empty($autoParams)) { 121 | $params['auto'] = implode(',', $autoParams); 122 | } 123 | // Handle interlaced images 124 | if (property_exists($transform, 'interlace') && ($transform->interlace !== 'none') 125 | && (!empty($params['fm'])) 126 | && ($params['fm'] === 'jpg')) { 127 | $params['fm'] = 'pjpg'; 128 | } 129 | if ($settings->autoSharpenScaledImages && $asset->getWidth() && $asset->getHeight()) { 130 | // See if the image has been scaled >= 50% 131 | $widthScale = (int)((($transform->width ?? $asset->getWidth()) / $asset->getWidth()) * 100); 132 | $heightScale = (int)((($transform->height ?? $asset->getHeight()) / $asset->getHeight()) * 100); 133 | if (($widthScale >= $settings->sharpenScaledImagePercentage) || ($heightScale >= $settings->sharpenScaledImagePercentage)) { 134 | $params['usm'] = ($this->unsharpMask ?? 25); 135 | } 136 | } 137 | // Handle the mode 138 | switch ($transform->mode) { 139 | case 'fit': 140 | $params['fit'] = 'clip'; 141 | break; 142 | 143 | case 'stretch': 144 | $params['fit'] = 'scale'; 145 | break; 146 | 147 | default: 148 | // Set a sane default 149 | if (empty($transform->position)) { 150 | $transform->position = 'center-center'; 151 | } 152 | // Fit mode 153 | $params['fit'] = 'crop'; 154 | $cropParams = []; 155 | // Handle the focal point 156 | $focalPoint = $asset->getFocalPoint(); 157 | if (!empty($focalPoint)) { 158 | $params['fp-x'] = $focalPoint['x']; 159 | $params['fp-y'] = $focalPoint['y']; 160 | $cropParams[] = 'focalpoint'; 161 | $params['crop'] = implode(',', $cropParams); 162 | } elseif (preg_match('/(top|center|bottom)-(left|center|right)/', $transform->position)) { 163 | // Imgix defaults to 'center' if no param is present 164 | $filteredCropParams = explode('-', $transform->position); 165 | $filteredCropParams = array_diff($filteredCropParams, ['center']); 166 | $cropParams = array_merge($cropParams, $filteredCropParams); 167 | // Imgix 168 | if ($transform->position !== 'center-center') { 169 | $params['crop'] = implode(',', $cropParams); 170 | } 171 | } 172 | break; 173 | } 174 | } else { 175 | // No transform was passed in; so just auto all the things 176 | $params['auto'] = 'format,compress'; 177 | } 178 | // Apply the Security Token, if set 179 | if (!empty($securityToken)) { 180 | $builder->setSignKey($securityToken); 181 | } 182 | // Finally, create the Imgix URL for this transformed image 183 | $assetUri = $this->getAssetUri($asset); 184 | $url = $builder->createURL($assetUri, $params); 185 | Craft::debug( 186 | 'Imgix transform created for: ' . $assetUri . ' - Params: ' . print_r($params, true) . ' - URL: ' . $url, 187 | __METHOD__ 188 | ); 189 | 190 | return $url; 191 | } 192 | 193 | /** 194 | * @inheritdoc 195 | */ 196 | public function getWebPUrl(string $url, Asset $asset, CraftImageTransformModel|string|array|null $transform): ?string 197 | { 198 | if ($transform) { 199 | $transform->format = 'webp'; 200 | } 201 | try { 202 | $webPUrl = $this->getTransformUrl($asset, $transform); 203 | } catch (Exception $e) { 204 | Craft::error($e->getMessage(), __METHOD__); 205 | } 206 | 207 | return $webPUrl ?? ''; 208 | } 209 | 210 | /** 211 | * @inheritdoc 212 | */ 213 | public function getPurgeUrl(Asset $asset): ?string 214 | { 215 | $domain = $this->domain ?? 'demos.imgix.net'; 216 | $apiKey = $this->apiKey; 217 | $securityToken = $this->securityToken; 218 | $domain = App::parseEnv($domain); 219 | $apiKey = App::parseEnv($apiKey); 220 | $securityToken = App::parseEnv($securityToken); 221 | $builder = new UrlBuilder($domain); 222 | $builder->setUseHttps(true); 223 | // Create the Imgix URL for purging this image 224 | $assetUri = $this->getAssetUri($asset); 225 | $url = $builder->createURL($assetUri, [ 226 | 'domain' => $domain, 227 | 'api-key' => $apiKey, 228 | 'security-token' => $securityToken, 229 | ]); 230 | 231 | // Strip the query string, so we just pass in the raw URL 232 | return UrlHelper::stripQueryString($url); 233 | } 234 | 235 | /** 236 | * @inheritdoc 237 | */ 238 | public function purgeUrl(string $url): bool 239 | { 240 | $result = false; 241 | 242 | $apiKey = $this->apiKey; 243 | if ($apiKey === '') { 244 | Craft::error( 245 | 'Imgix API key is not set', 246 | __METHOD__ 247 | ); 248 | return false; 249 | } 250 | 251 | $apiKey = App::parseEnv($apiKey); 252 | 253 | // Check the API key to see if it is deprecated or not 254 | if (strlen($apiKey) < 50) { 255 | try { 256 | Craft::$app->deprecator->log(__METHOD__, 'You are using a deprecated API key. Obtain a new API key to use the purging API. More info: https://blog.imgix.com/2020/10/16/api-deprecation'); 257 | } catch (DeprecationException $e) { 258 | Craft::error($e->getMessage(), __METHOD__); 259 | } 260 | } 261 | 262 | // create new guzzle client 263 | $guzzleClient = Craft::createGuzzleClient(['timeout' => 120, 'connect_timeout' => 120]); 264 | // Submit the sitemap index to each search engine 265 | try { 266 | $response = $guzzleClient->post(self::IMGIX_PURGE_ENDPOINT, [ 267 | 'headers' => [ 268 | 'Authorization' => 'Bearer ' . $apiKey, 269 | ], 270 | 'json' => [ 271 | 'data' => [ 272 | 'attributes' => [ 273 | 'url' => $url, 274 | ], 275 | 'type' => 'purges', 276 | ], 277 | ], 278 | ]); 279 | // See if it succeeded 280 | if (($response->getStatusCode() >= 200) 281 | && ($response->getStatusCode() < 400) 282 | ) { 283 | $result = true; 284 | } 285 | Craft::info( 286 | 'URL purged: ' . $url . ' - Response code: ' . $response->getStatusCode(), 287 | __METHOD__ 288 | ); 289 | } catch (GuzzleException $e) { 290 | Craft::error( 291 | 'Error purging URL: ' . $url . ' - ' . $e->getMessage(), 292 | __METHOD__ 293 | ); 294 | } 295 | 296 | return $result; 297 | } 298 | 299 | /** 300 | * @inheritdoc 301 | */ 302 | public function getAssetUri(Asset $asset): ?string 303 | { 304 | $volume = $asset->getVolume(); 305 | 306 | // If this is a local volume, it implies your are using a "Web Folder" 307 | // source in Imgix. We can then also infer that: 308 | // - This volume has URLs 309 | // - The "Base URL" in Imgix is set to your domain root, per the ImageOptimize docs. 310 | // 311 | // Therefore, we need to parse the path from the full URL, so that it 312 | // includes the path of the volume. 313 | $fs = $volume->getFs(); 314 | if ($fs instanceof Local) { 315 | $assetUrl = AssetsHelper::generateUrl($asset); 316 | 317 | return parse_url(rawurldecode($assetUrl), PHP_URL_PATH); 318 | } 319 | 320 | return parent::getAssetUri($asset); 321 | } 322 | 323 | /** 324 | * @inheritdoc 325 | */ 326 | public function getSettingsHtml(): ?string 327 | { 328 | return Craft::$app->getView()->renderTemplate('imgix-image-transform/settings/image-transforms/imgix.twig', [ 329 | 'imageTransform' => $this, 330 | ]); 331 | } 332 | 333 | /** 334 | * @inheritdoc 335 | */ 336 | public function rules(): array 337 | { 338 | $rules = parent::rules(); 339 | return array_merge($rules, [ 340 | [['domain', 'apiKey', 'securityToken'], 'default', 'value' => ''], 341 | [['domain', 'apiKey', 'securityToken'], 'string'], 342 | ]); 343 | } 344 | } 345 | --------------------------------------------------------------------------------