├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml └── workflows │ ├── build-assets.yml │ ├── php-cs-fixer.yml │ ├── run-tests.yml │ └── update-changelog.yml ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer.json ├── config └── responsive-images.php ├── dist ├── css │ └── responsive.css └── js │ └── responsive.js ├── docs └── fieldtype.png ├── icon.png ├── package.json ├── phpunit.xml ├── resources ├── css │ └── responsive.css ├── js │ ├── ResponsiveFieldtype.vue │ ├── ResponsiveFieldtypeIndex.vue │ ├── helpers.js │ └── responsive.js └── views │ ├── placeholderSvg.blade.php │ └── responsiveImage.blade.php ├── src ├── AssetNotFoundException.php ├── Breakpoint.php ├── Commands │ ├── GenerateResponsiveVersionsCommand.php │ └── RegenerateResponsiveVersionsCommand.php ├── DimensionCalculator.php ├── Dimensions.php ├── Exceptions │ └── InvalidAssetException.php ├── Fieldtypes │ ├── ResponsiveFields.php │ └── ResponsiveFieldtype.php ├── GraphQL │ ├── BreakpointType.php │ ├── ResponsiveField.php │ ├── ResponsiveFieldType.php │ ├── ResponsiveGraphqlArguments.php │ └── SourceType.php ├── Jobs │ ├── GenerateGlideImageJob.php │ ├── GenerateImageJob.php │ └── GeneratePlaceholderJob.php ├── Listeners │ ├── GenerateResponsiveVersions.php │ └── UpdateResponsiveReferences.php ├── Responsive.php ├── ResponsiveDimensionCalculator.php ├── ResponsiveReferenceUpdater.php ├── ServiceProvider.php ├── Source.php └── Tags │ └── ResponsiveTag.php ├── tests ├── Factories │ └── EntryFactory.php ├── Feature │ ├── AssetReferenceTest.php │ ├── AssetUploadedListenerTest.php │ ├── BreakpointTest.php │ ├── DimensionCalculatorTest.php │ ├── GenerateResponsiveVersionsCommandTest.php │ ├── GraphQLTest.php │ ├── ResponsiveTagTest.php │ ├── ResponsiveTest.php │ └── SourceTest.php ├── Pest.php ├── TestCase.php ├── TestSupport │ └── TestFiles │ │ ├── hackerman.gif │ │ ├── smallTest.jpg │ │ ├── test.jpg │ │ ├── test.svg │ │ └── zerowidth.svg └── __snapshots__ │ ├── GraphQLTest__querying_ResponsiveFieldType_field_resolves_it_to_data__1.yml │ ├── GraphQLTest__ratio_is_outputted_if_using_ResponsiveDimensionCalculator__1.json │ ├── GraphQLTest__responsive_field_accepts_responsive_fieldtype_data__1.yml │ ├── GraphQLTest__responsive_field_returns_data__1.json │ ├── ResponsiveTagTest__format_quality_is_set_on_breakpoints__1.txt │ ├── ResponsiveTagTest__it_adds_custom_parameters_to_the_attribute_string__1.txt │ ├── ResponsiveTagTest__it_can_add_custom_glide_parameters__1.txt │ ├── ResponsiveTagTest__it_can_render_a_responsive_image_with_the_directive__1.txt │ ├── ResponsiveTagTest__it_can_render_an_art_directed_image_as_array_with_the_directive__1.txt │ ├── ResponsiveTagTest__it_can_render_an_art_directed_image_with_the_directive__1.txt │ ├── ResponsiveTagTest__it_generates_no_conversions_for_gifs__1.txt │ ├── ResponsiveTagTest__it_generates_no_conversions_for_svgs__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_in_webp_and_avif_formats__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_in_webp_and_avif_formats__2.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_with_breakpoint_parameters__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_with_breakpoints_without_webp__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_with_parameters__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_without_a_placeholder__1.txt │ ├── ResponsiveTagTest__it_generates_responsive_images_without_webp__1.txt │ ├── ResponsiveTagTest__it_uses_an_alt_field_on_the_asset__1.txt │ └── ResponsiveTagTest__the_source_image_can_change_with_breakpoints__1.txt ├── webpack.mix.js └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Fill out a bug report to help us improve Responsive Images addon 3 | body: 4 | - type: textarea 5 | attributes: 6 | label: Bug description 7 | description: What happened? What did you expect to happen? Feel free to drop any screenshots in here. 8 | placeholder: I did this thing over here, and saw this error... 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: How to reproduce 14 | description: List the steps so we're able to recreate this bug. Bonus points if you can provide an example GitHub repo with this bug reproduced on a clean Statamic install. 15 | placeholder: Go here, Type this, Click that, Look over there. 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: Logs 21 | description: | 22 | You can paste any relevant logs here, they'll be automatically rendered in code blocks. You can find your logs in `storage/logs`. 23 | Please post full exception stack traces. 24 | render: shell 25 | - type: textarea 26 | attributes: 27 | label: Environment 28 | description: | 29 | Details about your environment. Versions of Statamic, PHP, Laravel, any addons that are installed, etc. 30 | (Go ahead and just paste the output of the `php please support:details` command.) 31 | render: yaml # the format of the command is close to yaml and gets highlighted nicely 32 | validations: 33 | required: true 34 | - type: dropdown 35 | attributes: 36 | label: Installation 37 | description: How did you install Statamic? 38 | options: 39 | - Fresh statamic/statamic site via CLI 40 | - Starter Kit using via CLI 41 | - Existing Laravel app 42 | - Other (please explain) 43 | validations: 44 | required: true 45 | - type: dropdown 46 | attributes: 47 | label: Antlers Parser 48 | description: Which Antlers Parser are you using? 49 | options: 50 | - regex (default) 51 | - runtime (new) 52 | validations: 53 | required: true 54 | - type: textarea 55 | attributes: 56 | label: Additional details 57 | description: Fancy setup? Anything else you need to share? 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Feature Requests, Ideas, How-Tos, Questions 5 | url: https://github.com/spatie/statamic-responsive-images/discussions 6 | about: Please use issues only for bug reports 7 | -------------------------------------------------------------------------------- /.github/workflows/build-assets.yml: -------------------------------------------------------------------------------- 1 | name: Build assets for distribution 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'resources/**' 9 | 10 | jobs: 11 | build-dist: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Install front-end dependencies 19 | run: yarn 20 | 21 | - name: Install PHP dependencies 22 | run: composer install 23 | 24 | - name: Compile assets 25 | run: yarn run production 26 | 27 | - name: Commit changes 28 | uses: stefanzweifel/git-auto-commit-action@v4 29 | with: 30 | commit_message: Build assets for distribution 31 | file_pattern: 'dist/*' 32 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Check & fix styling 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | 13 | - name: Fix style 14 | uses: docker://oskarstark/php-cs-fixer-ga 15 | with: 16 | args: --config=.php-cs-fixer.php --allow-risky=yes 17 | 18 | - name: Commit changes 19 | uses: stefanzweifel/git-auto-commit-action@v4 20 | with: 21 | commit_message: Fix styling 22 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | php: [8.1, 8.2, 8.3] 13 | 14 | name: PHP ${{ matrix.php }} - Statamic ${{ matrix.statamic }} 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, mysql, mysqli, pdo_mysql, bcmath, soap, intl, gd, exif, iconv, imagick 25 | coverage: none 26 | 27 | - name: Install dependencies 28 | run: | 29 | composer update --prefer-stable --prefer-dist --no-interaction --no-suggest 30 | 31 | - name: Execute tests 32 | run: vendor/bin/pest 33 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | update: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v2 14 | with: 15 | ref: main 16 | 17 | - name: Update Changelog 18 | uses: stefanzweifel/changelog-updater-action@v1 19 | with: 20 | latest-version: ${{ github.event.release.name }} 21 | release-notes: ${{ github.event.release.body }} 22 | 23 | - name: Commit updated CHANGELOG 24 | uses: stefanzweifel/git-auto-commit-action@v4 25 | with: 26 | branch: main 27 | commit_message: Update CHANGELOG 28 | file_pattern: CHANGELOG.md 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | vendor 3 | mix-manifest.json 4 | composer.lock 5 | .phpunit.result.cache 6 | .php-cs-fixer.cache 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | notPath('docs/*') 4 | ->notPath('vendor') 5 | ->in([ 6 | __DIR__.'/src', 7 | ]) 8 | ->name('*.php') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true); 11 | 12 | return (new PhpCsFixer\Config()) 13 | ->setRules([ 14 | '@PSR2' => true, 15 | 'array_syntax' => ['syntax' => 'short'], 16 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 17 | 'no_unused_imports' => true, 18 | 'not_operator_with_successor_space' => true, 19 | 'trailing_comma_in_multiline' => true, 20 | 'phpdoc_scalar' => true, 21 | 'unary_operator_spaces' => true, 22 | 'binary_operator_spaces' => true, 23 | 'logical_operators' => true, 24 | 'blank_line_before_statement' => [ 25 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 26 | ], 27 | 'phpdoc_single_line_var_spacing' => true, 28 | 'phpdoc_var_without_name' => true, 29 | 'class_attributes_separation' => [ 30 | 'elements' => [ 31 | 'method' => 'one' 32 | ], 33 | ], 34 | 'method_argument_space' => [ 35 | 'on_multiline' => 'ensure_fully_multiline', 36 | 'keep_multiple_spaces_after_comma' => true, 37 | ], 38 | ]) 39 | ->setFinder($finder); 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [](https://supportukrainenow.org) 3 | 4 | 5 | [![Latest Version](https://img.shields.io/github/release/spatie/statamic-responsive-images.svg?style=flat-square)](https://github.com/spatie/statamic-responsive-images/releases) 6 | ![Statamic 4.0+](https://img.shields.io/badge/Statamic-4.0+-FF269E?style=flat-square&link=https://statamic.com) 7 | 8 | # Responsive Images 9 | 10 | > Responsive Images for Statamic 3. 11 | 12 | 13 | 14 | This Addon provides responsive images inspired by [Our Medialibrary Package](https://github.com/spatie/laravel-medialibrary). 15 | 16 | ## Support us 17 | 18 | [](https://spatie.be/github-ad-click/statamic-responsive-images) 19 | 20 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 21 | 22 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 23 | 24 | ## Installation 25 | 26 | Require it using Composer. 27 | 28 | ``` 29 | composer require spatie/statamic-responsive-images 30 | ``` 31 | 32 | ## Fieldtype 33 | 34 | This addon includes a fieldtype that allows for full Art direction with responsive images. 35 | 36 | ![fieldtype](./docs/fieldtype.png) 37 | 38 | This fieldtype is configured with the following yaml configuration: 39 | 40 | ```yaml 41 | - 42 | handle: image 43 | field: 44 | use_breakpoints: true 45 | allow_ratio: true 46 | allow_fit: true 47 | breakpoints: 48 | - sm 49 | - md 50 | - lg 51 | display: Image 52 | instructions: 'Choose image using art direction.' 53 | type: responsive 54 | icon: assets 55 | listable: hidden 56 | container: assets 57 | restrict: false 58 | allow_uploads: true 59 | ``` 60 | 61 | The configuration above can be used within Antlers using the responsive tag: 62 | 63 | ```twig 64 | {{ responsive:image }} 65 | ``` 66 | 67 | The breakpoints are configured in the `breakpoints` array of the config file. 68 | 69 | ## Using Responsive Images 70 | 71 | Responsive Images will generate responsive versions of the images whenever a new asset is uploaded. These presets are determined by this package and not by your own Glide presets. 72 | 73 | We generally recommend setting `statamic.assets.image_manipulation.cache` to `false` so only images actually requested by a browser are generated. The first time the conversion is loaded will be slow, but Glide still has an internal cache that it will serve from the next time. This saves a lot on server resources and storage requirements. 74 | 75 | ## Templating 76 | 77 | Pass an image to the `responsive` tag. 78 | 79 | ```twig 80 | {{ responsive:image_field }} 81 | ``` 82 | 83 | This will render an image tag with the default srcsets. The tag uses JS to define the value of the sizes attribute. This way the browser will always download the correct image. 84 | 85 | ## Image ratio 86 | 87 | You can make sure images are a certain ratio by passing a `ratio` parameter, either as a string `16/10` or as a float `1.6`. 88 | 89 | ```twig 90 | {{ responsive:image_field ratio="16/9" }} 91 | ``` 92 | 93 | ## Responsive placeholder 94 | 95 | By default, responsive images generates a small base64 encoded placeholder to show while your image loads. If you want to disable this you can pass `placeholder="false"` to the tag. 96 | 97 | ```twig 98 | {{ responsive:image_field placeholder="false" }} 99 | ``` 100 | 101 | ## Additional image format generation 102 | 103 | By default, responsive tag generates original source image file format and WEBP variants of the image, so if you use a JPG image as source then by default JPG and WEBP variants will be generated. You can toggle WEBP and AVIF variant generation with the tag parameters. 104 | 105 | ```twig 106 | {{ responsive:image_field webp="true" avif="false" }} 107 | ``` 108 | 109 | You can also toggle this in responsive-images.php config file, it will apply your preferences globally. 110 | 111 | ```php 112 | 'webp' => true, 113 | 'avif' => false, 114 | ``` 115 | 116 | ## Image quality 117 | 118 | Image format quality settings can be set globally through config. If you wish to override the config quality values you can use tag parameters. You can utilize breakpoints for the quality parameter too! 119 | 120 | ```twig 121 | {{ responsive:image_field quality:webp="50" md:quality:webp="75" }} 122 | ``` 123 | 124 | ## Glide parameters 125 | 126 | You can still pass any parameters from the Glide tag that you would want to, just make sure to prefix them with `glide:`. 127 | Passing `glide:width` will consider the width as a max width, which can prevent unnecessary large images from being generated. 128 | 129 | ```twig 130 | {{ responsive:image_field glide:blur="20" glide:width="1600" }} 131 | ``` 132 | 133 | ## HTML Attributes 134 | 135 | If you want to add additional attributes (for example a title attribute) to your image, you can add them as parameters to the tag, any attributes will be added to the image. 136 | 137 | ```twig 138 | {{ responsive:image_field alt="{title}" class="my-class" }} 139 | ``` 140 | 141 | ## Breakpoints & Art direction 142 | 143 | You can define breakpoints in the config file, by default the breakpoints of TailwindCSS are used. 144 | 145 | Breakpoints allow you to use, for example, different ratios: 146 | 147 | ```twig 148 | {{ responsive:image_field ratio="1/1" lg:ratio="16/9" 2xl:ratio="2/1" }} 149 | ``` 150 | 151 | This will apply a default ratio of `1/1`. From breakpoint `lg` up to `2xl`, the ratio will be `16/9`. From `2xl` up, the ratio will be `2/1`. 152 | The breakpoints can be configured in the config and default to the breakpoints of Tailwind CSS. 153 | 154 | Or different assets: 155 | 156 | ```twig 157 | {{ responsive:image_field :lg:src="image_field_lg" :2xl:src="image_field_2xl" }} 158 | ``` 159 | 160 | Breakpoints support the `ratio`, `src`, `quality` and `glide` parameters. 161 | 162 | ## Customizing the generated html 163 | 164 | If you want to customize the generated html, you can publish the views using 165 | 166 | ```bash 167 | php artisan vendor:publish 168 | ``` 169 | 170 | and choosing `Spatie\ResponsiveImages\ServiceProvider` 171 | 172 | ## Generate command 173 | 174 | If you need to regenerate the responsive images for a reason, you can use the `regenerate` command which will clear the Glide cache and regenerate the versions. This command only works when you have the `statamic.assets.image_manipulation.cache` config option set to `true` (which we generally don't recommend). 175 | 176 | ```bash 177 | php please responsive:regenerate 178 | ``` 179 | 180 | If you are using a service, like Horizon, for queues then jobs will be queued to handle the image resizing. 181 | By default, the job is queued under the 'default' queue. This can be changed via the `queue` config key under `responsive-images.php` 182 | 183 | It is also possible to exclude certain containers from generation responsive variants. You can exclude these containers by adding the handle of the container to the `excluded_containers` array in `responsive-images.php`. 184 | 185 | ## GraphQL 186 | 187 | This addon comes with 2 GraphQL goodies, it adds a `responsive` field to assets and responsive fieldtype, allowing you to use this addon like you would with the Antlers tag. Secondly you can just let responsive fieldtype augment itself without passing any arguments. 188 | 189 | ### Responsive field on assets / assets fieldtype / responsive fieldtype 190 | 191 | You can retrieve a responsive version of an `image` asset fieldtype like this: 192 | 193 | ```graphql 194 | { 195 | entries { 196 | data { 197 | id, 198 | image { 199 | responsive(ratio: 1.2) { 200 | label 201 | minWidth 202 | widthUnit 203 | ratio 204 | sources { 205 | format 206 | mimeType 207 | minWidth 208 | mediaWidthUnit 209 | mediaString 210 | srcSet 211 | placeholder 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | ``` 219 | 220 | Majority of tag parameters are available as arguments in the responsive field, the parameters just need to have colons replaced with underscores. For example, `lg:glide:filter` would become `lg_glide_filter`. 221 | 222 | If you are unsure what arguments are available, try out the GraphQL explorer in the control panel located at `/cp/graphiql` and utilize the autocomplete feature. 223 | 224 | ### Images from the responsive fieldtype 225 | 226 | A responsive fieldtype has all the same fields as a normal responsive field from an asset, except they're under a `breakpoints` key and you cannot pass any arguments to it. 227 | 228 | ```graphql 229 | { 230 | entries { 231 | data { 232 | id, 233 | art_image { 234 | breakpoints { 235 | label 236 | minWidth 237 | widthUnit 238 | ratio 239 | sources { 240 | format 241 | mimeType 242 | minWidth 243 | mediaWidthUnit 244 | mediaString 245 | srcSet 246 | placeholder 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | ``` 254 | 255 | ## Changelog 256 | 257 | Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. 258 | 259 | ## Contributing 260 | 261 | Please see [CONTRIBUTING](https://github.com/spatie/.github/blob/main/CONTRIBUTING.md) for details. 262 | 263 | ## Security 264 | 265 | If you discover any security related issues, please email [security@spatie.be](mailto:security@spatie.be) instead of using the issue tracker. 266 | 267 | ## Credits 268 | 269 | - [Rias Van der Veken](https://github.com/riasvdv) 270 | - [All Contributors](../../contributors) 271 | 272 | ## License 273 | 274 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 275 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | # Upgrading to 3.x from 2.x 4 | 5 | `Breakpoint` object now contains `Source` objects which represent the `` tag. 6 | 7 | ## High impact 8 | 9 | ### responsiveImage.blade.php template changes 10 | 11 | If you had published the `responsiveImage.blade.php` template in your project, you will need to update it to use the 12 | new `Source` objects. 13 | 14 | If you had published the templates, you will find them in `resources/views/vendor/responsive-images`. If you do not see 15 | this file in your project then you do not have to do anything for this. 16 | 17 | To review the changes, please 18 | use [the history of the template](https://github.com/spatie/statamic-responsive-images/commits/main/resources/views/responsiveImage.blade.php) 19 | in this repository. We recommend just copy and pasting the whole template and adding your changes as you go. 20 | 21 | ### GraphQL query changes 22 | 23 | If you are using GraphQL in your project, you will have to update your queries, as each breakpoint will now contain array of sources. 24 | 25 | If you had a query like this: 26 | 27 | ```graphql 28 | { 29 | entries { 30 | data { 31 | id, 32 | image { 33 | responsive(ratio: 1.2) { 34 | label 35 | value 36 | unit 37 | ratio 38 | mediaString 39 | srcSet 40 | srcSetWebp 41 | srcSetAvif 42 | placeholder 43 | } 44 | } 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | Then that would become: 51 | 52 | ```graphql 53 | { 54 | entries { 55 | data { 56 | id, 57 | image { 58 | responsive(ratio: 1.2) { 59 | label 60 | minWidth 61 | widthUnit 62 | ratio 63 | sources { 64 | format 65 | mimeType 66 | minWidth 67 | mediaWidthUnit 68 | mediaString 69 | srcSet 70 | placeholder 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | Additionally, some properties have changed name to be more descriptive of what they are: 80 | - `value` to `minWidth` 81 | - `unit` to `widthUnit` 82 | 83 | A new addition is `mimeType` property which you should set on `` tags to help browsers determine which image format to serve. 84 | 85 | These changes are also in effect when you query responsive fieldtype, except the data is under `breakpoints` key, just like before. 86 | 87 | To easily test the GraphQL changes and see what is available, we recommend using built-in GUI which you can visit through `/cp/graphiql` in your Statamic project. This tool provides auto-completion. 88 | 89 | ## Low impact 90 | 91 | `` `src` URL will always now go through Glide instead of pointing to original asset. Meaning, the filename will become base64 encoded by Statamic Glide controller. If you relied on readable filenames for SEO purposes then this may impact you. As a workaround: 92 | - Let this happen and utilize `alt` attribute instead to help crawlers understand what the image contains 93 | - Update `src` attribute to be `{{ $asset['url'] }}`. This will point to the original, source asset. However, as this is original asset - any image manipulations you have set will not happen on this image. 94 | 95 | This is low impact change because most modern browsers will not hit the URL provided in `` tag if `` tags are provided. 96 | 97 | ## Very low impact 98 | 99 | If you had made any changes or used objects in a way that are out of scope of the provided README in this project then please review the commits manually. 100 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spatie/statamic-responsive-images", 3 | "license": "MIT", 4 | "type": "statamic-addon", 5 | "description": "Responsive Images for Statamic", 6 | "autoload": { 7 | "psr-4": { 8 | "Spatie\\ResponsiveImages\\": "src" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "Spatie\\ResponsiveImages\\Tests\\": "tests" 14 | } 15 | }, 16 | "extra": { 17 | "statamic": { 18 | "name": "Responsive Images", 19 | "description": "Responsive Images for Statamic" 20 | }, 21 | "laravel": { 22 | "providers": [ 23 | "Spatie\\ResponsiveImages\\ServiceProvider" 24 | ] 25 | } 26 | }, 27 | "require": { 28 | "statamic/cms": "^5.0" 29 | }, 30 | "require-dev": { 31 | "orchestra/testbench": "^6.9|^7.1|^8.0|^9.0|^10.0", 32 | "pestphp/pest": "^1.22", 33 | "spatie/pest-plugin-snapshots": "^1.1" 34 | }, 35 | "config": { 36 | "allow-plugins": { 37 | "pixelfear/composer-dist-plugin": true, 38 | "pestphp/pest-plugin": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /config/responsive-images.php: -------------------------------------------------------------------------------- 1 | true, 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Generate Image Job 19 | |-------------------------------------------------------------------------- 20 | | 21 | | The job used to generate images, by default this uses 22 | | \Spatie\ResponsiveImages\Jobs\GlideImageJob 23 | | 24 | */ 25 | 26 | 'image_job' => \Spatie\ResponsiveImages\Jobs\GenerateGlideImageJob::class, 27 | 28 | /* 29 | |-------------------------------------------------------------------------- 30 | | Force absolute URL 31 | |-------------------------------------------------------------------------- 32 | | 33 | | Useful if you are using GraphQL API and consuming it from another 34 | | app on a different domain. Normally Glide will return relative URLs, but 35 | | you can force it to return absolute URLs. 36 | | 37 | */ 38 | 'force_absolute_urls' => false, 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Queue 43 | |-------------------------------------------------------------------------- 44 | | 45 | | If the generate images job is being queued, specify the name of the 46 | | target queue. This falls back to the 'default' queue 47 | | 48 | */ 49 | 50 | 'queue' => 'default', 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Max Width 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Define a global max-width for generated images. 58 | | You can override this on the tag. 59 | | 60 | */ 61 | 62 | 'max_width' => null, 63 | 64 | /* 65 | |-------------------------------------------------------------------------- 66 | | Dimension Calculator Threshold 67 | |-------------------------------------------------------------------------- 68 | | 69 | | Define the file size threshold at which the default 70 | | dimension calculator decides to generate a new 71 | | variant. By default, this is 30% smaller. 72 | | Must be a value: 0 < x < 1 73 | | 74 | */ 75 | 76 | 'dimension_calculator_threshold' => 0.7, 77 | 78 | /* 79 | |-------------------------------------------------------------------------- 80 | | Placeholder 81 | |-------------------------------------------------------------------------- 82 | | 83 | | Define if you want to generate low-quality placeholders of your images. 84 | | You can override this on the tag. 85 | | 86 | */ 87 | 88 | 'placeholder' => true, 89 | 90 | /* 91 | |-------------------------------------------------------------------------- 92 | | Image formats 93 | |-------------------------------------------------------------------------- 94 | | 95 | | Define if you want to generate WebP or AVIF versions of your images. 96 | | You can override this on the tag. 97 | | 98 | */ 99 | 100 | 'webp' => true, 101 | 'avif' => false, 102 | 103 | /* 104 | |-------------------------------------------------------------------------- 105 | | Quality 106 | |-------------------------------------------------------------------------- 107 | | 108 | | Define quality value for each image encoding format. 109 | | Use null for default Glide quality. 110 | | 111 | */ 112 | 113 | 'quality' => [ 114 | 'jpg' => 90, 115 | 'webp' => 90, 116 | 'avif' => 45 117 | ], 118 | 119 | /* 120 | |-------------------------------------------------------------------------- 121 | | Breakpoints 122 | |-------------------------------------------------------------------------- 123 | | 124 | | Define the breakpoints to art direct your images 125 | | 126 | */ 127 | 128 | 'breakpoints' => [ 129 | 'sm' => 640, 130 | 'md' => 768, 131 | 'lg' => 1024, 132 | 'xl' => 1280, 133 | '2xl' => 1536, 134 | ], 135 | 136 | /* 137 | |-------------------------------------------------------------------------- 138 | | Breakpoint Unit 139 | |-------------------------------------------------------------------------- 140 | | 141 | | The unit that will be used for the breakpoint media queries 142 | | 143 | */ 144 | 145 | 'breakpoint_unit' => 'px', 146 | 147 | /* 148 | |-------------------------------------------------------------------------- 149 | | Excluded Containers 150 | |-------------------------------------------------------------------------- 151 | | 152 | | Define the containers which should be excluded from generation responsive variants 153 | | 154 | */ 155 | 156 | 'excluded_containers' => [], 157 | 158 | ]; 159 | -------------------------------------------------------------------------------- /dist/css/responsive.css: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /dist/js/responsive.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={109:(e,t,n)=>{n.d(t,{Z:()=>o});var r=n(645),i=n.n(r)()((function(e){return e[1]}));i.push([e.id,"@container (max-width: 125px){.responsive-field[data-v-09ebdbf6] .assets-fieldtype .assets-fieldtype-picker{flex-direction:row}.responsive-field[data-v-09ebdbf6] .assets-fieldtype .assets-fieldtype-picker .btn.btn-with-icon{overflow:hidden;white-space:nowrap}}@container (max-width: 148px){.responsive-field[data-v-09ebdbf6] .assets-fieldtype .assets-fieldtype-picker .btn.btn-with-icon svg{display:none}}@container (max-width: 265px){.responsive-field[data-v-09ebdbf6] .assets-fieldtype .assets-fieldtype-drag-container .asset-table-listing td.w-24{display:none}}",""]);const o=i},645:e=>{e.exports=function(e){var t=[];return t.toString=function(){return this.map((function(t){var n=e(t);return t[2]?"@media ".concat(t[2]," {").concat(n,"}"):n})).join("")},t.i=function(e,n,r){"string"==typeof e&&(e=[[null,e,""]]);var i={};if(r)for(var o=0;o{var r,i=function(){return void 0===r&&(r=Boolean(window&&document&&document.all&&!window.atob)),r},o=function(){var e={};return function(t){if(void 0===e[t]){var n=document.querySelector(t);if(window.HTMLIFrameElement&&n instanceof window.HTMLIFrameElement)try{n=n.contentDocument.head}catch(e){n=null}e[t]=n}return e[t]}}(),s=[];function a(e){for(var t=-1,n=0;n{var t=e&&e.__esModule?()=>e.default:()=>e;return n.d(t,{a:t}),t},n.d=(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},n.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),n.nc=void 0,(()=>{function e(e){return e.toString(16).padStart(2,"0")}function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},t(e)}function r(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function i(e,n,r){return(n=function(e){var n=function(e,n){if("object"!==t(e)||null===e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,n||"default");if("object"!==t(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(e)}(e,"string");return"symbol"===t(n)?n:String(n)}(n))in e?Object.defineProperty(e,n,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[n]=r,e}const o={mixins:[Fieldtype],computed:{publishContainerName:function(){return this.$props.handle+"."+(t=new Uint8Array((10||40)/2),window.crypto.getRandomValues(t),Array.from(t,e).join(""));var t},fields:function(){return _.chain(this.meta.fields).map((function(e){return function(e){for(var t=1;t 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | tests/Feature/ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /resources/css/responsive.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/statamic-responsive-images/fbf393499a91ba2a46d4c694940b1ebaeea22a73/resources/css/responsive.css -------------------------------------------------------------------------------- /resources/js/ResponsiveFieldtype.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 71 | 72 | -------------------------------------------------------------------------------- /resources/js/ResponsiveFieldtypeIndex.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /resources/js/helpers.js: -------------------------------------------------------------------------------- 1 | function dec2hex(dec) { 2 | return dec.toString(16).padStart(2, "0") 3 | } 4 | 5 | // Taken from: https://stackoverflow.com/a/27747377/757587 6 | export function generateId(len) { 7 | var arr = new Uint8Array((len || 40) / 2) 8 | window.crypto.getRandomValues(arr) 9 | return Array.from(arr, dec2hex).join('') 10 | } -------------------------------------------------------------------------------- /resources/js/responsive.js: -------------------------------------------------------------------------------- 1 | import ResponsiveFieldtype from "./ResponsiveFieldtype"; 2 | import ResponsiveFieldtypeIndex from "./ResponsiveFieldtypeIndex"; 3 | 4 | Statamic.booting(() => { 5 | Statamic.$components.register('responsive-fieldtype', ResponsiveFieldtype); 6 | Statamic.$components.register('responsive-fieldtype-index', ResponsiveFieldtypeIndex); 7 | }) 8 | -------------------------------------------------------------------------------- /resources/views/placeholderSvg.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resources/views/responsiveImage.blade.php: -------------------------------------------------------------------------------- 1 | @once 2 | 19 | @endonce 20 | 21 | 22 | @foreach (($breakpoints ?? []) as $breakpoint) 23 | @foreach($breakpoint->sources() ?? [] as $source) 24 | @php 25 | $srcSet = $source->getSrcset(); 26 | @endphp 27 | 28 | @if($srcSet !== null) 29 | getMimeType()) type="{{ $type }}" @endif 31 | @if($media = $source->getMediaString()) media="{{ $media }}" @endif 32 | srcset="{{ $srcSet }}" 33 | @if($includePlaceholder ?? false) sizes="1px" @endif 34 | > 35 | @endif 36 | @endforeach 37 | @endforeach 38 | 39 | {{ (string) $asset['alt'] ?: (string) $asset['title'] }} 51 | 52 | -------------------------------------------------------------------------------- /src/AssetNotFoundException.php: -------------------------------------------------------------------------------- 1 | error('Caching is not enabled for image manipulations, generating them will have no benefit.'); 28 | 29 | return; 30 | } 31 | 32 | $assets = $assets->all()->filter(function (Asset $asset) { 33 | return $asset->isImage() 34 | && $asset->extension() !== 'svg' 35 | && ! in_array($asset->container()->handle(), config('statamic.responsive-images.excluded_containers', [])); 36 | }); 37 | 38 | $this->info("Generating responsive image versions for {$assets->count()} assets."); 39 | 40 | $this->getOutput()->progressStart($assets->count()); 41 | 42 | /** @var \Statamic\Assets\AssetCollection $assets */ 43 | $assets->each(function (Asset $asset) { 44 | $responsive = new Responsive($asset, new Parameters()); 45 | 46 | /** 47 | * Dispatch job for default src 48 | */ 49 | $dimensions = app(DimensionCalculator::class) 50 | ->calculateForImgTag($responsive->defaultBreakpoint()); 51 | 52 | $width = $dimensions->getWidth(); 53 | $height = $dimensions->getHeight(); 54 | 55 | dispatch(app(GenerateImageJob::class, [ 56 | 'asset' => $responsive->asset, 57 | 'params' => array_merge(['width' => $width, 'height' => $height]), 58 | ])); 59 | 60 | /* 61 | * Dispatch a job for each breakpoint 62 | */ 63 | $responsive->breakPoints()->each(function (Breakpoint $breakpoint) { 64 | $breakpoint->sources()->each(function (Source $source) { 65 | $source->dispatchImageJobs(); 66 | }); 67 | }); 68 | 69 | $this->getOutput()->progressAdvance(); 70 | }); 71 | 72 | $this->getOutput()->progressFinish(); 73 | $this->info("All jobs dispatched."); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Commands/RegenerateResponsiveVersionsCommand.php: -------------------------------------------------------------------------------- 1 | error('Caching is not enabled for image manipulations, generating them will have no benefit.'); 22 | 23 | return; 24 | } 25 | 26 | $this->info("Clearing Glide cache..."); 27 | 28 | Artisan::call('statamic:glide:clear'); 29 | 30 | Artisan::call('statamic:responsive:generate'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/DimensionCalculator.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function calculateForBreakpoint(Source $source): Collection; 16 | 17 | /** 18 | * Specific dimension calculation for tag. Important for cases where it is important to maintain 19 | * aspect ratio, then browser (depending on provided CSS) can use `width` and `height` to maintain aspect ratio. 20 | * On other hand the returned values here are not as important if you plan to control styling in other ways 21 | * e.g. with the following CSS: width: 100%, height 100%. 22 | * 23 | * @param Breakpoint $breakpoint 24 | * @return Dimensions 25 | */ 26 | public function calculateForImgTag(Breakpoint $breakpoint): Dimensions; 27 | 28 | /** 29 | * Used for generating dimensions for placeholder image which is blurred. 30 | * We recommend a width of low value of 32px, as the image contents will be turned into a string 31 | * that gets output in the srcset. 32 | * 33 | * @param Breakpoint $breakpoint 34 | * @return Dimensions 35 | */ 36 | public function calculateForPlaceholder(Breakpoint $breakpoint): Dimensions; 37 | } 38 | -------------------------------------------------------------------------------- /src/Dimensions.php: -------------------------------------------------------------------------------- 1 | width = (int) $width; 20 | $this->height = (int) $height; 21 | } 22 | 23 | public function setWidth(int $width) 24 | { 25 | $this->width = $width; 26 | } 27 | 28 | public function setHeight(int $height) 29 | { 30 | $this->height = $height; 31 | } 32 | 33 | public function getWidth(): int 34 | { 35 | return $this->width; 36 | } 37 | 38 | public function getHeight(): int 39 | { 40 | return $this->height; 41 | } 42 | 43 | public function toArray(): array 44 | { 45 | return [ 46 | 'width' => $this->width, 47 | 'height' => $this->height, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidAssetException.php: -------------------------------------------------------------------------------- 1 | id()} has 0 width or height. Cannot create responsive variants."); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Fieldtypes/ResponsiveFields.php: -------------------------------------------------------------------------------- 1 | config = $config; 16 | } 17 | 18 | public static function new(?array $config): ResponsiveFields 19 | { 20 | return new static($config); 21 | } 22 | 23 | public function getConfig(): array 24 | { 25 | $fields = []; 26 | 27 | $breakpoints = collect($this->config['breakpoints'] ?? [])->sortBy(function ($breakpoint) { 28 | return config("statamic.responsive-images.breakpoints.{$breakpoint}"); 29 | }); 30 | 31 | $breakpoints = array_merge(['default'], $this->config['use_breakpoints'] 32 | ? $breakpoints->toArray() 33 | : []); 34 | 35 | 36 | foreach ($breakpoints as $index => $breakpoint) { 37 | if (! isset(config('statamic.responsive-images.breakpoints')[$breakpoint]) && $breakpoint !== 'default') { 38 | continue; 39 | } 40 | 41 | if ($this->config['use_breakpoints']) { 42 | $fields[] = [ 43 | 'handle' => "{$breakpoint}_section", 44 | 'field' => [ 45 | 'display' => $index === 0 46 | ? __('Default') 47 | : __(':label Breakpoint (:breakpoint:unit)', [ 48 | 'label' => strtoupper($breakpoint), 49 | 'breakpoint' => config('statamic.responsive-images.breakpoints')[$breakpoint], 50 | 'unit' => config('statamic.responsive-images.breakpoint_unit', 'px'), 51 | ]), 52 | 'instructions' => $index === 0 ? __('Set the default settings.') : __("Previous breakpoint’s choices will be used when empty."), 53 | 'type' => 'section', 54 | 'width' => $this->config['allow_fit'] ? 25 : 33, 55 | ], 56 | ]; 57 | } 58 | 59 | $fields[] = [ 60 | 'handle' => $breakpoint === 'default' ? 'src' : "{$breakpoint}:src", 61 | 'field' => [ 62 | 'display' => __('Image'), 63 | 'instructions' => $index === 0 64 | ? __('Choose an image to generate responsive versions from.') 65 | : '', 66 | 'type' => 'assets', 67 | 'localizable' => $this->config['localizable'] ?? false, 68 | 'container' => $this->config['container'] ?? optional(AssetContainer::all()->first())->handle(), 69 | 'folder' => $this->config['folder'] ?? '/', 70 | 'allow_uploads' => $this->config['allow_uploads'], 71 | 'restrict' => $this->config['restrict'] ?? false, 72 | 'max_files' => 1, 73 | 'mode' => 'list', 74 | 'width' => $this->config['use_breakpoints'] 75 | ? ($this->config['allow_ratio'] ? ($this->config['allow_fit'] ? 25 : 33) : 66) 76 | : ($this->config['allow_ratio'] ? ($this->config['allow_fit'] ? 50 : 66) : 100), 77 | 'required' => in_array('required', $this->config['validate'] ?? []) && $index === 0, 78 | 'validate' => array_filter([ 79 | new ImageRule(), 80 | ((in_array('sometimes', $this->config['validate'] ?? []) && $index === 0) ? 'sometimes' : null), 81 | ]), 82 | ], 83 | ]; 84 | 85 | if ($this->config['allow_ratio']) { 86 | $fields[] = [ 87 | 'handle' => $breakpoint === 'default' 88 | ? 'ratio' 89 | : "{$breakpoint}:ratio", 90 | 'field' => [ 91 | 'display' => __('Ratio'), 92 | 'instructions' => $index === 0 93 | ? __('Accepts a float (`1.55`) or a basic fraction (`16/9`).') 94 | : '', 95 | 'type' => 'text', 96 | 'width' => $this->config['allow_fit'] ? 25 : 33, 97 | ], 98 | ]; 99 | 100 | if ($this->config['allow_fit']) { 101 | $fields[] = [ 102 | 'handle' => $breakpoint === 'default' 103 | ? 'glide:fit' 104 | : "{$breakpoint}:glide:fit", 105 | 'field' => [ 106 | 'display' => __('Fit'), 107 | 'instructions' => $index === 0 108 | ? __('Sets how the image is fitted to its target ratio.') 109 | : '', 110 | 'type' => 'select', 111 | 'default' => null, 112 | 'options' => [ 113 | 'crop_focal' => __('Focal crop'), 114 | 'contain' => __('Contain'), 115 | 'max' => __('Max'), 116 | 'fill' => __('Fill'), 117 | 'stretch' => __('Stretch'), 118 | 'crop' => __('Crop'), 119 | ], 120 | 'width' => 25, 121 | ], 122 | ]; 123 | } 124 | } 125 | } 126 | 127 | return $fields; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Fieldtypes/ResponsiveFieldtype.php: -------------------------------------------------------------------------------- 1 | [ 37 | 'display' => __('Use breakpoints'), 38 | 'instructions' => __('Allow breakpoints to be added'), 39 | 'type' => 'toggle', 40 | 'default' => true, 41 | 'width' => 50, 42 | ], 43 | 'allow_ratio' => [ 44 | 'display' => __('Allow ratio'), 45 | 'instructions' => __('Allow ratio to be defined'), 46 | 'type' => 'toggle', 47 | 'default' => true, 48 | 'width' => 50, 49 | ], 50 | 'allow_fit' => [ 51 | 'display' => __('Allow fit'), 52 | 'instructions' => __('Allow fit to be defined'), 53 | 'type' => 'toggle', 54 | 'default' => true, 55 | 'width' => 50, 56 | 'if' => [ 57 | 'allow_ratio' => 'true', 58 | ], 59 | ], 60 | 'breakpoints' => [ 61 | 'display' => __('Breakpoints'), 62 | 'instructions' => __('Which breakpoints can be chosen.'), 63 | 'type' => 'select', 64 | 'multiple' => true, 65 | 'default' => array_keys(config('statamic.responsive-images.breakpoints')), 66 | 'options' => array_keys(config('statamic.responsive-images.breakpoints')), 67 | 'width' => 100, 68 | 'if' => [ 69 | 'use_breakpoints' => 'true', 70 | ], 71 | ], 72 | 'container' => [ 73 | 'display' => __('Container'), 74 | 'instructions' => __('statamic::fieldtypes.assets.config.container'), 75 | 'type' => 'asset_container', 76 | 'max_items' => 1, 77 | 'mode' => 'select', 78 | 'width' => 50, 79 | ], 80 | 'folder' => [ 81 | 'display' => __('Folder'), 82 | 'instructions' => __('statamic::fieldtypes.assets.config.folder'), 83 | 'type' => 'asset_folder', 84 | 'max_items' => 1, 85 | 'width' => 50, 86 | ], 87 | 'restrict' => [ 88 | 'display' => __('Restrict'), 89 | 'instructions' => __('statamic::fieldtypes.assets.config.restrict'), 90 | 'type' => 'toggle', 91 | 'width' => 50, 92 | ], 93 | 'allow_uploads' => [ 94 | 'display' => __('Allow Uploads'), 95 | 'instructions' => __('statamic::fieldtypes.assets.config.allow_uploads'), 96 | 'type' => 'toggle', 97 | 'default' => true, 98 | 'width' => 50, 99 | ], 100 | ]; 101 | } 102 | 103 | public function preload() 104 | { 105 | return [ 106 | 'fields' => $this->fieldConfig(), 107 | 'meta' => $this->fields()->addValues($this->field()->value() ?? [])->meta(), 108 | ]; 109 | } 110 | 111 | protected function fields() 112 | { 113 | return new BlueprintFields($this->fieldConfig()); 114 | } 115 | 116 | protected function fieldConfig() 117 | { 118 | return ResponsiveFields::new($this->config())->getConfig(); 119 | } 120 | 121 | public function extraRules(): array 122 | { 123 | $rules = collect($this->fieldConfig())->mapWithKeys(function ($field) { 124 | if ($field['field']['required'] ?? false) { 125 | $rules = ['required']; 126 | } else { 127 | $rules = ['nullable']; 128 | } 129 | 130 | $prefixedHandle = $this->field()->handle() . '.' . $field['handle']; 131 | 132 | return [ 133 | $prefixedHandle => array_merge($rules, $field['field']['validate'] ?? []), 134 | ]; 135 | }); 136 | 137 | return $rules->toArray(); 138 | } 139 | 140 | public function preProcess($data) 141 | { 142 | return $this->getFieldsWithValues($data)->preProcess()->values()->all(); 143 | } 144 | 145 | public function preProcessIndex($data) 146 | { 147 | $data = $this->augment($data); 148 | 149 | if (! isset($data['src'])) { 150 | return []; 151 | } 152 | 153 | try { 154 | $responsive = new Responsive($data['src'], Parameters::make($data, Context::make())); 155 | } catch (AssetNotFoundException | InvalidAssetException) { 156 | return []; 157 | } 158 | 159 | return $responsive->breakPoints() 160 | ->map(function (Breakpoint $breakpoint) { 161 | $arr = [ 162 | 'id' => $breakpoint->asset->id(), 163 | 'is_image' => $isImage = $breakpoint->asset->isImage(), 164 | 'extension' => $breakpoint->asset->extension(), 165 | 'url' => $breakpoint->asset->url(), 166 | 'breakpoint' => $breakpoint->label, 167 | ]; 168 | 169 | if ($isImage) { 170 | $arr['thumbnail'] = cp_route('assets.thumbnails.show', [ 171 | 'encoded_asset' => base64_encode($breakpoint->asset->id()), 172 | 'size' => 'small', 173 | ]); 174 | } 175 | 176 | return $arr; 177 | }); 178 | } 179 | 180 | public function process($data) 181 | { 182 | if (! is_iterable($data)) { 183 | return []; 184 | } 185 | 186 | return Arr::removeNullValues( 187 | $this->getFieldsWithValues($data)->process()->values()->all() 188 | ); 189 | } 190 | 191 | public function augment($data) 192 | { 193 | if (! is_iterable($data)) { 194 | return $data; 195 | } 196 | 197 | $fields = $this->getFieldsWithValues($data); 198 | 199 | try { 200 | $processedFields = $fields->process(); 201 | } catch (Throwable) { 202 | $processedFields = $fields; 203 | } 204 | 205 | return $processedFields 206 | ->augment() 207 | ->values() 208 | ->only(array_keys($data)) 209 | ->all(); 210 | } 211 | 212 | public function toGqlType() 213 | { 214 | return GraphQL::type(GraphQLResponsiveFieldtype::NAME); 215 | } 216 | 217 | protected function getFieldsWithValues(array $values): BlueprintFields 218 | { 219 | $fields = $this->fields()->all()->map(function (Field $field) use ($values) { 220 | return IlluminateArr::has($values, $field->handle()) 221 | ? $field->newInstance()->setValue(IlluminateArr::get($values, $field->handle())) 222 | : $field->newInstance(); 223 | }); 224 | 225 | return $this->fields()->setFields($fields); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/GraphQL/BreakpointType.php: -------------------------------------------------------------------------------- 1 | self::NAME, 15 | ]; 16 | 17 | public function fields(): array 18 | { 19 | return [ 20 | 'asset' => [ 21 | 'type' => GraphQL::type(AssetInterface::NAME), 22 | 'description' => 'The asset.', 23 | ], 24 | 'label' => [ 25 | 'type' => GraphQL::string(), 26 | 'description' => 'The breakpoint label.', 27 | ], 28 | 'minWidth' => [ 29 | 'type' => GraphQL::int(), 30 | 'description' => 'The min-width of this breakpoint.', 31 | ], 32 | 'widthUnit' => [ 33 | 'type' => GraphQL::string(), 34 | 'description' => 'The unit (px by default) of the breakpoint.', 35 | ], 36 | 'ratio' => [ 37 | 'type' => GraphQL::float(), 38 | 'description' => 'The image ratio on this breakpoint.', 39 | ], 40 | 'sources' => [ 41 | 'type' => GraphQL::listOf(GraphQL::type(SourceType::NAME)), 42 | 'description' => 'The sources for this breakpoint.', 43 | ], 44 | 'placeholder' => [ 45 | 'type' => GraphQL::string(), 46 | 'description' => 'The placeholder', 47 | ], 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/GraphQL/ResponsiveField.php: -------------------------------------------------------------------------------- 1 | 'Create a responsive image', 18 | ]; 19 | 20 | public function type(): Type 21 | { 22 | return GraphQL::listOf( 23 | GraphQL::nonNull( 24 | GraphQL::type(BreakPointType::NAME) 25 | ) 26 | ); 27 | } 28 | 29 | public function args(): array 30 | { 31 | return ResponsiveGraphqlArguments::args(); 32 | } 33 | 34 | protected function resolve(Asset|array $root, array $args) 35 | { 36 | $args = collect($args)->mapWithKeys(function ($value, $key) { 37 | return [str_replace('_', ':', $key) => $value]; 38 | })->toArray(); 39 | 40 | try { 41 | $responsive = new Responsive($root, new Parameters($args)); 42 | } catch (AssetNotFoundException $e) { 43 | logger()->error($e->getMessage()); 44 | 45 | return null; 46 | } 47 | 48 | return $responsive->breakPoints()->map(function (Breakpoint $breakpoint) use ($args) { 49 | return $breakpoint->toGql($args); 50 | })->toArray(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/GraphQL/ResponsiveFieldType.php: -------------------------------------------------------------------------------- 1 | self::NAME, 20 | ]; 21 | 22 | public function fields(): array 23 | { 24 | return [ 25 | 'breakpoints' => [ 26 | 'type' => GraphQL::listOf( 27 | GraphQL::nonNull( 28 | GraphQL::type(BreakpointType::NAME) 29 | ) 30 | ), 31 | 'resolve' => function (array $field, array $args, ?array $context, ResolveInfo $info) { 32 | $field = array_map(function ($value) { 33 | if ($value instanceof Value) { 34 | return $value->value(); 35 | } 36 | 37 | return $value; 38 | }, $field); 39 | 40 | if (! isset($field['src'])) { 41 | return null; 42 | } 43 | 44 | try { 45 | $responsive = new Responsive($field['src'], new Parameters($field)); 46 | 47 | return $responsive->breakPoints()->map(function (Breakpoint $breakpoint) { 48 | return $breakpoint->toGql([ 49 | 'placeholder' => config('statamic.responsive-images.placeholder'), 50 | ]); 51 | })->toArray(); 52 | } catch (AssetNotFoundException $e) { 53 | logger()->error($e->getMessage()); 54 | 55 | return null; 56 | } 57 | }, 58 | ], 59 | 'responsive' => ResponsiveField::class, 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/GraphQL/ResponsiveGraphqlArguments.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'type' => Type::float(), 15 | 'description' => 'The ratio of the image', 16 | ], 17 | 'width' => [ 18 | 'type' => Type::int(), 19 | 'description' => 'The maximum width of the image', 20 | ], 21 | 'webp' => [ 22 | 'type' => Type::boolean(), 23 | 'description' => 'Whether to generate WEBP images', 24 | ], 25 | 'avif' => [ 26 | 'type' => Type::boolean(), 27 | 'description' => 'Whether to generate AVIF images', 28 | ], 29 | 'placeholder' => [ 30 | 'type' => Type::boolean(), 31 | 'description' => 'Whether to generate and output placeholder string in the srcsets', 32 | ], 33 | 'quality' => [ 34 | 'type' => Type::int(), 35 | 'description' => 'The quality of the images', 36 | ], 37 | ]; 38 | 39 | // https://statamic.dev/tags/glide#parameters 40 | // Not all have been included as some may cause unexpected images 41 | $glideArgs = [ 42 | 'glide_fit' => [ 43 | 'type' => Type::string(), 44 | ], 45 | 'glide_crop' => [ 46 | 'type' => Type::string(), 47 | ], 48 | 'glide_orient' => [ 49 | 'type' => Type::string(), 50 | ], 51 | 'glide_flip' => [ 52 | 'type' => Type::string(), 53 | ], 54 | 'glide_bg' => [ 55 | 'type' => Type::string(), 56 | ], 57 | 'glide_blur' => [ 58 | 'type' => Type::int(), 59 | ], 60 | 'glide_brightness' => [ 61 | 'type' => Type::string(), 62 | ], 63 | 'glide_contrast' => [ 64 | 'type' => Type::string(), 65 | ], 66 | 'glide_gamma' => [ 67 | 'type' => Type::float(), 68 | ], 69 | 'glide_sharpen' => [ 70 | 'type' => Type::int(), 71 | ], 72 | 'glide_pixelate' => [ 73 | 'type' => Type::int(), 74 | ], 75 | 'glide_filter' => [ 76 | 'type' => Type::string(), 77 | ], 78 | 'glide_mark' => [ 79 | 'type' => Type::string(), 80 | ], 81 | 'glide_markw' => [ 82 | 'type' => Type::string(), 83 | ], 84 | 'glide_markh' => [ 85 | 'type' => Type::string(), 86 | ], 87 | 'glide_markfit' => [ 88 | 'type' => Type::string(), 89 | ], 90 | 'glide_markx' => [ 91 | 'type' => Type::string(), 92 | ], 93 | 'glide_marky' => [ 94 | 'type' => Type::string(), 95 | ], 96 | 'glide_markpad' => [ 97 | 'type' => Type::string(), 98 | ], 99 | 'glide_markpos' => [ 100 | 'type' => Type::string(), 101 | ], 102 | 'glide_width' => [ 103 | 'type' => Type::int(), 104 | ], 105 | ]; 106 | 107 | $defaultBreakpointArgs = array_merge($defaultBreakpointArgs, $glideArgs); 108 | 109 | $additionalBreakpointArgs = []; 110 | 111 | $unit = config('statamic.responsive-images.breakpoint_unit'); 112 | 113 | foreach (config('statamic.responsive-images.breakpoints') as $breakpoint => $width) { 114 | foreach ($defaultBreakpointArgs as $argKey => $argConfig) { 115 | if (isset($argConfig['description'])) { 116 | $argConfig['description'] = $argConfig['description'] . " for the {$breakpoint} ({$width}{$unit}) breakpoint"; 117 | } 118 | 119 | $additionalBreakpointArgs["{$breakpoint}_{$argKey}"] = $argConfig; 120 | } 121 | } 122 | 123 | return array_merge($defaultBreakpointArgs, $additionalBreakpointArgs); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/GraphQL/SourceType.php: -------------------------------------------------------------------------------- 1 | self::NAME, 13 | ]; 14 | 15 | public function fields(): array 16 | { 17 | return [ 18 | 'format' => [ 19 | 'type' => GraphQL::string(), 20 | 'description' => 'The format for this sources srcset (e.g. original, webp, avif)', 21 | ], 22 | 'mimeType' => [ 23 | 'type' => GraphQL::string(), 24 | 'description' => 'The mime type for sources srcset', 25 | ], 26 | 'minWidth' => [ 27 | 'type' => GraphQL::int(), 28 | 'description' => 'The minimum starting width for this source', 29 | ], 30 | 'mediaWidthUnit' => [ 31 | 'type' => GraphQL::string(), 32 | 'description' => 'The unit for the min-width in media query', 33 | ], 34 | 'mediaString' => [ 35 | 'type' => GraphQL::string(), 36 | 'description' => 'The media string for this source', 37 | ], 38 | 'srcSet' => [ 39 | 'type' => GraphQL::string(), 40 | 'description' => 'The srcSet string for this source', 41 | ], 42 | 'placeholder' => [ 43 | 'type' => GraphQL::string(), 44 | 'description' => 'The placeholder', 45 | ], 46 | ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Jobs/GenerateGlideImageJob.php: -------------------------------------------------------------------------------- 1 | asset); 13 | 14 | foreach ($this->params as $param => $value) { 15 | if (is_array($value)) { 16 | $value = $value['value'] ?? $value[0] ?? null; 17 | } 18 | 19 | $manipulator->$param($value); 20 | } 21 | 22 | $url = $manipulator->build(); 23 | 24 | if (config('statamic.responsive-images.force_absolute_urls', false)) { 25 | return URL::makeAbsolute($url); 26 | } 27 | 28 | return $url; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Jobs/GenerateImageJob.php: -------------------------------------------------------------------------------- 1 | asset = $asset; 25 | $this->params = $params; 26 | 27 | $this->queue = config('statamic.responsive-images.queue', 'default'); 28 | } 29 | 30 | public function handle(): string 31 | { 32 | return $this->imageUrl(); 33 | } 34 | 35 | public function getParams(): array 36 | { 37 | return $this->params; 38 | } 39 | 40 | abstract protected function imageUrl(): string; 41 | } 42 | -------------------------------------------------------------------------------- /src/Jobs/GeneratePlaceholderJob.php: -------------------------------------------------------------------------------- 1 | queue = config('statamic.responsive-images.queue', 'default'); 24 | } 25 | 26 | public function handle(): string 27 | { 28 | $dimensions = app(DimensionCalculator::class) 29 | ->calculateForPlaceholder($this->breakpoint); 30 | 31 | $params = [ 32 | 'w' => $dimensions->getWidth(), 33 | 'h' => $dimensions->getHeight(), 34 | 'blur' => 5, 35 | /** 36 | * Arbitrary parameter to change md5 hash for Glide manipulation cache key 37 | * to force Glide to generate new manipulated image if cache setting changes. 38 | * TODO: Remove this line once the issue has been resolved in statamic/cms package 39 | */ 40 | 'cache' => Config::get('statamic.assets.image_manipulation.cache', false), 41 | ]; 42 | 43 | try { 44 | return app(ImageGenerator::class)->generateByAsset($this->asset, $params); 45 | } catch (NotFoundHttpException $e) { 46 | return ''; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Listeners/GenerateResponsiveVersions.php: -------------------------------------------------------------------------------- 1 | asset->isImage()) { 16 | return; 17 | } 18 | 19 | if ($event->asset->extension() === 'svg') { 20 | return; 21 | } 22 | 23 | if (! config('statamic.assets.image_manipulation.cache')) { 24 | return; 25 | } 26 | 27 | if (! config('statamic.responsive-images.generate_on_upload', true)) { 28 | return; 29 | } 30 | 31 | $responsive = new Responsive($event->asset, new Parameters()); 32 | $responsive->breakPoints()->each(function (Breakpoint $breakpoint) { 33 | $breakpoint->sources()->each(function (Source $source) { 34 | $source->dispatchImageJobs(); 35 | }); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Listeners/UpdateResponsiveReferences.php: -------------------------------------------------------------------------------- 1 | listen(AssetSaved::class, [self::class, 'handleSaved']); 26 | $events->listen(AssetDeleted::class, [self::class, 'handleDeleted']); 27 | } 28 | 29 | /** 30 | * Handle asset saved event. 31 | * 32 | * @param AssetSaved $event 33 | */ 34 | public function handleSaved(AssetSaved $event) 35 | { 36 | $asset = $event->asset; 37 | 38 | $container = $asset->container()->handle(); 39 | $originalPath = $asset->getOriginal('path'); 40 | $newPath = $asset->path(); 41 | 42 | $this->replaceReferences($container, $originalPath, $newPath); 43 | } 44 | 45 | /** 46 | * Handle asset deleted event. 47 | * 48 | * @param AssetDeleted $event 49 | * @return void 50 | */ 51 | public function handleDeleted(AssetDeleted $event) 52 | { 53 | $asset = $event->asset; 54 | 55 | $container = $asset->container()->handle(); 56 | $originalPath = $asset->getOriginal('path'); 57 | $newPath = null; 58 | 59 | $this->replaceReferences($container, $originalPath, $newPath); 60 | } 61 | 62 | /** 63 | * @param $container 64 | * @param $originalPath 65 | * @param $newPath 66 | * @return void 67 | */ 68 | protected function replaceReferences($container, $originalPath, $newPath) 69 | { 70 | if (! $originalPath || $originalPath === $newPath) { 71 | return; 72 | } 73 | 74 | $newValue = $newPath ? "{$container}::{$newPath}" : null; 75 | 76 | $this->getItemsContainingData()->each(function ($item) use ($container, $originalPath, $newValue) { 77 | ResponsiveReferenceUpdater::item($item) 78 | ->filterByContainer($container) 79 | ->updateReferences($container . '::' . $originalPath, $newValue); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Responsive.php: -------------------------------------------------------------------------------- 1 | */ 25 | private Collection $breakpoints; 26 | 27 | /** 28 | * @param $assetParam 29 | * @param Parameters $parameters 30 | * @throws AssetNotFoundException 31 | * @throws InvalidAssetException 32 | */ 33 | public function __construct($assetParam, Parameters $parameters) 34 | { 35 | $this->parameters = $parameters; 36 | 37 | if ($assetParam instanceof Value && $assetParam->fieldtype() instanceof ResponsiveFieldtype) { 38 | $assetParam = $assetParam->value(); 39 | } 40 | 41 | if (is_array($assetParam) && isset($assetParam['src'])) { 42 | $this->parameters = collect($assetParam)->map(function ($value) { 43 | return $value instanceof Value ? $value->value() : $value; 44 | })->merge($this->parameters->toArray())->except('src'); 45 | 46 | $assetParam = $assetParam['src']; 47 | } 48 | 49 | $this->asset = $this->retrieveAsset($assetParam)->hydrate(); 50 | 51 | if ((int) $this->asset->width() === 0 || (int) $this->asset->height() === 0) { 52 | throw InvalidAssetException::zeroWidthOrHeight($this->asset); 53 | } 54 | } 55 | 56 | /** 57 | * @param $assetParam 58 | * @return Asset 59 | * @throws AssetNotFoundException 60 | */ 61 | private function retrieveAsset($assetParam): Asset 62 | { 63 | if ($assetParam instanceof Asset) { 64 | return $assetParam; 65 | } 66 | 67 | if (is_string($assetParam)) { 68 | $asset = AssetFacade::findByUrl($assetParam); 69 | 70 | if (! $asset) { 71 | $asset = AssetFacade::findByPath($assetParam); 72 | } 73 | } 74 | 75 | if ($assetParam instanceof Value) { 76 | $asset = $assetParam->value(); 77 | 78 | if (isset($asset) && method_exists($asset, 'first')) { 79 | $asset = $asset->first(); 80 | } 81 | } 82 | 83 | if (isset($asset) && is_string($asset)) { 84 | $asset = AssetFacade::findByUrl($assetParam); 85 | 86 | if (! $asset) { 87 | $asset = AssetFacade::findByPath($assetParam); 88 | } 89 | } 90 | 91 | if (is_array($assetParam) && isset($assetParam['url'])) { 92 | $asset = AssetFacade::findByUrl($assetParam['url']); 93 | } 94 | 95 | if (! isset($asset)) { 96 | throw AssetNotFoundException::create($assetParam); 97 | } 98 | 99 | if ($asset instanceof OrderedQueryBuilder) { 100 | $asset = $asset->first(); 101 | } 102 | 103 | return $asset; 104 | } 105 | 106 | /** 107 | * @return Collection 108 | */ 109 | public function breakPoints(): Collection 110 | { 111 | if (isset($this->breakpoints)) { 112 | return $this->breakpoints; 113 | } 114 | 115 | $parametersByBreakpoint = $this->parametersByBreakpoint(); 116 | 117 | $defaultParams = $parametersByBreakpoint->get('default') ?? collect(); 118 | $currentParams = array_merge([ 119 | 'src' => $this->asset, 120 | ], $defaultParams->mapWithKeys(function ($param) { 121 | return [$param['key'] => $param['value']]; 122 | })->toArray()); 123 | 124 | $breakpoints = $parametersByBreakpoint 125 | ->map(function (Collection $parameters, string $breakpoint) use (&$currentParams) { 126 | $value = config("statamic.responsive-images.breakpoints.$breakpoint"); 127 | 128 | if (! $value && $breakpoint !== 'default') { 129 | return null; 130 | } 131 | 132 | foreach ($parameters as $parameter) { 133 | if ($parameter['key'] === 'src' && ! $parameter['value'] instanceof Asset) { 134 | try { 135 | $parameter['value'] = $this->retrieveAsset($parameter['value'])->hydrate(); 136 | } catch (AssetNotFoundException $e) { 137 | logger()->error($e->getMessage()); 138 | $parameter['value'] = $this->asset; 139 | } 140 | 141 | if ((int) $parameter['value']->width() === 0 || (int) $parameter['value']->height() === 0) { 142 | throw InvalidAssetException::zeroWidthOrHeight($parameter['value']); 143 | } 144 | } 145 | 146 | if (Str::contains($parameter['key'], 'ratio') && Str::contains($parameter['value'], '/')) { 147 | [$width, $height] = explode('/', $parameter['value']); 148 | $parameter['value'] = (float) $width / (float) $height; 149 | } 150 | 151 | $currentParams[$parameter['key']] = $parameter['value']; 152 | } 153 | 154 | return new Breakpoint( 155 | $currentParams['src'], 156 | $breakpoint, 157 | $value ?? 0, 158 | Arr::except($currentParams, ['src']) 159 | ); 160 | }) 161 | ->filter(); 162 | 163 | $defaultBreakpoint = $breakpoints->first(function (Breakpoint $breakpoint) { 164 | return $breakpoint->label === 'default'; 165 | }); 166 | 167 | if (! $defaultBreakpoint) { 168 | $breakpoints->prepend(new Breakpoint($this->asset, 'default', 0, [ 169 | 'ratio' => $this->asset->width() / $this->asset->height(), 170 | ])); 171 | } 172 | 173 | return $this->breakpoints = $breakpoints 174 | ->sortByDesc('minWidth') 175 | ->values(); 176 | } 177 | 178 | public function defaultBreakpoint(): Breakpoint 179 | { 180 | return $this->breakPoints()->first(function (Breakpoint $breakpoint) { 181 | return $breakpoint->label === 'default'; 182 | }); 183 | } 184 | 185 | private function parametersByBreakpoint(): Collection 186 | { 187 | $breakpoints = collect(config('statamic.responsive-images.breakpoints')); 188 | 189 | return collect($this->parameters) 190 | ->map(function ($value, $key) use ($breakpoints) { 191 | $prefix = explode(':', $key)[0]; 192 | 193 | if (! $breakpoints->keys()->contains($prefix)) { 194 | $prefix = 'default'; 195 | } 196 | 197 | return [ 198 | 'prefix' => $prefix, 199 | 'key' => str_replace($prefix.':', '', $key), 200 | 'value' => $value, 201 | 'breakpoint' => $breakpoints->get($prefix) ?? 0, 202 | ]; 203 | }) 204 | ->values() 205 | ->sortBy('breakpoint') 206 | ->groupBy('prefix'); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/ResponsiveDimensionCalculator.php: -------------------------------------------------------------------------------- 1 | breakpoint->asset; 16 | $width = $asset->width(); 17 | $height = $asset->height(); 18 | $fileSize = $asset->size(); 19 | 20 | $ratio = $this->breakpointRatio($asset, $source->breakpoint); 21 | $glideParams = $source->breakpoint->getImageManipulationParams(); 22 | 23 | return $this 24 | ->calculateDimensions($fileSize, $width, $height, $ratio) 25 | ->sort() 26 | // Filter out widths by max width 27 | ->when((isset($glideParams['width']) || config('statamic.responsive-images.max_width') !== null), function ($dimensions) use ($glideParams, $ratio) { 28 | $maxWidth = $glideParams['width'] ?? config('statamic.responsive-images.max_width'); 29 | 30 | $filtered = $dimensions->filter(function (Dimensions $dimensions) use ($maxWidth) { 31 | return $dimensions->getWidth() <= $maxWidth; 32 | }); 33 | 34 | // We want at least one width to be returned 35 | if (! $filtered->count()) { 36 | $filtered = collect([ 37 | new Dimensions($maxWidth, round($maxWidth / $ratio)), 38 | ]); 39 | } 40 | 41 | return $filtered; 42 | }); 43 | } 44 | 45 | public function calculateForImgTag(Breakpoint $breakpoint): Dimensions 46 | { 47 | $maxWidth = ($breakpoint->parameters['glide:width'] ?? config('statamic.responsive-images.max_width') ?? null); 48 | 49 | $ratio = $this->breakpointRatio($breakpoint->asset, $breakpoint); 50 | 51 | $width = $maxWidth ?? $breakpoint->asset->width(); 52 | 53 | return new Dimensions($width, round($width / $ratio)); 54 | } 55 | 56 | public function calculateForPlaceholder(Breakpoint $breakpoint): Dimensions 57 | { 58 | return new Dimensions(32, 32 / $this->breakpointRatio($breakpoint->asset, $breakpoint)); 59 | } 60 | 61 | public function breakpointRatio(Asset $asset, Breakpoint $breakpoint): float 62 | { 63 | return $breakpoint->parameters['ratio'] ?? ($asset->width() / $asset->height()); 64 | } 65 | 66 | protected function calculateDimensions(int $assetFilesize, int $assetWidth, int $assetHeight, $ratio): Collection 67 | { 68 | $dimensions = collect(); 69 | 70 | $dimensions->push(new Dimensions($assetWidth, round($assetWidth / $ratio))); 71 | 72 | // For filesize calculations 73 | $ratioForFilesize = $assetHeight / $assetWidth; 74 | $area = $assetHeight * $assetWidth; 75 | 76 | $predictedFileSize = $assetFilesize; 77 | $pixelPrice = $predictedFileSize / $area; 78 | 79 | while (true) { 80 | $predictedFileSize *= config('statamic.responsive-images.dimension_calculator_threshold', 0.7); 81 | 82 | $newWidth = (int) floor(sqrt(($predictedFileSize / $pixelPrice) / $ratioForFilesize)); 83 | 84 | if ($this->finishedCalculating($predictedFileSize, $newWidth)) { 85 | return $dimensions; 86 | } 87 | 88 | $dimensions->push(new Dimensions($newWidth, round($newWidth / $ratio))); 89 | } 90 | } 91 | 92 | protected function finishedCalculating(float $predictedFileSize, int $newWidth): bool 93 | { 94 | if ($newWidth < 20) { 95 | return true; 96 | } 97 | 98 | if ($predictedFileSize < (1024 * 10)) { 99 | return true; 100 | } 101 | 102 | return false; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/ResponsiveReferenceUpdater.php: -------------------------------------------------------------------------------- 1 | updateResponsiveFieldValues($fields, $dottedPrefix) 20 | ->updateNestedFieldValues($fields, $dottedPrefix); 21 | } 22 | 23 | /** 24 | * Update assets field values. 25 | * 26 | * @param \Illuminate\Support\Collection $fields 27 | * @param null|string $dottedPrefix 28 | * @return $this 29 | */ 30 | protected function updateResponsiveFieldValues($fields, $dottedPrefix) 31 | { 32 | $fields 33 | ->filter(function ($field) { 34 | return $field->type() === 'responsive' 35 | && $this->getConfiguredAssetsFieldContainer($field) === $this->container; 36 | }) 37 | ->each(function ($field) use ($dottedPrefix) { 38 | $this->updateResponsiveValue($field, $dottedPrefix); 39 | }); 40 | 41 | return $this; 42 | } 43 | 44 | /** 45 | * Update responsive value on item. 46 | * 47 | * @see AssetReferenceUpdater::updateArrayValue() 48 | * @param \Statamic\Fields\Field $field 49 | * @param null|string $dottedPrefix 50 | */ 51 | protected function updateResponsiveValue($field, $dottedPrefix) 52 | { 53 | $data = $this->item->data()->all(); 54 | 55 | $dottedKey = $dottedPrefix.$field->handle(); 56 | 57 | $fieldData = collect( 58 | Arr::get($data, $dottedKey, []) 59 | ); 60 | 61 | $referencesUpdated = 0; 62 | 63 | $fieldData->transform(function ($value, $key) use (&$referencesUpdated) { 64 | if (! str_ends_with($key, 'src')) { 65 | return $value; 66 | } 67 | 68 | // In content files, the src value can be either string or array. 69 | // First handle the string value, and then handle the array value. 70 | // Handle asset deletion, return null now for filtering later. 71 | if ($value === $this->originalValue() && $this->isRemovingValue()) { 72 | $referencesUpdated++; 73 | 74 | return null; 75 | } 76 | 77 | if (is_string($value) && $value === $this->originalValue()) { 78 | $referencesUpdated++; 79 | 80 | return $this->newValue(); 81 | } 82 | 83 | // Handle array value. 84 | if (is_array($value) && in_array($this->originalValue(), $value)) { 85 | $transformedFieldDataArray = array_map(function ($item) use (&$referencesUpdated) { 86 | // Handle asset deletion, return null now for filtering. 87 | if ($item === $this->originalValue() && $this->isRemovingValue()) { 88 | $referencesUpdated++; 89 | 90 | return null; 91 | } 92 | 93 | if ($item === $this->originalValue()) { 94 | $referencesUpdated++; 95 | 96 | return $this->newValue(); 97 | } 98 | 99 | return $item; 100 | }, $value); 101 | 102 | return array_filter($transformedFieldDataArray, fn ($item) => $item !== null); 103 | } 104 | 105 | return $value; 106 | }); 107 | 108 | $fieldData = $fieldData->filter(fn ($item) => $item !== null); 109 | 110 | if ($referencesUpdated === 0) { 111 | return; 112 | } 113 | 114 | Arr::set($data, $dottedKey, $fieldData->all()); 115 | 116 | $this->item->data($data); 117 | 118 | $this->updated = true; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | [ 41 | GenerateResponsiveVersions::class, 42 | ], 43 | ]; 44 | 45 | protected $subscribe = [ 46 | UpdateResponsiveReferences::class, 47 | ]; 48 | 49 | protected $commands = [ 50 | GenerateResponsiveVersionsCommand::class, 51 | RegenerateResponsiveVersionsCommand::class, 52 | ]; 53 | 54 | public function boot() 55 | { 56 | parent::boot(); 57 | 58 | $this 59 | ->bootCommands() 60 | ->bootAddonViews() 61 | ->bootAddonConfig() 62 | ->bootDirectives() 63 | ->bindImageJob() 64 | ->bindDimensionCalculator() 65 | ->bootGraphQL(); 66 | } 67 | 68 | protected function bootAddonViews(): self 69 | { 70 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'responsive-images'); 71 | 72 | $this->publishes([ 73 | __DIR__.'/../resources/views' => resource_path('views/vendor/responsive-images'), 74 | ], 'responsive-images-views'); 75 | 76 | return $this; 77 | } 78 | 79 | protected function bootAddonConfig(): self 80 | { 81 | $this->mergeConfigFrom(__DIR__.'/../config/responsive-images.php', 'statamic.responsive-images'); 82 | 83 | $this->publishes([ 84 | __DIR__.'/../config/responsive-images.php' => config_path('statamic/responsive-images.php'), 85 | ], 'responsive-images-config'); 86 | 87 | return $this; 88 | } 89 | 90 | protected function bootDirectives(): self 91 | { 92 | Blade::directive('responsive', function ($arguments) { 93 | return ""; 94 | }); 95 | 96 | return $this; 97 | } 98 | 99 | private function bindImageJob(): self 100 | { 101 | $this->app->bind(GenerateImageJob::class, config('statamic.responsive-images.image_job')); 102 | 103 | return $this; 104 | } 105 | 106 | private function bindDimensionCalculator(): self 107 | { 108 | $this->app->bind(DimensionCalculator::class, ResponsiveDimensionCalculator::class); 109 | 110 | return $this; 111 | } 112 | 113 | private function bootGraphQL(): self 114 | { 115 | GraphQL::addType(BreakpointType::class); 116 | GraphQL::addType(GraphQLResponsiveFieldType::class); 117 | GraphQL::addType(SourceType::class); 118 | 119 | GraphQL::addField('AssetInterface', 'responsive', function () { 120 | return (new ResponsiveField())->toArray(); 121 | }); 122 | 123 | return $this; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/Source.php: -------------------------------------------------------------------------------- 1 | tag in HTML 12 | * @property-read Breakpoint $breakpoint 13 | */ 14 | class Source implements Arrayable 15 | { 16 | public Breakpoint $breakpoint; 17 | protected string $format; 18 | protected string $mediaWidthUnit; 19 | 20 | public function __construct(Breakpoint $breakpoint, ?string $format = 'original') 21 | { 22 | $this->breakpoint = $breakpoint; 23 | $this->format = $format; 24 | $this->mediaWidthUnit = config('statamic.responsive-images.breakpoint_unit', 'px'); 25 | } 26 | 27 | public function __set($name, $value): void 28 | { 29 | throw new Exception(sprintf('Cannot modify property %s', $name)); 30 | } 31 | 32 | public function getMimeType(): string|null 33 | { 34 | $mimeTypesBySetFormat = [ 35 | 'webp' => 'image/webp', 36 | 'avif' => 'image/avif', 37 | ]; 38 | 39 | if (isset($mimeTypesBySetFormat[$this->format])) { 40 | return $mimeTypesBySetFormat[$this->format]; 41 | } 42 | 43 | return $this->breakpoint->asset->mimeType(); 44 | } 45 | 46 | /** 47 | * @param string|null $format 48 | * @param bool|null $includePlaceholder 49 | * @return string|null 50 | */ 51 | public function getSrcSet(string $format = null, ?bool $includePlaceholder = null): ?string 52 | { 53 | // In order of importance: override (e.g. from GraphQL), breakpoint param, config 54 | $includePlaceholder = $includePlaceholder ?? $this->includePlaceholder(); 55 | 56 | $dimensionsCollection = $this->getDimensions(); 57 | 58 | if ($dimensionsCollection->isEmpty()) { 59 | return null; 60 | } 61 | 62 | return $dimensionsCollection 63 | ->map(function (Dimensions $dimensions) use ($format) { 64 | return "{$this->buildImageJob($dimensions->width, $dimensions->height, $this->format)->handle()} {$dimensions->width}w"; 65 | }) 66 | ->when($includePlaceholder, function (Collection $dimensions) { 67 | $placeholderSrc = $this->breakpoint->placeholderSrc(); 68 | 69 | if (empty($placeholderSrc)) { 70 | return $dimensions; 71 | } 72 | 73 | return $dimensions->prepend($placeholderSrc); 74 | }) 75 | ->implode(', '); 76 | } 77 | 78 | public function buildImageJob(int $width, int $height = null, ?string $format = null): GenerateImageJob 79 | { 80 | $params = $this->breakpoint->getImageManipulationParams($format); 81 | 82 | $params['width'] = $width; 83 | $params['height'] = $height; 84 | 85 | return app(GenerateImageJob::class, ['asset' => $this->breakpoint->asset, 'params' => $params]); 86 | } 87 | 88 | public function getMediaString(): null|string 89 | { 90 | if (! $this->breakpoint->minWidth) { 91 | return null; 92 | } 93 | 94 | return "(min-width: {$this->breakpoint->minWidth}{$this->mediaWidthUnit})"; 95 | } 96 | 97 | /** 98 | * @return Collection 99 | */ 100 | private function getDimensions(): Collection 101 | { 102 | return app(DimensionCalculator::class)->calculateForBreakpoint($this); 103 | } 104 | 105 | public function getFormat(): string 106 | { 107 | return $this->format; 108 | } 109 | 110 | public function dispatchImageJobs(): void 111 | { 112 | $format = $this->format === 'original' ? null : $this->format; 113 | 114 | $this->getDimensions()->map(function (Dimensions $dimensions) use ($format) { 115 | dispatch($this->buildImageJob($dimensions->width, $dimensions->height, $format)); 116 | }); 117 | 118 | if ($this->includePlaceholder()) { 119 | dispatch($this->breakpoint->buildPlaceholderJob()); 120 | } 121 | } 122 | 123 | private function includePlaceholder(): bool 124 | { 125 | return $this->breakpoint->parameters['placeholder'] 126 | ?? config('statamic.responsive-images.placeholder', false); 127 | } 128 | 129 | public function toGql(array $args) 130 | { 131 | return $this->toArray(); 132 | } 133 | 134 | public function toArray() 135 | { 136 | return [ 137 | 'format' => $this->format, 138 | 'mimeType' => $this->getMimeType(), 139 | 'media' => $this->getMediaString(), 140 | 'mediaWidthUnit' => $this->mediaWidthUnit, 141 | 'minWidth' => $this->breakpoint->minWidth, 142 | 'srcSet' => $this->getSrcSet(), 143 | ]; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Tags/ResponsiveTag.php: -------------------------------------------------------------------------------- 1 | setContext(['url' => $asset]); 25 | $responsive->setParameters($parameters); 26 | 27 | return $responsive->wildcard('url'); 28 | } 29 | 30 | public function wildcard($tag) 31 | { 32 | $this->params->put('src', $this->context->get($tag)); 33 | 34 | return $this->index(); 35 | } 36 | 37 | public function index() 38 | { 39 | try { 40 | $responsive = new Responsive($this->params->get('src'), $this->params); 41 | } catch (AssetNotFoundException|NotFoundHttpException $e) { 42 | return ''; 43 | } 44 | 45 | if (in_array($responsive->asset->extension(), ['svg', 'gif'])) { 46 | return view('responsive-images::responsiveImage', [ 47 | 'attributeString' => $this->getAttributeString(), 48 | 'src' => $responsive->asset->url(), 49 | 'width' => $responsive->asset->width(), 50 | 'height' => $responsive->asset->height(), 51 | 'asset' => $responsive->asset->toAugmentedArray(), 52 | 'hasSources' => false, 53 | ])->render(); 54 | } 55 | 56 | $dimensions = app(DimensionCalculator::class) 57 | ->calculateForImgTag($responsive->defaultBreakpoint()); 58 | 59 | $width = $dimensions->getWidth(); 60 | $height = $dimensions->getHeight(); 61 | 62 | $src = app(GenerateImageJob::class, [ 63 | 'asset' => $responsive->asset, 64 | 'params' => array_merge($this->getGlideParams(), ['width' => $width, 'height' => $height]), 65 | ])->handle(); 66 | 67 | $includePlaceholder = $this->includePlaceholder(); 68 | 69 | $breakpoints = $responsive->breakPoints(); 70 | 71 | return view('responsive-images::responsiveImage', [ 72 | 'attributeString' => $this->getAttributeString(), 73 | 'includePlaceholder' => $includePlaceholder, 74 | 'src' => $src, 75 | 'breakpoints' => $breakpoints, 76 | 'width' => round($width), 77 | 'height' => round($height), 78 | 'asset' => $responsive->asset->toAugmentedArray(), 79 | 'hasSources' => $breakpoints->map(function ($breakpoint) { 80 | return $breakpoint->sources(); 81 | })->flatten()->count() > 0, 82 | ])->render(); 83 | } 84 | 85 | private function getGlideParams(): array 86 | { 87 | return collect($this->params) 88 | ->reject(fn ($value, $name) => ! str_starts_with($name, 'glide:')) 89 | ->mapWithKeys(fn ($value, $name) => [str_replace('glide:', '', $name) => $value]) 90 | ->toArray(); 91 | } 92 | 93 | private function getAttributeString(): string 94 | { 95 | $breakpointPrefixes = collect(array_keys(config('statamic.responsive-images.breakpoints'))) 96 | ->map(function ($breakpoint) { 97 | return "{$breakpoint}:"; 98 | })->toArray(); 99 | 100 | $attributesToExclude = ['src', 'placeholder', 'webp', 'avif', 'ratio', 'glide:', 'default:', 'quality:']; 101 | 102 | return collect($this->params) 103 | ->reject(function ($value, $name) use ($breakpointPrefixes, $attributesToExclude) { 104 | if (Str::contains($name, array_merge($attributesToExclude, $breakpointPrefixes))) { 105 | return true; 106 | } 107 | 108 | return false; 109 | }) 110 | ->map(function ($value, $name) { 111 | return $name . '="' . $value . '"'; 112 | })->implode(' '); 113 | } 114 | 115 | private function includePlaceholder(): bool 116 | { 117 | return $this->params->has('placeholder') 118 | ? $this->params->get('placeholder') 119 | : config('statamic.responsive-images.placeholder', true); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /tests/Factories/EntryFactory.php: -------------------------------------------------------------------------------- 1 | reset(); 28 | } 29 | 30 | public function id($id) 31 | { 32 | $this->id = $id; 33 | 34 | return $this; 35 | } 36 | 37 | public function slug($slug) 38 | { 39 | $this->slug = $slug; 40 | 41 | return $this; 42 | } 43 | 44 | public function collection($collection) 45 | { 46 | $this->collection = $collection; 47 | 48 | return $this; 49 | } 50 | 51 | public function data($data) 52 | { 53 | $this->data = $data; 54 | 55 | return $this; 56 | } 57 | 58 | public function date($date) 59 | { 60 | $this->date = $date; 61 | 62 | return $this; 63 | } 64 | 65 | public function published($published) 66 | { 67 | $this->published = $published; 68 | 69 | return $this; 70 | } 71 | 72 | public function locale($locale) 73 | { 74 | $this->locale = $locale; 75 | 76 | return $this; 77 | } 78 | 79 | public function origin($origin) 80 | { 81 | $this->origin = $origin; 82 | 83 | return $this; 84 | } 85 | 86 | public function make() 87 | { 88 | $entry = Entry::make() 89 | ->locale($this->locale) 90 | ->collection($this->createCollection()) 91 | ->slug($this->slug) 92 | ->data($this->data) 93 | ->date($this->date) 94 | ->origin($this->origin) 95 | ->published($this->published); 96 | 97 | if ($this->id) { 98 | $entry->id($this->id); 99 | } 100 | 101 | $this->reset(); 102 | 103 | return $entry; 104 | } 105 | 106 | public function create() 107 | { 108 | return tap($this->make())->save(); 109 | } 110 | 111 | protected function createCollection() 112 | { 113 | if ($this->collection instanceof StatamicCollection) { 114 | return $this->collection; 115 | } 116 | 117 | return Collection::findByHandle($this->collection) 118 | ?? Collection::make($this->collection) 119 | ->sites(['en']) 120 | ->dated(true) 121 | ->save(); 122 | } 123 | 124 | private function reset() 125 | { 126 | $this->id = null; 127 | $this->slug = null; 128 | $this->data = []; 129 | $this->date = null; 130 | $this->published = true; 131 | $this->order = null; 132 | $this->locale = 'en'; 133 | $this->origin = null; 134 | $this->collection = null; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tests/Feature/AssetReferenceTest.php: -------------------------------------------------------------------------------- 1 | responsiveFieldConfiguration = [ 9 | 'type' => 'responsive', 10 | 'container' => 'test_container', 11 | 'max_files' => 1, 12 | 'use_breakpoints' => true, 13 | 'allow_ratio' => false, 14 | 'allow_fit' => true, 15 | 'restrict' => false, 16 | 'allow_uploads' => true, 17 | 'display' => 'Avatar', 18 | 'icon' => 'assets', 19 | 'listable' => 'hidden', 20 | 'instructions_position' => 'above', 21 | 'visibility' => 'visible', 22 | ]; 23 | 24 | $this->asset = $this->uploadTestImageToTestContainer(); 25 | 26 | Stache::clear(); 27 | 28 | $this->entryBlueprintWithSingleResponsiveField = [ 29 | 'fields' => [ 30 | [ 31 | 'handle' => 'avatar', 32 | 'field' => $this->responsiveFieldConfiguration, 33 | ] 34 | ], 35 | ]; 36 | }); 37 | 38 | test('asset string reference gets updated after asset rename', function () { 39 | $entry = test()->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ 40 | 'avatar' => [ 41 | 'src' => 'test_container::test.jpg', 42 | 'ratio' => '16/9', 43 | 'sm:src' => 'test_container::test.jpg', 44 | 'sm:ratio' => '16/9', 45 | ], 46 | ]); 47 | 48 | expect(Arr::get($entry->get('avatar'), 'src'))->toEqual('test_container::test.jpg'); 49 | 50 | $this->asset->rename('new-test2'); 51 | 52 | expect(Arr::get($entry->fresh()->get('avatar'), 'src'))->toEqual('test_container::new-test2.jpg'); 53 | }); 54 | 55 | test('asset array reference gets updated after asset rename', function () { 56 | $startingAvatarData = [ 57 | 'src' => [ 58 | 'test_container::test.jpg' 59 | ], 60 | 'sm:src' => [ 61 | 'test_container::test.jpg' 62 | ], 63 | ]; 64 | 65 | $entry = test()->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ 66 | 'avatar' => $startingAvatarData, 67 | ]); 68 | 69 | expect($entry->get('avatar'))->toEqual($startingAvatarData); 70 | 71 | $this->asset->rename('new-test2'); 72 | 73 | expect($entry->fresh()->get('avatar'))->toEqual([ 74 | 'src' => [ 75 | 'test_container::new-test2.jpg' 76 | ], 77 | 'sm:src' => [ 78 | 'test_container::new-test2.jpg' 79 | ], 80 | ]); 81 | }); 82 | 83 | test('asset reference gets updated in replicator set after asset rename', function () { 84 | $blueprintContents = [ 85 | 'fields' => [ 86 | [ 87 | 'handle' => 'test_replicator_field', 88 | 'field' => [ 89 | 'collapse' => false, 90 | 'previews' => true, 91 | 'sets' => [ 92 | 'new_test_set' => [ 93 | 'display' => 'New Test Set', 94 | 'fields' => [ 95 | [ 96 | 'handle' => 'responsive_test_replicator', 97 | 'field' => $this->responsiveFieldConfiguration, 98 | ], 99 | ], 100 | ], 101 | ], 102 | 'display' => 'Test Replicator Field', 103 | 'type' => 'replicator', 104 | 'icon' => 'replicator', 105 | 'listable' => 'hidden', 106 | 'instructions_position' => 'above', 107 | 'visibility' => 'visible', 108 | ], 109 | ] 110 | ] 111 | ]; 112 | 113 | $entryData = [ 114 | 'test_replicator_field' => [ 115 | [ 116 | 'responsive_test_replicator' => [ 117 | 'src' => [ 118 | 'test_container::test.jpg' 119 | ], 120 | ], 121 | 'type' => 'new_test_set', 122 | 'enabled' => true, 123 | ], 124 | ], 125 | ]; 126 | 127 | $entry = test()->createDummyCollectionEntry($blueprintContents, $entryData); 128 | 129 | expect( 130 | Arr::get($entry->get('test_replicator_field'), '0.responsive_test_replicator.src.0') 131 | )->toEqual('test_container::test.jpg'); 132 | 133 | $this->asset->rename('new-test2'); 134 | 135 | expect( 136 | Arr::get($entry->fresh()->get('test_replicator_field'), '0.responsive_test_replicator.src.0') 137 | )->toEqual('test_container::new-test2.jpg'); 138 | }); 139 | 140 | test('asset reference gets removed after asset deletion', function () { 141 | $entry = test()->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ 142 | 'avatar' => [ 143 | 'src' => 'test_container::test.jpg', 144 | 'md:src' => 'test_container::test.jpg', 145 | 'ratio' => '16/9', 146 | 'md:ratio' => '16/9', 147 | 'lg:src' => [ 148 | 'test_container::test.jpg' 149 | ], 150 | ], 151 | ]); 152 | 153 | expect( 154 | Arr::get($entry->get('avatar'), 'src') 155 | )->toEqual('test_container::test.jpg'); 156 | 157 | $this->asset->delete(); 158 | 159 | expect($entry->fresh()->data()->get('avatar'))->not->toHaveKey('src') 160 | ->and($entry->fresh()->data()->get('avatar'))->not->toHaveKey('md:src') 161 | ->and(Arr::get($entry->fresh()->data()->get('avatar'), 'lg:src'))->toBeEmpty() 162 | ->and(Arr::get($entry->fresh()->data()->get('avatar'), 'ratio'))->toEqual('16/9'); 163 | }); 164 | 165 | test('asset reference stays unchanged after asset deletion when reference updating is off', function () { 166 | config()->set('statamic.system.update_references', false); 167 | // Set up environment again because listeners in UpdateResponsiveReferences@subscribe depend on config value 168 | $this->setUp(); 169 | // Re-upload because setUp() deletes asset that we uploaded in beforeEach() 170 | $this->asset = $this->uploadTestImageToTestContainer(); 171 | 172 | $entry = test()->createDummyCollectionEntry($this->entryBlueprintWithSingleResponsiveField, [ 173 | 'avatar' => [ 174 | 'src' => 'test_container::test.jpg', 175 | ], 176 | ]); 177 | 178 | expect( 179 | Arr::get($entry->get('avatar'), 'src') 180 | )->toEqual('test_container::test.jpg'); 181 | 182 | $this->asset->delete(); 183 | 184 | expect( 185 | Arr::get($entry->fresh()->get('avatar'), 'src') 186 | )->toEqual('test_container::test.jpg'); 187 | }); 188 | -------------------------------------------------------------------------------- /tests/Feature/AssetUploadedListenerTest.php: -------------------------------------------------------------------------------- 1 | set('statamic.assets.image_manipulation.cache', true); 9 | 10 | $this->asset = test()->uploadTestImageToTestContainer(); 11 | 12 | $pushedJobs = Queue::pushed(GenerateGlideImageJob::class); 13 | 14 | $noFormatJobCount = $pushedJobs->filter(function ($job) { 15 | return !isset($job->getParams()['fm']); 16 | })->count(); 17 | 18 | $webpFormatJobCount = $pushedJobs->filter(function ($job) { 19 | return isset($job->getParams()['fm']) && $job->getParams()['fm'] === 'webp'; 20 | })->count(); 21 | 22 | expect($noFormatJobCount)->toBe(3); 23 | expect($webpFormatJobCount)->toBe(3); 24 | 25 | Queue::assertPushed(GenerateGlideImageJob::class, 6); 26 | }); 27 | 28 | test('jobs are not generated when image manipulation cache is disabled', function () { 29 | Queue::fake(); 30 | config()->set('statamic.assets.image_manipulation.cache', false); 31 | 32 | $this->asset = test()->uploadTestImageToTestContainer(); 33 | 34 | Queue::assertPushed(GenerateGlideImageJob::class, 0); 35 | }); 36 | 37 | test('jobs are not generated when generate on upload is disabled', function () { 38 | Queue::fake(); 39 | config()->set('statamic.assets.image_manipulation.cache', true); 40 | config()->set('statamic.responsive-images.generate_on_upload', false); 41 | 42 | $this->asset = test()->uploadTestImageToTestContainer(); 43 | 44 | Queue::assertPushed(GenerateGlideImageJob::class, 0); 45 | }); 46 | 47 | test('jobs are not generated when uploading svg asset', function () { 48 | Queue::fake(); 49 | config()->set('statamic.assets.image_manipulation.cache', true); 50 | 51 | $this->asset = test()->uploadTestImageToTestContainer(test()->getTestSvg(), 'test.svg'); 52 | 53 | Queue::assertPushed(GenerateGlideImageJob::class, 0); 54 | }); -------------------------------------------------------------------------------- /tests/Feature/BreakpointTest.php: -------------------------------------------------------------------------------- 1 | delete('*'); 16 | $this->asset = test()->uploadTestImageToTestContainer(); 17 | Stache::clear(); 18 | }); 19 | 20 | it('generates placeholder data url when toggling cache form on to off', function () { 21 | /** 22 | * Clear regular cache and both Glide path cache storages 23 | * @see: https://statamic.dev/image-manipulation#path-cache-store 24 | */ 25 | Config::set('statamic.assets.image_manipulation.cache', false); 26 | $this->artisan(GlideClear::class); 27 | Config::set('statamic.assets.image_manipulation.cache', true); 28 | $this->artisan(GlideClear::class); 29 | 30 | // Glide server has already initialized in service container, we clear it so the cache config value gets read. 31 | App::forgetInstance(\League\Glide\Server::class); 32 | 33 | $cacheDiskPathBefore = \Statamic\Facades\Glide::cacheDisk()->getConfig()['root']; 34 | 35 | // Generate placeholder 36 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 37 | $firstPlaceholder = $breakpoint->placeholderSrc(); 38 | 39 | /** 40 | * We use Blink cache for placeholder generation that we need to clear just in case 41 | * @see https://statamic.dev/extending/blink-cache 42 | * @see Breakpoint::placeholder() 43 | */ 44 | Blink::store()->flush(); 45 | 46 | Config::set('statamic.assets.image_manipulation.cache', false); 47 | 48 | // Once again, because we are running in the same session, we need Glide server instance to be forgotten 49 | // so that it uses different Filesystem that depends on the statamic.assets.image_manipulation.cache value 50 | App::forgetInstance(\League\Glide\Server::class); 51 | 52 | $cacheDiskPathAfter = \Statamic\Facades\Glide::cacheDisk()->getConfig()['root']; 53 | 54 | // Generate placeholder again 55 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 56 | $secondPlaceholder = $breakpoint->placeholderSrc(); 57 | 58 | expect($secondPlaceholder)->toEqual($firstPlaceholder) 59 | ->and($cacheDiskPathAfter)->not->toEqual($cacheDiskPathBefore); 60 | }); 61 | 62 | it("doesn't crash when the placeholder image cannot be read", function () { 63 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 64 | 65 | // Generate placeholder to trigger caching 66 | $breakpoint->placeholderSrc(); 67 | 68 | // Forget cached files 69 | $pathPrefix = \Statamic\Imaging\ImageGenerator::assetCachePathPrefix($this->asset); 70 | 71 | \Statamic\Facades\Glide::server()->deleteCache($pathPrefix.'/'.$this->asset->path()); 72 | 73 | Blink::store()->flush(); 74 | 75 | // Generate new placeholder 76 | $breakpoint->placeholderSrc(); 77 | })->expectNotToPerformAssertions(); -------------------------------------------------------------------------------- /tests/Feature/DimensionCalculatorTest.php: -------------------------------------------------------------------------------- 1 | createMock(Asset::class); 17 | $stubbedAsset->method('size')->willReturn($fileSize); 18 | $stubbedAsset->method('width')->willReturn($width); 19 | $stubbedAsset->method('height')->willReturn($height); 20 | return $stubbedAsset; 21 | } 22 | 23 | function getWidths(Asset $asset, Breakpoint $breakpoint): array 24 | { 25 | $source = new Source($breakpoint); 26 | 27 | return app(DimensionCalculator::class) 28 | ->calculateForBreakpoint($source) 29 | ->map(function ($dimension) { 30 | return $dimension->width; 31 | }) 32 | ->toArray(); 33 | } 34 | 35 | it('can calculate the optimized widths from an asset', function () { 36 | Storage::fake('public'); 37 | 38 | $asset = test()->uploadTestImageToTestContainer(); 39 | 40 | $breakpoint = new Breakpoint($asset, 'default', 0, []); 41 | 42 | $widths = getWidths($asset, $breakpoint); 43 | 44 | expect($widths)->toEqual([ 45 | 0 => 340, 46 | 1 => 284, 47 | 2 => 237, 48 | ]); 49 | 50 | $smallAsset = test()->uploadTestImageToTestContainer(test()->getSmallTestJpg()); 51 | 52 | $breakpoint = new Breakpoint($smallAsset, 'default', 0, []); 53 | 54 | $widths = getWidths($smallAsset, $breakpoint); 55 | 56 | expect($widths)->toEqual([ 57 | 0 => 150, 58 | ]); 59 | }); 60 | 61 | it('can calculate the optimized widths for different dimensions', function () { 62 | $stubbedAsset = stubAsset(300, 200, 300 * 1024); 63 | $breakpoint = new Breakpoint($stubbedAsset, 'default', 0, []); 64 | 65 | $widths = getWidths($stubbedAsset, $breakpoint); 66 | 67 | expect($widths)->toEqual([ 68 | 0 => 300, 69 | 1 => 250, 70 | 2 => 210, 71 | 3 => 175, 72 | 4 => 147, 73 | 5 => 122, 74 | 6 => 102, 75 | 7 => 86, 76 | 8 => 72, 77 | 9 => 60, 78 | ]); 79 | 80 | $stubbedAsset = stubAsset(2400, 1800, 3000 * 1024); 81 | $breakpoint = new Breakpoint($stubbedAsset, 'default', 0, []); 82 | 83 | $widths = getWidths($stubbedAsset, $breakpoint); 84 | 85 | expect($widths)->toEqual([ 86 | 0 => 2400, 87 | 1 => 2007, 88 | 2 => 1680, 89 | 3 => 1405, 90 | 4 => 1176, 91 | 5 => 983, 92 | 6 => 823, 93 | 7 => 688, 94 | 8 => 576, 95 | 9 => 482, 96 | 10 => 403, 97 | 11 => 337, 98 | 12 => 282, 99 | 13 => 236, 100 | 14 => 197, 101 | 15 => 165, 102 | ]); 103 | 104 | $stubbedAsset = stubAsset(8200, 5500, 12000 * 1024); 105 | $breakpoint = new Breakpoint($stubbedAsset, 'default', 0, []); 106 | 107 | $widths = getWidths($stubbedAsset, $breakpoint); 108 | 109 | expect($widths)->toEqual([ 110 | 0 => 8200, 111 | 1 => 6860, 112 | 2 => 5740, 113 | 3 => 4802, 114 | 4 => 4017, 115 | 5 => 3361, 116 | 6 => 2812, 117 | 7 => 2353, 118 | 8 => 1968, 119 | 9 => 1647, 120 | 10 => 1378, 121 | 11 => 1153, 122 | 12 => 964, 123 | 13 => 807, 124 | 14 => 675, 125 | 15 => 565, 126 | 16 => 472, 127 | 17 => 395, 128 | 18 => 330, 129 | 19 => 276, 130 | ]); 131 | }); 132 | 133 | it('can calculate the optimized widths for different dimensions with a custom threshold', function () { 134 | config()->set('statamic.responsive-images.dimension_calculator_threshold', 0.25); 135 | 136 | $stubbedAsset = stubAsset(2400, 1800, 3000 * 1024); 137 | $breakpoint = new Breakpoint($stubbedAsset, 'default', 0, []); 138 | 139 | $widths = getWidths($stubbedAsset, $breakpoint); 140 | 141 | expect($widths)->toEqual([ 142 | 0 => 2400, 143 | 1 => 1200, 144 | 2 => 600, 145 | 3 => 300, 146 | 4 => 150, 147 | ]); 148 | }); 149 | 150 | it('filters out widths to be less than max width specified in config', function() { 151 | config()->set('statamic.responsive-images.max_width', 300); 152 | 153 | $asset = test()->uploadTestImageToTestContainer(); 154 | 155 | $breakpoint = new Breakpoint($asset, 'default', 0, []); 156 | 157 | expect(getWidths($asset, $breakpoint))->toEqualCanonicalizing([237, 284]); 158 | }); 159 | 160 | it('filters out widths to be less than max width specified in glide width param', function() { 161 | $asset = test()->uploadTestImageToTestContainer(); 162 | 163 | $breakpoint = new Breakpoint($asset, 'default', 0, ['glide:width' => 300]); 164 | 165 | expect(getWidths($asset, $breakpoint))->toEqualCanonicalizing([237, 284]); 166 | }); 167 | 168 | test('max width from glide width param takes precedence over config when filtering widths', function() { 169 | config()->set('statamic.responsive-images.max_width', 250); 170 | 171 | $asset = test()->uploadTestImageToTestContainer(); 172 | 173 | $breakpoint = new Breakpoint($asset, 'default', 0, ['glide:width' => 300]); 174 | 175 | expect(getWidths($asset, $breakpoint))->toEqualCanonicalizing([237, 284]); 176 | }); 177 | 178 | it('returns one dimension with equal width of max width when all dimensions have been filtered out', function () { 179 | config()->set('statamic.responsive-images.max_width', 25); 180 | 181 | $asset = test()->uploadTestImageToTestContainer(); 182 | 183 | $breakpoint = new Breakpoint($asset, 'default', 0, []); 184 | 185 | $widths = getWidths($asset, $breakpoint); 186 | 187 | expect($widths)->toHaveCount(1); 188 | expect($widths[0])->toBe(25); 189 | }); 190 | 191 | it('uses custom dimension calculator', function () { 192 | $this->mock(DimensionCalculator::class, function ($mock) { 193 | $mock->shouldReceive('calculateForBreakpoint')->andReturn(collect([new Dimensions(100, 100)])); 194 | $mock->shouldReceive('calculateForImgTag')->andReturn(new Dimensions(100, 100)); 195 | $mock->shouldReceive('calculateForPlaceholder')->andReturn(new Dimensions(100, 100)); 196 | }); 197 | 198 | $asset = test()->uploadTestImageToTestContainer(); 199 | 200 | $responsive = new Responsive($asset, new Parameters(['placeholder' => false, 'webp' => false])); 201 | 202 | expect( 203 | $responsive->defaultBreakpoint()->sources()->first()->toArray()['srcSet'] 204 | )->toContain('w=100&h=100'); 205 | }); 206 | 207 | test('ResponsiveDimensionCalculator returns correct height for img tag without specifying ratio', function () { 208 | $asset = test()->uploadTestImageToTestContainer(); 209 | 210 | $breakpoint = new Breakpoint($asset, 'default', 0, []); 211 | 212 | $calculatedDimensions = app(DimensionCalculator::class)->calculateForImgTag($breakpoint); 213 | 214 | expect($calculatedDimensions->getHeight())->toEqual(280); 215 | }); 216 | 217 | test('ResponsiveDimensionCalculator returns correct height for img tag when specifying ratio', function () { 218 | $asset = test()->uploadTestImageToTestContainer(); 219 | 220 | $breakpoint = new Breakpoint($asset, 'default', 0, ['ratio' => 2 / 1]); 221 | 222 | $calculatedDimensions = app(DimensionCalculator::class)->calculateForImgTag($breakpoint); 223 | 224 | expect($calculatedDimensions->getHeight())->toEqual(170); 225 | }); 226 | -------------------------------------------------------------------------------- /tests/Feature/GenerateResponsiveVersionsCommandTest.php: -------------------------------------------------------------------------------- 1 | asset = $this->uploadTestImageToTestContainer(); 10 | Stache::clear(); 11 | }); 12 | 13 | it('requires caching to be set') 14 | ->tap(fn () => config()->set('statamic.assets.image_manipulation.cache', false)) 15 | ->artisan(GenerateResponsiveVersionsCommand::class) 16 | ->expectsOutput('Caching is not enabled for image manipulations, generating them will have no benefit.') 17 | ->assertExitCode(0); 18 | 19 | it('dispatches jobs for all assets that are images', function () { 20 | Queue::fake(); 21 | config()->set('statamic.assets.image_manipulation.cache', true); 22 | 23 | $this->artisan(GenerateResponsiveVersionsCommand::class) 24 | ->expectsOutput("Generating responsive image versions for 1 assets.") 25 | ->assertExitCode(0); 26 | 27 | $pushedJobs = Queue::pushed(GenerateGlideImageJob::class); 28 | 29 | $noFormatJobCount = $pushedJobs->filter(function ($job) { 30 | return !isset($job->getParams()['fm']); 31 | })->count(); 32 | 33 | $webpFormatJobCount = $pushedJobs->filter(function ($job) { 34 | return isset($job->getParams()['fm']) && $job->getParams()['fm'] === 'webp'; 35 | })->count(); 36 | 37 | expect($noFormatJobCount)->toBe(4); 38 | expect($webpFormatJobCount)->toBe(3); 39 | Queue::assertPushed(GenerateGlideImageJob::class, 7); 40 | }); 41 | 42 | it('dispatches less jobs when webp is disabled', function () { 43 | Queue::fake(); 44 | config()->set('statamic.assets.image_manipulation.cache', true); 45 | config()->set('statamic.responsive-images.webp', false); 46 | 47 | $this->artisan(GenerateResponsiveVersionsCommand::class) 48 | ->expectsOutput("Generating responsive image versions for 1 assets.") 49 | ->assertExitCode(0); 50 | 51 | $pushedJobs = Queue::pushed(GenerateGlideImageJob::class); 52 | 53 | $noFormatJobCount = $pushedJobs->filter(function ($job) { 54 | return !isset($job->getParams()['fm']); 55 | })->count(); 56 | 57 | $webpFormatJobCount = $pushedJobs->filter(function ($job) { 58 | return isset($job->getParams()['fm']) && $job->getParams()['fm'] === 'webp'; 59 | })->count(); 60 | 61 | expect($noFormatJobCount)->toBe(4); 62 | expect($webpFormatJobCount)->toBe(0); 63 | Queue::assertPushed(GenerateGlideImageJob::class, 4); 64 | }); 65 | 66 | it('dispatches more jobs when avif and webp is enabled', function () { 67 | Queue::fake(); 68 | config()->set('statamic.assets.image_manipulation.cache', true); 69 | config()->set('statamic.responsive-images.webp', true); 70 | config()->set('statamic.responsive-images.avif', true); 71 | 72 | $this->artisan(GenerateResponsiveVersionsCommand::class) 73 | ->expectsOutput("Generating responsive image versions for 1 assets.") 74 | ->assertExitCode(0); 75 | 76 | $pushedJobs = Queue::pushed(GenerateGlideImageJob::class); 77 | 78 | $noFormatJobCount = $pushedJobs->filter(function ($job) { 79 | return !isset($job->getParams()['fm']); 80 | })->count(); 81 | 82 | $webpFormatJobCount = $pushedJobs->filter(function ($job) { 83 | return isset($job->getParams()['fm']) && $job->getParams()['fm'] === 'webp'; 84 | })->count(); 85 | 86 | $avifFormatJobCount = $pushedJobs->filter(function ($job) { 87 | return isset($job->getParams()['fm']) && $job->getParams()['fm'] === 'avif'; 88 | })->count(); 89 | 90 | expect($noFormatJobCount)->toBe(4); 91 | expect($webpFormatJobCount)->toBe(3); 92 | expect($avifFormatJobCount)->toBe(3); 93 | Queue::assertPushed(GenerateGlideImageJob::class, 10); 94 | }); 95 | 96 | it('can skip excluded containers', function () { 97 | Queue::fake(); 98 | 99 | config()->set('statamic.assets.image_manipulation.cache', true); 100 | config()->set('statamic.responsive-images.excluded_containers', ['test_container']); 101 | 102 | $this 103 | ->artisan(GenerateResponsiveVersionsCommand::class) 104 | ->expectsOutput("Generating responsive image versions for 0 assets.") 105 | ->assertExitCode(0); 106 | 107 | Queue::assertNotPushed(GenerateGlideImageJob::class); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/Feature/ResponsiveTest.php: -------------------------------------------------------------------------------- 1 | asset = $this->uploadTestImageToTestContainer(); 18 | Stache::clear(); 19 | }); 20 | 21 | it('can initialize using an asset', function () { 22 | $responsive = new Responsive($this->asset, new Parameters()); 23 | 24 | expect($responsive->asset->id())->toEqual($this->asset->id()); 25 | }); 26 | 27 | it('throws on a zero width or height image', function () { 28 | $file = new UploadedFile($this->getZeroWidthTestSvg(), 'zerowidthtest.svg'); 29 | $path = ltrim('/' . $file->getClientOriginalName(), '/'); 30 | $asset = $this->assetContainer->makeAsset($path)->upload($file); 31 | 32 | new Responsive($asset, new Parameters()); 33 | })->throws(InvalidAssetException::class); 34 | 35 | it('can initialize using the assets path', function () { 36 | $responsive = new Responsive($this->asset->resolvedPath(), new Parameters()); 37 | 38 | expect($responsive->asset->id())->toEqual($this->asset->id()); 39 | }); 40 | 41 | it('can initialize using the assets url', function () { 42 | $responsive = new Responsive($this->asset->url(), new Parameters()); 43 | 44 | expect($responsive->asset->id())->toEqual($this->asset->id()); 45 | }); 46 | 47 | it('can initialize using an argument asset', function () { 48 | $responsive = new Responsive($this->asset->toAugmentedArray(), new Parameters()); 49 | 50 | expect($responsive->asset->id())->toEqual($this->asset->id()); 51 | }); 52 | 53 | it('can initialize using a value', function () { 54 | $value = new Value($this->asset); 55 | 56 | $responsive = new Responsive($value, new Parameters()); 57 | 58 | expect($responsive->asset->id())->toEqual($this->asset->id()); 59 | }); 60 | 61 | it('can initialize using a collection value', function () { 62 | $value = new Value(new Collection([$this->asset])); 63 | 64 | $responsive = new Responsive($value, new Parameters()); 65 | 66 | expect($responsive->asset->id())->toEqual($this->asset->id()); 67 | }); 68 | 69 | it('can initialize using a query builder', function () { 70 | $value = new Value(Asset::query()->where('container', $this->assetContainer->handle())); 71 | 72 | $responsive = new Responsive($value, new Parameters()); 73 | 74 | expect($responsive->asset->id())->toEqual($this->asset->id()); 75 | }); 76 | 77 | it('can initialize using a string value', function () { 78 | $value = new Value($this->asset->resolvedPath(), 'url'); 79 | 80 | $responsive = new Responsive($value, new Parameters()); 81 | 82 | expect($responsive->asset->id())->toEqual($this->asset->id()); 83 | }); 84 | 85 | it('can initialize using a string url value', function () { 86 | $value = new Value($this->asset->url(), 'url'); 87 | 88 | $responsive = new Responsive($value, new Parameters()); 89 | 90 | expect($responsive->asset->id())->toEqual($this->asset->id()); 91 | }); 92 | 93 | it('can initialize using values from the fieldtype', function () { 94 | $fieldtype = new ResponsiveFieldtype(); 95 | 96 | $field = new Field('image', [ 97 | 'breakpoints' => [], 98 | 'use_breakpoints' => false, 99 | 'container' => $this->asset->containerHandle(), 100 | 'allow_uploads' => true, 101 | 'allow_ratio' => true, 102 | 'allow_fit' => true, 103 | ]); 104 | $fieldtype->setField($field); 105 | 106 | $value = new Value([ 107 | 'src' => $this->asset->path(), 108 | ], 'image', $fieldtype); 109 | 110 | $responsive = new Responsive($value, new Parameters()); 111 | 112 | expect($responsive->asset->id())->toEqual($this->asset->id()); 113 | }); 114 | 115 | it("throws if it can't find an asset", function () { 116 | new Responsive('doesnt-exist', new Parameters()); 117 | })->throws(AssetNotFoundException::class); 118 | 119 | it('can generate a set of breakpoints for an asset', function () { 120 | $responsive = new Responsive($this->asset, new Parameters([ 121 | 'ratio' => 1, 122 | 'lg:ratio' => 1.5, 123 | ])); 124 | 125 | expect( 126 | $responsive->breakPoints()->toArray() 127 | )->toEqual([ 128 | ['asset' => $this->asset, 'label' => 'lg', 'minWidth' => 1024, 'parameters' => ['ratio' => 1.5], 'widthUnit' => 'px'], 129 | ['asset' => $this->asset, 'label' => 'default', 'minWidth' => 0, 'parameters' => ['ratio' => 1], 'widthUnit' => 'px'], 130 | ]); 131 | }); 132 | 133 | it('can parse a basic fraction', function () { 134 | $responsive = new Responsive($this->asset, new Parameters([ 135 | 'ratio' => 1, 136 | 'lg:ratio' => '1 / 2', 137 | ])); 138 | 139 | expect( 140 | $responsive->breakPoints()->toArray() 141 | )->toEqual([ 142 | ['asset' => $this->asset, 'label' => 'lg', 'minWidth' => 1024, 'parameters' => ['ratio' => 1 / 2], 'widthUnit' => 'px'], 143 | ['asset' => $this->asset, 'label' => 'default', 'minWidth' => 0, 'parameters' => ['ratio' => 1.0], 'widthUnit' => 'px'], 144 | ]); 145 | }); 146 | 147 | it("uses the default asset ratio if a default isn't provided", function () { 148 | $responsive = new Responsive($this->asset, new Parameters([ 149 | 'lg:ratio' => 1.5, 150 | ])); 151 | 152 | expect( 153 | $responsive->breakPoints()->toArray() 154 | )->toEqual([ 155 | ['asset' => $this->asset, 'label' => 'lg', 'minWidth' => 1024, 'parameters' => ['ratio' => 1.5], 'widthUnit' => 'px'], 156 | ['asset' => $this->asset, 'label' => 'default', 'minWidth' => 0, 'parameters' => ['ratio' => 1.2142857142857142], 'widthUnit' => 'px'], 157 | ]); 158 | }); 159 | 160 | test('unknown breakpoints get ignored', function () { 161 | $responsive = new Responsive($this->asset, new Parameters([ 162 | 'lg:ratio' => 1.5, 163 | 'bla:ratio' => 2, 164 | ])); 165 | 166 | expect( 167 | $responsive->breakPoints()->toArray() 168 | )->toEqual([ 169 | ['asset' => $this->asset, 'label' => 'lg', 'minWidth' => 1024, 'parameters' => ['ratio' => 1.5, 'bla:ratio' => 2], 'widthUnit' => 'px'], 170 | ['asset' => $this->asset, 'label' => 'default', 'minWidth' => 0, 'parameters' => ['bla:ratio' => 2], 'widthUnit' => 'px'], 171 | ]); 172 | }); 173 | 174 | it('can retrieve the default breakpoint', function () { 175 | $responsive = new Responsive($this->asset, new Parameters([ 176 | 'lg:ratio' => 1.5, 177 | ])); 178 | 179 | expect( 180 | $responsive->defaultBreakpoint()->toArray() 181 | )->toEqual([ 182 | 'asset' => $this->asset, 183 | 'label' => 'default', 184 | 'minWidth' => 0, 185 | 'parameters' => [ 186 | 'ratio' => 1.2142857142857142, 187 | ], 188 | 'widthUnit' => 'px' 189 | ]); 190 | }); 191 | 192 | test('can toggle formats between all breakpoints', function () { 193 | config()->set('statamic.responsive-images.webp', false); 194 | config()->set('statamic.responsive-images.avif', false); 195 | 196 | $responsive = new Responsive($this->asset, new Parameters([ 197 | 'ratio' => 1, 198 | 'avif' => true, 199 | 'sm:ratio' => 1, 200 | 'sm:avif' => false, 201 | 'md:ratio' => 1, 202 | 'md:avif' => true, 203 | 'lg:ratio' => 1, 204 | 'lg:avif' => false, 205 | 'xl:ratio' => 1, 206 | 'xl:avif' => true, 207 | '2xl:ratio' => 1, 208 | ])); 209 | 210 | $breakpointsWithSources = $responsive->breakPoints()->map(function (Breakpoint $breakpoint) { 211 | $breakpointArr = $breakpoint->toArray(); 212 | $breakpointArr['sources'] = collect($breakpoint->sources()->toArray()); 213 | return $breakpointArr; 214 | }); 215 | 216 | 217 | $avifPerBreakpoint = [ 218 | 'default' => true, 219 | 'sm' => false, 220 | 'md' => true, 221 | 'lg' => false, 222 | 'xl' => true, 223 | '2xl' => true 224 | ]; 225 | 226 | foreach ($avifPerBreakpoint as $breakpointLabel => $isAvifEnabled) { 227 | expect( 228 | $breakpointsWithSources 229 | ->where('label', $breakpointLabel) 230 | ->first()['sources'] 231 | ->where('format', 'avif') 232 | ->count() === 1 233 | )->toBe($isAvifEnabled); 234 | } 235 | }); 236 | 237 | test('can toggle placeholder in srcsets between all breakpoints', function () { 238 | config()->set('statamic.responsive-images.webp', false); 239 | config()->set('statamic.responsive-images.avif', false); 240 | 241 | $responsive = new Responsive($this->asset, new Parameters([ 242 | 'ratio' => 1, 243 | 'placeholder' => true, 244 | 'sm:ratio' => 1, 245 | 'sm:placeholder' => false, 246 | 'md:ratio' => 1, 247 | 'md:placeholder' => true, 248 | 'lg:ratio' => 1, 249 | ])); 250 | 251 | $breakpointsWithSources = $responsive->breakPoints()->map(function (Breakpoint $breakpoint) { 252 | $breakpointArr = $breakpoint->toArray(); 253 | $breakpointArr['sources'] = collect($breakpoint->sources()->toArray()); 254 | return $breakpointArr; 255 | }); 256 | 257 | $avifPerBreakpoint = [ 258 | 'default' => true, 259 | 'sm' => false, 260 | 'md' => true, 261 | 'lg' => true, 262 | ]; 263 | 264 | foreach ($avifPerBreakpoint as $breakpointLabel => $isPlaceholderOutput) { 265 | $srcset = $breakpointsWithSources 266 | ->where('label', $breakpointLabel) 267 | ->first()['sources'] 268 | ->first()['srcSet']; 269 | 270 | preg_match('/data:image\/svg\+xml;base64,(.*) 32w/', $srcset, $svgMatches); 271 | 272 | expect(isset($svgMatches[1]))->toBe($isPlaceholderOutput); 273 | } 274 | }); -------------------------------------------------------------------------------- /tests/Feature/SourceTest.php: -------------------------------------------------------------------------------- 1 | delete('*'); 17 | $this->asset = $this->uploadTestImageToTestContainer(); 18 | Stache::clear(); 19 | }); 20 | 21 | it('can build an image', function () { 22 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 23 | $source = new Source($breakpoint); 24 | 25 | expect( 26 | $source->buildImageJob(100)->handle() 27 | )->toContain('?q=90&fit=crop-50-50&w=100'); 28 | }); 29 | 30 | it('can build an image with parameters', function () { 31 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 32 | $source = new Source($breakpoint); 33 | 34 | expect( 35 | $source->buildImageJob(100, null,'webp')->handle() 36 | )->toContain('?fm=webp&q=90&fit=crop-50-50&w=100'); 37 | }); 38 | 39 | it("doesn't crash with a `null` ratio", function () { 40 | $breakpoint = new Breakpoint($this->asset, 'default', 0, [ 41 | 'ratio' => null, 42 | ]); 43 | 44 | $source = new Source($breakpoint); 45 | 46 | $source->getSrcSet(); 47 | })->expectNotToPerformAssertions(); 48 | 49 | it('does not generate image url with crop focus when auto crop is disabled', function () { 50 | config()->set('statamic.assets.auto_crop', false); 51 | 52 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 53 | 54 | $source = new Source($breakpoint); 55 | 56 | expect( 57 | $source->buildImageJob(100, 100, 'webp')->handle() 58 | )->toContain('?fm=webp&q=90&w=100&h=100',); 59 | }); 60 | 61 | it('does not generate image url with crop focus when a `glide:fit` param is provided', function () { 62 | $breakpoint = new Breakpoint($this->asset, 'default', 0, ['glide:fit' => 'fill']); 63 | 64 | $source = new Source($breakpoint); 65 | 66 | expect( 67 | $source->buildImageJob(100, 100, 'webp')->handle() 68 | )->toContain('?fit=fill&fm=webp&q=90&w=100&h=100'); 69 | }); 70 | 71 | it('uses crop focus value from assets metadata', function () { 72 | $metaDataPath = $this->asset->metaPath(); 73 | 74 | // Get original metadata that was generated when the asset was uploaded 75 | $metaData = YAML::file( 76 | Storage::disk('assets')->path($metaDataPath) 77 | )->parse(); 78 | 79 | // Set some focus value 80 | $metaData['data'] = [ 81 | 'focus' => '29-71-3.6' 82 | ]; 83 | 84 | // Dump the YAML data back into the metadata yaml file 85 | Storage::disk('assets')->put($metaDataPath, YAML::dump($metaData)); 86 | 87 | // Flush the cache so Statamic is not using outdated metadata 88 | Cache::flush(); 89 | 90 | // Fetch the asset from the container again, triggering metadata hydration 91 | $asset = $this->assetContainer->asset('test.jpg'); 92 | 93 | $breakpoint = new Breakpoint($asset, 'default', 0, []); 94 | $source = new Source($breakpoint); 95 | 96 | expect( 97 | $source->buildImageJob(100)->handle() 98 | )->toContain('?q=90&fit=crop-29-71-3.6&w=100'); 99 | }); 100 | 101 | it('generates absolute url when using custom filesystem with custom url for glide cache', function () { 102 | config(['filesystems.disks.absolute_test' => [ 103 | 'driver' => 'local', 104 | 'root' => __DIR__ . '/tmp', 105 | 'url' => 'https://responsive.test/test', 106 | ]]); 107 | 108 | config([ 109 | 'statamic.assets.image_manipulation.cache' => 'absolute_test', 110 | ]); 111 | 112 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 113 | $source = new Source($breakpoint); 114 | 115 | expect( 116 | $source->buildImageJob(100)->handle() 117 | )->toStartWith('https://responsive.test/'); 118 | }); 119 | 120 | it('generates absolute url when force enabled through config', function () { 121 | config([ 122 | 'statamic.responsive-images.force_absolute_urls' => true, 123 | ]); 124 | 125 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 126 | $source = new Source($breakpoint); 127 | 128 | expect( 129 | $source->buildImageJob(100)->handle() 130 | )->toStartWith('http://localhost/'); 131 | }); 132 | 133 | it('generates relative url when absolute urls are disabled through config', function () { 134 | config([ 135 | 'statamic.responsive-images.force_absolute_urls' => false, 136 | ]); 137 | 138 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 139 | $source = new Source($breakpoint); 140 | 141 | expect( 142 | $source->buildImageJob(100)->handle() 143 | )->toStartWith('/img/asset/'); 144 | }); 145 | 146 | it('determines mimetype from pre-determined source formats', function () { 147 | config()->set('statamic.responsive-images.avif', true); 148 | config()->set('statamic.responsive-images.webp', true); 149 | 150 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 151 | 152 | $expectedMimeTypes = [ 153 | 'webp' => 'image/webp', 154 | 'avif' => 'image/avif' 155 | ]; 156 | 157 | $breakpoint->sources()->filter(function ($source) { 158 | return $source->getFormat() !== 'original'; 159 | })->each(function ($source) use($expectedMimeTypes) { 160 | expect($source->getMimeType())->toBe($expectedMimeTypes[$source->getFormat()]); 161 | }); 162 | }); 163 | 164 | it('determines mimetype from asset for original source format', function () { 165 | $breakpoint = new Breakpoint($this->asset, 'default', 0, []); 166 | 167 | $source = $breakpoint->sources()->first(function ($source) { 168 | return $source->getFormat() === 'original'; 169 | }); 170 | 171 | expect($source->getMimeType())->toBe('image/jpeg'); 172 | }); -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('.'); 4 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | getTempDirectory()); 35 | 36 | $this->setUpTempTestFiles(); 37 | 38 | $this->artisan(GlideClear::class); 39 | 40 | config(['filesystems.disks.assets' => [ 41 | 'driver' => 'local', 42 | 'root' => $this->getTempDirectory('assets'), 43 | 'url' => '/test', 44 | ]]); 45 | 46 | config()->set('statamic.assets.image_manipulation.secure', false); 47 | 48 | /** @var \Statamic\Assets\AssetContainer $assetContainer */ 49 | $this->assetContainer = (new AssetContainer) 50 | ->handle('test_container') 51 | ->disk('assets') 52 | ->save(); 53 | } 54 | 55 | protected function tearDown(): void 56 | { 57 | File::deleteDirectory($this->getTempDirectory()); 58 | Stache::clear(); 59 | 60 | parent::tearDown(); 61 | } 62 | 63 | protected function getPackageProviders($app) 64 | { 65 | return [ 66 | \Rebing\GraphQL\GraphQLServiceProvider::class, 67 | \Statamic\Providers\StatamicServiceProvider::class, 68 | \Wilderborn\Partyline\ServiceProvider::class, 69 | \Spatie\ResponsiveImages\ServiceProvider::class, 70 | ]; 71 | } 72 | 73 | protected function getPackageAliases($app) 74 | { 75 | return [ 76 | 'Statamic' => Statamic::class, 77 | ]; 78 | } 79 | 80 | protected function getEnvironmentSetUp($app) 81 | { 82 | parent::getEnvironmentSetUp($app); 83 | 84 | $app->make(Manifest::class)->manifest = [ 85 | 'spatie/statamic-responsive-images' => [ 86 | 'id' => 'spatie/statamic-responsive-images', 87 | 'namespace' => 'Spatie\\ResponsiveImages', 88 | ], 89 | ]; 90 | } 91 | 92 | protected function resolveApplicationConfiguration($app) 93 | { 94 | parent::resolveApplicationConfiguration($app); 95 | 96 | $configs = [ 97 | 'assets', 98 | 'cp', 99 | 'forms', 100 | 'routes', 101 | 'static_caching', 102 | // 'sites', 103 | 'stache', 104 | 'system', 105 | 'users', 106 | ]; 107 | 108 | foreach ($configs as $config) { 109 | $app['config']->set("statamic.$config", require(__DIR__ . "/../vendor/statamic/cms/config/{$config}.php")); 110 | } 111 | 112 | // Setting the user repository to the default flat file system 113 | $app['config']->set('statamic.users.repository', 'file'); 114 | 115 | // Assume the pro edition within tests 116 | $app['config']->set('statamic.editions.pro', true); 117 | 118 | // Define config settings for all of our tests 119 | $app['config']->set("statamic.responsive-images", require(__DIR__ . "/../config/responsive-images.php")); 120 | 121 | $app['config']->set('statamic.assets.image_manipulation.driver', 'imagick'); 122 | 123 | $app['config']->set('statamic.graphql.enabled', true); 124 | $app['config']->set('statamic.graphql.cache', false); 125 | $app['config']->set('statamic.graphql.resources', [ 126 | 'collections' => true, 127 | 'assets' => true, 128 | ]); 129 | 130 | $app['config']->set('statamic.stache.stores.collections.directory', $this->getTempDirectory('/content/collections')); 131 | $app['config']->set('statamic.stache.stores.entries.directory', $this->getTempDirectory('/content/collections')); 132 | $app['config']->set('statamic.stache.stores.asset-containers.directory', $this->getTempDirectory( '/content/assets')); 133 | 134 | Statamic::booted(function () { 135 | Blueprint::setDirectory($this->getTempDirectory('/resources/blueprints')); 136 | }); 137 | } 138 | 139 | protected function setUpTempTestFiles() 140 | { 141 | $this->initializeDirectory(__DIR__ . '/TestSupport/tmp'); 142 | $this->initializeDirectory($this->getTestFilesDirectory()); 143 | File::copyDirectory(__DIR__ . '/TestSupport/TestFiles', $this->getTestFilesDirectory()); 144 | } 145 | 146 | protected function initializeDirectory($directory) 147 | { 148 | if (File::isDirectory($directory)) { 149 | File::deleteDirectory($directory); 150 | } 151 | 152 | File::makeDirectory($directory, 0755, true); 153 | } 154 | 155 | public function getTempDirectory($suffix = ''): string 156 | { 157 | return __DIR__ . '/TestSupport/tmp' . ($suffix == '' ? '' : '/' . $suffix); 158 | } 159 | 160 | public function getTestFilesDirectory($suffix = ''): string 161 | { 162 | return $this->getTempDirectory() . '/testfiles' . ($suffix == '' ? '' : '/' . $suffix); 163 | } 164 | 165 | public function getTestJpg(): string 166 | { 167 | return $this->getTestFilesDirectory('test.jpg'); 168 | } 169 | 170 | public function getSmallTestJpg(): string 171 | { 172 | return $this->getTestFilesDirectory('smallTest.jpg'); 173 | } 174 | 175 | public function getTestSvg(): string 176 | { 177 | return $this->getTestFilesDirectory('test.svg'); 178 | } 179 | 180 | public function getZeroWidthTestSvg(): string 181 | { 182 | return $this->getTestFilesDirectory('zerowidth.svg'); 183 | } 184 | 185 | public function getTestGif(): string 186 | { 187 | return $this->getTestFilesDirectory('hackerman.gif'); 188 | } 189 | 190 | public function setInBlueprints($namespace, $blueprintContents): void 191 | { 192 | $blueprint = tap(Blueprint::make('set-in-blueprints')->setContents($blueprintContents))->save(); 193 | 194 | Blueprint::shouldReceive('in')->with($namespace)->andReturn(collect([$blueprint])); 195 | } 196 | 197 | public function createDummyCollectionEntry($blueprintConfiguration, $entryData) 198 | { 199 | // Create collection 200 | $collection = tap(Collection::make('articles'))->save(); 201 | 202 | $blueprintContents = $blueprintConfiguration; 203 | 204 | // Create blueprint for collection 205 | $this->setInBlueprints('collections/articles', $blueprintContents); 206 | 207 | // Create entry in the collection 208 | return tap(Entry::make()->collection($collection)->data($entryData))->save(); 209 | } 210 | 211 | public function uploadTestImageToTestContainer(?string $testImagePath = null, ?string $filename = 'test.jpg') 212 | { 213 | if ($testImagePath === null) { 214 | $testImagePath = test()->getTestJpg(); 215 | } 216 | 217 | // Duplicate file because in Statamic 3.4 the source asset is deleted after upload 218 | $duplicateImagePath = preg_replace('/(\.[^.]+)$/', '-' . Carbon::now()->timestamp . '$1', $testImagePath); 219 | File::copy($testImagePath, $duplicateImagePath); 220 | 221 | $file = new UploadedFile($duplicateImagePath, $filename); 222 | $path = ltrim('/' . $file->getClientOriginalName(), '/'); 223 | return $this->assetContainer->makeAsset($path)->upload($file); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /tests/TestSupport/TestFiles/hackerman.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/statamic-responsive-images/fbf393499a91ba2a46d4c694940b1ebaeea22a73/tests/TestSupport/TestFiles/hackerman.gif -------------------------------------------------------------------------------- /tests/TestSupport/TestFiles/smallTest.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/statamic-responsive-images/fbf393499a91ba2a46d4c694940b1ebaeea22a73/tests/TestSupport/TestFiles/smallTest.jpg -------------------------------------------------------------------------------- /tests/TestSupport/TestFiles/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spatie/statamic-responsive-images/fbf393499a91ba2a46d4c694940b1ebaeea22a73/tests/TestSupport/TestFiles/test.jpg -------------------------------------------------------------------------------- /tests/TestSupport/TestFiles/test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SVG Simple Logo Tiny 5 | Designed for the SVG Logo Contest in 2006 by Harvey Rayner. It is available under the Creative Commons license for those who have an SVG product or who are using SVG on their site. 6 | 7 | 8 | Creative Commons License 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/TestSupport/TestFiles/zerowidth.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__snapshots__/GraphQLTest__querying_ResponsiveFieldType_field_resolves_it_to_data__1.yml: -------------------------------------------------------------------------------- 1 | title: 'Responsive Images addon is awesome' 2 | hero: 3 | breakpoints: [{ asset: { path: test.jpg }, label: default, minWidth: 0, widthUnit: px, ratio: 1.2142857142857, sources: [{ format: original, mimeType: image/jpeg, minWidth: 0, mediaWidthUnit: px, mediaString: null, srcSet: '/img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=237&h=195 237w, /img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=284&h=234 284w, /img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=340&h=280 340w' }] }] 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/GraphQLTest__ratio_is_outputted_if_using_ResponsiveDimensionCalculator__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "asset": { 4 | "responsive": [ 5 | { 6 | "ratio": 1.2142857142857142 7 | } 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/__snapshots__/GraphQLTest__responsive_field_accepts_responsive_fieldtype_data__1.yml: -------------------------------------------------------------------------------- 1 | title: 'Responsive Images addon is awesome' 2 | hero: 3 | responsive: [{ asset: { id: 'test_container::test.jpg' }, label: default, minWidth: 0, widthUnit: px, ratio: 1.2142857142857, sources: [{ format: original, mimeType: image/jpeg, minWidth: 0, mediaWidthUnit: px, mediaString: null, srcSet: '/img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=237&h=195 237w, /img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=284&h=234 284w, /img/asset/dGVzdF9jb250YWluZXIvdGVzdC5qcGc/test.jpg?q=90&fit=crop-50-50&w=340&h=280 340w' }] }] 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/GraphQLTest__responsive_field_returns_data__1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "asset": { 4 | "responsive": [ 5 | { 6 | "asset": { 7 | "id": "test_container::test.jpg" 8 | }, 9 | "label": "default", 10 | "minWidth": 0, 11 | "widthUnit": "px", 12 | "ratio": 1.2142857142857142, 13 | "sources": [ 14 | { 15 | "format": "original", 16 | "mimeType": "image\/jpeg", 17 | "minWidth": 0, 18 | "mediaWidthUnit": "px", 19 | "mediaString": null, 20 | "srcSet": "\/img\/asset\/dGVzdF9jb250YWluZXIvdGVzdC5qcGc\/test.jpg?q=90&fit=crop-50-50&w=237&h=195 237w, \/img\/asset\/dGVzdF9jb250YWluZXIvdGVzdC5qcGc\/test.jpg?q=90&fit=crop-50-50&w=284&h=234 284w, \/img\/asset\/dGVzdF9jb250YWluZXIvdGVzdC5qcGc\/test.jpg?q=90&fit=crop-50-50&w=340&h=280 340w" 21 | } 22 | ] 23 | } 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__format_quality_is_set_on_breakpoints__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | 32 | 33 | test.jpg 39 | 40 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_adds_custom_parameters_to_the_attribute_string__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | Some alt tag 34 | 35 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_can_add_custom_glide_parameters__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_can_render_a_responsive_image_with_the_directive__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_can_render_an_art_directed_image_as_array_with_the_directive__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_can_render_an_art_directed_image_with_the_directive__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_no_conversions_for_gifs__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | hackerman.gif 26 | 27 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_no_conversions_for_svgs__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | test.svg 26 | 27 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_in_webp_and_avif_formats__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | 32 | 33 | test.jpg 39 | 40 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_in_webp_and_avif_formats__2.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | 32 | 33 | test.jpg 39 | 40 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_with_breakpoint_parameters__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | 32 | 33 | 36 | 37 | test.jpg 43 | 44 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_with_breakpoints_without_webp__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_with_parameters__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_without_a_placeholder__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | test.jpg 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_generates_responsive_images_without_webp__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | test.jpg 31 | 32 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__it_uses_an_alt_field_on_the_asset__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | My asset alt tag 35 | 36 | -------------------------------------------------------------------------------- /tests/__snapshots__/ResponsiveTagTest__the_source_image_can_change_with_breakpoints__1.txt: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 24 | 25 | 28 | 29 | 32 | 33 | 36 | 37 | test.jpg 43 | 44 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require("laravel-mix"); 2 | 3 | mix.js("resources/js/responsive.js", "dist/js").vue(); 4 | mix.styles("resources/css/responsive.css", "dist/css/responsive.css"); 5 | --------------------------------------------------------------------------------