Using Laravel to help generate Tailwind CSS class names usages for a user-managed Statamic site

Published July 4th, 2023

What I'm discussing in this post is doing exactly that... using dynamic class names.

When working with user-editable content where styles may be needed, but may not be in the files at the time of the build process, this process is incredibly useful, saves on code replication, and makes it easier to ensure all required colours and necessary variants are available.

This has been working well for this specific use case... but do note if you're developing a more closed system (i.e. Vue components for a backend app) where you do have full control over the scope of colours needed, I'd strongly recommend avoiding dynamic class names.

You may not agree and it may not be for you, and that's OK too. But it is just an idea.

Tailwind CSS is very clear about using dynamic class names: don't do it.

To me, this makes most sense for a website where users can edit content and choose colours - such as within a Statamic site. If you're developing an app with more rigid and defined components, this process may send you down a bad-practice rabbit hole.

As an example, in Statamic world, this may look like:

1<div class="text-{{ error ? 'red' : 'green' }}-500"></div>
Copied!

To me this makes perfect sense - and when building an app with Laravel (think Vue/Inertia), any components I make have no dramas specifying a defined set of classes.

The issue

But the challenge comes when using Tailwind CSS for the front end of a site, where users have access to content management which can involve colour changes. (And of course I'm saying from a prescribed set of carefully curated colours, not just all colours. We don't need another "Papyrus" disaster on our hands)

Let's say your site's brand palette has 5 colours. And you need to have a number of colour-based classes for each colour - hovers, groups, texts, backgrounds. But writing a conditional statement in an element's class definition that caters to 5 different colours can become out of control rather fast - hard to read, hard to update, hard to nest, hard to indent, and hard to syntax highlight. Such as:

1<div class="{{ if colour === 'blue' }}text-blue-500{{ elseif colour === 'red' }}text-red-500{{ elseif colour ==='yellow' }}text-yellow-500{{ else }}omg{{ /if }}"></div>
Copied!

Another way to approach this could be to move the conditional outside the div, and then replicate the div for each colour. This is clean and consistent.

1{{ if colour === 'blue' }}
2 <div class="text-blue-500">
3 ...
4 </div>
5{{ elseif colour === 'red' }}
6 <div class="text-red-500">
7 ...
8 </div>
9{{ elseif colour === 'yellow' }}
10 <div class="text-yellow-500">
11 ...
12 </div>
13{{ else }}
14 <div class="omg">
15 ...
16 </div>
17{{ /if }}
Copied!

But if you have a complex chunk of markup, with colour scattered throughout, there's a lot of copy-and-paste and tinkering here. If your 500 becomes 600, for 5 colours, that's at least 5 changes in one file. Easy for us humans to mess something up, and lots of replicated code. Admittedly the best for readability, but lots of replicated code.

While we could also use a partial and a slot, but may not be adequate to avoid replication too - colours could be used in multiple elements within each conditional block, or perhaps we need multiple slots, which Antlers doesn't have.

The scenario

For a site's Blueprints in Statamic, we often have a Colour field where the author can make a choice. We don't confuse them with the Tailwind shades, we think of what they need. A user needs a blue button, so they need to be able to select "Blue" from a list.

Make sense?

This means within our Antlers templates we have a variable with their colour - such as, the smartly named, colour variable.

It is so easy to use this variable in our Antlers to help make a button have a dynamic class:

1<button class="text-{{ colour }}-500 bg-{{ colour }}-100 hover:bg-{{ colour }}-200">...</button>
Copied!

But as we know, Tailwind has no idea what this is meant to do. We want it to be text-blue-500... or text-purple-500... or text-red-500... but without each class being used elsewhere in the site, we can't guarantee that the author selecting a colour will actually work.

In our tailwind.config.js file we define all of the content files we want Tailwind CSS to process when determining what to keep (and what to purge). There's Antlers files, JS files, content-filled Markdown files. The classes used in these files dictate what classes Tailwind CSS will keep in its production-ready CSS file.

