254 lines
7.1 KiB
Go
254 lines
7.1 KiB
Go
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:]
|
|
}
|