Can we solve the tailwind-interpolation-problem with a macro?

tailwind will go through your files and find classes using a RE.
So you can’t interpolate tailwind classes.

<div class={"w-#{@foo}"}> <!-- DOES NOT WORK -->

You have to

<div class={[
  @foo==1 && "w-1",
  @foo==2 && "w-2",
  ...
  @foo==96 && "w-96"]}>

(or use @style)

How about a heex macro tailwind (and an accompanying attr type which gives all possible values)

attr :foo, :tailwind, range: [1, 2, 3, 4, 10, 24, 96]
...
<div class={[tailwind("w-#{@foo}"), "other classes ..."]}>

or maybe sth like this using a sigil

<div class={[~t"w-#{@foo}", "other classes ..."]}>

all tailwind macros could compile to an extra file (which is listed in tailwind.config to get parsed by the tailwind preprocessor)

# tailwind.lst
w-1, w-2, w-3, w-4, w-10, w-24, w-96 
9 Likes

Oh my god, just banged my head for a few hours to understand that this is happening.

The thing is that this is not only not consistent, if you use same classes elsewhere, it will work (this also includes cached builds), but it also doesn’t show any kind of errors or warnings.

Actually as it currently stands, it seems that the limitation is that the string should be known at compile-time, things such as this work:

<div class={[@badge_type]}>
</div>
badge_type = get_badge(type)
assign(socket, badge_type: badge_type)
defp badge_type (type) do
    case type do
      :success -> "badge-success"
      :warning -> "badge-warning"
      :error -> "badge-error"
      :inactive -> "badge-ghost"
      :not_found -> "badge-info"
    end
  end

I have both the cases where a separate .heex file is used and when render/1 from an .ex file is used, and both seem to be working when the strings are known at compile-time. Looking at this now, it seems that the configuration watches in .ex files too:

content = [
  ...
  "../lib/*_web/**/*.*ex"
]

Have you found any solutions so somebody else would not stumble onto this bug?

1 Like

Have a look at: Content Configuration - Tailwind CSS

If you really need to have some classes, you can safelist them (see further on that page).

Basically, tailwind creates minimal css files by only including the class you’ve used. It can’t guess what classes you need.

I think my tails library could be a good place to put this solution: Tails - Tailwind & Tailwind Component Utilities

We can make classes into a macro instead of a function, and support something like this:

classes(["foo-#{x}", ...], x: [1, 2, 3, 4, 10, 24, 96])

Then it would yell if you interpolate a variable in your classes but don’t provide a list of possible values. Then it would write the file that you’ve mentioned.

5 Likes

This is a great resource, but the reality is that the most likely case is the one I stumbled across, where you have to invest a lot of time to understand what is happening.

It would be great if the tailwind elixir library could somehow detect and warn the user that class string interpolation can impact directly tailwind classes when used in conjunction with phoenix.

I think having it more prominently documented would go a long way. As it stands, as linked by @tcoopman, the Tailwind documentation mentions it three sections in and a good scroll-length down the page. For Phoenix users, phx_new could maybe add a comment about it to app.css and/or tailwind.config since so many people run into this (I did too).

Warning via a macro would probably be really tricky as interpolating non-tailwind classes is perfectly valid. It would be hard to reliably judge what is and isn’t a Tailwind class.

EDIT: I guess you could warn on interpolation so long as there is way to turn it off since this is one of those things that once you know, you know.

1 Like

I think this is totally doable, especially with the tools elixir has at disposal. Think of it the following way:

  1. Magic for finding dynamic class content;
  2. Magic for finding interpolated strings in dynamic classes;
  3. Use a opt-out approach where you always warn the user about the potential problem, then offer him a way out, for example by using a ~dont_care sigil;
  4. Make it globally configurable if you don’t care at all.

I think this is very important, especially for beginners that have a lot of complexity without this small implementation detail that is extremely hard to trace down.

I wasn’t saying that any of that was hard. What’s potentially hard is determining what is an isn’t a Tailwind class, especially since TW class names are highly configurable. But ya, so long as you could turn it off it should be fine. And obviously not fire if you’ve used --no-tailwind.

Plus this is perfectly valid:

