121 lines
4.4 KiB
Python
121 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
||
import argparse
|
||
import os
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Dict, List, Set, Tuple
|
||
|
||
# --- Regexy do zbierania klas z CSS ---
|
||
# Łapie ".class-name" w selektorach (pomija np. ".5" i rzeczy z escapami – prosto, ale działa w praktyce)
|
||
CSS_CLASS_RE = re.compile(r'(?<![\\\w-])\.([a-zA-Z_-][\w-]*)')
|
||
|
||
# --- Regexy do zbierania stringów klas z Astro/JSX/TSX ---
|
||
# 1) class="..."
|
||
CLASS_ATTR_RE = re.compile(r'\bclass\s*=\s*("([^"]*)"|\'([^\']*)\')', re.IGNORECASE)
|
||
# 2) className="..."
|
||
CLASSNAME_ATTR_RE = re.compile(r'\bclassName\s*=\s*("([^"]*)"|\'([^\']*)\')', re.IGNORECASE)
|
||
|
||
# Astro: class:list={{ a: cond, "b c": cond2 }} / class:list={[...]} – tu łapiemy stringi w środku
|
||
STRING_LIT_RE = re.compile(r'("([^"]+)"|\'([^\']+)\')')
|
||
|
||
# Dla wyszukiwania tokenów klas (żeby "f-card" nie matchowało jako fragment "f-card-x")
|
||
def token_pattern(cls: str) -> re.Pattern:
|
||
return re.compile(r'(?<![\w-])' + re.escape(cls) + r'(?![\w-])')
|
||
|
||
def read_text(path: Path) -> str:
|
||
try:
|
||
return path.read_text(encoding="utf-8", errors="ignore")
|
||
except Exception:
|
||
return ""
|
||
|
||
def collect_css_classes(css_dir: Path) -> Tuple[Set[str], Dict[str, Set[Path]]]:
|
||
classes: Set[str] = set()
|
||
where: Dict[str, Set[Path]] = {}
|
||
|
||
for p in css_dir.rglob("*.css"):
|
||
txt = read_text(p)
|
||
for m in CSS_CLASS_RE.finditer(txt):
|
||
c = m.group(1)
|
||
classes.add(c)
|
||
where.setdefault(c, set()).add(p)
|
||
|
||
return classes, where
|
||
|
||
def collect_used_classes(code_dir: Path, candidates: Set[str]) -> Tuple[Set[str], Dict[str, Set[Path]]]:
|
||
used: Set[str] = set()
|
||
used_where: Dict[str, Set[Path]] = {c: set() for c in candidates}
|
||
|
||
exts = {".astro", ".jsx", ".tsx", ".js", ".ts"}
|
||
# prekompilacja patternów dla szybkości
|
||
patterns = {c: token_pattern(c) for c in candidates}
|
||
|
||
for p in code_dir.rglob("*"):
|
||
if not p.is_file():
|
||
continue
|
||
if p.suffix.lower() not in exts:
|
||
continue
|
||
|
||
txt = read_text(p)
|
||
if not txt:
|
||
continue
|
||
|
||
# szybki filtr: jeśli żaden kandydat nie ma nawet prefiksu "f-" / "jmb-" itd,
|
||
# to i tak skan tokenowy jest ok, ale tu robimy prosty scan wszystkich.
|
||
for c, pat in patterns.items():
|
||
if pat.search(txt):
|
||
used.add(c)
|
||
used_where[c].add(p)
|
||
|
||
# usuń puste wpisy
|
||
used_where = {k: v for k, v in used_where.items() if v}
|
||
return used, used_where
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Znajdź potencjalnie nieużywane klasy CSS w projekcie Astro/JSX.")
|
||
ap.add_argument("--css", required=True, help="Katalog z plikami CSS (np. src/styles)")
|
||
ap.add_argument("--code", required=True, help="Katalog z kodem (np. src)")
|
||
ap.add_argument("--min-len", type=int, default=3, help="Minimalna długość nazwy klasy (domyślnie 3)")
|
||
ap.add_argument("--prefix", action="append", default=[], help="Filtruj klasy po prefiksie (np. --prefix f- --prefix jmb-)")
|
||
ap.add_argument("--show-where", action="store_true", help="Pokaż gdzie zdefiniowano klasę w CSS")
|
||
args = ap.parse_args()
|
||
|
||
css_dir = Path(args.css).resolve()
|
||
code_dir = Path(args.code).resolve()
|
||
|
||
if not css_dir.exists():
|
||
raise SystemExit(f"Brak katalogu CSS: {css_dir}")
|
||
if not code_dir.exists():
|
||
raise SystemExit(f"Brak katalogu code: {code_dir}")
|
||
|
||
all_classes, defined_where = collect_css_classes(css_dir)
|
||
|
||
# filtr długości + prefiksów
|
||
classes = {c for c in all_classes if len(c) >= args.min_len}
|
||
if args.prefix:
|
||
classes = {c for c in classes if any(c.startswith(px) for px in args.prefix)}
|
||
|
||
used, used_where = collect_used_classes(code_dir, classes)
|
||
unused = sorted(classes - used)
|
||
|
||
print(f"CSS katalog: {css_dir}")
|
||
print(f"CODE katalog: {code_dir}")
|
||
print(f"Klasy w CSS: {len(classes)} (po filtrach)")
|
||
print(f"Użyte w kodzie:{len(used)}")
|
||
print(f"NIEUŻYTE: {len(unused)}")
|
||
print("-" * 60)
|
||
|
||
for c in unused:
|
||
print(c)
|
||
if args.show_where:
|
||
files = sorted(defined_where.get(c, []))
|
||
for f in files:
|
||
rel = f.relative_to(css_dir.parent) if css_dir.parent in f.parents else f
|
||
print(f" defined in: {rel}")
|
||
print("-" * 60)
|
||
|
||
# opcjonalnie: pokaż top kilka użyć
|
||
# (jak chcesz, dopiszę flagę na raport "gdzie użyte")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|