Plain is headed towards 1.0! Subscribe for development updates →

plain.tailwind

Integrate Tailwind CSS without JavaScript or npm.

Made possible by the Tailwind standalone CLI, which is installed for you.

$ plain tailwind
Usage: plain tailwind [OPTIONS] COMMAND [ARGS]...

  Tailwind CSS

Options:
  --help  Show this message and exit.

Commands:
  compile  Compile a Tailwind CSS file
  init     Install Tailwind, create a tailwind.config.js...
  update   Update the Tailwind CSS version

Installation

Add plain.tailwind to your INSTALLED_PACKAGES:

# settings.py
INSTALLED_PACKAGES = [
    # ...
    "plain.tailwind",
]

Create a new tailwind.config.js file in your project root:

plain tailwind init

This will also create a tailwind.css file at static/src/tailwind.css where additional CSS can be added. You can customize where these files are located if you need to, but this is the default (requires STATICFILES_DIR = BASE_DIR / "static").

The src/tailwind.css file is then compiled into dist/tailwind.css by running tailwind compile:

plain tailwind compile

When you're working locally, add --watch to automatically compile as changes are made:

plain tailwind compile --watch

Then include the compiled CSS in your base template <head>:

{% tailwind_css %}

In your repo you will notice a new .plain directory that contains tailwind (the standalone CLI binary) and tailwind.version (to track the version currently installed). You should add .plain to your .gitignore file.

Updating Tailwind

This package manages the Tailwind versioning by comparing the value in your pyproject.toml to .plain/tailwind.version.

# pyproject.toml
[tool.plain.tailwind]
version = "3.4.1"

When you run tailwind compile, it will automatically check whether your local installation needs to be updated and will update it if necessary.

You can use the update command to update your project to the latest version of Tailwind:

plain tailwind update

Adding custom CSS

If you need to actually write some CSS, it should be done in app/static/src/tailwind.css.

@tailwind base;


@tailwind components;

/* Add your own "components" here */
.btn {
    @apply bg-blue-500 hover:bg-blue-700 text-white;
}

@tailwind utilities;

/* Add your own "utilities" here */
.bg-pattern-stars {
    background-image: url("/static/images/stars.png");
}

Read the Tailwind docs for more about using custom styles →

Deployment

If possible, you should add static/dist/tailwind.css to your .gitignore and run the plain tailwind compile --minify command as a part of your deployment pipeline.

When you run plain tailwind compile, it will automatically check whether the Tailwind standalone CLI has been installed, and install it if it isn't.

When using Plain on Heroku, we do this for you automatically in our Plain buildpack.

  1import os
  2import platform
  3import subprocess
  4import sys
  5
  6import requests
  7import tomlkit
  8
  9from plain.runtime import settings
 10
 11DEFAULT_CSS = """@tailwind base;
 12
 13
 14@tailwind components;
 15
 16
 17@tailwind utilities;
 18"""
 19
 20
 21class Tailwind:
 22    @property
 23    def target_directory(self) -> str:
 24        return str(settings.PLAIN_TEMP_PATH)
 25
 26    @property
 27    def standalone_path(self) -> str:
 28        return os.path.join(self.target_directory, "tailwind")
 29
 30    @property
 31    def version_lockfile_path(self) -> str:
 32        return os.path.join(self.target_directory, "tailwind.version")
 33
 34    @property
 35    def config_path(self) -> str:
 36        return os.path.join(
 37            os.path.dirname(self.target_directory), "tailwind.config.js"
 38        )
 39
 40    @property
 41    def src_css_path(self) -> str:
 42        return settings.TAILWIND_SRC_PATH
 43
 44    @property
 45    def dist_css_path(self) -> str:
 46        return settings.TAILWIND_DIST_PATH
 47
 48    def invoke(self, *args, cwd=None) -> None:
 49        result = subprocess.run([self.standalone_path] + list(args), cwd=cwd)
 50        if result.returncode != 0:
 51            sys.exit(result.returncode)
 52
 53    def is_installed(self) -> bool:
 54        if not os.path.exists(self.target_directory):
 55            os.mkdir(self.target_directory)
 56        return os.path.exists(os.path.join(self.target_directory, "tailwind"))
 57
 58    def config_exists(self) -> bool:
 59        return os.path.exists(self.config_path)
 60
 61    def create_config(self):
 62        self.invoke("init", self.config_path)
 63
 64    def src_css_exists(self) -> bool:
 65        return os.path.exists(self.src_css_path)
 66
 67    def create_src_css(self):
 68        os.makedirs(os.path.dirname(self.src_css_path), exist_ok=True)
 69        with open(self.src_css_path, "w") as f:
 70            f.write(DEFAULT_CSS)
 71
 72    def needs_update(self) -> bool:
 73        if not os.path.exists(self.version_lockfile_path):
 74            return True
 75
 76        with open(self.version_lockfile_path) as f:
 77            locked_version = f.read().strip()
 78
 79        if locked_version != self.get_version_from_config():
 80            return True
 81
 82        return False
 83
 84    def get_version_from_config(self) -> str:
 85        pyproject_path = os.path.join(
 86            os.path.dirname(self.target_directory), "pyproject.toml"
 87        )
 88
 89        if not os.path.exists(pyproject_path):
 90            return ""
 91
 92        with open(pyproject_path) as f:
 93            config = tomlkit.load(f)
 94            return (
 95                config.get("tool", {})
 96                .get("plain", {})
 97                .get("tailwind", {})
 98                .get("version", "")
 99            )
100
101    def set_version_in_config(self, version):
102        pyproject_path = os.path.join(
103            os.path.dirname(self.target_directory), "pyproject.toml"
104        )
105
106        with open(pyproject_path) as f:
107            config = tomlkit.load(f)
108
109        config.setdefault("tool", {}).setdefault("plain", {}).setdefault(
110            "tailwind", {}
111        )["version"] = version
112
113        with open(pyproject_path, "w") as f:
114            tomlkit.dump(config, f)
115
116    def download(self, version="") -> str:
117        if version:
118            if not version.startswith("v"):
119                version = f"v{version}"
120            url = f"https://github.com/tailwindlabs/tailwindcss/releases/download/{version}/tailwindcss-{self.detect_platform_slug()}"
121        else:
122            url = f"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-{self.detect_platform_slug()}"
123
124        with requests.get(url, stream=True) as response:
125            response.raise_for_status()
126            with open(self.standalone_path, "wb") as f:
127                for chunk in response.iter_content(chunk_size=8192):
128                    f.write(chunk)
129
130        os.chmod(self.standalone_path, 0o755)
131
132        if not version:
133            # Get the version from the redirect chain (latest -> vX.Y.Z)
134            version = response.history[1].url.split("/")[-2]
135
136        version = version.lstrip("v")
137
138        with open(self.version_lockfile_path, "w") as f:
139            f.write(version)
140
141        return version
142
143    def install(self, version="") -> str:
144        installed_version = self.download(version)
145        self.set_version_in_config(installed_version)
146        return installed_version
147
148    @staticmethod
149    def detect_platform_slug() -> str:
150        uname = platform.uname()[0]
151
152        if uname == "Windows":
153            return "windows-x64.exe"
154
155        if uname == "Linux" and platform.uname()[4] == "aarch64":
156            return "linux-arm64"
157
158        if uname == "Linux":
159            return "linux-x64"
160
161        if uname == "Darwin" and platform.uname().machine == "arm64":
162            return "macos-arm64"
163
164        if uname == "Darwin":
165            return "macos-x64"
166
167        raise Exception("Unsupported platform for Tailwind standalone")