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