package main // scan.go finds where colours are paired. The engine's own scanner (tw.Scan) // flattens a source tree into a flat set of candidate classes, which is right for // compiling CSS but wrong for contrast: contrast is a property of a foreground and a // background that appear *together* on one element, and flattening throws the // pairing away. // // So we do our own pass. The grouping unit is a single string literal: a class list // is written as one string — `class="… bg-primary text-white …"`, or in Go // `vdom.Attr("class", "…")` — and the utilities inside one literal are the ones that // land on the same element. We pull each literal out with its line number, and hand // its tokens on for classification. Colours split across two literals (a `clsx`-style // merge) are not paired; that is the known limitation of a static, per-literal view. import ( "io/fs" "os" "path/filepath" "regexp" "strings" ) // ClassGroup is one string literal that plausibly holds a class list, with the // tokens found inside it and where it lives. type ClassGroup struct { File string Line int Snip string // the literal's content, trimmed for display Tokens []string } // looksLikeUtility is a cheap pre-filter: a literal is only interesting if it holds a // token that could be a bg-/text- colour utility (optionally behind variants). var looksLikeUtility = regexp.MustCompile(`(^|\s)([a-z0-9-]+:)*(bg|text)-`) // DefaultExts are the source kinds a kjol/Tailwind project authors classes in. var DefaultExts = []string{".tsx", ".ts", ".jsx", ".js", ".html", ".go"} // ignoredDirs are never worth scanning and are often huge. var ignoredDirs = map[string]bool{ ".git": true, "node_modules": true, "vendor": true, "dist": true, "build": true, "wwwroot": true, ".cache": true, "testdata": true, } // ScanTree walks roots (each relative to base, or base itself if none) and returns // every class-list literal found in files whose extension is in exts. func ScanTree(base string, roots []string, exts []string) ([]ClassGroup, error) { extSet := map[string]bool{} for _, e := range exts { extSet[e] = true } if len(roots) == 0 { roots = []string{"."} } var groups []ClassGroup seenFile := map[string]bool{} for _, root := range roots { start := filepath.Join(base, root) err := filepath.WalkDir(start, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { if ignoredDirs[d.Name()] { return fs.SkipDir } return nil } if !extSet[strings.ToLower(filepath.Ext(path))] { return nil } if seenFile[path] { return nil } seenFile[path] = true g, err := scanFile(path) if err != nil { return err } groups = append(groups, g...) return nil }) if err != nil { return nil, err } } return groups, nil } func scanFile(path string) ([]ClassGroup, error) { src, err := os.ReadFile(path) if err != nil { return nil, err } var groups []ClassGroup for _, lit := range extractLiterals(src) { if !looksLikeUtility.MatchString(lit.content) { continue } groups = append(groups, ClassGroup{ File: path, Line: lit.line, Snip: collapse(lit.content), Tokens: strings.Fields(lit.content), }) } return groups, nil } // literal is one string literal's content and the line it started on. type literal struct { content string line int } // extractLiterals pulls string literals out of Go/TS/JS/HTML source with a small // state machine. It skips `//` and `/* */` comments — the source of the nastiest // desync, where an apostrophe in a comment ("panel's") or an unbalanced quote pairs // with a delimiter far below and swallows unrelated code — and honours backslash // escapes inside strings. // // Single-quoted strings are only opened in expression position (after an operator or // opener, or an attribute `=`). That keeps apostrophes in JSX/HTML text (`don't`) and // Go rune-in-prose from being read as string starts, while still catching real // single-quoted class lists (`class='…'`, `cond ? 'a' : 'b'`). func extractLiterals(src []byte) []literal { var out []literal n := len(src) line := 1 var prev byte // most recent non-whitespace byte, for the '-in-expression test for i := 0; i < n; { c := src[i] // Comments. if c == '/' && i+1 < n && src[i+1] == '/' { for i < n && src[i] != '\n' { i++ } continue } if c == '/' && i+1 < n && src[i+1] == '*' { i += 2 for i+1 < n && !(src[i] == '*' && src[i+1] == '/') { if src[i] == '\n' { line++ } i++ } i += 2 continue } // String literals. if c == '"' || c == '`' || (c == '\'' && exprPosition(prev)) { quote := c startLine := line i++ var b strings.Builder for i < n { ch := src[i] if ch == '\\' && i+1 < n { b.WriteByte(ch) b.WriteByte(src[i+1]) if src[i+1] == '\n' { line++ } i += 2 continue } if ch == quote { i++ break } if ch == '\n' { line++ } b.WriteByte(ch) i++ } out = append(out, literal{content: b.String(), line: startLine}) prev = quote continue } if c == '\n' { line++ } if c != ' ' && c != '\t' && c != '\r' && c != '\n' { prev = c } i++ } return out } // exprPosition reports whether a `'` following prev begins a string literal (rather // than being an apostrophe in text or a Go rune after a value). prev is the previous // non-whitespace byte; 0 means start of file. func exprPosition(prev byte) bool { switch prev { case 0, '(', '[', '{', ',', ':', ';', '=', '?', '>', '<', '&', '|', '!', '+', '-', '*', '/', '~', '^', '%', '\\': return true } return false } // collapse squeezes whitespace (including the newlines a multi-line class string may // contain) to single spaces for a compact one-line display. func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } // ColorCandidates returns the de-duplicated set of tokens across all groups that are // shaped like a bg-/text- colour utility (optionally variant-prefixed). This is the // candidate list handed to the Tailwind engine for compilation. func ColorCandidates(groups []ClassGroup) []string { set := map[string]bool{} for _, g := range groups { for _, tok := range g.Tokens { if _, base := splitVariants(tok); strings.HasPrefix(base, "bg-") || strings.HasPrefix(base, "text-") { set[tok] = true } } } out := make([]string, 0, len(set)) for tok := range set { out = append(out, tok) } return out } // splitVariants separates a token's variant prefixes from its base utility. The base // is the segment after the last top-level `:` — but a `:` inside an arbitrary value // (`text-[color:red]`) or an escaped bracket must not be treated as a variant // separator, so we split at bracket depth zero only. func splitVariants(token string) (variants []string, base string) { depth := 0 start := 0 for i := 0; i < len(token); i++ { switch token[i] { case '[', '(': depth++ case ']', ')': depth-- case ':': if depth == 0 { variants = append(variants, token[start:i]) start = i + 1 } } } return variants, token[start:] }