Skip to content

Commit d177eb3

Browse files
committed
Tooling: Add CSS checker
1 parent 2540752 commit d177eb3

7 files changed

Lines changed: 612 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package main
2+
3+
// Allowlists for CSS classes and variables that can't be detected by static analysis.
4+
// Add entries here with a comment explaining why they're needed.
5+
6+
// allowedUnusedClasses lists CSS classes that are defined but used dynamically
7+
// (constructed at runtime, used in third-party libs, or referenced via string interpolation).
8+
var allowedUnusedClasses = map[string]bool{
9+
// Example: "my-dynamic-class": true, // Used in SomeComponent.svelte via `class={dynamicValue}`
10+
}
11+
12+
// allowedUnusedVariables lists CSS custom properties that are defined but used dynamically,
13+
// or defined for future use / theming purposes.
14+
var allowedUnusedVariables = map[string]bool{
15+
// Example: "color-future-feature": true, // Reserved for upcoming feature X
16+
}
17+
18+
// allowedUndefinedClasses lists classes used in templates that don't need CSS definitions
19+
// (used for JS selection, third-party libs, or semantic purposes).
20+
var allowedUndefinedClasses = map[string]bool{
21+
// Example: "js-dropdown-trigger": true, // Used for JS event binding, no styling needed
22+
}

scripts/check-css-unused/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module cmdr/scripts/check-css-unused
2+
3+
go 1.25

scripts/check-css-unused/main.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Package main provides a tool to detect unused and undefined CSS classes and custom properties.
2+
// Run: cd scripts/check-css-unused && go run .
3+
// Or: go run -C scripts/check-css-unused .
4+
package main
5+
6+
import (
7+
"flag"
8+
"fmt"
9+
"os"
10+
"path/filepath"
11+
)
12+
13+
func main() {
14+
verbose := flag.Bool("verbose", false, "Show file locations for each issue")
15+
flag.Parse()
16+
17+
rootDir, err := findRootDir()
18+
if err != nil {
19+
fmt.Fprintf(os.Stderr, "%sError: %v%s\n", colorRed, err, colorReset)
20+
os.Exit(1)
21+
}
22+
23+
srcDir := filepath.Join(rootDir, "apps", "desktop", "src")
24+
25+
// Scan the codebase
26+
result, err := ScanDesktopApp(srcDir)
27+
if err != nil {
28+
fmt.Fprintf(os.Stderr, "%sError scanning files: %v%s\n", colorRed, err, colorReset)
29+
os.Exit(1)
30+
}
31+
32+
// Analyze for issues
33+
issues := AnalyzeResults(result)
34+
35+
// Report findings
36+
issues.Report(*verbose)
37+
38+
if issues.HasIssues() {
39+
fmt.Printf("%s❌ %s%s\n", colorRed, issues.Summary(), colorReset)
40+
fmt.Println()
41+
fmt.Println("To allowlist items, edit: scripts/check-css-unused/allowlist.go")
42+
os.Exit(1)
43+
}
44+
45+
fmt.Printf("%s✅ No CSS issues found%s\n", colorGreen, colorReset)
46+
}

