fxcss 0.6.1__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.
- fxcss/__init__.py +3 -0
- fxcss/__main__.py +6 -0
- fxcss/audit.py +595 -0
- fxcss/catalogue.py +385 -0
- fxcss/cli.py +558 -0
- fxcss/compare.py +189 -0
- fxcss/core.py +999 -0
- fxcss/fetch.py +255 -0
- fxcss/probe.py +220 -0
- fxcss-0.6.1.dist-info/METADATA +572 -0
- fxcss-0.6.1.dist-info/RECORD +15 -0
- fxcss-0.6.1.dist-info/WHEEL +5 -0
- fxcss-0.6.1.dist-info/entry_points.txt +2 -0
- fxcss-0.6.1.dist-info/licenses/LICENSE +21 -0
- fxcss-0.6.1.dist-info/top_level.txt +1 -0
fxcss/__init__.py
ADDED
fxcss/__main__.py
ADDED
fxcss/audit.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find selectors in a theme that no longer match anything, and suggest fixes.
|
|
3
|
+
|
|
4
|
+
Firefox renames and removes chrome elements between releases, and a rule that
|
|
5
|
+
targets a name which no longer exists fails silently -- the theme just stops
|
|
6
|
+
styling that part, with no error anywhere. This walks every id and class a theme
|
|
7
|
+
mentions, resolves each against a running Firefox, and reports the ones that
|
|
8
|
+
resolve to nothing, with a suggested replacement where one can be inferred.
|
|
9
|
+
|
|
10
|
+
Suggestions are derived from the live browser rather than a hardcoded list, so
|
|
11
|
+
they stay correct as Firefox changes:
|
|
12
|
+
|
|
13
|
+
renamed the same name exists, but as a class instead of an id (or the
|
|
14
|
+
reverse). This is the common case and the suggestion is exact.
|
|
15
|
+
similar no exact counterpart, but a close name exists -- usually a typo
|
|
16
|
+
in the theme, or a name that gained or lost a suffix.
|
|
17
|
+
unresolved nothing close. Reported separately and not counted as a problem,
|
|
18
|
+
because it is usually an element that only appears in a state
|
|
19
|
+
this tool cannot reach rather than one that has been removed.
|
|
20
|
+
|
|
21
|
+
`changelog` runs the same collection against two Firefox builds and diffs them,
|
|
22
|
+
which is how you find what a new release changed before it reaches users.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import difflib
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
COMMENT = re.compile(r"/\*.*?\*/", re.S)
|
|
31
|
+
URLS = re.compile(r"url\([^)]*\)")
|
|
32
|
+
STRINGS = re.compile(r"(['\"])(?:\\.|(?!\1).)*\1", re.S)
|
|
33
|
+
TOKEN = re.compile(r"(?<![\w-])([#.])([A-Za-z_][\w-]*)")
|
|
34
|
+
HEXCOLOR = re.compile(r"^[0-9a-fA-F]+$")
|
|
35
|
+
|
|
36
|
+
# Selectors that intentionally match nothing. `:not(#hack)` is a well-known
|
|
37
|
+
# trick for raising specificity without changing what a rule matches, so
|
|
38
|
+
# flagging it as a dead selector would be noise.
|
|
39
|
+
SPECIFICITY_HACKS = {"#hack", "#nope", "#never", "#no", "#none", "#fake"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _looks_like_colour(kind, name):
|
|
43
|
+
return kind == "#" and len(name) in (3, 4, 6, 8) and HEXCOLOR.match(name)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract_tokens(theme: Path):
|
|
47
|
+
"""Map every id/class token in the theme to where it is written.
|
|
48
|
+
|
|
49
|
+
Deliberately token-level rather than whole-selector: Firefox breaks themes
|
|
50
|
+
by renaming individual ids and classes, and a whole selector containing
|
|
51
|
+
`&` nesting or `::part()` cannot be handed to querySelectorAll anyway.
|
|
52
|
+
"""
|
|
53
|
+
found = {}
|
|
54
|
+
for path in sorted((theme / "chrome").rglob("*.css")):
|
|
55
|
+
try:
|
|
56
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# Blank comments across the whole file, not per line: a block comment
|
|
61
|
+
# spans lines, and a selector named in prose is not a selector. Newlines
|
|
62
|
+
# are preserved so reported line numbers still line up with the source.
|
|
63
|
+
blanked = COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), raw)
|
|
64
|
+
source_lines = raw.splitlines()
|
|
65
|
+
|
|
66
|
+
for index, scanned in enumerate(blanked.splitlines()):
|
|
67
|
+
cleaned = STRINGS.sub("''", URLS.sub("url()", scanned))
|
|
68
|
+
# Skip declaration-only lines so a property value cannot be mistaken
|
|
69
|
+
# for a selector.
|
|
70
|
+
if "{" not in cleaned and ";" in cleaned and ":" in cleaned:
|
|
71
|
+
continue
|
|
72
|
+
for kind, name in TOKEN.findall(cleaned):
|
|
73
|
+
if _looks_like_colour(kind, name):
|
|
74
|
+
continue
|
|
75
|
+
token = kind + name
|
|
76
|
+
if token in SPECIFICITY_HACKS:
|
|
77
|
+
continue
|
|
78
|
+
found.setdefault(token, []).append({
|
|
79
|
+
"file": str(path.relative_to(theme)),
|
|
80
|
+
"line": index + 1,
|
|
81
|
+
"text": source_lines[index].rstrip(),
|
|
82
|
+
})
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
COLLECT_DOM = """
|
|
87
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
88
|
+
const doc = win.document;
|
|
89
|
+
const ids = new Set(), classes = new Set();
|
|
90
|
+
for (const el of doc.querySelectorAll("*")) {
|
|
91
|
+
if (el.id) ids.add(el.id);
|
|
92
|
+
const c = el.getAttribute("class");
|
|
93
|
+
if (c) { for (const x of c.trim().split(/\\s+/)) classes.add(x); }
|
|
94
|
+
}
|
|
95
|
+
return {ids: [...ids], classes: [...classes]};
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
# Large parts of browser.xhtml are built lazily -- the app menu's contents do
|
|
99
|
+
# not exist as elements until the menu has been opened once. Collecting from a
|
|
100
|
+
# single resting state would report hundreds of live elements as missing.
|
|
101
|
+
OPEN_APPMENU = """
|
|
102
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
103
|
+
try { win.PanelUI.show(); } catch (e) {}
|
|
104
|
+
return true;
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
CLOSE_APPMENU = """
|
|
108
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
109
|
+
try { win.PanelUI.hide(); } catch (e) {}
|
|
110
|
+
return true;
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
OPEN_CONTEXT_MENUS = """
|
|
114
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
115
|
+
const doc = win.document;
|
|
116
|
+
let opened = 0;
|
|
117
|
+
for (const id of ["tabContextMenu", "contentAreaContextMenu", "toolbar-context-menu"]) {
|
|
118
|
+
const popup = doc.getElementById(id);
|
|
119
|
+
if (!popup) { continue; }
|
|
120
|
+
try {
|
|
121
|
+
popup.openPopupAtScreen(win.screenX + 60, win.screenY + 120, false);
|
|
122
|
+
popup.hidePopup();
|
|
123
|
+
opened++;
|
|
124
|
+
} catch (e) {}
|
|
125
|
+
}
|
|
126
|
+
return opened;
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
URLBAR_RESULTS = """
|
|
130
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
131
|
+
win.gURLBar.focus();
|
|
132
|
+
win.gURLBar.value = "a";
|
|
133
|
+
try { win.gURLBar.startQuery({searchString: "a", allowAutofill: false}); } catch (e) {}
|
|
134
|
+
return true;
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def collect_dom(session, verbose=True):
|
|
139
|
+
"""Union of every id and class present across the states we can produce."""
|
|
140
|
+
from . import core
|
|
141
|
+
|
|
142
|
+
ids, classes = set(), set()
|
|
143
|
+
|
|
144
|
+
def sweep(label):
|
|
145
|
+
result = session.m.script(COLLECT_DOM)
|
|
146
|
+
ids.update(result["ids"])
|
|
147
|
+
classes.update(result["classes"])
|
|
148
|
+
if verbose:
|
|
149
|
+
print(f" {label:<22} {len(ids)} ids, {len(classes)} classes", flush=True)
|
|
150
|
+
|
|
151
|
+
session.setup_window()
|
|
152
|
+
time.sleep(2.0)
|
|
153
|
+
sweep("resting")
|
|
154
|
+
|
|
155
|
+
session.m.script(core.OPEN_FINDBAR)
|
|
156
|
+
time.sleep(1.2)
|
|
157
|
+
sweep("find bar open")
|
|
158
|
+
|
|
159
|
+
session.m.script(URLBAR_RESULTS)
|
|
160
|
+
time.sleep(1.5)
|
|
161
|
+
sweep("address bar results")
|
|
162
|
+
session.m.script(core.BLUR_URLBAR)
|
|
163
|
+
|
|
164
|
+
session.m.script(OPEN_APPMENU)
|
|
165
|
+
time.sleep(2.0)
|
|
166
|
+
sweep("app menu opened")
|
|
167
|
+
session.m.script(CLOSE_APPMENU)
|
|
168
|
+
time.sleep(0.8)
|
|
169
|
+
|
|
170
|
+
session.m.script(OPEN_CONTEXT_MENUS)
|
|
171
|
+
time.sleep(1.5)
|
|
172
|
+
sweep("context menus built")
|
|
173
|
+
|
|
174
|
+
session.set_dark(True)
|
|
175
|
+
time.sleep(1.5)
|
|
176
|
+
sweep("dark mode")
|
|
177
|
+
|
|
178
|
+
return {"ids": ids, "classes": classes}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _differing_chars(a, b):
|
|
182
|
+
"""Characters that differ between two names, ignoring shared runs."""
|
|
183
|
+
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
|
184
|
+
matched = sum(block.size for block in matcher.get_matching_blocks())
|
|
185
|
+
return (len(a) - matched) + (len(b) - matched)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_near_miss(token_name, candidate):
|
|
189
|
+
"""Is this close enough to be the same element under a new name?
|
|
190
|
+
|
|
191
|
+
A plain similarity ratio is not enough. Chrome ids share long scaffolding
|
|
192
|
+
(`appMenu-…-button`), so `appMenu-paste-button` and
|
|
193
|
+
`appMenu-translate-button` score highly while being unrelated controls --
|
|
194
|
+
suggesting one for the other would be worse than saying nothing.
|
|
195
|
+
|
|
196
|
+
Two shapes are trustworthy: a short suffix appearing or disappearing, which
|
|
197
|
+
is how Firefox versions its chrome (`…-button` became `…-button2`), and a
|
|
198
|
+
difference of a character or two, which is a typo.
|
|
199
|
+
"""
|
|
200
|
+
shorter, longer = sorted((token_name, candidate), key=len)
|
|
201
|
+
if longer.startswith(shorter) and len(longer) - len(shorter) <= 2:
|
|
202
|
+
return True
|
|
203
|
+
return _differing_chars(token_name, candidate) <= 2
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def suggest(token, dom):
|
|
207
|
+
"""Infer a replacement for a token that matches nothing."""
|
|
208
|
+
kind, name = token[0], token[1:]
|
|
209
|
+
ids, classes = dom["ids"], dom["classes"]
|
|
210
|
+
|
|
211
|
+
# An id that is now a class, or the reverse. Exact and by far the commonest.
|
|
212
|
+
if kind == "#" and name in classes:
|
|
213
|
+
return {"replacement": "." + name, "confidence": "renamed",
|
|
214
|
+
"reason": "same name, now a class rather than an id"}
|
|
215
|
+
if kind == "." and name in ids:
|
|
216
|
+
return {"replacement": "#" + name, "confidence": "renamed",
|
|
217
|
+
"reason": "same name, now an id rather than a class"}
|
|
218
|
+
|
|
219
|
+
# A near-miss in the same namespace: usually a typo, or a suffix change.
|
|
220
|
+
pool = sorted(ids if kind == "#" else classes)
|
|
221
|
+
close = [c for c in difflib.get_close_matches(name, pool, n=4, cutoff=0.80)
|
|
222
|
+
if _is_near_miss(name, c)][:1]
|
|
223
|
+
if close:
|
|
224
|
+
return {"replacement": kind + close[0], "confidence": "similar",
|
|
225
|
+
"reason": f"no exact match; closest live name is {kind}{close[0]}"}
|
|
226
|
+
|
|
227
|
+
# Same name in the other namespace but only as a near-miss.
|
|
228
|
+
other = sorted(classes if kind == "#" else ids)
|
|
229
|
+
close = [c for c in difflib.get_close_matches(name, other, n=4, cutoff=0.80)
|
|
230
|
+
if _is_near_miss(name, c)][:1]
|
|
231
|
+
if close:
|
|
232
|
+
flip = "." if kind == "#" else "#"
|
|
233
|
+
return {"replacement": flip + close[0], "confidence": "similar",
|
|
234
|
+
"reason": f"closest live name is {flip}{close[0]}"}
|
|
235
|
+
|
|
236
|
+
return {"replacement": None, "confidence": "unresolved",
|
|
237
|
+
"reason": "no similar element found in any state fxcss could produce"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def audit(session, theme: Path, verbose=True):
|
|
241
|
+
tokens = extract_tokens(theme)
|
|
242
|
+
if verbose:
|
|
243
|
+
print(f" {len(tokens)} distinct id/class tokens in the theme", flush=True)
|
|
244
|
+
print(" collecting live elements:", flush=True)
|
|
245
|
+
dom = collect_dom(session, verbose=verbose)
|
|
246
|
+
|
|
247
|
+
live = {"#" + i for i in dom["ids"]} | {"." + c for c in dom["classes"]}
|
|
248
|
+
findings = []
|
|
249
|
+
for token, uses in sorted(tokens.items()):
|
|
250
|
+
if token in live:
|
|
251
|
+
continue
|
|
252
|
+
info = suggest(token, dom)
|
|
253
|
+
info.update({"token": token, "uses": uses})
|
|
254
|
+
findings.append(info)
|
|
255
|
+
|
|
256
|
+
order = {"renamed": 0, "similar": 1, "unresolved": 2}
|
|
257
|
+
findings.sort(key=lambda f: (order[f["confidence"]], f["token"]))
|
|
258
|
+
return {"tokens": len(tokens), "live": len(live), "findings": findings}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def replace_in_line(text, token, replacement):
|
|
262
|
+
"""Swap one id/class token in a line, leaving the rest of the rule alone."""
|
|
263
|
+
pattern = re.compile(r"(?<![\w-])" + re.escape(token) + r"(?![\w-])")
|
|
264
|
+
return pattern.sub(replacement, text)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
BOLD, DIM, RED, GREEN, YELLOW, RESET = (
|
|
268
|
+
"\033[1m", "\033[2m", "\033[31m", "\033[32m", "\033[33m", "\033[0m")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def report(result, show_all=False, colour=True):
|
|
272
|
+
def c(code, s):
|
|
273
|
+
return f"{code}{s}{RESET}" if colour else s
|
|
274
|
+
|
|
275
|
+
actionable = [f for f in result["findings"] if f["confidence"] != "unresolved"]
|
|
276
|
+
unresolved = [f for f in result["findings"] if f["confidence"] == "unresolved"]
|
|
277
|
+
|
|
278
|
+
print()
|
|
279
|
+
if not actionable:
|
|
280
|
+
print(" No selectors need attention: every id and class the theme uses "
|
|
281
|
+
"was found\n in the running Firefox.")
|
|
282
|
+
else:
|
|
283
|
+
print(c(BOLD, f" {len(actionable)} selector"
|
|
284
|
+
f"{'s' if len(actionable) != 1 else ''} need attention"))
|
|
285
|
+
|
|
286
|
+
for finding in actionable:
|
|
287
|
+
label = "RENAMED" if finding["confidence"] == "renamed" else "SIMILAR"
|
|
288
|
+
tint = GREEN if finding["confidence"] == "renamed" else YELLOW
|
|
289
|
+
print()
|
|
290
|
+
print(f" {c(tint, label)} {c(BOLD, finding['token'])}"
|
|
291
|
+
f" → {c(BOLD, finding['replacement'])}")
|
|
292
|
+
print(f" {c(DIM, finding['reason'])}")
|
|
293
|
+
for use in finding["uses"][:3]:
|
|
294
|
+
after = replace_in_line(use["text"], finding["token"], finding["replacement"])
|
|
295
|
+
print()
|
|
296
|
+
print(f" {c(DIM, use['file'] + ':' + str(use['line']))}")
|
|
297
|
+
print(f" {c(RED, '- ' + use['text'].strip())}")
|
|
298
|
+
print(f" {c(GREEN, '+ ' + after.strip())}")
|
|
299
|
+
extra = len(finding["uses"]) - 3
|
|
300
|
+
if extra > 0:
|
|
301
|
+
plural = "s" if extra != 1 else ""
|
|
302
|
+
print()
|
|
303
|
+
print(f" {c(DIM, f'… and {extra} more occurrence{plural}')}")
|
|
304
|
+
|
|
305
|
+
if unresolved:
|
|
306
|
+
print()
|
|
307
|
+
print(f" {c(DIM, f'{len(unresolved)} other token(s) were not seen in any state fxcss could')}")
|
|
308
|
+
print(f" {c(DIM, 'produce. That usually means a platform-specific or state-specific')}")
|
|
309
|
+
print(f" {c(DIM, 'element rather than a removed one, so they are not counted above.')}")
|
|
310
|
+
if show_all:
|
|
311
|
+
for finding in unresolved:
|
|
312
|
+
first = finding["uses"][0]
|
|
313
|
+
print(f" {finding['token']:<44} {c(DIM, first['file'] + ':' + str(first['line']))}")
|
|
314
|
+
else:
|
|
315
|
+
print(f" {c(DIM, 'Pass --all to list them.')}")
|
|
316
|
+
print()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def write_patch(result, theme: Path, out: Path):
|
|
320
|
+
"""Emit a unified diff of the confident replacements, for review."""
|
|
321
|
+
edits = {}
|
|
322
|
+
for finding in result["findings"]:
|
|
323
|
+
if finding["confidence"] != "renamed":
|
|
324
|
+
continue
|
|
325
|
+
for use in finding["uses"]:
|
|
326
|
+
edits.setdefault(use["file"], []).append(
|
|
327
|
+
(use["line"], finding["token"], finding["replacement"]))
|
|
328
|
+
|
|
329
|
+
if not edits:
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
chunks = []
|
|
333
|
+
for rel, changes in sorted(edits.items()):
|
|
334
|
+
path = theme / rel
|
|
335
|
+
original = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
336
|
+
updated = list(original)
|
|
337
|
+
for line_no, token, replacement in changes:
|
|
338
|
+
index = line_no - 1
|
|
339
|
+
if 0 <= index < len(updated):
|
|
340
|
+
updated[index] = replace_in_line(updated[index], token, replacement)
|
|
341
|
+
chunks.extend(difflib.unified_diff(
|
|
342
|
+
original, updated, fromfile=f"a/{rel}", tofile=f"b/{rel}", lineterm=""))
|
|
343
|
+
|
|
344
|
+
out.write_text("\n".join(chunks) + "\n", encoding="utf-8")
|
|
345
|
+
return len(edits)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def changelog(before, after, theme_tokens=None):
|
|
349
|
+
"""Diff two collected DOM snapshots."""
|
|
350
|
+
gone_ids = sorted(before["ids"] - after["ids"])
|
|
351
|
+
new_ids = sorted(after["ids"] - before["ids"])
|
|
352
|
+
gone_classes = sorted(before["classes"] - after["classes"])
|
|
353
|
+
new_classes = sorted(after["classes"] - before["classes"])
|
|
354
|
+
|
|
355
|
+
result = {
|
|
356
|
+
"removed": [f"#{i}" for i in gone_ids] + [f".{c}" for c in gone_classes],
|
|
357
|
+
"added": [f"#{i}" for i in new_ids] + [f".{c}" for c in new_classes],
|
|
358
|
+
}
|
|
359
|
+
if theme_tokens is not None:
|
|
360
|
+
result["affects_theme"] = sorted(set(result["removed"]) & set(theme_tokens))
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# --- unused code -----------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
IMPORT_RE = re.compile(r"""@import\s+(?:url\(\s*)?["']?([^"')]+)["']?\s*\)?\s*;""")
|
|
367
|
+
PROP_DEF = re.compile(r"(--[\w-]+)\s*:")
|
|
368
|
+
PROP_USE = re.compile(r"var\(\s*(--[\w-]+)")
|
|
369
|
+
|
|
370
|
+
# Entry points Firefox loads by name. Anything not reachable from one of these
|
|
371
|
+
# is only reachable if something imports it.
|
|
372
|
+
ENTRY_SHEETS = ("userChrome.css", "userContent.css")
|
|
373
|
+
|
|
374
|
+
# Directories of deliberately opt-in sheets. A theme ships these expecting its
|
|
375
|
+
# installer (or the user) to enable them, so "nothing imports it" is the
|
|
376
|
+
# intended state rather than a finding.
|
|
377
|
+
OPTIONAL_DIRS = {"custom", "optional", "options", "extras", "variants"}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def import_graph(theme: Path):
|
|
381
|
+
"""Every stylesheet reachable by following @import from the entry sheets."""
|
|
382
|
+
chrome = theme / "chrome"
|
|
383
|
+
reachable, queue = set(), []
|
|
384
|
+
for entry in ENTRY_SHEETS:
|
|
385
|
+
path = chrome / entry
|
|
386
|
+
if path.exists():
|
|
387
|
+
queue.append(path.resolve())
|
|
388
|
+
|
|
389
|
+
while queue:
|
|
390
|
+
current = queue.pop()
|
|
391
|
+
if current in reachable or not current.exists():
|
|
392
|
+
continue
|
|
393
|
+
reachable.add(current)
|
|
394
|
+
text = COMMENT.sub("", current.read_text(encoding="utf-8", errors="replace"))
|
|
395
|
+
for target in IMPORT_RE.findall(text):
|
|
396
|
+
target = target.strip()
|
|
397
|
+
if re.match(r"^(chrome:|resource:|https?:|data:)", target):
|
|
398
|
+
continue
|
|
399
|
+
queue.append((current.parent / target).resolve())
|
|
400
|
+
return reachable
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def custom_properties(paths):
|
|
404
|
+
"""Where each custom property is defined and where it is used."""
|
|
405
|
+
defined, used = {}, {}
|
|
406
|
+
for path in paths:
|
|
407
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
408
|
+
blanked = COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
|
409
|
+
for number, line in enumerate(blanked.splitlines(), 1):
|
|
410
|
+
for name in PROP_DEF.findall(line):
|
|
411
|
+
defined.setdefault(name, []).append((path, number))
|
|
412
|
+
for name in PROP_USE.findall(line):
|
|
413
|
+
used.setdefault(name, []).append((path, number))
|
|
414
|
+
return defined, used
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
PROBE_PROPERTIES = """
|
|
418
|
+
const [names] = arguments;
|
|
419
|
+
const win = Services.wm.getMostRecentWindow("navigator:browser");
|
|
420
|
+
const doc = win.document;
|
|
421
|
+
const remaining = new Set(names);
|
|
422
|
+
const found = [];
|
|
423
|
+
// Custom properties can be declared on any element, not only :root, so probe
|
|
424
|
+
// the whole document. The remaining set shrinks as names are resolved, so this
|
|
425
|
+
// stays cheap in practice.
|
|
426
|
+
for (const el of [doc.documentElement, ...doc.querySelectorAll("*")]) {
|
|
427
|
+
if (!remaining.size) { break; }
|
|
428
|
+
const cs = win.getComputedStyle(el);
|
|
429
|
+
for (const name of [...remaining]) {
|
|
430
|
+
if (cs.getPropertyValue(name).trim() !== "") {
|
|
431
|
+
found.push(name);
|
|
432
|
+
remaining.delete(name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return found;
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def probe_properties(session, names):
|
|
441
|
+
"""Which of these custom properties does this browser resolve to a value?"""
|
|
442
|
+
if not names:
|
|
443
|
+
return set()
|
|
444
|
+
return set(session.m.script(PROBE_PROPERTIES, [sorted(names)]))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def collect_unused(theme: Path):
|
|
448
|
+
"""The parts that need no browser: reachability and property bookkeeping."""
|
|
449
|
+
chrome = theme / "chrome"
|
|
450
|
+
if not chrome.is_dir():
|
|
451
|
+
return None
|
|
452
|
+
|
|
453
|
+
reachable = import_graph(theme)
|
|
454
|
+
orphans, optional = [], []
|
|
455
|
+
for sheet in sorted(chrome.rglob("*.css")):
|
|
456
|
+
if sheet.resolve() in reachable:
|
|
457
|
+
continue
|
|
458
|
+
if OPTIONAL_DIRS & {p.name for p in sheet.relative_to(chrome).parents}:
|
|
459
|
+
optional.append(sheet)
|
|
460
|
+
else:
|
|
461
|
+
orphans.append(sheet)
|
|
462
|
+
|
|
463
|
+
defined, used = custom_properties(sorted(reachable))
|
|
464
|
+
return {
|
|
465
|
+
"theme": theme,
|
|
466
|
+
"reachable": len(reachable),
|
|
467
|
+
"orphans": [p.relative_to(theme) for p in orphans],
|
|
468
|
+
"optional": [p.relative_to(theme) for p in optional],
|
|
469
|
+
"defined": defined,
|
|
470
|
+
"used": used,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def classify_unused(static, firefox_knows):
|
|
475
|
+
"""Separate real dead code from names Firefox itself reads or provides.
|
|
476
|
+
|
|
477
|
+
Both directions need the browser's opinion, and asking an *unthemed*
|
|
478
|
+
Firefox is what makes the answer meaningful:
|
|
479
|
+
|
|
480
|
+
* A property used but not defined here may still be one Firefox provides --
|
|
481
|
+
`--toolbarbutton-inner-padding` is Firefox's, not the theme's.
|
|
482
|
+
* A property defined here but never read here is usually the whole point:
|
|
483
|
+
setting `--arrowpanel-background` exists precisely so Firefox's own rules
|
|
484
|
+
pick it up. Only a name Firefox has never heard of is dead.
|
|
485
|
+
"""
|
|
486
|
+
if static is None:
|
|
487
|
+
return None
|
|
488
|
+
theme = static["theme"]
|
|
489
|
+
defined, used = static["defined"], static["used"]
|
|
490
|
+
|
|
491
|
+
missing = [name for name in sorted(set(used) - set(defined))
|
|
492
|
+
if name not in firefox_knows]
|
|
493
|
+
dead = [name for name in sorted(set(defined) - set(used))
|
|
494
|
+
if name not in firefox_knows]
|
|
495
|
+
overrides = sorted((set(defined) - set(used)) & firefox_knows)
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"reachable": static["reachable"],
|
|
499
|
+
"orphans": static["orphans"],
|
|
500
|
+
"optional": static["optional"],
|
|
501
|
+
"overrides": len(overrides),
|
|
502
|
+
"unused_properties": [
|
|
503
|
+
{"name": name,
|
|
504
|
+
"file": str(defined[name][0][0].relative_to(theme)),
|
|
505
|
+
"line": defined[name][0][1]}
|
|
506
|
+
for name in dead],
|
|
507
|
+
"missing_properties": [
|
|
508
|
+
{"name": name,
|
|
509
|
+
"file": str(used[name][0][0].relative_to(theme)),
|
|
510
|
+
"line": used[name][0][1],
|
|
511
|
+
"uses": len(used[name])}
|
|
512
|
+
for name in missing],
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def report_unused(unused, colour=True, show_all=False):
|
|
517
|
+
def c(code, s):
|
|
518
|
+
return f"{code}{s}{RESET}" if colour else s
|
|
519
|
+
|
|
520
|
+
if not unused:
|
|
521
|
+
return
|
|
522
|
+
total = (len(unused["orphans"]) + len(unused["unused_properties"])
|
|
523
|
+
+ len(unused["missing_properties"]))
|
|
524
|
+
print(c(BOLD, " Unused and unreachable"))
|
|
525
|
+
print(f" {c(DIM, 'Housekeeping, not breakage — nothing here stops the theme working.')}")
|
|
526
|
+
print()
|
|
527
|
+
|
|
528
|
+
if unused["orphans"]:
|
|
529
|
+
print(f" {c(YELLOW, 'NOT IMPORTED')} {len(unused['orphans'])} stylesheet"
|
|
530
|
+
f"{'s' if len(unused['orphans']) != 1 else ''} nothing reaches")
|
|
531
|
+
for path in unused["orphans"][:12]:
|
|
532
|
+
print(f" {path}")
|
|
533
|
+
extra = len(unused["orphans"]) - 12
|
|
534
|
+
if extra > 0:
|
|
535
|
+
print(" " + c(DIM, f"… and {extra} more"))
|
|
536
|
+
print()
|
|
537
|
+
|
|
538
|
+
if unused["missing_properties"]:
|
|
539
|
+
print(f" {c(RED, 'UNDEFINED')} {len(unused['missing_properties'])} custom "
|
|
540
|
+
f"propert{'ies' if len(unused['missing_properties']) != 1 else 'y'} used "
|
|
541
|
+
f"but never set")
|
|
542
|
+
print(f" {c(DIM, 'Firefox does not provide these either, so the var() falls back or fails.')}")
|
|
543
|
+
for item in unused["missing_properties"][:10]:
|
|
544
|
+
where = f"{item['file']}:{item['line']}"
|
|
545
|
+
print(f" {item['name']:<44} {c(DIM, where)} {c(DIM, '×' + str(item['uses']))}")
|
|
546
|
+
print()
|
|
547
|
+
|
|
548
|
+
if unused.get("overrides"):
|
|
549
|
+
count = unused["overrides"]
|
|
550
|
+
noun = "property is" if count == 1 else "properties are"
|
|
551
|
+
print(" " + c(DIM, f"{count} more {noun} set here but read only by Firefox —"))
|
|
552
|
+
print(" " + c(DIM, "deliberate overrides, not dead code."))
|
|
553
|
+
print()
|
|
554
|
+
|
|
555
|
+
if unused["unused_properties"]:
|
|
556
|
+
print(f" {c(DIM, 'DEFINED ONLY')} {len(unused['unused_properties'])} custom "
|
|
557
|
+
f"propert{'ies' if len(unused['unused_properties']) != 1 else 'y'} set here "
|
|
558
|
+
f"and read nowhere in the theme")
|
|
559
|
+
print(" " + c(DIM, "An unthemed Firefox does not resolve these names either, so they are"))
|
|
560
|
+
print(" " + c(DIM, "likely renamed or dropped. Worth checking rather than deleting: a"))
|
|
561
|
+
print(" " + c(DIM, "name Firefox references without setting would look the same here."))
|
|
562
|
+
limit = None if show_all else 10
|
|
563
|
+
for item in unused["unused_properties"][:limit]:
|
|
564
|
+
print(f" {item['name']:<44} {c(DIM, item['file'] + ':' + str(item['line']))}")
|
|
565
|
+
if limit and len(unused["unused_properties"]) > limit:
|
|
566
|
+
print(f" {c(DIM, 'Pass --all to list them.')}")
|
|
567
|
+
print()
|
|
568
|
+
|
|
569
|
+
if total == 0:
|
|
570
|
+
print(f" {c(DIM, 'Nothing unused found.')}\n")
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# --- snapshots -------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
def make_snapshot(session, verbose=False):
|
|
576
|
+
"""A record of every chrome name this Firefox has, plus its version.
|
|
577
|
+
|
|
578
|
+
Committing one of these lets a scheduled job answer "what did the new
|
|
579
|
+
Firefox change" without keeping an old browser around to compare against.
|
|
580
|
+
"""
|
|
581
|
+
info = session.info()
|
|
582
|
+
dom = collect_dom(session, verbose=verbose)
|
|
583
|
+
return {
|
|
584
|
+
"version": info["version"],
|
|
585
|
+
"buildID": info["buildID"],
|
|
586
|
+
"os": info["os"],
|
|
587
|
+
"ids": sorted(dom["ids"]),
|
|
588
|
+
"classes": sorted(dom["classes"]),
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def load_snapshot(path: Path):
|
|
593
|
+
import json
|
|
594
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
595
|
+
return data, {"ids": set(data["ids"]), "classes": set(data["classes"])}
|