Skip to content

Commit df8d716

Browse files
bmispelonfelixxm
authored andcommitted
Updated box-shadow scss reset
Also introduced a script that automatically generates the _noshadows.scss file to make it easier to update Trac.
1 parent 8d832b3 commit df8d716

File tree

2 files changed

+296
-109
lines changed

2 files changed

+296
-109
lines changed

noshadows.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import argparse
2+
from datetime import datetime
3+
from functools import partial, singledispatch
4+
from pathlib import Path
5+
import sys
6+
import unittest
7+
8+
try:
9+
from tinycss2 import parse_stylesheet
10+
from tinycss2 import ast as tokens
11+
except ImportError:
12+
print("Missing requirement: tinycss2", file=sys.stderr)
13+
sys.exit(1)
14+
15+
16+
def get_parser():
17+
parser = argparse.ArgumentParser(
18+
description="Scan trac's CSS files to detect box-shadow rules and generate a stylesheet that resets them."
19+
)
20+
parser.add_argument("cssfiles", nargs="*", type=Path, help="The CSS files to scan")
21+
parser.add_argument(
22+
"--outfile",
23+
"-o",
24+
type=argparse.FileType("w"),
25+
default="-",
26+
help="Where to write the output",
27+
)
28+
parser.add_argument("--tests", action="store_true")
29+
return parser
30+
31+
32+
def tripletwise(iterable): # no relation to Jeff
33+
"""
34+
Like itertools.pairwise, but for triplets instead of pairs.
35+
"""
36+
i = iter(iterable)
37+
try:
38+
x, y, z = next(i), next(i), next(i)
39+
except StopIteration:
40+
return
41+
42+
yield x, y, z
43+
for el in i:
44+
x, y, z = y, z, el
45+
yield x, y, z
46+
47+
48+
def skip_whitespace(nodes):
49+
return filter(lambda node: node.type != "whitespace", nodes)
50+
51+
52+
def has_shadow(rule):
53+
if not rule.content:
54+
return False
55+
56+
for a, b, c in tripletwise(skip_whitespace(rule.content)):
57+
if isinstance(a, tokens.IdentToken) and a.value == "box-shadow":
58+
assert isinstance(
59+
c, (tokens.IdentToken, tokens.DimensionToken, tokens.NumberToken)
60+
), f"Unexpected node type {c.type}"
61+
return isinstance(c, (tokens.DimensionToken, tokens.NumberToken))
62+
63+
64+
def find_shadow(rules):
65+
for rule in rules:
66+
if has_shadow(rule):
67+
yield rule
68+
69+
70+
@singledispatch
71+
def tokenstr(token: tokens.Node):
72+
return token.value
73+
74+
75+
@tokenstr.register
76+
def _(token: tokens.WhitespaceToken):
77+
return " "
78+
79+
80+
@tokenstr.register
81+
def _(token: tokens.SquareBracketsBlock):
82+
return "[" + tokenlist_to_str(token.content) + "]"
83+
84+
85+
@tokenstr.register
86+
def _(token: tokens.HashToken):
87+
return f"#{token.value}"
88+
89+
90+
@tokenstr.register
91+
def _(token: tokens.StringToken):
92+
return f'"{token.value}"'
93+
94+
95+
def tokenlist_to_str(tokens):
96+
return "".join(map(tokenstr, tokens))
97+
98+
99+
def selector_str(rule):
100+
"""
101+
Return the given rule's selector as a string
102+
"""
103+
return tokenlist_to_str(rule.prelude).strip()
104+
105+
106+
def reset_css_str(selector):
107+
return f"{selector}{{\n @include noshadow;\n}}"
108+
109+
110+
class NoShadowTestCase(unittest.TestCase):
111+
@classmethod
112+
def run_and_exit(cls):
113+
"""
114+
Run all tests on the class and exit with the proper exit status (1 if any failures occured,
115+
0 otherwise)
116+
"""
117+
runner = unittest.TextTestRunner()
118+
suite = unittest.defaultTestLoader.loadTestsFromTestCase(cls)
119+
result = runner.run(suite)
120+
retval = 0 if result.wasSuccessful() else 1
121+
sys.exit(retval)
122+
123+
def test_tripletwise(self):
124+
self.assertEqual(
125+
list(tripletwise("ABCDEF")),
126+
[("A", "B", "C"), ("B", "C", "D"), ("C", "D", "E"), ("D", "E", "F")],
127+
)
128+
129+
def test_tripletwise_too_short(self):
130+
self.assertEqual(list(tripletwise("AB")), [])
131+
132+
def test_skip_whitespace(self):
133+
rules = parse_stylesheet("html { color: red ; }")
134+
self.assertEqual(len(rules), 1)
135+
non_whitespace_content = list(skip_whitespace(rules[0].content))
136+
self.assertEqual(
137+
len(non_whitespace_content), 4
138+
) # attr, colon, value, semicolon
139+
140+
def test_has_shadow(self):
141+
(rule,) = parse_stylesheet("html {box-shadow: 10px 5px 5px red;}")
142+
self.assertTrue(has_shadow(rule))
143+
144+
def test_has_shadow_with_box_shadow_none(self):
145+
(rule,) = parse_stylesheet("html {box-shadow: none;}")
146+
self.assertFalse(has_shadow(rule))
147+
148+
def test_has_shadow_empty_rule(self):
149+
(rule,) = parse_stylesheet("html {}")
150+
self.assertFalse(has_shadow(rule))
151+
152+
def test_selector_str_tag(self):
153+
(rule,) = parse_stylesheet("html {}")
154+
self.assertEqual(selector_str(rule), "html")
155+
156+
def test_selector_str_classname(self):
157+
(rule,) = parse_stylesheet(".className {}")
158+
self.assertEqual(selector_str(rule), ".className")
159+
160+
def test_selector_str_id(self):
161+
(rule,) = parse_stylesheet("#identifier {}")
162+
self.assertEqual(selector_str(rule), "#identifier")
163+
164+
def test_selector_str_with_brackets(self):
165+
(rule,) = parse_stylesheet('input[type="text"] {}')
166+
self.assertEqual(selector_str(rule), 'input[type="text"]')
167+
168+
def test_selector_str_with_brackets_noquotes(self):
169+
(rule,) = parse_stylesheet("input[type=text] {}")
170+
self.assertEqual(selector_str(rule), "input[type=text]")
171+
172+
def test_selector_str_with_comma(self):
173+
(rule,) = parse_stylesheet("a, button {}")
174+
self.assertEqual(selector_str(rule), "a, button")
175+
176+
def test_selector_str_with_comma_and_newline(self):
177+
(rule,) = parse_stylesheet("a,\nbutton {}")
178+
self.assertEqual(selector_str(rule), "a, button")
179+
180+
def test_selector_str_pseudoclass(self):
181+
(rule,) = parse_stylesheet("a:visited {}")
182+
self.assertEqual(selector_str(rule), "a:visited")
183+
184+
def test_selector_str_pseudoclass_nonstandard(self):
185+
(rule,) = parse_stylesheet("button::-moz-focus-inner {}")
186+
self.assertEqual(selector_str(rule), "button::-moz-focus-inner")
187+
188+
189+
SCSS_NOSHADOW_MIXIN_HEADER = """\
190+
// Trac uses box-shadow and text-shadow everywhere but their 90s look doesn't
191+
// fit well with our design.
192+
@mixin noshadow {
193+
box-shadow: none;
194+
border-radius: unset;
195+
}
196+
"""
197+
198+
199+
if __name__ == "__main__":
200+
parser = get_parser()
201+
options = parser.parse_args()
202+
203+
if options.tests:
204+
NoShadowTestCase.run_and_exit()
205+
206+
echo = partial(print, file=options.outfile)
207+
208+
echo(
209+
f"// Generated by {Path(__file__).name} on {datetime.now().isoformat()}",
210+
end="\n\n",
211+
)
212+
echo(SCSS_NOSHADOW_MIXIN_HEADER)
213+
echo()
214+
215+
for i, filepath in enumerate(sorted(options.cssfiles)):
216+
rules = parse_stylesheet(
217+
filepath.read_text(), skip_comments=True, skip_whitespace=True
218+
)
219+
shadowrules = list(find_shadow(rules))
220+
if shadowrules:
221+
if i > 0:
222+
echo()
223+
echo()
224+
echo(f"// {filepath.name}")
225+
combined_selector = ",\n".join(map(selector_str, shadowrules))
226+
echo(reset_css_str(combined_selector))

0 commit comments

Comments
 (0)