#!/usr/bin/env python3 """ Pre-commit hook to ensure all LDR-specific CSS class names are prefixed with 'ldr-'. This prevents CSS class name conflicts when Vite bundles CSS from dependencies. """ import re import sys from pathlib import Path from typing import List, Tuple # Allowed patterns - ONLY third-party framework classes # Everything else MUST have ldr- prefix ALLOWED_PATTERNS = { # Already prefixed with ldr- r"^ldr-", # Bootstrap 5 specific classes (no wildcards unless necessary) # Layout r"^(container|container-fluid|container-sm|container-md|container-lg|container-xl|container-xxl)$", r"^row$", r"^col$", r"^col-(1|2|3|4|5|6|7|8|9|10|11|12)$", r"^col-(sm|md|lg|xl|xxl)-(1|2|3|4|5|6|7|8|9|10|11|12)$", # Bootstrap components (with specific prefixes) r"^btn(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-link|-outline-primary|-outline-secondary|-outline-success|-outline-danger|-outline-warning|-outline-info|-outline-light|-outline-dark|-sm|-lg|-block|-close|-close-white)?$", r"^alert(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-dismissible)?$", r"^badge(-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-pill)?$", r"^card(-body|-header|-footer|-title|-subtitle|-text|-link|-img-top|-img-bottom)?$", r"^navbar(-brand|-nav|-toggler|-collapse|-expand|-expand-sm|-expand-md|-expand-lg|-expand-xl|-dark|-light)?$", r"^nav(-link|-item|-pills|-tabs|-fill|-justified)?$", r"^dropdown(-toggle|-menu|-menu-end|-menu-start|-item|-divider|-header)?$", r"^modal(-dialog|-dialog-centered|-dialog-scrollable|-content|-header|-body|-footer|-title|-backdrop|-static|-sm|-lg|-xl|-fullscreen)?$", r"^form(-control|-label|-text|-check|-check-input|-check-label|-check-inline|-switch|-select|-range|-floating|-group|-row)?$", r"^input-group(-text|-prepend|-append)?$", r"^list-group(-item|-item-action|-flush)?$", r"^table(-dark|-striped|-bordered|-borderless|-hover|-sm|-responsive)?$", # Bootstrap utilities with exact values r"^text-(start|end|center|primary|secondary|success|danger|warning|info|light|dark|body|muted|white|black-50|white-50)$", r"^text-(lowercase|uppercase|capitalize|nowrap|truncate|break|monospace)$", r"^bg-(primary|secondary|success|danger|warning|info|light|dark|body|white|transparent)$", r"^border(-top|-end|-bottom|-start|-primary|-secondary|-success|-danger|-warning|-info|-light|-dark|-white)?$", r"^border-(0|1|2|3|4|5)$", r"^rounded(-top|-end|-bottom|-start|-circle|-pill|-0|1|2|3)?$", r"^shadow(-sm|-lg|-none)?$", # Display utilities r"^d-(none|inline|inline-block|block|table|table-row|table-cell|flex|inline-flex|grid)$", r"^d-(sm|md|lg|xl|xxl)-(none|inline|inline-block|block|table|table-row|table-cell|flex|inline-flex|grid)$", # Flexbox utilities r"^flex-(row|row-reverse|column|column-reverse|wrap|nowrap|wrap-reverse)$", r"^justify-content-(start|end|center|between|around|evenly)$", r"^align-items-(start|end|center|baseline|stretch)$", r"^align-self-(start|end|center|baseline|stretch)$", r"^flex-(fill|grow-0|grow-1|shrink-0|shrink-1)$", # Spacing utilities (margins and padding) r"^m-(0|1|2|3|4|5|auto)$", r"^mt-(0|1|2|3|4|5|auto)$", r"^mb-(0|1|2|3|4|5|auto)$", r"^ms-(0|1|2|3|4|5|auto)$", r"^me-(0|1|2|3|4|5|auto)$", r"^mx-(0|1|2|3|4|5|auto)$", r"^my-(0|1|2|3|4|5|auto)$", r"^p-(0|1|2|3|4|5)$", r"^pt-(0|1|2|3|4|5)$", r"^pb-(0|1|2|3|4|5)$", r"^ps-(0|1|2|3|4|5)$", r"^pe-(0|1|2|3|4|5)$", r"^px-(0|1|2|3|4|5)$", r"^py-(0|1|2|3|4|5)$", # Sizing utilities r"^w-(25|50|75|100|auto)$", r"^h-(25|50|75|100|auto)$", r"^mw-100$", r"^mh-100$", # Position utilities r"^position-(static|relative|absolute|fixed|sticky)$", r"^top-(0|50|100)$", r"^bottom-(0|50|100)$", r"^start-(0|50|100)$", r"^end-(0|50|100)$", # Typography utilities r"^fs-(1|2|3|4|5|6)$", r"^fw-(light|lighter|normal|bold|bolder)$", r"^fst-(normal|italic)$", r"^lh-(1|sm|base|lg)$", r"^font-monospace$", r"^text-decoration-(none|underline|line-through)$", # Visibility utilities r"^(visible|invisible)$", r"^visually-hidden$", r"^visually-hidden-focusable$", r"^overflow-(auto|hidden|visible|scroll)$", r"^user-select-(all|auto|none)$", # Interactive utilities r"^pe-(none|auto)$", r"^(active|disabled|show|hide|collapse|collapsed|fade|collapsing|close)$", # Form validation states (Bootstrap 5) r"^(is-valid|is-invalid|valid-feedback|invalid-feedback|valid-tooltip|invalid-tooltip)$", # Toast component (Bootstrap 5) r"^toast(-header|-body)?$", # Progress bars (Bootstrap 5) r"^progress(-bar|-bar-striped|-bar-animated|-stacked)?$", r"^btn-group(-vertical|-sm|-lg)?$", # Image utilities (Bootstrap 5) r"^img-(fluid|thumbnail)$", # Other Bootstrap utilities r"^(clearfix|sr-only|sr-only-focusable|small)$", r"^spinner-(border|border-sm|grow|grow-sm)$", r"^placeholder(-glow|-wave)?$", # Bootstrap Icons (bi-*) r"^bi(-[a-z0-9-]+)?$", # Font Awesome icons r"^(fa|fas|far|fab|fal|fad)$", r"^fa-[a-z0-9-]+$", # KaTeX math rendering r"^katex(-[a-z0-9-]+)?$", } def is_allowed_class(class_name: str) -> bool: """Check if a class name is allowed without ldr- prefix.""" # Remove any leading/trailing whitespace class_name = class_name.strip() # Empty class names are invalid if not class_name: return False # Check against allowed patterns for pattern in ALLOWED_PATTERNS: if re.match(pattern, class_name, re.IGNORECASE): return True return False def check_css_file(file_path: Path) -> List[Tuple[int, str, str]]: """Check CSS file for non-prefixed class definitions.""" errors = [] try: content = file_path.read_text(encoding="utf-8") lines = content.split("\n") # Pattern to match CSS class selectors, including compound # selectors like .class1.class2 # First match the leading dot (not preceded by a word char or /) # then capture everything including subsequent .class segments class_pattern = re.compile( r"(? List[Tuple[int, str, str]]: """Check HTML file for non-prefixed class usage and CSS definitions in style tags.""" errors = [] try: content = file_path.read_text(encoding="utf-8") lines = content.split("\n") # Pattern to match class attributes in HTML # Simplified pattern to avoid exponential backtracking # Matches: class="..." or class='...' # Uses two separate patterns to handle each quote type correctly class_attr_patterns = [ re.compile(r'class\s*=\s*"([^"]*)"', re.IGNORECASE), re.compile(r"class\s*=\s*'([^']*)'", re.IGNORECASE), ] # Pattern to match CSS class definitions (same as in check_css_file) css_class_pattern = re.compile( r"(?" in line: continue # Skip Jinja2 template comments if "{#" in line or "#}" in line: continue # Check for style tag boundaries if "" in line.lower(): in_style_tag = False # If we're inside a style tag, check for CSS class definitions if in_style_tag: # Skip CSS comments if ( "/*" in line or "*/" in line or line.strip().startswith("//") ): continue # Check for CSS class definitions css_matches = css_class_pattern.findall(line) for match in css_matches: # Split compound selectors like "class1.class2" for class_name in match.split("."): if not class_name: continue if not is_allowed_class(class_name): errors.append( ( line_num, class_name, f"CSS class '.{class_name}' in style tag should be prefixed with 'ldr-'", ) ) continue # Skip HTML class checking when in style tag # Try both patterns to handle different quote types matches = [] for pattern in class_attr_patterns: matches.extend(pattern.findall(line)) for class_attr in matches: # Skip if this is a JavaScript template literal (contains ${...}) if "${" in class_attr: continue # Skip if the entire class attribute contains Jinja2 template variables # This handles cases like class="alert alert-{{ category }}" if "{{" in class_attr or "{%" in class_attr: # Extract only the static class names (those not part of Jinja2 expressions) # Remove the Jinja2 parts and check remaining static classes # Remove Jinja2 expressions but keep the rest cleaned_attr = re.sub( r"\{\{[^}]*\}\}|\{%[^%]*%\}", "", class_attr ) classes = cleaned_attr.split() else: # No Jinja2 in this attribute, check all classes classes = class_attr.split() for class_name in classes: # Additional safety check for individual class names if ( "{{" in class_name or "{%" in class_name or "{" in class_name or "}" in class_name ): continue # Skip JavaScript template literals and variables if any( char in class_name for char in [ "$", "=", "!", "<", ">", "(", ")", "[", "]", "||", "&&", "?", ":", ] ): continue # Skip common Jinja2 patterns if class_name in [ "if", "else", "endif", "for", "endfor", "block", "endblock", "include", "extends", ]: continue # Skip Bootstrap icon classes if class_name.startswith("bi-") or class_name == "bi": continue if not is_allowed_class(class_name): errors.append( ( line_num, class_name, f"HTML class '{class_name}' should be prefixed with 'ldr-'", ) ) except Exception as e: print(f"Error reading {file_path}: {e}", file=sys.stderr) return errors def check_js_file(file_path: Path) -> List[Tuple[int, str, str]]: """Check JavaScript file for non-prefixed class usage.""" errors = [] try: content = file_path.read_text(encoding="utf-8") lines = content.split("\n") # Patterns to match class usage in JavaScript (excluding querySelector/jQuery) patterns = [ # classList.add('classname') re.compile( r'classList\.(add|remove|toggle|contains)\s*\(\s*["\']([^"\']+)["\']' ), # className = 'classname' or className: 'classname' re.compile(r'className\s*[:=]\s*["\']([^"\']+)["\']'), # getElementsByClassName('classname') re.compile(r'getElementsByClassName\s*\(\s*["\']([^"\']+)["\']'), # hasClass('classname'), addClass('classname'), removeClass('classname') re.compile( r'\.(hasClass|addClass|removeClass|toggleClass)\s*\(\s*["\']([^"\']+)["\']' ), ] # Pattern for template literal className assignments: # className = `some-class ${dynamic}` or className: `some-class` template_literal_pattern = re.compile(r"className\s*[:=]\s*`([^`]+)`") # Special patterns for querySelector and jQuery that need different handling querySelector_pattern = re.compile( r'querySelector(?:All)?\s*\(\s*["\']([^"\']+)["\']' ) jquery_pattern = re.compile(r'\$\s*\(\s*["\']([^"\']+)["\']') for line_num, line in enumerate(lines, 1): # Skip comments if "//" in line or "/*" in line or "*/" in line: # Simple comment detection (not perfect but good enough) comment_start = line.find("//") if comment_start >= 0: line = line[:comment_start] # Handle querySelector and jQuery selectors specially for selector_match in querySelector_pattern.findall(line): # Extract class names from CSS selectors (e.g., '.class1 .class2', '.class1.class2') # Only match class selectors (starting with .) class_matches = re.findall( r"\.([a-zA-Z0-9_-]+)", selector_match ) for cls in class_matches: if not is_allowed_class(cls): errors.append( ( line_num, cls, f"JavaScript class '.{cls}' should be prefixed with 'ldr-'", ) ) for jquery_match in jquery_pattern.findall(line): # Extract class names from jQuery selectors class_matches = re.findall(r"\.([a-zA-Z0-9_-]+)", jquery_match) for cls in class_matches: if not is_allowed_class(cls): errors.append( ( line_num, cls, f"JavaScript class '.{cls}' should be prefixed with 'ldr-'", ) ) # Handle template literal className assignments for tl_match in template_literal_pattern.findall(line): # Extract static class tokens by removing ${...} expressions static_part = re.sub(r"\$\{[^}]*\}", " ", tl_match) for cls in static_part.split(): # Skip partial tokens that are fragments of dynamic # class names (e.g. "alert-" from "alert-${type}") if cls.endswith("-") or cls.startswith("-"): continue if not is_allowed_class(cls): errors.append( ( line_num, cls, f"JavaScript class '{cls}' should be prefixed with 'ldr-'", ) ) # Handle other patterns for pattern in patterns: matches = pattern.findall(line) for match in matches: # Handle different capture groups if isinstance(match, tuple): # For patterns with multiple groups, get the class name class_name = match[-1] if len(match) > 1 else match[0] else: class_name = match # Split multiple classes if present classes = class_name.split() for cls in classes: if not is_allowed_class(cls): errors.append( ( line_num, cls, f"JavaScript class '{cls}' should be prefixed with 'ldr-'", ) ) except Exception as e: print(f"Error reading {file_path}: {e}", file=sys.stderr) return errors def main(): """Main function to check files passed as arguments.""" if len(sys.argv) < 2: print("No files to check") return 0 has_errors = False for file_arg in sys.argv[1:]: file_path = Path(file_arg) if not file_path.exists(): continue # Match path components, not substrings: "lib" must be an actual # directory segment, not a fragment of "library.html" etc. if any( vendor in file_path.parts for vendor in [ "vendor", "dist", "build", "lib", "libs", "node_modules", ] ): continue errors = [] # Check based on file extension if file_path.suffix == ".css": errors = check_css_file(file_path) elif file_path.suffix in [".html", ".htm"]: errors = check_html_file(file_path) elif file_path.suffix in [".js", ".jsx", ".ts", ".tsx", ".mjs"]: errors = check_js_file(file_path) if errors: has_errors = True print(f"\nāŒ CSS class prefix errors in {file_path}:") for line_num, class_name, message in errors: print(f" Line {line_num}: {message}") if has_errors: print("\n" + "=" * 60) print("CSS Class Naming Convention:") print(" All LDR-specific CSS classes must be prefixed with 'ldr-'") print( " This prevents conflicts when Vite bundles CSS from dependencies" ) print(" Example: .custom-button → .ldr-custom-button") print("=" * 60) return 1 return 0 if __name__ == "__main__": sys.exit(main())