Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Flysystem <3 LiipImagineBundle

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Flysystem is killing it for us! But... there's a problem hiding... like, a it-won't-actually-work-in-the-real-world kind of problem. Yikes! In theory, we should be able to go into the oneup_flysystem.yaml file right now, change the adapter to S3 and everything would work. In theory.

How LiipImagineBundle Finds Images

The problem is LiipImagineBundle. Open up templates/article/homepage.html.twig: we call uploaded_asset(), pass that article.imagePath and that value is passed into imagine_filter. So basically, a string like uploads/article_image/something.jpg is passed to the filter.

The problem? By default, LiipImagineBundle reads the source image file from the filesystem. If we refactored to use S3... well... imagine would be looking in the wrong place!

You can see this by running:

php bin/console debug:config liip_imagine

This is the current config for this bundle, which includes all of its default values. Near the bottom, see that "loaders" section? The "loader" is the piece that's responsible for reading the source image. It defaults to using the filesystem and it knows to look in the public/ directory! So when we pass it upload/article_image/ some filename, it finds it perfectly. Well... it works until our files don't live on the server anymore.

The solution? We need this to use Flysystem.

Flysystem Loader

Let's go to back to the LiipImagineBundle documentation: find their GitHub page and then click down here on the "Download the Bundle" link as an easy way to get into their full docs. Now, go back to the main page and... down here near the bottom, it talks about different "data loaders". The default is "File System", we want Flysystem.

Let's see... yea, we've already installed the bundle. Copy this loaders section - we already have our Flysystem config all set up. Then, open our liip_imagine.yaml file and, really, anywhere, paste!

This creates a loader called profile_photos - that name can be anything. Let's use flysystem_loader. The critical part is the key flysystem: that says to use the "Flysystem" loader that comes with the bundle. The only thing it needs to know, is the service id of the filesystem that we want to use.

liip_imagine:
... lines 2 - 5
loaders:
flysystem_loader:
flysystem:
... lines 9 - 57

For that, go back to config/services.yaml and copy the long service id from the bind section. Back in liip_imagine.yaml, paste!

liip_imagine:
... lines 2 - 5
loaders:
flysystem_loader:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
... lines 10 - 57

We now have a "loader" called flysystem_loader, and a "loader's" job is to... ya know, "load" the source file. You can technically have multiple loaders, though I've never had to do that. To always have LiipImagineBundle load the files via Flysystem, below, add data_loader set to the loader's name: flysystem_loader. I'll add a comment:

default loader to use for all filter sets

liip_imagine:
... lines 2 - 5
loaders:
flysystem_loader:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
# default loader to use for all filter sets
data_loader: flysystem_loader
... lines 13 - 57

Because, you can technically specify which loader you want to use on each filterset. Again, I've never had to do that: we always want to use flysystem.

Cool! Let's try it! Go into the public/ directory... let me find it... and delete all the existing thumbnails - let's delete media/cache/ entirely. By doing this, the bundle will use the data loader to get the contents of each image so that it can recreate the thumbnails.

Correcting the Path to LiipImagineBundle

Testing time! Let's go back to, how about, the homepage. And... it doesn't work. Drat! Inspect element. Hmm, it does start with the media/cache/resolve part. Then, the path at the end is - uploads/article_image/lightspeed...png. That's the path that we're passing to the filter.

Go back to the homepage template. The problem now - and it's really cool - is that we told LiipImagineBundle to use Flysystem to load files... but the root of our filesystem is the public/uploads directory. In other words, if you want to read a file from our filesystem, the path needs to be relative to this directory. In other words, it should not contain the uploads/ part

The fix? Remove the uploaded_asset() function: we can just pass article.imagePath, which will be article_image/ the filename.

... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
... lines 6 - 8
<div class="col-sm-12 col-md-8">
... lines 10 - 21
<div class="article-container my-1">
<a href="{{ path('article_show', {slug: article.slug}) }}">
<img class="article-img" src="{{ article.imagePath|imagine_filter('squared_thumbnail_small') }}">
... lines 25 - 37
</a>
</div>
... line 40
</div>
... lines 42 - 61
</div>
</div>
{% endblock %}

I love this! Need to thumbnail something? Just pass it the Flysystem path: you don't need the word uploads or anything like that. The uploaded_asset() function will still be useful if you want the public path to an asset without thumbnailing, but if you're using imagine_filter, passing the short, relative path is all you need.

