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