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