afv-library/skills/generating-lwc-components/hooks/scripts/validate_slds.py
sandipkumar-yadav 37aa84df42
feat: @W-22444026@ Introducing Core Skills, Datacloud Skills, Industries and Utility Skills. (#268)
* Migrating Core Salesforce Skills

* Updating pr comments

* updat reference

* Updating a skill

* Migrating Datacloud skills

* Migrating Industries cloud skills

* Validating - skills fixing

---------

Co-authored-by: Sandip Kumar Yadav <sandipkumar.yadav+sfemu@salesforce.com>
2026-05-14 19:32:15 +05:30

596 lines
26 KiB
Python

#!/usr/bin/env python3
"""
SLDS 2 Validator for Lightning Web Components.
Provides a 165-point scoring system across 9 categories:
1. SLDS Class Usage (25 pts) - Valid SLDS 2 utility classes
2. Accessibility (25 pts) - aria-*, role, alt-text
3. Dark Mode (25 pts) - No hardcoded colors, uses CSS variables
4. SLDS Migration (20 pts) - No deprecated SLDS 1 patterns
5. Styling Hooks (20 pts) - Proper --slds-g-* variable usage
6. Component Structure (15 pts) - Uses lightning-* base components
7. Performance (10 pts) - Efficient selectors, no !important
8. GraphQL Patterns (15 pts) - Proper wire adapter usage, cursor pagination
9. Focus Management (10 pts) - ESC handlers, focus trap for modals
"""
import os
import re
import json
from pathlib import Path
from typing import Dict, List, Any, Optional
# Script directory for loading data files
SCRIPT_DIR = Path(__file__).parent
class SLDSValidator:
"""SLDS 2 validation engine for LWC files."""
# Maximum scores per category (165 total)
max_scores = {
'slds_class_usage': 25,
'accessibility': 25,
'dark_mode': 25,
'slds_migration': 20,
'styling_hooks': 20,
'component_structure': 15,
'performance': 10,
'graphql_patterns': 15,
'focus_management': 10,
}
def __init__(self, file_path: str):
"""
Initialize validator with file path.
Args:
file_path: Path to .html, .css, or .js file
"""
self.file_path = file_path
self.file_name = os.path.basename(file_path)
self.ext = Path(file_path).suffix.lower()
self.content = ""
self.lines = []
# Load file content
try:
with open(file_path, 'r', encoding='utf-8') as f:
self.content = f.read()
self.lines = self.content.splitlines()
except Exception:
pass
# Load validation data
self._load_data()
def _load_data(self):
"""Load JSON data files for validation rules."""
data_dir = SCRIPT_DIR / 'slds_data'
# Valid SLDS classes
self.valid_slds_classes = set()
try:
with open(data_dir / 'valid_slds_classes.json', 'r') as f:
data = json.load(f)
for category_classes in data.values():
if isinstance(category_classes, list):
self.valid_slds_classes.update(category_classes)
except Exception:
pass
# Deprecated patterns
self.deprecated_patterns = {}
try:
with open(data_dir / 'deprecated_patterns.json', 'r') as f:
self.deprecated_patterns = json.load(f)
except Exception:
pass
# Valid styling hooks
self.valid_hooks = set()
try:
with open(data_dir / 'styling_hooks.json', 'r') as f:
data = json.load(f)
for category_hooks in data.values():
if isinstance(category_hooks, list):
self.valid_hooks.update(category_hooks)
except Exception:
pass
def validate(self) -> Dict[str, Any]:
"""
Run all validations and return results.
Returns:
dict with score, max_score, scores by category, issues, and rating
"""
scores = {cat: max_score for cat, max_score in self.max_scores.items()}
issues = []
if not self.content:
return {
'score': 0,
'max_score': sum(self.max_scores.values()),
'scores': scores,
'issues': [{'severity': 'CRITICAL', 'message': 'Could not read file'}],
'rating': 'Error'
}
# Run validators based on file type
if self.ext == '.html':
self._validate_html(scores, issues)
elif self.ext == '.css':
self._validate_css(scores, issues)
elif self.ext == '.js':
self._validate_js(scores, issues)
# Calculate total score
total_score = sum(scores.values())
max_total = sum(self.max_scores.values())
return {
'score': total_score,
'max_score': max_total,
'scores': scores,
'issues': issues,
'rating': self._get_rating(total_score, max_total)
}
def _get_rating(self, score: int, max_score: int) -> str:
"""Get rating string from score."""
pct = (score / max_score * 100) if max_score > 0 else 0
if pct >= 90:
return "Production-ready SLDS 2"
elif pct >= 80:
return "Good"
elif pct >= 70:
return "Functional"
elif pct >= 60:
return "Needs work"
else:
return "Critical issues"
# ═══════════════════════════════════════════════════════════════════════
# HTML VALIDATION
# ═══════════════════════════════════════════════════════════════════════
def _validate_html(self, scores: Dict[str, int], issues: List[Dict]):
"""Validate HTML template file."""
self._check_slds_classes(scores, issues)
self._check_accessibility(scores, issues)
self._check_component_structure(scores, issues)
def _check_slds_classes(self, scores: Dict[str, int], issues: List[Dict]):
"""Check SLDS class usage in HTML."""
# Find all class attributes
class_pattern = r'class\s*=\s*["\']([^"\']+)["\']'
for i, line in enumerate(self.lines, 1):
matches = re.findall(class_pattern, line)
for class_attr in matches:
classes = class_attr.split()
for cls in classes:
if cls.startswith('slds-'):
# Check if it's a valid SLDS class
if self.valid_slds_classes and cls not in self.valid_slds_classes:
# Allow pattern-based classes we might not have in our list
if not self._is_valid_slds_pattern(cls):
scores['slds_class_usage'] = max(0, scores['slds_class_usage'] - 2)
issues.append({
'severity': 'WARNING',
'category': 'slds_class_usage',
'message': f"Unknown SLDS class: {cls}",
'line': i,
'fix': f"Verify '{cls}' is a valid SLDS 2 class"
})
def _is_valid_slds_pattern(self, cls: str) -> bool:
"""Check if class matches valid SLDS naming patterns."""
patterns = [
r'^slds-p-(around|horizontal|vertical|left|right|top|bottom)_',
r'^slds-m-(around|horizontal|vertical|left|right|top|bottom)_',
r'^slds-size_\d+-of-\d+$',
# Responsive sizing: slds-small-size_, slds-medium-size_, slds-large-size_
r'^slds-(small|medium|large|max-small|max-medium|max-large)-size_\d+-of-\d+$',
r'^slds-text-(heading|body|color|align)_',
r'^slds-grid(_|$)',
r'^slds-col(_|$)',
r'^slds-button(_|$)',
r'^slds-input(_|$)',
r'^slds-form(_|$)',
r'^slds-card(_|$)',
r'^slds-modal(_|$)',
r'^slds-notify(_|$)',
r'^slds-illustration(_|$)',
r'^slds-table(_|$)',
r'^slds-box(_|$)',
r'^slds-badge(_|$)',
r'^slds-spinner(_|$)',
r'^slds-alert(_|$)',
# Utility patterns
r'^slds-has-',
r'^slds-no-',
r'^slds-var-',
r'^slds-is-',
r'^slds-theme_',
r'^slds-icon(_|$)',
r'^slds-media(_|$)',
r'^slds-list(_|$)',
r'^slds-tile(_|$)',
r'^slds-popover(_|$)',
r'^slds-dropdown(_|$)',
r'^slds-tabs_',
r'^slds-path(_|$)',
r'^slds-progress(_|$)',
]
return any(re.match(p, cls) for p in patterns)
def _check_accessibility(self, scores: Dict[str, int], issues: List[Dict]):
"""Check accessibility requirements in HTML."""
content = self.content
# Check lightning-icon without alternative-text
icon_pattern = r'<lightning-icon[^>]*>'
for i, line in enumerate(self.lines, 1):
for match in re.finditer(icon_pattern, line):
icon_tag = match.group(0)
if 'alternative-text' not in icon_tag:
scores['accessibility'] = max(0, scores['accessibility'] - 3)
issues.append({
'severity': 'WARNING',
'category': 'accessibility',
'message': 'lightning-icon missing alternative-text attribute',
'line': i,
'fix': 'Add alternative-text="description" for screen readers'
})
# Check lightning-button-icon without label
button_icon_pattern = r'<lightning-button-icon[^>]*>'
for i, line in enumerate(self.lines, 1):
for match in re.finditer(button_icon_pattern, line):
tag = match.group(0)
if 'aria-label' not in tag and 'alternative-text' not in tag:
scores['accessibility'] = max(0, scores['accessibility'] - 3)
issues.append({
'severity': 'WARNING',
'category': 'accessibility',
'message': 'lightning-button-icon missing aria-label or alternative-text',
'line': i,
'fix': 'Add aria-label="action description" for accessibility'
})
# Check for slds-assistive-text usage (good practice)
if 'slds-assistive-text' not in content and 'aria-live' not in content:
# Only deduct if there's dynamic content indicators
if '{' in content and '}' in content:
scores['accessibility'] = max(0, scores['accessibility'] - 2)
issues.append({
'severity': 'INFO',
'category': 'accessibility',
'message': 'Consider adding aria-live for dynamic content updates',
'line': 0,
'fix': 'Use aria-live="polite" for status updates'
})
def _check_component_structure(self, scores: Dict[str, int], issues: List[Dict]):
"""Check component structure for SLDS compliance."""
# Check for lightning-* base components (good)
lightning_components = re.findall(r'<lightning-[a-z-]+', self.content)
if not lightning_components:
scores['component_structure'] = max(0, scores['component_structure'] - 5)
issues.append({
'severity': 'INFO',
'category': 'component_structure',
'message': 'No lightning-* base components found',
'line': 0,
'fix': 'Consider using lightning-* components for SLDS consistency'
})
# ═══════════════════════════════════════════════════════════════════════
# CSS VALIDATION
# ═══════════════════════════════════════════════════════════════════════
def _validate_css(self, scores: Dict[str, int], issues: List[Dict]):
"""Validate CSS file."""
self._check_dark_mode(scores, issues)
self._check_styling_hooks(scores, issues)
self._check_slds_migration(scores, issues)
self._check_css_performance(scores, issues)
def _check_dark_mode(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for dark mode compatibility (no hardcoded colors)."""
# Patterns for hardcoded colors
color_patterns = [
(r'#[0-9A-Fa-f]{3,8}(?![0-9A-Fa-f])', 'hex color'),
(r'rgb\s*\([^)]+\)', 'RGB color'),
(r'rgba\s*\([^)]+\)', 'RGBA color'),
(r'hsl\s*\([^)]+\)', 'HSL color'),
(r'hsla\s*\([^)]+\)', 'HSLA color'),
]
for i, line in enumerate(self.lines, 1):
# Skip comments
if line.strip().startswith('/*') or line.strip().startswith('//'):
continue
# Skip if it's inside a var() - that's allowed
line_without_vars = re.sub(r'var\s*\([^)]+\)', '', line)
for pattern, color_type in color_patterns:
matches = re.findall(pattern, line_without_vars)
for match in matches:
# Skip transparent and common exceptions
if match.lower() in ['#fff', '#ffffff', '#000', '#000000']:
scores['dark_mode'] = max(0, scores['dark_mode'] - 5)
issues.append({
'severity': 'HIGH',
'category': 'dark_mode',
'message': f'Hardcoded {color_type} ({match}) breaks dark mode',
'line': i,
'fix': f'Use var(--slds-g-color-*) instead of {match}'
})
elif match.lower() not in ['transparent', 'inherit', 'currentcolor']:
scores['dark_mode'] = max(0, scores['dark_mode'] - 3)
issues.append({
'severity': 'MODERATE',
'category': 'dark_mode',
'message': f'Hardcoded {color_type} ({match}) may break dark mode',
'line': i,
'fix': f'Consider using var(--slds-g-color-*) instead'
})
def _check_styling_hooks(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for proper SLDS 2 styling hooks usage."""
# Find all CSS variable references
var_pattern = r'var\s*\(\s*(--[a-zA-Z0-9-]+)'
for i, line in enumerate(self.lines, 1):
matches = re.findall(var_pattern, line)
for var_name in matches:
# Check if it's an SLDS variable
if var_name.startswith('--slds-'):
# SLDS 2 global hooks use --slds-g-
if var_name.startswith('--slds-c-'):
scores['styling_hooks'] = max(0, scores['styling_hooks'] - 3)
issues.append({
'severity': 'WARNING',
'category': 'styling_hooks',
'message': f'Component hooks ({var_name}) not yet supported in SLDS 2',
'line': i,
'fix': 'Use --slds-g-* global hooks or wait for SLDS 2 component hook support'
})
elif not var_name.startswith('--slds-g-'):
scores['styling_hooks'] = max(0, scores['styling_hooks'] - 2)
issues.append({
'severity': 'INFO',
'category': 'styling_hooks',
'message': f'Non-standard SLDS variable: {var_name}',
'line': i,
'fix': 'Use --slds-g-* for SLDS 2 compatibility'
})
def _check_slds_migration(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for deprecated SLDS 1 patterns."""
deprecated_tokens = self.deprecated_patterns.get('tokens', {})
deprecated_classes = self.deprecated_patterns.get('classes', {})
for i, line in enumerate(self.lines, 1):
# Check deprecated Sass tokens
for old_token, replacement in deprecated_tokens.items():
if old_token in line:
scores['slds_migration'] = max(0, scores['slds_migration'] - 5)
issues.append({
'severity': 'HIGH',
'category': 'slds_migration',
'message': f'Deprecated SLDS 1 token: {old_token}',
'line': i,
'fix': f'Replace with {replacement}'
})
# Check --lwc- prefix (old format)
if '--lwc-' in line:
scores['slds_migration'] = max(0, scores['slds_migration'] - 3)
issues.append({
'severity': 'MODERATE',
'category': 'slds_migration',
'message': 'Old --lwc-* token format detected',
'line': i,
'fix': 'Migrate to --slds-g-* styling hooks'
})
def _check_css_performance(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for CSS performance issues."""
for i, line in enumerate(self.lines, 1):
# Check for !important
if '!important' in line:
scores['performance'] = max(0, scores['performance'] - 3)
issues.append({
'severity': 'WARNING',
'category': 'performance',
'message': '!important override detected',
'line': i,
'fix': 'Avoid !important; use more specific selectors or SLDS utilities'
})
# Check for overly deep selectors (> 3 levels)
selector_pattern = r'^[^{]+{'
for i, line in enumerate(self.lines, 1):
if '{' in line:
# Count selector depth
selector = line.split('{')[0]
depth = len(re.findall(r'\s+', selector.strip()))
if depth > 3:
scores['performance'] = max(0, scores['performance'] - 2)
issues.append({
'severity': 'INFO',
'category': 'performance',
'message': 'Deep CSS selector detected (>3 levels)',
'line': i,
'fix': 'Simplify selector for better performance'
})
# ═══════════════════════════════════════════════════════════════════════
# JAVASCRIPT VALIDATION
# ═══════════════════════════════════════════════════════════════════════
def _validate_js(self, scores: Dict[str, int], issues: List[Dict]):
"""Validate JavaScript controller file."""
# JS files have limited SLDS-specific validation
# Focus on inline styles, classList manipulation, GraphQL, and focus management
for i, line in enumerate(self.lines, 1):
# Check for inline style manipulation with colors
if '.style.' in line and any(c in line.lower() for c in ['color', 'background', 'border']):
if re.search(r'#[0-9A-Fa-f]{3,8}|rgb\s*\(', line):
scores['dark_mode'] = max(0, scores['dark_mode'] - 5)
issues.append({
'severity': 'HIGH',
'category': 'dark_mode',
'message': 'Inline style with hardcoded color detected',
'line': i,
'fix': 'Use CSS classes or CSS variables instead of inline styles'
})
# Check for classList with invalid SLDS classes
if 'classList' in line and 'slds-' in line:
# Extract class names from string literals
classes = re.findall(r'["\']([slds-][^"\']+)["\']', line)
for cls in classes:
if cls.startswith('slds-') and self.valid_slds_classes and cls not in self.valid_slds_classes:
if not self._is_valid_slds_pattern(cls):
scores['slds_class_usage'] = max(0, scores['slds_class_usage'] - 2)
issues.append({
'severity': 'WARNING',
'category': 'slds_class_usage',
'message': f"Unknown SLDS class in JS: {cls}",
'line': i,
'fix': f"Verify '{cls}' is a valid SLDS 2 class"
})
# Check GraphQL patterns
self._check_graphql_patterns(scores, issues)
# Check focus management patterns
self._check_focus_management(scores, issues)
def _check_graphql_patterns(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for proper GraphQL wire adapter usage."""
content = self.content
# Check if using GraphQL wire adapter
has_graphql_import = 'lightning/uiGraphQLApi' in content
has_gql_import = "import { gql" in content or "import { graphql" in content
if has_graphql_import or has_gql_import:
# Verify proper patterns
# Check for storing wire result for refresh
if '@wire(graphql' in content or '@wire(gql' in content:
# Check if result is stored for refreshGraphQL
if 'refreshGraphQL' in content:
# Good pattern - using refreshGraphQL
pass
else:
# Check if the wire is used with a function that stores result
wire_pattern = r'@wire\s*\(\s*graphql[^)]*\)\s*(\w+)'
matches = re.findall(wire_pattern, content)
for match in matches:
# If it's a function pattern, check if it stores result
if f'wired{match[0].upper()}' not in content and 'Result' not in match:
scores['graphql_patterns'] = max(0, scores['graphql_patterns'] - 5)
issues.append({
'severity': 'INFO',
'category': 'graphql_patterns',
'message': 'GraphQL wire result not stored for potential refresh',
'line': 0,
'fix': 'Store wire result in a property for use with refreshGraphQL()'
})
# Check for cursor-based pagination pattern
if 'first:' in content and 'after:' not in content and 'pageInfo' not in content:
scores['graphql_patterns'] = max(0, scores['graphql_patterns'] - 3)
issues.append({
'severity': 'INFO',
'category': 'graphql_patterns',
'message': 'GraphQL query uses first: but missing pagination',
'line': 0,
'fix': 'Add pageInfo { hasNextPage endCursor } for cursor pagination'
})
# Check for proper error handling
if 'graphQLErrors' not in content and '.errors' not in content:
scores['graphql_patterns'] = max(0, scores['graphql_patterns'] - 2)
issues.append({
'severity': 'INFO',
'category': 'graphql_patterns',
'message': 'GraphQL error handling not detected',
'line': 0,
'fix': 'Handle graphQLErrors in wire result or catch block'
})
def _check_focus_management(self, scores: Dict[str, int], issues: List[Dict]):
"""Check for proper focus management patterns in modals/dialogs."""
content = self.content
# Check if this appears to be a modal component
is_modal = any(indicator in content.lower() for indicator in [
'modal', 'dialog', 'overlay', 'popup', 'backdrop'
])
if is_modal:
# Check for ESC key handler
has_esc_handler = any(pattern in content for pattern in [
"'Escape'", '"Escape"', 'key === 27', 'keyCode === 27',
"code === 'Escape'", 'code === "Escape"'
])
if not has_esc_handler:
scores['focus_management'] = max(0, scores['focus_management'] - 3)
issues.append({
'severity': 'WARNING',
'category': 'focus_management',
'message': 'Modal component missing ESC key handler',
'line': 0,
'fix': "Add window.addEventListener('keyup', handler) to close on Escape"
})
# Check for focus trap pattern
has_focus_trap = any(pattern in content for pattern in [
'focusable', 'tabbable', '.focus()', 'tabindex',
'querySelectorAll', 'firstElementChild'
])
if not has_focus_trap:
scores['focus_management'] = max(0, scores['focus_management'] - 3)
issues.append({
'severity': 'INFO',
'category': 'focus_management',
'message': 'Modal may need focus trap for accessibility',
'line': 0,
'fix': 'Implement focus trap to keep focus within modal'
})
# Check for cleanup in disconnectedCallback
if 'addEventListener' in content:
if 'removeEventListener' not in content and 'disconnectedCallback' not in content:
scores['focus_management'] = max(0, scores['focus_management'] - 4)
issues.append({
'severity': 'HIGH',
'category': 'focus_management',
'message': 'Event listener added but not cleaned up',
'line': 0,
'fix': 'Add disconnectedCallback to remove event listeners'
})
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python validate_slds.py <file.html|file.css|file.js>")
sys.exit(1)
validator = SLDSValidator(sys.argv[1])
results = validator.validate()
print(json.dumps(results, indent=2))