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")