Bradley Kirton's Blog

Published on June 24, 2023

Go home

A 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:

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:

@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

Output

Stylesheet

Rulesets