v0.163.0
  1from __future__ import annotations
  2
  3import os
  4import platform
  5import subprocess
  6import sys
  7import tomllib
  8from pathlib import Path
  9from typing import Any
 10
 11import click
 12import httpx
 13import tomlkit
 14from plain.packages import packages_registry
 15from plain.runtime import APP_PATH, PLAIN_CACHE_PATH, PLAIN_TEMP_PATH, settings
 16
 17
 18class Tailwind:
 19    @property
 20    def target_directory(self) -> str:
 21        return str(PLAIN_TEMP_PATH)
 22
 23    def binary_path(self, version: str) -> Path:
 24        """Machine-level cache path for a specific Tailwind version."""
 25        filename = "tailwind.exe" if platform.system() == "Windows" else "tailwind"
 26        return PLAIN_CACHE_PATH / "tailwind" / version / filename
 27
 28    @property
 29    def src_css_path(self) -> Path:
 30        return settings.TAILWIND_SRC_PATH
 31
 32    @property
 33    def dist_css_path(self) -> Path:
 34        return settings.TAILWIND_DIST_PATH
 35
 36    def update_plain_sources(self) -> None:
 37        source_paths: list[str] = []
 38        import_paths: list[str] = []
 39        abs_app_path = APP_PATH.absolute()
 40
 41        def rel_to_target(p: Path) -> str:
 42            # CSS uses forward slashes regardless of platform, and backslashes
 43            # in CSS strings are escape sequences — so normalize to POSIX.
 44            return Path(os.path.relpath(p, self.target_directory)).as_posix()
 45
 46        for package_config in packages_registry.get_package_configs():
 47            abs_package_path = Path(package_config.path).absolute()
 48
 49            # App-local packages are already covered by Tailwind's default
 50            # scan of the project root, so we skip @source for them — but
 51            # still pick up any tailwind.css they contribute.
 52            if not abs_package_path.is_relative_to(abs_app_path):
 53                source_paths.append(rel_to_target(abs_package_path))
 54
 55            tailwind_css = abs_package_path / "tailwind.css"
 56            if tailwind_css.is_file():
 57                import_paths.append(rel_to_target(tailwind_css))
 58
 59        os.makedirs(self.target_directory, exist_ok=True)
 60        plain_sources_path = os.path.join(self.target_directory, "tailwind.css")
 61        with open(plain_sources_path, "w") as f:
 62            # @import rules must come before any other rules per the CSS spec.
 63            f.writelines(f'@import "{path}";\n' for path in import_paths)
 64            f.writelines(f'@source "{path}";\n' for path in source_paths)
 65
 66    def invoke(self, *args: Any, cwd: str | None = None) -> None:
 67        version = self.get_version_from_config()
 68        if not version:
 69            raise RuntimeError(
 70                "No Tailwind version configured in pyproject.toml — run `plain tailwind install`"
 71            )
 72        result = subprocess.run(
 73            [self.binary_path(version)] + list(args), cwd=cwd, check=False
 74        )
 75        if result.returncode != 0:
 76            sys.exit(result.returncode)
 77
 78    def is_installed(self) -> bool:
 79        version = self.get_version_from_config()
 80        return bool(version) and self.binary_path(version).exists()
 81
 82    def create_src_css(self) -> None:
 83        os.makedirs(os.path.dirname(self.src_css_path), exist_ok=True)
 84        with open(self.src_css_path, "w") as f:
 85            f.write("""@import "tailwindcss";\n@import "./.plain/tailwind.css";\n""")
 86
 87    def get_version_from_config(self) -> str:
 88        pyproject_path = os.path.join(
 89            os.path.dirname(self.target_directory), "pyproject.toml"
 90        )
 91
 92        if not os.path.exists(pyproject_path):
 93            return ""
 94
 95        with open(pyproject_path, "rb") as f:
 96            config = tomllib.load(f)
 97            return (
 98                config.get("tool", {})
 99                .get("plain", {})
100                .get("tailwind", {})
101                .get("version", "")
102            )
103
104    def set_version_in_config(self, version: str) -> None:
105        pyproject_path = os.path.join(
106            os.path.dirname(self.target_directory), "pyproject.toml"
107        )
108
109        with open(pyproject_path) as f:
110            config = tomlkit.load(f)
111
112        config.setdefault("tool", {}).setdefault("plain", {}).setdefault(
113            "tailwind", {}
114        )["version"] = version
115
116        with open(pyproject_path, "w") as f:
117            tomlkit.dump(config, f)
118
119    def download(self, version: str = "") -> str:
120        if version:
121            if not version.startswith("v"):
122                version = f"v{version}"
123            url = f"https://github.com/tailwindlabs/tailwindcss/releases/download/{version}/tailwindcss-{self.detect_platform_slug()}"
124        else:
125            url = f"https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-{self.detect_platform_slug()}"
126
127        headers = {
128            "Accept-Encoding": "gzip, deflate, br",
129            "User-Agent": "plain-tailwind/1.0",
130        }
131
132        # Download to a temp file first, then atomically move it into the
133        # versioned cache path (parallel checkouts can download concurrently).
134        download_dir = PLAIN_CACHE_PATH / "tailwind"
135        download_dir.mkdir(parents=True, exist_ok=True)
136        tmp_path = download_dir / f".download-{os.getpid()}"
137
138        try:
139            with (
140                httpx.Client(
141                    transport=httpx.HTTPTransport(retries=3),
142                    follow_redirects=True,
143                    timeout=300,
144                ) as client,
145                client.stream("GET", url, headers=headers) as response,
146            ):
147                response.raise_for_status()
148                total = int(response.headers.get("Content-Length", 0))
149
150                with (
151                    open(tmp_path, "wb") as f,
152                    click.progressbar(
153                        length=total,
154                        label="Downloading Tailwind",
155                        width=0,
156                    ) as bar,
157                ):
158                    for chunk in response.iter_bytes(chunk_size=1024 * 1024):
159                        if chunk:
160                            f.write(chunk)
161                            bar.update(len(chunk))
162
163            os.chmod(tmp_path, 0o755)
164
165            if not version:
166                # Get the version from the redirect chain (latest -> vX.Y.Z)
167                version = str(response.history[1].url).split("/")[-2]
168
169            version = version.lstrip("v")
170
171            binary_path = self.binary_path(version)
172            binary_path.parent.mkdir(parents=True, exist_ok=True)
173            os.replace(tmp_path, binary_path)
174        finally:
175            tmp_path.unlink(missing_ok=True)
176
177        return version
178
179    def install(self, version: str = "") -> str:
180        installed_version = self.download(version)
181        self.set_version_in_config(installed_version)
182        return installed_version
183
184    @staticmethod
185    def detect_platform_slug() -> str:
186        uname = platform.uname()[0]
187
188        if uname == "Windows":
189            return "windows-x64.exe"
190
191        if uname == "Linux" and platform.uname()[4] == "aarch64":
192            return "linux-arm64"
193
194        if uname == "Linux":
195            return "linux-x64"
196
197        if uname == "Darwin" and platform.uname().machine == "arm64":
198            return "macos-arm64"
199
200        if uname == "Darwin":
201            return "macos-x64"
202
203        raise RuntimeError("Unsupported platform for Tailwind standalone")