Add aria check program to go/cmd
This commit is contained in:
253
go/cmd/aria-check/scan.go
Normal file
253
go/cmd/aria-check/scan.go
Normal file
@@ -0,0 +1,253 @@
|
||||
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:]
|
||||
}
|
||||
Reference in New Issue
Block a user