Sure, we could add these classes to a safelist in the Tailwind config (but that's still a lot of variants to include), or use the full CDN-build of Tailwind (but that's crazy because it's so large to include everything "just in case").

So what can we do?

A manual solution

Tailwind CSS's build process looks at an array of content file types.

So why don't we create a file that's not actually part of our rendered site, but matches one of these rules, that includes the class names we need?

1<div class="text-blue-500 bg-blue-100 hover:bg-blue-100"></div>
2<div class="text-green-500 bg-green-100 hover:bg-green-100"></div>
3<div class="text-purple-500 bg-purple-100 hover:bg-purple-100"></div>
4<div class="text-red-500 bg-red-100 hover:bg-red-100"></div>
5<div class="text-yellow-500 bg-yellow-100 hover:bg-yellow-100"></div>
Copied!

This works. And has worked well for us. But if we want to change "500" to "700", that's still a lot of manual changes. If we need to add some new classes, that's adding it once for each colour. It works... but is manual.

An automated solution

Because each line in that previous example is the same - except the colour name - wouldn't it make sense to be able to generate this "dummy" file automatically based on a list of colours and class names?

So I wrote a handy little console command for Laravel (for use with Statamic as it uses a Statamic-specific Facade) that does just that.

To start with, we need a configuration file to tell the command what classes to generate. I've placed mine in resources/tailwind/config.yaml, and the console command in this post will look for this path - if you're using this, update it to match yours, obviously.

1colours:
2 - blue
3 - green
4 - purple
5 - red
6 - yellow
7classes:
8 - text-[colour]-500
9 - bg-[colour]-100
10 - hover:bg-[colour]-200
Copied!

We've got two lists here - one for colours, and one for classes.

The colours list is simply the colour names we use in our Tailwind CSS configuration.

The classes list is a map of the Tailwind CSS classes we want to have generated for each colour. You'll notice we have a [colour] token - this is where the Tailwind CSS colour name will be. Hopefully that's obvious.

By using this approach, if we add a new colour, or need to change 500 to 600, we make one change now, and run the artisan command, and we have a new dummy HTML file ready to go with our complete class names for Tailwind CSS to use during its build.

So what does this command do? Basically it

  • loads the YAML config (easily readable with Statamic's YAML Facade),

  • makes sure it has what it needs,

  • dynamically merges colours in to classes, and then

  • outputs a HTML file with all of the complete class names

This output file does match a content path pattern in the tailwind.config.js file, meaning all of the class names in this file will be included in your production build.

1<?php
2 
3namespace App\Console\Commands;
4 
5use Exception;
6use Illuminate\Console\Command;
7use Statamic\Facades\YAML;
8 
9class TailwindGenerateCommand extends Command
10{
11 protected $signature = 'tailwind:generate';
12 
13 protected $description = 'Generate a HTML file of complete class names for a set of colours and classes.';
14 
15 public function handle(): void
16 {
17 // open the resources/tailwind/config.yaml file
18 $config = resource_path('tailwind/config.yaml');
19 $target = resource_path('tailwind/generated.html');
20 
21 if (!file_exists($config))
22 {
23 $this->error($config . ' could not be found. Cannot run generator.');
24 return;
25 }
26 
27 $yaml = null;
28 try {
29 // parse the yaml
30 $yaml = YAML::parse(file_get_contents($config));
31 } catch (Exception $e) {
32 $this->error($config . ' could not be parsed. Cannot run generator.');
33 return;
34 }
35 
36 // do we have the expected format
37 if (!array_key_exists('colours', $yaml)) {
38 $this->error('YAML is missing "colours" property.');
39 }
40 
41 if (!array_key_exists('classes', $yaml)) {
42 $this->error('YAML is missing "classes" property.');
43 }
44 
45 // store the html here
46 $html = [];
47 
48 // start the loop
49 foreach ($yaml['colours'] as $colour) {
50 $classes = [];
51 foreach($yaml['classes'] as $class) {
52 // merge the colour to the class
53 $classes[] = str_replace('[colour]', $colour, strtolower($class));
54 }
55 
56 // add to the html
57 $html[] = '<div class="'.implode(' ' , $classes).'"></div>';
58 }
59 
60 $contents = '<!-- THIS FILE IS GENERATED BY THE COMMAND php artisan tailwind:generate -->' . "\r\n";
61 $contents .= '<!-- Make changes to your config.yaml file, then re-run the command to update this file -->' . "\r\n";
62 $contents .= implode("\r\n", $html);
63 
64 // save the output
65 file_put_contents($target, $contents);
66 
67 $this->info('Huzzah! Your config has been generated auto-magically!');
68 }
69}
Copied!

When your command is ready to go, you can generate the resources/tailwind/generated.html file by running:

1php artisan tailwind:generate
Copied!

Rather than copying-and-pasting (and replacing) parts of class names, we can define our site's theme colours (maybe there's 3, maybe 5, hopefully not 10, but you never know), and the classes that would be required by our site's blocks and components, and have them merged together automatically.

This can help:

  • reduce manual error when adding or changing a class or colour

  • reduce duplication of complex or nested code unnecessarily

  • improve readability of code when colour is a single aspect of a chunk of code

Yes, this concept goes against Tailwind CSS's "don't construct class names dynamically" rule. Sort of. It's not actually constructing dynamic names, but in Antlers, allows us to use a variable in a dynamic name which can help make your Antlers templates easier to manage.

Just instead of having to have a dummy file manually managed (or these classes having guaranteed use somewhere else in the site), this command helps automate it without missing a variant.

It could even be run as part of your site's deploy script so that even if you forget to manually run the command prior to committing, your deployment can generate the required file on the fly.

Remember, this is a useful idea for this specific use case.

If you're developing a more controlled-scope app (think reusable components that don't change classes based on user-editable content), then it's probably a good idea to not use this approach. The readability of your code will increase for improved longer term manageability.

You may be interested in...