color =
  case assigns.color do
    :green -> "bg-green-500 text-green-50"
    # ...
   end

assigns = assign(assigns, :color, color)

~H"""
<p class={"font-bold #{@color}"}>foo</p>
"""

But yes, so long as there is a way to turn it off it would be good.

Indeed, this is very similar to the example I posted above.

What I want to focus on is not the commodity on how to approach this, but how to avoid having this almost impossible to trace bug.

This is the first time in my 5 years of elixir development when I literally had no idea what was going on, because after checking out on another branch (that was most probably when the cache was invalidated) the project was no longer working as expected, without any warnings, traces or ways to reproduce what happened.

Cool, I clearly misunderstood your intent.

I agree it’s a problem.

1 Like

On a more general note, I think that is not very smart to scan for classes in source code, especially since elixir has metaprogramming. In theory it would be possible to scan for dynamic classes throughout the phoenix compiled templates and detect specifically a couple of things:

  1. Compute dynamic strings that can be resolved at compile-time (macros);
  2. Deliver warnings to user (this can be done by a separate system, however it would be nice if integrated);
  3. Have less content to scan (handling only compiled templates to tailwind, it doesn’t need to scan entire codebase blindly).

@zachdaniel it would be great to know what your opinion is and what the Tails library was aimed to solve, and if it can be used to create some of these features.

Yep! We shouldn’t scan source code, we should provide a macro or sigil that does the validation. The tails library solves for class merging and conditional classes. Class merging is necessary for allowing components to have their classes overridden by parents passing down classes(my preferred way of overriding components) and tails will do a semantic merge based on what classes “conflict” in tailwind.

I’m already working on a macro version of classes as well as a sigil. The macro version will, for now, warn on interpolation unless possible values for the interpolated expression is provided.

3 Likes

I was thinking on a opt-out way to do this, because my thinking is that new people coming to the ecosystem will have the most trouble not doing this mistake, moreover sane default options always beat optionals.

This, of course implies a conceptually different approach, however I think that would be possible if you would add it as an compiler in mix after elixir. I’m not sure about limitations or performance penalty on this, you should know this much better than me.

Do you think the sigil opt-in approach is more friendly?

Well, I’d love for it to be doable in phoenix directly, I even made a PR that would allow configuring a handler for all class properties, but it was rejected(no hard feelings). I use tails everywhere anyway :joy: . I do see that it kind of defeats the real goal of helping people avoid the footgun. A custom compiler could work. It would be…pretty difficult though. Likely filled with false negatives or false positives depending on the implementation.

1 Like

Maybe that would be possible in the future, I have a feeling that heex will evolve to the point that it will fully parse and understand the html, at least that seems the direction it is taking.

This is what I was afraid to hear, going into internal implementation details of phoenix (especially as a separated library) would be the same as shooting yourself in the foot, more potential problems than gain.

Okay, decided I don’t actually have time to do the thing I wanted :laughing: but PRs welcome if someone wants it. I did manage to add a sigil that should clean things up.

1 Like

We’ve had some really nice wins in this space by moving to using tailwind in conjunction css variables and data attributes.

This video is a good summary

We use it for a heap of different use cases. themes (dark, light, high-contrast etc) , status (warning, success, danger, info), surfaces (standard, faint, inverse), categorical charts colours etc. CSS variables are amazing and really under rated.

Once in place you end up with much cleaners views.

No more dark:text-white

No more explicit class definitions

defp badge_type (type) do
    case type do
      :success -> "badge-success"
      :warning -> "badge-warning"
      :error -> "badge-error"
      :inactive -> "badge-ghost"
      :not_found -> "badge-info"
    end
  end

If you adapt the fundamentals in that video, you’ll end up with something that looks like this:

<body data-theme={@theme} class="text-standard bg-standard">
    <div data-status={@status} class="bg-status text-status">
      <% @status.message %>
    </div>  
</body>

It also responds to any changes on the client. E.g. switching a data-theme from light to dark. You can even take this much further by allowing users to configure their own theme within the app and then assigning those values to your css variables. We haven’t tried this, but it’s certainly possible.

There’s a bit of upfront work to put your design system in place, but the flow on benefits have been huge for us.