tailwhip 0.9a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tailwhip/__init__.py +11 -0
- tailwhip/__main__.py +8 -0
- tailwhip/cli.py +148 -0
- tailwhip/constants.py +311 -0
- tailwhip/datatypes.py +23 -0
- tailwhip/files.py +170 -0
- tailwhip/process.py +240 -0
- tailwhip/sorting.py +243 -0
- tailwhip/tests/__init__.py +1 -0
- tailwhip/tests/conftest.py +33 -0
- tailwhip/tests/test_cli.py +36 -0
- tailwhip/tests/test_file_finder.py +219 -0
- tailwhip/tests/test_sorting.py +366 -0
- tailwhip-0.9a3.dist-info/METADATA +92 -0
- tailwhip-0.9a3.dist-info/RECORD +19 -0
- tailwhip-0.9a3.dist-info/WHEEL +5 -0
- tailwhip-0.9a3.dist-info/entry_points.txt +2 -0
- tailwhip-0.9a3.dist-info/licenses/LICENSE +25 -0
- tailwhip-0.9a3.dist-info/top_level.txt +1 -0
tailwhip/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Tailwhip - Sort Tailwind CSS classes in HTML and CSS files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
from tailwhip.cli import main
|
|
8
|
+
|
|
9
|
+
__all__ = ["main"]
|
|
10
|
+
__version__ = metadata.version("tailwhip")
|
|
11
|
+
__author__ = "Martin Mahner"
|
tailwhip/__main__.py
ADDED
tailwhip/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path # noqa: TC003
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from tailwhip.constants import CONSOLE_THEME, GLOBS, SKIP_EXPRESSIONS, VERBOSITY_LOUD
|
|
15
|
+
from tailwhip.datatypes import Config
|
|
16
|
+
from tailwhip.files import apply_changes, find_files
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def version_callback(value: bool) -> None:
|
|
20
|
+
"""Print version and exit."""
|
|
21
|
+
if value:
|
|
22
|
+
version = metadata.version("tailwhip")
|
|
23
|
+
typer.echo(f"tailwhip {version}")
|
|
24
|
+
raise typer.Exit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
help="Sort Tailwind CSS classes in HTML and CSS files",
|
|
29
|
+
add_completion=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
"""Entrypoint for the CLI."""
|
|
35
|
+
app()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def run( # noqa: PLR0913
|
|
40
|
+
paths: Annotated[
|
|
41
|
+
list[Path],
|
|
42
|
+
typer.Argument(
|
|
43
|
+
help=(
|
|
44
|
+
f"File or directory paths to process. Plain directories "
|
|
45
|
+
f"(e.g., 'templates/') use default patterns ({', '.join(GLOBS)}). "
|
|
46
|
+
f"Glob patterns (e.g., 'templates/**/*.postcss') are matched as-is."
|
|
47
|
+
)
|
|
48
|
+
),
|
|
49
|
+
],
|
|
50
|
+
version: Annotated[ # noqa: ARG001
|
|
51
|
+
bool,
|
|
52
|
+
typer.Option(
|
|
53
|
+
"--version",
|
|
54
|
+
callback=version_callback,
|
|
55
|
+
is_eager=True,
|
|
56
|
+
help="Show version and exit.",
|
|
57
|
+
),
|
|
58
|
+
] = False,
|
|
59
|
+
write_mode: Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--write",
|
|
63
|
+
help="Apply sorting changes to files. Without this flag, runs in check-only mode to preview changes.",
|
|
64
|
+
),
|
|
65
|
+
] = False,
|
|
66
|
+
skip_expressions: Annotated[
|
|
67
|
+
str,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--skip-expressions",
|
|
70
|
+
help="Template syntax markers that indicate code blocks to skip sorting. Comma-separated list (e.g., '{{,{%,<%'). Prevents sorting dynamic template expressions.",
|
|
71
|
+
),
|
|
72
|
+
] = ",".join(SKIP_EXPRESSIONS),
|
|
73
|
+
quiet: Annotated[
|
|
74
|
+
bool,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--quiet",
|
|
77
|
+
"-q",
|
|
78
|
+
help="Minimal output mode. Only displays errors and warnings.",
|
|
79
|
+
),
|
|
80
|
+
] = False,
|
|
81
|
+
verbosity: Annotated[
|
|
82
|
+
int,
|
|
83
|
+
typer.Option(
|
|
84
|
+
"--verbose",
|
|
85
|
+
"-v",
|
|
86
|
+
count=True,
|
|
87
|
+
help="Increase output detail level. Use -v for changes, -vv for file processing, -vvv for debug info.",
|
|
88
|
+
),
|
|
89
|
+
] = 0,
|
|
90
|
+
custom_colors: Annotated[
|
|
91
|
+
str,
|
|
92
|
+
typer.Option(
|
|
93
|
+
"--custom-colors",
|
|
94
|
+
help=(
|
|
95
|
+
"Comma-separated list of custom color names from your Tailwind config "
|
|
96
|
+
"(e.g., 'brand,company,accent'). These colors will be recognized and sorted "
|
|
97
|
+
"alongside standard Tailwind colors. WARNING: Classes using these custom colors "
|
|
98
|
+
"will sort differently if you run tailwhip again without providing the same "
|
|
99
|
+
"--custom-colors values. Omitting previously-used custom colors will cause those "
|
|
100
|
+
"utilities to be treated as non-color classes and sorted into different positions."
|
|
101
|
+
),
|
|
102
|
+
),
|
|
103
|
+
] = "",
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Sort Tailwind CSS classes in HTML and CSS files."""
|
|
106
|
+
console = Console(quiet=quiet, theme=CONSOLE_THEME)
|
|
107
|
+
skip_expressions = [
|
|
108
|
+
expr.strip() for expr in skip_expressions.split(",") if expr.strip()
|
|
109
|
+
]
|
|
110
|
+
custom_colors_list = {
|
|
111
|
+
color.strip() for color in custom_colors.split(",") if color.strip()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
config = Config(
|
|
115
|
+
console=console,
|
|
116
|
+
paths=paths,
|
|
117
|
+
write=write_mode,
|
|
118
|
+
skip_expressions=skip_expressions,
|
|
119
|
+
verbosity=verbosity + 1,
|
|
120
|
+
custom_colors=custom_colors_list,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
console.print("")
|
|
124
|
+
|
|
125
|
+
start_time = time.time()
|
|
126
|
+
targets = find_files(config=config)
|
|
127
|
+
found_any, skipped, changed = apply_changes(targets=targets, config=config)
|
|
128
|
+
duration = time.time() - start_time
|
|
129
|
+
|
|
130
|
+
if not found_any:
|
|
131
|
+
config.console.print("[red]Error: No files found[/red]")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
if config.verbosity < VERBOSITY_LOUD:
|
|
135
|
+
console.print(
|
|
136
|
+
"\nUse [important] -v [/important] (show unchanged files) or [important] -vv [/important] (show diff preview) for more detail."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if not config.write:
|
|
140
|
+
console.print(
|
|
141
|
+
"\n:warning: Dry Run. No files were actually written. "
|
|
142
|
+
"Use [important] --write [/important] to write changes."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
console.print(
|
|
146
|
+
f"⏱ Completed in [bold]{duration:.3f}s[/bold] for {changed} files. [dim]({skipped} skipped)[/dim]",
|
|
147
|
+
highlight=False,
|
|
148
|
+
)
|
tailwhip/constants.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Constants used by tailwhip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from rich.theme import Theme
|
|
8
|
+
|
|
9
|
+
# File glob(s) to process
|
|
10
|
+
GLOBS = ["**/*.html", "**/*.css"]
|
|
11
|
+
|
|
12
|
+
# Skip if a template expression appears inside the class attribute
|
|
13
|
+
# List of strings that indicate template expressions to skip
|
|
14
|
+
SKIP_EXPRESSIONS = ["{{", "{%", "<%"]
|
|
15
|
+
|
|
16
|
+
# Recognize Tailwind variants (order doesn't matter; we sort lexicographically)
|
|
17
|
+
# e.g. "sm:hover:focus:text-blue-500" => variants ["focus", "hover", "sm"]
|
|
18
|
+
VARIANT_SEP = ":"
|
|
19
|
+
|
|
20
|
+
# A lightweight utility ranking derived from common Tailwind "best practice" ordering.
|
|
21
|
+
# This is not exhaustive, but it's practical. Unknown utilities fall to the end.
|
|
22
|
+
#
|
|
23
|
+
# SORTING MECHANISM: ------------------
|
|
24
|
+
#
|
|
25
|
+
# Each regex pattern in this list defines a GROUP. Groups are matched in order (top
|
|
26
|
+
# to bottom). When a utility class is encountered, it's tested against each pattern
|
|
27
|
+
# until a match is found. The matched group's index becomes the utility's rank (lower
|
|
28
|
+
# index = sorts earlier).
|
|
29
|
+
#
|
|
30
|
+
# WHY MULTIPLE PATTERNS IN ONE GROUP?
|
|
31
|
+
#
|
|
32
|
+
# Some logical categories (e.g., "Layout & display") contain multiple related patterns.
|
|
33
|
+
# These are kept as separate regex entries rather than one mega-pattern because:
|
|
34
|
+
#
|
|
35
|
+
# 1. Readability: Easier to see what utilities belong together
|
|
36
|
+
# 2. Maintainability: Simpler to add/remove/modify individual patterns
|
|
37
|
+
# 3. Order control: You can fine-tune the sort order within a category
|
|
38
|
+
# (e.g., "flex" before "grid" before "table")
|
|
39
|
+
#
|
|
40
|
+
# WHY SINGLE PATTERNS?
|
|
41
|
+
#
|
|
42
|
+
# Single patterns are used when:
|
|
43
|
+
#
|
|
44
|
+
# - The utility is standalone or unique (e.g., "container")
|
|
45
|
+
# - It needs precise positioning in the sort order
|
|
46
|
+
# - It's a catch-all for a prefix (e.g., "text-" matches text-sm, text-lg, etc.)
|
|
47
|
+
#
|
|
48
|
+
# WITHIN-GROUP SORTING:
|
|
49
|
+
#
|
|
50
|
+
# Classes matching the SAME group index are then sorted:
|
|
51
|
+
#
|
|
52
|
+
# 1. Non-color utilities before color utilities
|
|
53
|
+
# 2. Alphabetically by the full class name
|
|
54
|
+
#
|
|
55
|
+
# Example: "flex", "flex-col", "flex-row" all match r"(flex|inline-flex)" and sort alphabetically.
|
|
56
|
+
#
|
|
57
|
+
GROUPS = [
|
|
58
|
+
#
|
|
59
|
+
# 0) Components (Tailwind "components" layer comes before utilities)
|
|
60
|
+
r"(^| )(container)( |$)",
|
|
61
|
+
#
|
|
62
|
+
# 1) Layout & display
|
|
63
|
+
r"(hidden|visible|invisible)",
|
|
64
|
+
r"(z)-",
|
|
65
|
+
r"(block|inline-block|inline|flow-root|contents)",
|
|
66
|
+
r"(flex|inline-flex)",
|
|
67
|
+
r"(grid|inline-grid)",
|
|
68
|
+
r"(table|inline-table)",
|
|
69
|
+
r"(box-border|box-content|box-decoration-(slice|clone))",
|
|
70
|
+
r"(isolate|isolation-auto)",
|
|
71
|
+
r"(static|fixed|absolute|relative|sticky)",
|
|
72
|
+
r"(inset|top|right|bottom|left|z)-",
|
|
73
|
+
r"(float|clear)-(left|right|none|start|end)",
|
|
74
|
+
#
|
|
75
|
+
# floats/clears
|
|
76
|
+
r"(columns)-",
|
|
77
|
+
#
|
|
78
|
+
# CSS multi-column
|
|
79
|
+
r"(break)-(inside|before|after)-",
|
|
80
|
+
#
|
|
81
|
+
# page/column breaks
|
|
82
|
+
r"(overflow|overscroll)-",
|
|
83
|
+
#
|
|
84
|
+
# scrolling/overflow
|
|
85
|
+
r"(scroll|scroll-m|scroll-p|scroll-smooth)",
|
|
86
|
+
#
|
|
87
|
+
# scroll behavior & margins
|
|
88
|
+
r"(snap)-",
|
|
89
|
+
#
|
|
90
|
+
# 2) Flexbox & Grid
|
|
91
|
+
r"(order|grow|shrink|basis)-",
|
|
92
|
+
r"(grid-cols|grid-rows|grid-flow|auto-cols|auto-rows)-",
|
|
93
|
+
r"(col-(span|start|end)|row-(span|start|end))-",
|
|
94
|
+
r"(gap|space-[xy])-",
|
|
95
|
+
#
|
|
96
|
+
# 3) Alignment & distribution (box alignment)
|
|
97
|
+
r"(place|content|items|self|justify|align)-",
|
|
98
|
+
#
|
|
99
|
+
# 4) Sizing
|
|
100
|
+
r"(size)-",
|
|
101
|
+
r"(w)-",
|
|
102
|
+
r"(min-w)-",
|
|
103
|
+
r"(max-w)-",
|
|
104
|
+
r"(h)-",
|
|
105
|
+
r"(min-h)-",
|
|
106
|
+
r"(max-h)-",
|
|
107
|
+
r"(aspect)-",
|
|
108
|
+
#
|
|
109
|
+
# 5) Spacing (x,y,t,r,b,l)
|
|
110
|
+
r"(m)-",
|
|
111
|
+
r"(mx)-",
|
|
112
|
+
r"(my)-",
|
|
113
|
+
r"(mt)-",
|
|
114
|
+
r"(mr)-",
|
|
115
|
+
r"(mb)-",
|
|
116
|
+
r"(ml)-",
|
|
117
|
+
r"(p)-",
|
|
118
|
+
r"(px)-",
|
|
119
|
+
r"(py)-",
|
|
120
|
+
r"(pt)-",
|
|
121
|
+
r"(pr)-",
|
|
122
|
+
r"(pb)-",
|
|
123
|
+
r"(pl)-",
|
|
124
|
+
#
|
|
125
|
+
# 6) Typography
|
|
126
|
+
r"(font)-",
|
|
127
|
+
r"(text-(balance|pretty|wrap))",
|
|
128
|
+
r"(text)-",
|
|
129
|
+
r"(tracking)-",
|
|
130
|
+
r"(leading)-",
|
|
131
|
+
r"(list)-",
|
|
132
|
+
r"(indent)-",
|
|
133
|
+
r"(placeholder)-",
|
|
134
|
+
r"(whitespace|break|hyphens)-",
|
|
135
|
+
r"(tab-size)-",
|
|
136
|
+
r"(line-clamp)-",
|
|
137
|
+
#
|
|
138
|
+
# official plugin utility
|
|
139
|
+
r"(antialiased|subpixel-antialiased|truncate)",
|
|
140
|
+
#
|
|
141
|
+
# single-token type utils
|
|
142
|
+
r"(tabular-nums|slashed-zero|lining-nums|oldstyle-nums|proportional-nums|ordinal)",
|
|
143
|
+
#
|
|
144
|
+
# content-['…'] utilities
|
|
145
|
+
r"(content)-",
|
|
146
|
+
#
|
|
147
|
+
# 7) Backgrounds
|
|
148
|
+
# includes bg-clip, -origin, -repeat, -size, -position, -attachment, colors
|
|
149
|
+
r"(bg)-",
|
|
150
|
+
#
|
|
151
|
+
# gradients
|
|
152
|
+
r"(from)-",
|
|
153
|
+
r"(to)-",
|
|
154
|
+
r"(via)-",
|
|
155
|
+
#
|
|
156
|
+
#
|
|
157
|
+
# 8) Borders & outlines & rings & divide
|
|
158
|
+
r"(rounded)-",
|
|
159
|
+
r"(border)-",
|
|
160
|
+
r"(divide)-",
|
|
161
|
+
r"(ring)-",
|
|
162
|
+
r"(outline)-",
|
|
163
|
+
#
|
|
164
|
+
# 9) Effects
|
|
165
|
+
r"(shadow)-",
|
|
166
|
+
r"(opacity)-",
|
|
167
|
+
r"(mix-blend)-",
|
|
168
|
+
r"(backdrop|filter|blur|brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia|drop-shadow)-",
|
|
169
|
+
#
|
|
170
|
+
# 10) Transforms
|
|
171
|
+
r"(transform|scale|rotate|translate|skew|origin)-",
|
|
172
|
+
#
|
|
173
|
+
# 11) Transitions & animation
|
|
174
|
+
r"(transition|duration|ease|delay|animate)-",
|
|
175
|
+
#
|
|
176
|
+
# 12) Interactivity & misc
|
|
177
|
+
r"(cursor|select|pointer-events|resize|appearance|accent|caret|touch)-",
|
|
178
|
+
r"(object)-",
|
|
179
|
+
#
|
|
180
|
+
# object-fit/position
|
|
181
|
+
r"(outline-none)",
|
|
182
|
+
#
|
|
183
|
+
# single token
|
|
184
|
+
r"(stroke|fill)-",
|
|
185
|
+
#
|
|
186
|
+
# SVG
|
|
187
|
+
#
|
|
188
|
+
# 13) Accessibility helpers (kept near the end so utility groups dominate)
|
|
189
|
+
r"(sr-only|not-sr-only)",
|
|
190
|
+
#
|
|
191
|
+
# 14) Group/peer & attribute base selectors (as raw classes, not variants)
|
|
192
|
+
r"(peer|group|aria-|data-)(:|$)",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
VARIANT_PREFIX_ORDER = [
|
|
196
|
+
#
|
|
197
|
+
# Container queries (most specific, come first)
|
|
198
|
+
r"@container:",
|
|
199
|
+
r"@\w+:",
|
|
200
|
+
#
|
|
201
|
+
# Direction & theme
|
|
202
|
+
r"(rtl|ltr):",
|
|
203
|
+
r"dark:",
|
|
204
|
+
#
|
|
205
|
+
# Media-like variants
|
|
206
|
+
r"(portrait|landscape|print):",
|
|
207
|
+
r"(motion-safe|motion-reduce):",
|
|
208
|
+
r"(contrast-more|contrast-less):",
|
|
209
|
+
#
|
|
210
|
+
# Container & size queries (if using v4 container queries)
|
|
211
|
+
r"(supports-\[.*\]|supports-.*:)",
|
|
212
|
+
#
|
|
213
|
+
# supports: variant patterns
|
|
214
|
+
r"(size-.*:|cq-.*:)",
|
|
215
|
+
#
|
|
216
|
+
# Breakpoints (mobile-first)
|
|
217
|
+
r"min-\[.*\]:",
|
|
218
|
+
r"max-\[.*\]:",
|
|
219
|
+
r"sm:",
|
|
220
|
+
r"md:",
|
|
221
|
+
r"lg:",
|
|
222
|
+
r"xl:",
|
|
223
|
+
r"2xl:",
|
|
224
|
+
#
|
|
225
|
+
# Structural/state
|
|
226
|
+
r"(first):",
|
|
227
|
+
r"(last):",
|
|
228
|
+
r"(only):",
|
|
229
|
+
r"(odd):",
|
|
230
|
+
r"(even):",
|
|
231
|
+
r"(first-of-type|last-of-type|only-of-type):",
|
|
232
|
+
r"(visited|target|open|empty):",
|
|
233
|
+
r"(enabled|disabled|read-only|required|optional):",
|
|
234
|
+
r"(checked|indeterminate|default):",
|
|
235
|
+
r"(valid|invalid):",
|
|
236
|
+
r"(in-range|out-of-range):",
|
|
237
|
+
r"(placeholder-shown):",
|
|
238
|
+
r"(autofill):",
|
|
239
|
+
r"(focus-within):",
|
|
240
|
+
r"(hover|focus|focus-visible|focus-within|active):",
|
|
241
|
+
r"(pressed|selected):",
|
|
242
|
+
r"(current):",
|
|
243
|
+
r"(aria-.*:|data-.*:)",
|
|
244
|
+
#
|
|
245
|
+
# attribute variants
|
|
246
|
+
r"(group(-\[.*\])?:.*:|group-.*:)",
|
|
247
|
+
#
|
|
248
|
+
# group/compound (e.g. group-hover:)
|
|
249
|
+
r"(peer(-\[.*\])?:.*:|peer-.*:)",
|
|
250
|
+
#
|
|
251
|
+
# peer/compound (e.g. peer-checked:)
|
|
252
|
+
r"(marker:|selection:|file:|placeholder:|backdrop:|first-letter:|first-line:)",
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# Standard Tailwind color names (extracted from the palette)
|
|
257
|
+
TAILWIND_COLORS = {
|
|
258
|
+
"transparent",
|
|
259
|
+
"current",
|
|
260
|
+
"black",
|
|
261
|
+
"white",
|
|
262
|
+
"amber",
|
|
263
|
+
"blue",
|
|
264
|
+
"blue-gray",
|
|
265
|
+
"cool-gray",
|
|
266
|
+
"cyan",
|
|
267
|
+
"emerald",
|
|
268
|
+
"fuchsia",
|
|
269
|
+
"gray",
|
|
270
|
+
"green",
|
|
271
|
+
"indigo",
|
|
272
|
+
"light-blue",
|
|
273
|
+
"lime",
|
|
274
|
+
"orange",
|
|
275
|
+
"pink",
|
|
276
|
+
"purple",
|
|
277
|
+
"red",
|
|
278
|
+
"rose",
|
|
279
|
+
"sky",
|
|
280
|
+
"teal",
|
|
281
|
+
"true-gray",
|
|
282
|
+
"violet",
|
|
283
|
+
"warm-gray",
|
|
284
|
+
"yellow",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
GROUP_PATTERNS = [re.compile("^" + g) for g in GROUPS]
|
|
289
|
+
|
|
290
|
+
CLASS_ATTR_RE = re.compile(
|
|
291
|
+
r"""(?P<full>\bclass\s*=\s*(?P<quote>["'])(?P<val>.*?)(?P=quote))""",
|
|
292
|
+
re.IGNORECASE | re.DOTALL,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
APPLY_RE = re.compile(
|
|
296
|
+
r"""@apply\s+(?P<classes>[^;]+);""",
|
|
297
|
+
re.MULTILINE,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
CONSOLE_THEME = Theme(
|
|
301
|
+
{
|
|
302
|
+
"important": "white on deep_pink4",
|
|
303
|
+
"highlight": "yellow1",
|
|
304
|
+
"filename": "white",
|
|
305
|
+
"bold": "sky_blue1",
|
|
306
|
+
}
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
VERBOSITY_NONE = 0
|
|
310
|
+
VERBOSITY_LOUD = 2
|
|
311
|
+
VERBOSITY_ALL = 3
|
tailwhip/datatypes.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Datatypes for tailwhip."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Config:
|
|
16
|
+
"""Argument parser config."""
|
|
17
|
+
|
|
18
|
+
console: Console
|
|
19
|
+
paths: list[Path]
|
|
20
|
+
write: bool
|
|
21
|
+
skip_expressions: list[str]
|
|
22
|
+
verbosity: int
|
|
23
|
+
custom_colors: set[str]
|
tailwhip/files.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""File-related utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from rich.padding import Padding
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
|
|
12
|
+
from tailwhip.constants import GLOBS, VERBOSITY_ALL, VERBOSITY_LOUD, VERBOSITY_NONE
|
|
13
|
+
from tailwhip.process import process_text
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Generator, Iterable
|
|
17
|
+
|
|
18
|
+
from tailwhip.datatypes import Config
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_files(*, config: Config) -> Generator[Path]: # noqa: C901
|
|
22
|
+
"""Find all HTML/CSS files from a list of paths.
|
|
23
|
+
|
|
24
|
+
Processes multiple path inputs (files, directories, or glob patterns), expands each
|
|
25
|
+
one, and returns a deduplicated generator of all matching HTML/CSS files. If no paths
|
|
26
|
+
are provided, defaults to scanning the current directory.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
config: Argument configuration object
|
|
30
|
+
|
|
31
|
+
Yields:
|
|
32
|
+
Path objects for all HTML/CSS files found (deduplicated and sorted)
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
>>> config.paths = [Path('templates/'), Path('styles/')]
|
|
36
|
+
>>> list(find_files(config))
|
|
37
|
+
[PosixPath('templates/index.html'), PosixPath('styles/main.css')]
|
|
38
|
+
|
|
39
|
+
>>> config.paths = [Path('src/**/*.html'), Path('components/**/*.html')]
|
|
40
|
+
>>> list(find_files(config))
|
|
41
|
+
[PosixPath('src/pages/home.html'), PosixPath('components/nav.html')]
|
|
42
|
+
|
|
43
|
+
>>> config.paths = []
|
|
44
|
+
>>> list(find_files(config)) # Defaults to current directory
|
|
45
|
+
[PosixPath('./index.html'), PosixPath('./styles.css')]
|
|
46
|
+
|
|
47
|
+
>>> config.paths = [Path('index.html'), Path('about.html'), Path('index.html')]
|
|
48
|
+
>>> list(find_files(config))
|
|
49
|
+
[PosixPath('index.html'), PosixPath('about.html')] # Deduplication
|
|
50
|
+
|
|
51
|
+
>>> config.paths = [Path('*.html'), Path('static/*.css')]
|
|
52
|
+
>>> list(find_files(config))
|
|
53
|
+
[PosixPath('home.html'), PosixPath('static/app.css')]
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
seen = set()
|
|
57
|
+
|
|
58
|
+
for entry in config.paths:
|
|
59
|
+
p = Path(entry)
|
|
60
|
+
|
|
61
|
+
# Case 1: Existing directory - search within it using configured glob patterns
|
|
62
|
+
# Example: entry="src/" with config.globs=["**/*.html"]
|
|
63
|
+
# → yields: src/index.html, src/pages/about.html
|
|
64
|
+
if p.is_dir():
|
|
65
|
+
for pattern in GLOBS:
|
|
66
|
+
for match in p.glob(pattern):
|
|
67
|
+
resolved = match.resolve()
|
|
68
|
+
if resolved not in seen:
|
|
69
|
+
seen.add(resolved)
|
|
70
|
+
yield resolved
|
|
71
|
+
|
|
72
|
+
# Case 2: Existing file - yield it directly without pattern matching
|
|
73
|
+
# Example: entry="index.html" (file exists)
|
|
74
|
+
# → yields: /absolute/path/to/index.html
|
|
75
|
+
elif p.is_file():
|
|
76
|
+
resolved = p.resolve()
|
|
77
|
+
if resolved not in seen:
|
|
78
|
+
seen.add(resolved)
|
|
79
|
+
yield resolved
|
|
80
|
+
|
|
81
|
+
# Case 3: Non-existent path - interpret as recursive glob pattern from cwd
|
|
82
|
+
# Example: entry="**/*.css" or entry="templates/*.html" (path doesn't exist as literal)
|
|
83
|
+
# → yields: styles/main.css, components/button.css (all matching files from cwd)
|
|
84
|
+
else:
|
|
85
|
+
for match in Path().rglob(str(entry)):
|
|
86
|
+
if match.is_file():
|
|
87
|
+
resolved = match.resolve()
|
|
88
|
+
if resolved not in seen:
|
|
89
|
+
seen.add(resolved)
|
|
90
|
+
yield resolved
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_diff(path: Path, old_text: str, new_text: str) -> Syntax:
|
|
94
|
+
"""Show a nice diff using Rich."""
|
|
95
|
+
# Create a text diff between old and new text
|
|
96
|
+
diff = difflib.unified_diff(
|
|
97
|
+
old_text.splitlines(),
|
|
98
|
+
new_text.splitlines(),
|
|
99
|
+
fromfile=str(path),
|
|
100
|
+
tofile=str(path),
|
|
101
|
+
n=1,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Remove blank lines
|
|
105
|
+
code = "\n".join([line.strip() for line in diff])
|
|
106
|
+
return Syntax(code, "diff", theme="ansi_dark", background_color="default")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def apply_changes(*, targets: Iterable[Path], config: Config) -> tuple[bool, int, int]:
|
|
110
|
+
"""Process target files and apply Tailwind class sorting changes.
|
|
111
|
+
|
|
112
|
+
Reads each file, processes it to sort Tailwind classes (skipping any with
|
|
113
|
+
template syntax), and either writes the changes back or reports what would be
|
|
114
|
+
changed. Provides detailed diff output at higher verbosity levels.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
targets: List of Path objects for files to process
|
|
118
|
+
config: Argument configuration object
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
A tuple of (skipped_count, changed_count) where:
|
|
122
|
+
- skipped_count: Number of files with no changes needed
|
|
123
|
+
- changed_count: Number of files that were modified or would be modified
|
|
124
|
+
|
|
125
|
+
Examples:
|
|
126
|
+
>>> apply_changes(targets=[Path('index.html')], config=config)
|
|
127
|
+
(0, 1) # 0 skipped, 1 changed
|
|
128
|
+
|
|
129
|
+
>>> apply_changes(targets=[Path('a.html'), Path('b.html')], config=config)
|
|
130
|
+
(1, 1) # 1 skipped (no changes), 1 changed
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
skipped = 0
|
|
134
|
+
changed = 0
|
|
135
|
+
found_any = False
|
|
136
|
+
|
|
137
|
+
for f in targets:
|
|
138
|
+
found_any = True
|
|
139
|
+
old_text = f.read_text(encoding="utf-8")
|
|
140
|
+
new_text = process_text(old_text, config)
|
|
141
|
+
|
|
142
|
+
# Skip files that don't need changes
|
|
143
|
+
if old_text == new_text:
|
|
144
|
+
if config.verbosity >= VERBOSITY_LOUD:
|
|
145
|
+
config.console.print(
|
|
146
|
+
f"[grey30]Already sorted {f}[/grey30]", highlight=False
|
|
147
|
+
)
|
|
148
|
+
skipped += 1
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
changed += 1
|
|
152
|
+
|
|
153
|
+
# Write changes if in write mode, otherwise just report
|
|
154
|
+
if config.write:
|
|
155
|
+
f.write_text(new_text, encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
# No report if verbosity is low
|
|
158
|
+
if config.verbosity == VERBOSITY_NONE:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if config.write:
|
|
162
|
+
config.console.print(f"[dim]Updated[/dim] [filename]{f}[/filename]")
|
|
163
|
+
else:
|
|
164
|
+
config.console.print(f"[dim]Would update[/dim] [filename]{f}[/filename]")
|
|
165
|
+
|
|
166
|
+
if config.verbosity >= VERBOSITY_ALL:
|
|
167
|
+
diff = get_diff(f, old_text, new_text)
|
|
168
|
+
config.console.print(Padding(diff, (1, 0, 1, 4)))
|
|
169
|
+
|
|
170
|
+
return found_any, skipped, changed
|