Try it! Refresh! It still doesn't work? Oh yea! A few minutes ago, we deleted all of the original images from the fixtures. But we did re-upload a few of them. So if you scroll down... here we go - here's the Earth image we uploaded. So, it is now working perfectly.

Let's reload our fixtures to make sure:

php bin/console doctrine:fixtures:load

Now the homepage... yes - everything is here. Let's make the same change in the other two places we're thumbnailing. Click onto the show page. This lives in templates/article/show.html.twig: remove uploaded_asset there. Refresh... good!

... lines 1 - 4
{% block content_body %}
<div class="row">
<div class="col-sm-12">
<img class="show-article-img" src="{{ article.imagePath|imagine_filter('squared_thumbnail_medium') }}">
... lines 9 - 25
</div>
</div>
... lines 28 - 78
{% endblock %}
... lines 80 - 86

For the other one, go back to the admin article section - log back in with password "engage", because we reloaded the database. When we're editing an image, yep, also broken.

Find this in templates/article_admin/_form.html.twig: take off uploaded_asset().

{{ form_start(articleForm) }}
... lines 2 - 5
<div class="row">
... lines 7 - 13
<div class="col-sm-3">
{% if articleForm.vars.data.imageFilename %}
<img src="{{ articleForm.vars.data.imagePath|imagine_filter('squared_thumbnail_small') }}" height="100">
{% endif %}
</div>
</div>
... lines 20 - 38
{{ form_end(articleForm) }}

And... got it!

The Resolver: Saving the Images to Flysystem

So, the "data loader" is responsible for reading the original image. But, there's another important concept from LiipImagineBundle called "resolvers". Click down on the "Flysystem Resolver" in their docs. The resolver is responsible for saving the thumbnail image back to the filesystem after all of the transformations. By default, no surprise, LiipImagineBundle writes things directly to the filesystem. So even if we moved Flysystem to s3, LiipImagineBundle would still be writing the thumbnail files back to our server - into the public/media directory.

Tip

You can also completely offload the processing and storage of your files to a cloud service like rokka.io by leveraging LiipRokkaImagineBundle.

Let's change that! In the docs, copy the resolvers section. Back in our liip_imagine.yaml file, paste that. It's pretty much the same as before: we'll call it flysystem_resolver and tell it to save the images using the same filesystem service. Remove visibility - that sets the Flysystem visibility, which is a concept we'll talk about soon. True is the default value anyways, which basically means these files will be publicly accessible.

liip_imagine:
... lines 2 - 13
resolvers:
flysystem_resolver:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
cache_prefix: media/cache
root_url: /uploads
... lines 21 - 67

cache_prefix is the subdirectory within the filesystem where the files should be stored and root_url is the URL that all the paths will be prefixed with when the image paths are rendered. Right now, it needs to be /uploads.

For example, if LiipImagineBundle stores a file called media/cache/foo.jpg into Flysystem, we know that the public path to this will be /uploads/media/cache/foo.jpg. We'll talk more about this setting later when we move to s3.

Ok, delete the media/ directory entirely. Oh, and I almost forgot the last step: add cache set to flysystem_resolver - let's put an "r" on that.

liip_imagine:
... lines 2 - 13
resolvers:
flysystem_resolver:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
cache_prefix: media/cache
root_url: /uploads
# default cache resolver for saving thumbnails
cache: flysystem_resolver
... lines 23 - 67

This tells the bundle to always use this resolver. I'm not sure why it's called "cache" - the bundle seems to use "resolver" and "cache" to describe this one concept.

Ok! Moment of truth! Refresh. Ha! It works! Go check out where the thumbnails are stored: there is no media/ directory anymore! The Flysystem filesystem points to the public/uploads directory, so the media/cache directory lives there. And thanks to the /uploads root_url, when it renders the path, it knows to start with /uploads and then the path in Flysystem.

I love this! It's a bit tricky to get these two libraries to play together perfectly. But now we are much more prepared to switch between local uploads and S3.

Next: we can generate public URLs to thumbnailed files or the original files. But, what if you need to force all the URLs to include the domain name? This is something you don't think about until you need to generate a PDF or send an email from a console command or worker. Then... it can be a nightmare. Let's add this to our asset system in a way that we love.

Leave a comment!

24
Login or Register to join the conversation
Kehlhoffner E. Avatar
Kehlhoffner E. Avatar Kehlhoffner E. | posted 1 year ago

case 1. If I use no filter and point to the original photo, I have no pb.

