Processing Encore Files through inline_css()
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe just used Encore to build an email.scss
file that we want to process through inline_css()
to style our emails. The problem is that, instead of building just one email.css
file in public/build
, it split it into two for performance reasons. That wouldn't be a problem, except that the way Webpack splits the files might change over time - we can't guarantee that it will always be these two files. To make matters worse, an Encore production build will add a dynamic "hash" to every file - like email.123abc.css
.
Basically... pointing inline_css()
directly at these two files... isn't going to work.
How Dynamic Files are Normally Rendered
This is why, in base.html.twig
we simply use encore_entry_link_tags()
and it takes care of everything. How? Behind the scenes, it looks in the public/build/
directory for an entrypoints.json
file that Encore builds. This is the key: it tells us exactly which CSS and JS files are needed for each entrypoint - like app
. Or, for email
, yep! It contains the two CSS files.
The problem is that we don't want to just output link
tags. We actually need to read the source of those files and pass that to inline_css()
.
Let's create a new Twig Function!
Since there's no built-in way to do that, let's make our own Twig function where we can say encore_entry_css_source()
, pass it email
, and it will figure out all the CSS files it needs, load their contents, and return it as one big, giant, beautiful string.
{% apply inky_to_html|inline_css(encore_entry_css_source('email')) %} | |
// ... lines 2 - 30 | |
{% endapply %} |
To create the function, our app already has a Twig extension called AppExtension
. Inside, say new TwigFunction()
, call it encore_entry_css_source
and when this function is used, Twig should call a getEncoreEntryCssSource
method.
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 24 | |
public function getFunctions(): array | |
{ | |
return [ | |
// ... line 28 | |
new TwigFunction('encore_entry_css_source', [$this, 'getEncoreEntryCssSource']), | |
]; | |
} | |
// ... lines 32 - 75 | |
} |
Copy that name and create it below: public function getEncoreEntryCssSource()
with a string $entryName
argument. This will return the string
CSS source.
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 53 | |
public function getEncoreEntryCssSource(string $entryName): string | |
{ | |
// ... lines 56 - 65 | |
} | |
// ... lines 67 - 75 | |
} |
Inside, we need to look into the entrypoints.json
file to find the CSS filenames needed for this $entryName
. Fortunately, Symfony has a service that already does that. We can get it by using the EntrypointLookupInterface
type-hint.
For reasons I don't want to get into in this tutorial, instead of using normal constructor injection - where we add an argument type-hinted with EntrypointLookupInterface
- we're using a "service subscriber". You can learn about this in, oddly-enough, our tutorial about Symfony & Doctrine.
To fetch the service, go down to getSubscribedServices()
and add EntrypointLookupInterface::class
.
// ... lines 1 - 8 | |
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface; | |
// ... lines 10 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 67 | |
public static function getSubscribedServices() | |
{ | |
return [ | |
// ... lines 71 - 72 | |
EntrypointLookupInterface::class, | |
]; | |
} | |
} |
Back up in getEncoreEntryCssSource()
, we can say $files = $this->container->get(EntrypointLookupInterface::class)
- that's how you access the service using a service subscriber - then ->getCssFiles($entryName)
.
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 53 | |
public function getEncoreEntryCssSource(string $entryName): string | |
{ | |
$files = $this->container | |
->get(EntrypointLookupInterface::class) | |
->getCssFiles($entryName); | |
// ... lines 59 - 65 | |
} | |
// ... lines 67 - 75 | |
} |
This will return an array with something like these two paths. Next, foreach
over $files as $file
and, above create a new $source
variable set to an empty string. All we need to do now is look for each file inside the public/
directory and fetch its contents.
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 53 | |
public function getEncoreEntryCssSource(string $entryName): string | |
{ | |
$files = $this->container | |
->get(EntrypointLookupInterface::class) | |
->getCssFiles($entryName); | |
$source = ''; | |
foreach ($files as $file) { | |
// ... line 62 | |
} | |
// ... lines 64 - 65 | |
} | |
// ... lines 67 - 75 | |
} |
Adding a publicDir Binding
We could hardcode the path to the public/
directory right here. But instead, let's set up a new "binding" that we can pass through the constructor. Open up config/services.yaml
. In our Symfony Fundamentals Course, we talk about how the global bind
below _defaults
can be used to allow scalar arguments to be autowired into our services. Add a new one: string $publicDir
set to %kernel.project_dir%
- that's a built-in parameter - /public
.
// ... lines 1 - 12 | |
services: | |
// ... line 14 | |
_defaults: | |
// ... lines 16 - 22 | |
bind: | |
// ... lines 24 - 27 | |
string $publicDir: '%kernel.project_dir%/public' | |
// ... lines 29 - 54 |
This string
part before $publicDir
is optional. But by adding it, we're literally saying that this value should be passed if an argument is exactly string $publicDir
. Being able to add the type-hint to a bind is a new feature in Symfony 4.2. We didn't use it on the earlier binds... but we could have.
Back in AppExtension
, add the string $publicDir
argument. I'll hit "Alt + Enter" and go to "Initialize fields" to create that property and set it.
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... line 16 | |
private $publicDir; | |
// ... line 18 | |
public function __construct(ContainerInterface $container, string $publicDir) | |
{ | |
// ... line 21 | |
$this->publicDir = $publicDir; | |
} | |
// ... lines 24 - 75 | |
} |
Down in the method, we can say $source .= file_get_contents($this->publicDir.$file)
- each $file
path should already have a /
at the beginning. Finish the method with return $source
.
Tip
To avoid missing CSS if you send your emails via Messenger (or if you send multiple emails
during the same request), "reset" Encore's internal cache before calling getCssFiles()
:
// replace the first 3 lines with these
$entryPointLookupInterface = $this->container->get(EntrypointLookupInterface::class);
$entryPointLookupInterface->reset();
$files = $entryPointLookupInterface->getCssFiles($entryName);
$source = '';
// ...
// ... lines 1 - 13 | |
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface | |
{ | |
// ... lines 16 - 53 | |
public function getEncoreEntryCssSource(string $entryName): string | |
{ | |
$files = $this->container | |
->get(EntrypointLookupInterface::class) | |
->getCssFiles($entryName); | |
$source = ''; | |
foreach ($files as $file) { | |
$source .= file_get_contents($this->publicDir.'/'.$file); | |
} | |
return $source; | |
} | |
// ... lines 67 - 75 | |
} |
Whew! Let's try this! We're already running Encore... so it already dumped the email.css
and vendors~email.css
files. Ok, let's go send an email. I'll hit back to get to the registration page, bump the email, type any password, hit register and... wow! No errors! Over in Mailtrap... nothing here... Of course! We refactored to use Messenger... so emails are not sent immediately!
By the way, if that annoys you in development, there is a way to handle async messages immediately while coding. Check out the Messenger tutorial.
Let's start the worker and send the email. I'll open another tab in my terminal and run:
php bin/console messenger:consume -vv
Message received... and... message handled. Go check it out! The styling look great: they're inlined and coming from a proper Sass file.
And... we've made it to the end! You are now an email expert... I mean, not just a Mailer expert... we really dove deep. Congrats!
Go forth and use your great power responsibly. Let us know what cool emails you're sending... heck... you could even send them to us... and, as always, we're here to help down in the comments section.
Alright friends, seeya next time!
Hello!
After changing to encore_entry_css_source, inline_css does not work anymore. I have checked that the twig extension is successfully called (it found the css files and store them in source variable) but it's not included in the mail anymore. I am developping locally. Stop/start messenger consumer command, removed cache, ... but still not working. Any idea?
The twig extension:
Thx!