scripts/check-css-unused/parser.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package main
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// Regex patterns for CSS parsing
9+
var (
10+
// Matches CSS custom property definitions: --property-name: value
11+
cssVarDefRegex = regexp.MustCompile(`--([a-zA-Z][a-zA-Z0-9-]*)\s*:`)
12+
13+
// Matches CSS custom property usage: var(--property-name)
14+
cssVarUseRegex = regexp.MustCompile(`var\(--([a-zA-Z][a-zA-Z0-9-]*)\)`)
15+
16+
// Matches class names in CSS selectors (handles compound selectors like .parent.child)
17+
// Captures any .class-name pattern in CSS
18+
cssClassDefRegex = regexp.MustCompile(`\.([a-zA-Z_][a-zA-Z0-9_-]*)`)
19+
20+
// Matches dynamic class binding in Svelte: class:name={...} or class:name
21+
classDynamicRegex = regexp.MustCompile(`class:([a-zA-Z_][a-zA-Z0-9_-]*)`)
22+
)
23+
24+
// reservedCssNames contains pseudo-classes/elements that look like class names
25+
var reservedCssNames = map[string]bool{
26+
"root": true, "before": true, "after": true, "hover": true, "focus": true,
27+
"active": true, "first": true, "last": true, "nth": true, "not": true,
28+
"global": true, "checked": true, "disabled": true, "empty": true,
29+
"enabled": true, "visited": true, "link": true, "target": true,
30+
}
31+
32+
// extractStyleSection extracts content between <style> and </style> tags.
33+
func extractStyleSection(svelteContent string) string {
34+
startIdx := strings.Index(svelteContent, "<style")
35+
if startIdx == -1 {
36+
return ""
37+
}
38+
tagEndIdx := strings.Index(svelteContent[startIdx:], ">")
39+
if tagEndIdx == -1 {
40+
return ""
41+
}
42+
contentStart := startIdx + tagEndIdx + 1
43+
44+
endIdx := strings.Index(svelteContent[contentStart:], "</style>")
45+
if endIdx == -1 {
46+
return ""
47+
}
48+
49+
return svelteContent[contentStart : contentStart+endIdx]
50+
}
51+
52+
// extractTemplateSection extracts content outside of <script> and <style> tags (the template).
53+
func extractTemplateSection(svelteContent string) string {
54+
result := svelteContent
55+
56+
// Remove script sections
57+
for {
58+
startIdx := strings.Index(result, "<script")
59+
if startIdx == -1 {
60+
break
61+
}
62+
endIdx := strings.Index(result[startIdx:], "</script>")
63+
if endIdx == -1 {
64+
break
65+
}
66+
result = result[:startIdx] + result[startIdx+endIdx+9:]
67+
}
68+
69+
// Remove style sections
70+
for {
71+
startIdx := strings.Index(result, "<style")
72+
if startIdx == -1 {
73+
break
74+
}
75+
endIdx := strings.Index(result[startIdx:], "</style>")
76+
if endIdx == -1 {
77+
break
78+
}
79+
result = result[:startIdx] + result[startIdx+endIdx+8:]
80+
}
81+
82+
return result
83+
}
84+
85+
// findVarDefinitions extracts CSS variable definitions from CSS content.
86+
func findVarDefinitions(cssContent string) []string {
87+
var vars []string
88+
for _, match := range cssVarDefRegex.FindAllStringSubmatch(cssContent, -1) {
89+
vars = append(vars, match[1])
90+
}
91+
return vars
92+
}
93+
94+
// findVarUsages extracts CSS variable usages from any content.
95+
func findVarUsages(content string) []string {
96+
var vars []string
97+
for _, match := range cssVarUseRegex.FindAllStringSubmatch(content, -1) {
98+
vars = append(vars, match[1])
99+
}
100+
return vars
101+
}
102+
103+
// stripCssComments removes /* ... */ comments from CSS content.
104+
func stripCssComments(cssContent string) string {
105+
commentRegex := regexp.MustCompile(`/\*[\s\S]*?\*/`)
106+
return commentRegex.ReplaceAllString(cssContent, "")
107+
}
108+
109+
// findClassDefinitions extracts CSS class definitions from CSS content.
110+
func findClassDefinitions(cssContent string) []string {
111+
// Strip comments first to avoid false positives from URLs and file paths
112+
cleanContent := stripCssComments(cssContent)
113+
114+
var classes []string
115+
for _, match := range cssClassDefRegex.FindAllStringSubmatch(cleanContent, -1) {
116+
className := match[1]
117+
if !reservedCssNames[className] {
118+
classes = append(classes, className)
119+
}
120+
}
121+
return classes
122+
}
123+
124+
// findClassUsagesInTemplate extracts class usages from Svelte template content only.
125+
// This should be called with template content (outside <script> and <style> sections).
126+
func findClassUsagesInTemplate(templateContent string) []string {
127+
classSet := make(map[string]bool)
128+
129+
// Find static class usages: class="foo bar baz"
130+
staticClassRegex := regexp.MustCompile(`class\s*=\s*"([^"]+)"`)
131+
for _, match := range staticClassRegex.FindAllStringSubmatch(templateContent, -1) {
132+
for _, cls := range strings.Fields(match[1]) {
133+
if isValidClassName(cls) {
134+
classSet[cls] = true
135+
}
136+
}
137+
}
138+
139+
// Find Svelte conditional class directives: class:foo={...} or class:foo
140+
// This is a real class usage - when condition is true, the class is applied
141+
for _, match := range classDynamicRegex.FindAllStringSubmatch(templateContent, -1) {
142+
cls := match[1]
143+
if isValidClassName(cls) {
144+
classSet[cls] = true
145+
}
146+
}
147+
148+
var classes []string
149+
for cls := range classSet {
150+
classes = append(classes, cls)
151+
}
152+
return classes
153+
}
154+
155+
// isValidClassName checks if a string is a valid CSS class name (not a JS operator or event name).
156+
func isValidClassName(s string) bool {
157+
if s == "" {
158+
return false
159+
}
160+
161+
// Must start with letter or underscore
162+
first := s[0]
163+
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || first == '_') {
164+
return false
165+
}
166+
167+
// Must not contain JS operators or special chars
168+
invalidChars := []string{"=", "&", "|", "!", "?", "(", ")", "{", "}", "[", "]", ".", ",", ";"}
169+
for _, char := range invalidChars {
170+
if strings.Contains(s, char) {
171+
return false
172+
}
173+
}
174+
175+
// Filter out obvious non-class patterns (event names, test IDs)
176+
if looksLikeEventName(s) || looksLikeTestId(s) {
177+
return false
178+
}
179+
180+
return true
181+
}
182+
183+
// looksLikeEventName returns true if the string looks like a Tauri/DOM event name.
184+
func looksLikeEventName(s string) bool {
185+
eventPatterns := []string{
186+
"-complete", "-progress", "-error", "-cancelled", "-changed",
187+
"-mounted", "-unmounted", "-found", "-lost", "-resolved",
188+
"-conflict", "-state-changed",
189+
}
190+
for _, pattern := range eventPatterns {
191+
if strings.HasSuffix(s, pattern) {
192+
return true
193+
}
194+
}
195+
return false
196+
}
197+
198+
// looksLikeTestId returns true if the string looks like a test identifier.
199+
func looksLikeTestId(s string) bool {
200+
testPatterns := []string{"test-", "mock-", "invalid-", "valid-", "tampered-"}
201+
for _, pattern := range testPatterns {
202+
if strings.HasPrefix(s, pattern) {
203+
return true
204+
}
205+
}
206+
// Also filter listing-N patterns used in tests
207+
if strings.HasPrefix(s, "listing-") {
208+
return true
209+
}
210+
return false
211+
}

0 commit comments

Comments
 (0)