#!/usr/bin/env python3 # ============================================================================ # purger_css.py - Repère (et retire, sur demande) les règles CSS qui ne s'appliquent à rien (synthèse §50). # # Une règle est retirée si CHACUN de ses sélecteurs cite un identifiant (#...) ou une classe (.…) qui n'apparaît nulle # part dans les fichiers PHP / JS / HTML / QML du site (dossiers vendor exclus). Prudence : # - noms construits par le code : un nom est jugé utilisé si lui-même apparaît, OU si l'un de ses débuts terminés par # "_" est suivi d'un guillemet (".statut_actif" gardé grâce à 'statut_'.$classe), OU si l'une de ses fins commençant # par "_" est précédée d'un guillemet (".EN_acc" gardé grâce à $langue.'_acc') ; # - classes d'état posées par des scripts (Bootstrap...) toujours gardées (show, active, collapse...) ; # - commentaires (y compris des règles mises en commentaire) conservés tels quels dans le CSS ; les lignes de # commentaire du code (//, #, /* ... */) ne comptent pas comme un usage ; # - un bloc @media devenu vide est retiré. # # Usage : python3 purger_css.py fichier.css racine_du_site [--appliquer] # sans --appliquer : rapport seulement (à relire !) ; avec : réécrit fichier.css, l'original étant conservé en # fichier.css.avant_purge. # ============================================================================ import os, re, sys css_chemin, racine = sys.argv[1], sys.argv[2] appliquer = '--appliquer' in sys.argv morceaux = [] for dossier, sous_dossiers, fichiers in os.walk(racine): sous_dossiers[:] = [d for d in sous_dossiers if d not in ('vendor', 'vendor_OLD', '.git')] for f in fichiers: if f.endswith(('.php', '.js', '.html', '.qml')): with open(os.path.join(dossier, f), encoding='utf-8', errors='replace') as fh: # Lignes de commentaire ignorées (code mis en commentaire, notes) : un nom qui n'y figure plus que là # n'est plus utilisé. morceaux.extend(l for l in fh if not re.match(r'\s*(//|#|\*|/\*)', l)) corpus = '\n'.join(morceaux) TOUJOURS_GARDEES = {'show', 'hide', 'active', 'actif', 'fade', 'collapse', 'collapsing', 'collapsed', 'open', 'disabled', 'selected', 'hidden', 'visible'} def nom_utilise(nom): if nom in TOUJOURS_GARDEES: return True if re.search(r'(? 0: debut, fin = nom[:i + 1], nom[i:] # Uniquement une construction par concaténation : début suivi d'un guillemet ('statut_'.$x), # ou fin précédée d'un guillemet ($x.'_acc') - "pied_de_page_mobile" ne justifie pas "#menu_mobile". if any(q + fin in corpus or debut + q in corpus for q in ("'", '"')): return True return False def selecteur_utile(sel): sel = re.sub(r'::?[\w-]+(\([^)]*\))?', '', sel) # pseudo-classes et pseudo-éléments sel = re.sub(r'\[[^\]]*\]', '', sel) # sélecteurs d'attribut noms = re.findall(r'[#.]([A-Za-z_][\w-]*)', sel) return all(nom_utilise(n) for n in noms) texte = open(css_chemin, encoding='utf-8', errors='replace').read() # Commentaires mis de côté (marqueurs sans accolades), restitués tels quels à la fin : commentaires = [] def mettre_de_cote(m): commentaires.append(m.group(0)) return '\x00' + str(len(commentaires) - 1) + '\x00' texte = re.sub(r'/\*.*?\*/', mettre_de_cote, texte, flags=re.S) restituer = lambda t: re.sub(r'\x00(\d+)\x00', lambda m: commentaires[int(m.group(1))], t) retirees = [] def traiter(bloc): res, pos = [], 0 for m in re.finditer(r'([^{}]*)\{([^{}]*)\}', bloc): sel_brut = m.group(1) marqueurs = ''.join(re.findall(r'\s*\x00\d+\x00\s*', sel_brut)) # commentaires placés avant la règle sel = re.sub(r'\x00\d+\x00', '', sel_brut).strip() selecteurs = [x.strip() for x in sel.split(',') if x.strip()] res.append(bloc[pos:m.start()]) if selecteurs and not any(selecteur_utile(x) for x in selecteurs): retirees.append(' '.join(sel.split())) res.append(marqueurs) else: res.append(m.group(0)) pos = m.end() res.append(bloc[pos:]) return ''.join(res) sortie, pos = [], 0 for m in re.finditer(r'@media[^{]*\{((?:[^{}]*\{[^{}]*\})*[^{}]*)\}', texte): sortie.append(traiter(texte[pos:m.start()])) interieur = traiter(m.group(1)) if '{' in interieur: sortie.append(texte[m.start():m.start(1)] + interieur + '}') else: retirees.append(' '.join(texte[m.start():m.start(1)].split()) + ' (bloc devenu vide)') pos = m.end() sortie.append(traiter(texte[pos:])) resultat = restituer(''.join(sortie)) print(f"{len(retirees)} règle(s) sans usage :") try: for r in retirees: print(" -", r[:120]) except BrokenPipeError: pass if appliquer: os.replace(css_chemin, css_chemin + '.avant_purge') with open(css_chemin, 'w', encoding='utf-8') as fh: fh.write(resultat) print(f"Écrit : {css_chemin} (original conservé : {css_chemin}.avant_purge)")