case 2. If I point to the original photo with the "square thumbnail small" filter, I get a response with status 403 (forbidden).

case 3. If I point directly to media / cache and the user has not viewed the photo at least once, it does not exist in this folder



"mRender": function (data, type, full) {
case 1 - return "<img src="https://folder/" + full.photo_file_name + "" )="" }}="">" => ok
case 2 - return "<img src="https://folder/" + full.photo_file_name + "| imagine_filter(" squared_thumbnail_small')')="" }}="">" => 403. status forbiden
case 3 - return "<img src="https://.../media/cache/squared_thumbnail_small/photos/" + full.photo_file_name + "" )="" }}="">" => does not exist if the user has not seen the photo at least once

Do you think this is from my setup on S3? if yes do you have a solution if not do you have any suggestions

bucket: character
folder1: photo
file2: passport
folder3: media / cache
....

Do I have to show my scripts

Reply
Kehlhoffner E. Avatar

ok i understood the origin of my problem. I was generating my filter from javascript through Datatable. The filter was not performing correctly. So, I generate it directly in TWIG and everything works normally. I will look later on how to dynamically generate a filter with js or if you have already done this I am interested

Reply

Hi Tom Bricoreur!

I'm glad you figured it out - and thanks for following up with your solution :).

If I understand the situation correctly, it's a very common thing. Since your Symfony app (via the Twig filter) is responsible for thumbnailing the image and returning the correct path, that logic *must* be done in your Symfony app (via the Twig filter), it can't be done in JavaScript (as you correctly determined). There's... not much way around this: it would effectively be similar to trying to look up a value in the database from JavaScript: it's just not information / a capability that JavaScript has. If you *did* want to do the work in JavaScript, you would need to probably setup an API endpoint that you could send the original image filename to... and it would return the thumbnailed path. That's... probably added complexity that's not worth the benefit.

What I would personally do (and this may be the exact solution you're doing) is return HTML from your data tables AJAX endpoint (if possible, I can't remember the rules around datatables). This isn't a workaround: I love doing this! My Twig template is already really good at returning HTML... so even if I need some new content from JavaScript, I'll get that content by making an AJAX call that returns the HTML (instead of returning the JSON... and then turning that into HTML in JavaScript). But, again, this all depends on how your app works and the rules of datatables (which you know better).

Anyways, that was a long way of saying: I would probably stick with whatever your current Twig solution is: that is the correct solution to me :). But if you have any questions or doubts, let us know.

Cheers!

Reply

case 1 is ok
case 2 and 3 should be the same, I mean there shouldn't be any difference in this 2 definitions

So from my point of view we need to solve this 403 error, why it's thrown and what part of code is responsible for it. To check case 3 try to run php bin/console debug:router and check for this routes

 liip_imagine_filter_runtime                    GET        ANY      ANY    /media/cache/resolve/{filter}/rc/{hash}/{path}              
liip_imagine_filter GET ANY ANY /media/cache/resolve/{filter}/{path}

BTW there is one way to burst the cache programatically


/** @var CacheManager */
$imagineCacheManager = $this->get('liip_imagine.cache.manager');

/** @var string */
$resolvedPath = $imagineCacheManager->getBrowserPath('/relative/path/to/image.jpg', 'my_thumb');

Cheers!

Reply
Kehlhoffner E. Avatar
Kehlhoffner E. Avatar Kehlhoffner E. | posted 1 year ago

Hello, this is the first time that I participate in a discussion...

First of all, well done for the quality of the explanations. I managed to set up Uploads of files (pdf) + photos (jpg) :)

If I understood correctly, the thumbnail is stored in media / cache at the time of reading?

Here is my problem:

A user (role_user) saves a photo from a form and is then redirected to a page.

Consequently, the user has not necessarily viewed his photo.

So at the moment the photo is not yet stored in media / cache.

As director (role_dir) I would like to have access to this thumbnail but from media / cache and not from the original photo folder (too heavy & slower)

In other words, can I force the saving of the photo in media / cache even before it is viewed by the user (role_user)

Or do you have another solution?

I don't know where to put my scripts

Reply

Hey Tom Bricoreur

Hm probably I don't see the full picture, but it's no matter who is trying to see your photo thumbnail it totally depends on template and what url of image are you using. It should be generated on the fly. You can see examples in the chapter 11 of this course!

Cheers

Reply
Petru L. Avatar
Petru L. Avatar Petru L. | posted 1 year ago

