Published on June 24, 2023
Go homeA Python based TailwindCSS Experiment
I decided to experiment with writing my own version of TailwindCSS. The POC I have is called headwind for now and only supports a few utility classes. I am not convinced the API I have created is good. Having said that, as with all things programming related, the easy stuff is well easy.
I have followed a similar approach to tailwind and unocss in that the headwind
makes use of regular expressions to match the utility classes.
But why?
I have always wished that tailwind
was a high level language specification with many implementations. This is the idea behind TurboCSS which I really like in principle. If this were true you could have a tailwind implementation written in .
With multiple implementations, building an application with anything besides javascript
would mean not requiring node or node modules to generate your stylesheets. This idea is achievable to some extent since the release of the tailwind Standalone-CLI. In the python
ecosystem we have the pytailwindcss package which bundles the standalone-cli
and allows you to use it without explicitly installing nodejs
. There are limitations when using the standalone-cli
, specifically you can't make use of 3rd party plugins because that requires node modules. Irrespective of this, this is how I am using tailwind for most of the work I am currently doing and I am very happy with it.
However I do still feel like build processes would be simpler if they just required a single language. This is partially why I decided to build this POC. The rest of my motivation for building this POC is because it is fun and because I can. Having said that, I strongly believe that to build a product that is as polished as tailwind
is an immense amount of work and therefore this POC will likely remain a POC.
Ideas
Some of the ideas I had when I was initially thinking about this POC included:
- Render the styles for different screens to separate files. These files could be loaded using the
media
attribute of the link element. Themedia
attribute will ensure that only the styles required by the device are loaded. This would reduce the bundle sizes and allow us to ship less to the clients. - Use sqlite to cache the rendered CSS rulesets. Given that the utility definitions are quite static there is an opportunity to store the rendered rulesets. I can imagine just adding the rulesets that are not already rendered to a sqlite database and then extracting the stylesheets with a sql query.
- Allow for using a model to provide the values in the design system. So rather than using a set of pre-defined values for each value of a utility class, for example h-7 equals 1.75rem, we could provide a curve which allows us to extend the design system beyond a pre-defined set of values.
- Integrate it into
django
as a package.
API
Ruleset
My naive POC currently models a CSS ruleset as follows.
@dataclasses.dataclass
class RuleSet:
"""Models a CSS ruleset."""
selector: str
screen_size: str
declaration: str
def render(self) -> str:
"""Render the CSS ruleset."""
return f"{self.selector}{{{self.declaration}}}"
The ruleset can be rendered into CSS by calling the render function. At the moment the screen size
is not included in the rendering as a media query. The idea I have behind this is that perhaps it would be more optimal to render the styles for different screen sizes into separate files and then load these files using the media
attribute on the link tag.
<link rel="stylesheet" type="text/css" href="styles.css"> <!-- This would include all non specific styles -->
<link rel="stylesheet" type="text/css" href="styles.sm.css" media="(min-width: 640px)">
<link rel="stylesheet" type="text/css" href="styles.md.css" media="(min-width: 768px)">
<link rel="stylesheet" type="text/css" href="styles.lg.css" media="(min-width: 1024px)">
<link rel="stylesheet" type="text/css" href="styles.xl.css" media="(min-width: 1280px)">
<link rel="stylesheet" type="text/css" href="styles.2xl.css" media="(min-width: 1536px)">
Given the above strategy only the styles for your device will be loaded. This could result in even smaller bundles.
Extractor
The extractor defines the interface which is used to match utility classes and extract rulesets from these matches. I came up with the following API for the extractor.
class Extractor(abc.ABC):
"""An extractor is responsible for extracting a ruleset from a utility class."""
@abc.abstractmethod
def get_pattern(self) -> re.Pattern:
"""Return a compiled regex pattern.
This regex pattern can be used for matching utility classes.
"""
@abc.abstractmethod
def __call__(self, text: str) -> RuleSet:
"""Extract a ruleset from the provided text."""
HeadwindExtractor
The HeadwindExtractor
is the unfinished and incorrect tailwind
implementation. The program accepts a regular expression for matching the utility class declaration, a list of pseudo classes and screen sizes and a DeclarationRenderer
.
When called the program does the following:
- Extract selector
- Extract screen size
- Extract declaration
- Return a
Ruleset
@dataclasses.dataclass
class HeadwindExtractor(Extractor):
"""A tailwind like implementation."""
_declaration_regex: str
_pseudo_classes: list[str]
_screen_sizes: list[str]
_render_declaration: DeclarationRenderer
def __post_init__(self) -> None:
self._declaration_pattern = re.compile(self._declaration_regex)
self._pseudo_class_pattern = re.compile(r"(:?{}):*".format(
"|".join(self._pseudo_classes))
)
self._screen_size_pattern = re.compile(r"(:?{}):*".format(
"|".join(self._screen_sizes))
)
def _extract_selector(self, text: str) -> str:
selector = text.replace(":", r"\:")
tokens = self._pseudo_class_pattern.findall(text)
if len(tokens) != len(set(tokens)):
raise ValueError(f"Duplicate pseudo classes found {tokens=}")
match tokens:
case []:
suffix = ""
case _:
pseudo_classes = ":".join(tokens)
suffix = f":{pseudo_classes}"
return f".{selector}{suffix}"
def _extract_screen_size(self, text: str) -> str:
tokens = self._screen_size_pattern.findall(text)
match tokens:
case []:
return ""
case [value]:
return value
case _:
raise ValueError(
"Only one screen size modifier is permitted per style block."
)
def _extract_declaration(self, text: str) -> str:
if match := self._declaration_pattern.search(text):
tokens = match.groups()
return self._render_declaration(tokens=tokens)
else:
raise ValueError(f"Failed to extract declaration {text=}")
@classmethod
def build(
cls,
declaration_regex: str,
render_declaration: DeclarationRenderer,
) -> "HeadwindExtractor":
return cls(
_declaration_regex=declaration_regex,
_pseudo_classes=["hover", "focus"],
_screen_sizes=["sm", "md", "lg", "xl", "2xl"],
_render_declaration=render_declaration,
)
def __call__(self, text: str) -> RuleSet:
selector = self._extract_selector(text=text)
screen_size = self._extract_screen_size(text=text)
declaration = self._extract_declaration(text=text)
return RuleSet(
selector=selector,
screen_size=screen_size,
declaration=declaration,
)
def get_pattern(self) -> re.Pattern:
modifiers = self._pseudo_classes + self._screen_sizes
serialized_modifiers = "|".join(modifiers)
modifiers_regex = r"((:?{serialized_modifiers}):)*".format(
serialized_modifiers=serialized_modifiers
)
regex = r"{modifiers_regex}{declaration_regex}".format(
declaration_regex=self._declaration_regex,
modifiers_regex=modifiers_regex,
)
return re.compile(regex)
A DeclarationRenderer
is a callable which simply takes a tuple of matched declaration tokens and returns a rendered CSS style declaration. Below is an example of a declaration renderer for the margin utility.
class DeclarationRenderer(t.Protocol):
"""Describes the interface of a declaration renderer."""
def __call__(self, tokens: tuple[str]) -> str:
...
def render_margin_declaration(tokens: tuple[str]) -> str:
"""Render the CSS declaration for the margin utility."""
match tokens:
case ["m", raw_value]:
value = float(raw_value) / 4
declaration = f"margin:{value}rem;"
case ["mt", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-top:{value}rem;"
case ["mr", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-right:{value}rem;"
case ["mb", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-bottom:{value}rem;"
case ["ml", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-left:{value}rem;"
case ["mx", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-left:{value}rem;margin-right:{value}rem;"
case ["my", raw_value]:
value = float(raw_value) / 4
declaration = f"margin-top:{value}rem;margin-bottom:{value}rem;"
case _:
raise ValueError(f"Failed to parse margin {tokens=}")
return declaration
Given the above we could process a template as follows.
extractors = [
HeadwindExtractor.build(
declaration_regex=r"\b(m[trblxy]?)-(\d+)\b",
render_declaration=render_margin_declaration,
)
]
template = '<button class="px-2 py-2">Hello</button>'
for extractor in extractors:
pattern = extractor.get_pattern()
for match in pattern.finditer(template):
text = match.group()
try:
ruleset = extractor(text=text)
except Exception:
continue
rendered_css = ruleset.render() # Do something with rendered_css
Playground
This playground processes the textarea below and prints out the ruleset and rendered CSS.
The following tailwind
utilities are supported.
p,m,flex,grid,block,inline-block,justify,items