Skip to content

Commit 452ad24

Browse files
committed
normalize CSS for <style> tags (including @media and at-rules)
1 parent b39b234 commit 452ad24

File tree

2 files changed

+85
-33
lines changed

2 files changed

+85
-33
lines changed

htmlcompare/compare_css.py

Lines changed: 69 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from operator import attrgetter
44

55
import tinycss2
6-
from tinycss2.ast import Declaration, NumberToken, QualifiedRule
6+
from tinycss2.ast import AtRule, Declaration, NumberToken, QualifiedRule
77

88

99
__all__ = ['compare_css', 'compare_stylesheet']
@@ -80,48 +80,84 @@ def normalize_stylesheet(css_str):
8080
this function handles full stylesheets with selectors like:
8181
body { margin: 0; }
8282
.foo { color: red; }
83+
@media screen { .foo { color: blue; } }
8384
"""
8485
rules = tinycss2.parse_stylesheet(css_str, skip_comments=True, skip_whitespace=True)
86+
return _normalize_rule_list(rules)
87+
88+
89+
def _normalize_rule_list(rules):
90+
"""Normalize a list of CSS rules (qualified rules, at-rules, etc.)."""
8591
normalized_rules = []
8692

8793
for rule in rules:
8894
if rule.type == 'qualified-rule':
89-
prelude = _strip_whitespace(rule.prelude)
90-
91-
# parse and normalize the content (declarations)
92-
content_decls = tinycss2.parse_declaration_list(
93-
rule.content, skip_comments=True, skip_whitespace=True
94-
)
95-
normalized_decls = []
96-
for decl in content_decls:
97-
if decl.type == 'declaration':
98-
tokens = _strip_whitespace(decl.value)
99-
tokens = _strip_zero_units(tokens)
100-
_decl = Declaration(
101-
line = decl.source_line,
102-
column = decl.source_column,
103-
name = decl.name,
104-
lower_name = decl.lower_name,
105-
value = tokens,
106-
important = decl.important
107-
)
108-
normalized_decls.append(_decl)
109-
110-
# sort declarations by name for order-independent comparison
111-
sorted_decls = sorted(normalized_decls, key=attrgetter('name'))
112-
113-
normalized_rule = QualifiedRule(
114-
rule.source_line,
115-
rule.source_column,
116-
prelude,
117-
sorted_decls,
118-
)
95+
normalized_rule = _normalize_qualified_rule(rule)
11996
normalized_rules.append(normalized_rule)
12097
elif rule.type == 'at-rule':
121-
# keep at-rules as-is for now (could be extended later)
122-
normalized_rules.append(rule)
98+
normalized_rule = _normalize_at_rule(rule)
99+
normalized_rules.append(normalized_rule)
123100
elif rule.type == 'error':
124101
# keep errors for debugging
125102
normalized_rules.append(rule)
126103

127104
return tuple(normalized_rules)
105+
106+
107+
def _normalize_qualified_rule(rule):
108+
"""Normalize a qualified rule (selector { declarations })."""
109+
prelude = _strip_whitespace(rule.prelude)
110+
111+
# parse and normalize the content (declarations)
112+
content_decls = tinycss2.parse_declaration_list(
113+
rule.content, skip_comments=True, skip_whitespace=True
114+
)
115+
normalized_decls = []
116+
for decl in content_decls:
117+
if decl.type == 'declaration':
118+
tokens = _strip_whitespace(decl.value)
119+
tokens = _strip_zero_units(tokens)
120+
_decl = Declaration(
121+
line = decl.source_line,
122+
column = decl.source_column,
123+
name = decl.name,
124+
lower_name = decl.lower_name,
125+
value = tokens,
126+
important = decl.important
127+
)
128+
normalized_decls.append(_decl)
129+
130+
# sort declarations by name for order-independent comparison
131+
sorted_decls = sorted(normalized_decls, key=attrgetter('name'))
132+
133+
return QualifiedRule(
134+
rule.source_line,
135+
rule.source_column,
136+
prelude,
137+
sorted_decls,
138+
)
139+
140+
141+
def _normalize_at_rule(rule):
142+
"""Normalize an at-rule (@media, @keyframes, etc.)."""
143+
prelude = _strip_whitespace(rule.prelude)
144+
145+
# normalize the content if it contains nested rules (like @media)
146+
if rule.content is not None:
147+
content_rules = tinycss2.parse_rule_list(
148+
rule.content,
149+
skip_comments=True,
150+
skip_whitespace=True,
151+
)
152+
normalized_content = list(_normalize_rule_list(content_rules))
153+
else:
154+
normalized_content = None
155+
156+
return AtRule(
157+
rule.source_line,
158+
rule.source_column,
159+
rule.at_keyword,
160+
rule.lower_at_keyword,
161+
prelude,
162+
normalized_content,
163+
)

htmlcompare/tests/compare_test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,3 +609,19 @@ def test_style_tag_with_multiple_selectors():
609609
'<style>#outlook a { padding:0; } body { margin:0; }</style>',
610610
)
611611
assert result.is_equal
612+
613+
614+
def test_style_tag_with_media_query():
615+
result = compare_html(
616+
'<style>@media only screen and (min-width:480px) { .foo { width: 100%; } }</style>',
617+
'<style>@media only screen and (min-width:480px) { .foo { width:100%; } }</style>',
618+
)
619+
assert result.is_equal
620+
621+
622+
def test_style_tag_detects_different_media_query_content():
623+
result = compare_html(
624+
'<style>@media screen { .foo { width: 100%; } }</style>',
625+
'<style>@media screen { .foo { width: 50%; } }</style>',
626+
)
627+
assert not result.is_equal

0 commit comments

Comments
 (0)