Hey weaverryan ,

Came across an error:

Liip\ImagineBundle\Imagine\Cache\Resolver\FlysystemResolver::__construct(): Argument #1 ($flysystem) must be of type League\Flysystem\FilesystemInterface, League\Flysystem\Filesystem given, called in /
var/www/erp/var/cache/dev/ContainerEyPR8ga/App_KernelDevDebugContainer.php on line 1323

liip_imagine.yaml

resolvers:
flysystem_resolver:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
cache_prefix: media/cache
root_url: '/uploads'
# default cache resolver for saving thumbnails
cache: flysystem_resolver

Reply

Hey Petru Lebada

What version of the Flysistem library are you using? The latest version comes with a few critical changes that are not compatible with the version used for this tutorial

Cheers!

Reply
Petru L. Avatar

Hey Diego Aguiar ,

"oneup/flysystem-bundle": "^4.0",

Is there anything i could do to fix it? This package simplify my work greatly and i would like to keep using it. Thank you

Reply

Hey Petru! Yea, there must be a way. Have you read this answer of Ryan? https://symfonycasts.com/sc...
Perhaps if you read this guide you may find out a solution https://flysystem.thephplea...

Cheers!

Reply
Petru L. Avatar

Diego Aguiar , yes i've done exactly what he did, and i couldn't find anything on that migration guide. I'm guessing this is more of a oneup_flysystem bundle issue?

Reply

Hi,
I try to use Flysystem loader. I write this


loaders:
public_uploads_adapter:
flysystem:
filesystem_service: oneup_flysystem.public_uploads_adapter_filesystem
data_loader: public_uploads_adapter

into liip_imagine.yaml and I change my template src="{{ photo.imagePath|imagine_filter('image_thumb') }}"
but there is no generation of new thumbnail image
The dump of {{ dump(photo.imagePath) }} display the right path for image and name of file

Have you any advise to debug this ?

Reply

Hey Stéphane!

Hmm. Yes, this stuff can be difficult to debug! When you use the |imagine_filter('image_thumb') filter, that should output a different URL that (when things work) returns the image. Depending on your situation, the URL that this will output will be 1 of 2 things:

1) If imagine_filter detects that the thumbnail for this image does *not* exist yet, it will return a URL that actually points to a Symfony route & controller from the bundle. When the user's browser hits this URL it should (when things are working) actually *create* the thumbnail, save it, and return it.

2) If imagine_filter DOES think that the thumbnail for the image already exists (this is possible, in your situation, it's possible it thinks the thumbnail exists, but then it generates the wrong URL to it), then it will generate a direct URL to the real image (it will not point to a Symfony route and controller).

So to debug this, I would look at the src= attribute on your image. If the URL has a path like /media/cache/resolve in it, then you are in situation 1. Else, you are in situation 2.

Situation (1) is a bit easier. You can open the URL of the image in a new browser tab and (hopefully) it will show you some error (like it can't find the source image for some reason). This is trickier to debug when you have Flysystem as an extra layer of abstraction.

Situation (2) is a bit trickier. It means (most likely) that the thumbnail WAS successfully created. But then it is generating an incorrect URL to the public image. This would, once again, be some sort of configuration issue - related to the public path of the images.

Let me know if this helps!

Cheers!

Reply

Hey @Ryan

Thank for your reply.

I have try to open the URL of image and I have a page error 500 :

Liip\ImagineBundle\Binary\Loader\FlysystemLoader::__construct(): Argument #2 ($filesystem) must be of type League\Flysystem\FilesystemInterface, League\Flysystem\Filesystem given, called in /home/stephane/www/perso/symfony/snowTrickv2/var/cache/dev/ContainerIG8Gtvi/getLiipImagine_Binary_Loader_PublicUploadsAdapterService.php on line 26

I think that is why I use $publicUploadsFilesystem: '@oneup_flysystem.public_uploads_adapter_filesystem' into my service.yaml file.

I don't know this is the right service to use.

When I use symfony console debug:container flysystem

I have this list :
[0 ] liip_imagine.binary.loader.prototype.flysystem
[1 ] liip_imagine.cache.resolver.prototype.flysystem
[2 ] oneup_flysystem.adapter_factory.local
[3 ] oneup_flysystem.adapter_factory.awss3v3
[4 ] oneup_flysystem.adapter_factory.ftp
[5 ] oneup_flysystem.adapter_factory.sftp
[6 ] oneup_flysystem.adapter_factory.in_memory
[7 ] oneup_flysystem.adapter_factory.customadapter
[8 ] oneup_flysystem.adapter_factory.async_aws_s3
[9 ] oneup_flysystem.adapter_factory.gitlab
[10] oneup_flysystem.adapter.local
[11] oneup_flysystem.adapter.awss3v3
[12] oneup_flysystem.adapter.ftp
[13] oneup_flysystem.adapter.sftp
[14] oneup_flysystem.adapter.memory
[15] oneup_flysystem.adapter.async_aws_s3
[16] oneup_flysystem.adapter.gitlab
[17] oneup_flysystem.mount_manager
[18] oneup_flysystem.filesystem
[19] oneup_flysystem.public_uploads_adapter_adapter
[20] oneup_flysystem.public_uploads_adapter_filesystem
[21] League\Flysystem\Filesystem
[22] League\Flysystem\FilesystemOperator $publicUploadsAdapterFilesystem

Have you an idea of which is the right one ?

Cheers!

Reply

Hi again!

Sorry for the slow reply! Ok, here’s the situation. You’re using a new version of Flysystem (v2). That’s fine except that LiipImagineBundle doesn’t support it yet. Or, to be more accurate, support for v2 *has* been added, but we are waiting for a new release of the bundle that contains this change :).

So, you have 2 options:

- use flysystem v1. You would do that by changing the version of one up flysystem bundle in your composer.json to ^3.7 and then running composer update (v4 of that bundle only works with flysystem 2).

2) or, use the unreleased version of liip imagine bundle. You would do this by setting the version to “dev-main” in composer.json and run composer update.

