Add aria check program to go/cmd

This commit is contained in:
2026-07-20 12:05:55 -04:00
parent 03b5bea72d
commit c0ddef5923
7 changed files with 1871 additions and 0 deletions

253
go/cmd/aria-check/scan.go Normal file
View 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:]
}