I’m going to follow up on tying to get a new release for the bundle... and check to see if flysystem 2 will require any other changes. Sorry for the troubles!

Cheers!

Reply

Hi Ryan,

Thank for your advice.

I have succeeded to display images with flysystem loader to update liipimagine bundle to dev branch (2.x-dev exactly).

Finally I use https://github.com/thephple... for manage flysystem and not https://github.com/1up-lab/... that you use for this tutorial.

What do you thing if it is better or not ?

I have seen that you are speaker for SymfonyLive Online on 9 april. Nice. I am impatient to learn more about panther.

Cheers!

Reply

Hi Stéphane!

Nice work! And thanks for following up so that I know that this combination works :).

About your question about the phpleague flysystem, you probably already saw my reply on your other thread, but for the benefit of others, here’s the link: https://symfonycasts.com/sc...

> I have seen that you are speaker for SymfonyLive Online on 9 april. Nice. I am impatient to learn more about panther.

Yaaaay! I’m super excited by MANY enhancements in testing tools over the past 6 months, Panther being just one of them ;).

“See” you there!

Cheers!

Reply
Roman P. Avatar
Roman P. Avatar Roman P. | posted 2 years ago

Hey, how's it going?

I thought a couple of minutes about a twig ext function we made before and why not use it so far ('uploaded_asset()')?
Just delete a 'public' from path which is provided by \App\Service\UploadHelper::getPublicPath and that's it

Reply

Hey Bagar Davidyan

It may make sense to do that but it depends on your application. If other services will need to know the public path of your assets, then you want to have such logic in only one place, and injecting a TwigExtension class into other services is not really a good idea

Cheers!

Reply
Steve D. Avatar
Steve D. Avatar Steve D. | posted 3 years ago

Hi

How would you go about deleting the cache thumbnails when the main image is changed? Is this possible or should I just clear the cached images and have them re-generate?

I've copied the code that deletes the main image and set it to :

$result = $filesystem->delete('media/cache/squared_thumbnail_small/'.$directory.'/'.$existingFilename);

However I need to check all the thumbnail locations (filter sets from liip_imagine.yaml).

Thanks

Steve

Reply

Hey Steve

Here is a slightly better approach: https://github.com/liip/Lii...
Also there are some commands for clearing cached images: https://symfony.com/doc/2.0...

Cheers!

Reply
Steve D. Avatar

Hi Diego

Thank you for the info. Thats pretty much what I did. I think I've learnt more on this tutorial than any other. Superb series. Thanks

1 Reply

Cool! Let's keep learning ;)

Reply
Steve D. Avatar

I've managed to sort this, brought in the cache manager and passed the directory and filename.

$this->cacheManager->remove($directory . '/' . $existingFilename);

Regards

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3
    }
}