Add aria check program to go/cmd
This commit is contained in:
228
go/cmd/aria-check/main.go
Normal file
228
go/cmd/aria-check/main.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// Command aria-check is a static accessibility linter for kjol front-ends. It reads
|
||||
// source (Solid .tsx and gowasm .go alike — anything that authors Tailwind classes as
|
||||
// string literals) and reports problems that can be caught without a browser.
|
||||
//
|
||||
// The first and only check today is COLOUR CONTRAST, implemented against the same
|
||||
// algorithm as WebAIM's contrast checker (https://webaim.org/resources/contrastchecker/):
|
||||
// WCAG 2.x relative luminance in sRGB, ratio (L1+0.05)/(L2+0.05).
|
||||
//
|
||||
// How it works:
|
||||
//
|
||||
// 1. Scan sources for string literals that hold class lists, keeping the utilities
|
||||
// that co-occur in one literal together (contrast is about a foreground and a
|
||||
// background on the SAME element — see scan.go).
|
||||
// 2. Compile every bg-/text- token through kjol's own Tailwind engine (package tw)
|
||||
// and read back the colours it resolves — palette OKLCH, hex tokens, semantic
|
||||
// tokens, opacity modifiers and arbitrary values all included (theme.go).
|
||||
// 3. For each co-occurring foreground/background pair, in both the light and dark
|
||||
// appearances, composite away any translucency and measure the ratio against the
|
||||
// WCAG threshold (check.go, color.go).
|
||||
//
|
||||
// Because the tokens are resolved by the real engine, aria-check stays correct as the
|
||||
// design system changes: it never hard-codes a colour.
|
||||
//
|
||||
// Usage (globs/roots are relative to -base; omit them to scan the whole base):
|
||||
//
|
||||
// aria-check -base . -entry app/style.css frontend
|
||||
// aria-check -base kjol/go/webui -level AAA
|
||||
// aria-check -assume-surface -json .
|
||||
//
|
||||
// Exit status is non-zero when any contrast failure is found (unless -warn), so it
|
||||
// drops straight into CI or a pre-commit hook.
|
||||
//
|
||||
// Extending it: contrast is one Checker; the scan + resolve scaffolding is meant to
|
||||
// carry more. Natural next checks that are equally static-friendly — missing alt text
|
||||
// on images, controls with no accessible label, heading-order jumps, redundant/absent
|
||||
// ARIA roles — would each add a pass over the same file walk. See the closing notes in
|
||||
// the repository discussion for the full list.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
base = flag.String("base", ".", "root directory to scan and resolve -entry against")
|
||||
entry = flag.String("entry", "", "app brand Tailwind entry stylesheet (optional; kjol's theme layer is always included)")
|
||||
level = flag.String("level", "AA", "WCAG conformance level: AA or AAA")
|
||||
min = flag.Float64("min", 0, "override the required ratio for normal-size text (AA only)")
|
||||
exts = flag.String("ext", strings.Join(DefaultExts, ","), "comma-separated source file extensions to scan")
|
||||
assumeSurface = flag.Bool("assume-surface", false, "also check text-only elements against the page surface colour")
|
||||
asJSON = flag.Bool("json", false, "emit findings as JSON")
|
||||
verbose = flag.Bool("v", false, "also report passing pairs")
|
||||
warn = flag.Bool("warn", false, "always exit 0, even when failures are found")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
opt := Options{Level: *level, MinOverride: *min, AssumeSurface: *assumeSurface}
|
||||
|
||||
groups, err := ScanTree(*base, flag.Args(), splitExts(*exts))
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
var entryCSS string
|
||||
if *entry != "" {
|
||||
b, err := os.ReadFile(*entry)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
entryCSS = string(b)
|
||||
}
|
||||
|
||||
resolver, err := NewResolver(entryCSS, *base, ColorCandidates(groups))
|
||||
if err != nil {
|
||||
fatal(fmt.Errorf("compiling theme: %w", err))
|
||||
}
|
||||
|
||||
findings := Check(groups, resolver, opt)
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
if findings[i].File != findings[j].File {
|
||||
return findings[i].File < findings[j].File
|
||||
}
|
||||
if findings[i].Line != findings[j].Line {
|
||||
return findings[i].Line < findings[j].Line
|
||||
}
|
||||
return findings[i].Ratio < findings[j].Ratio
|
||||
})
|
||||
|
||||
failures := 0
|
||||
for _, f := range findings {
|
||||
if !f.Pass {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
if *asJSON {
|
||||
reportJSON(findings, *verbose)
|
||||
} else {
|
||||
reportText(findings, failures, len(groups), *verbose)
|
||||
}
|
||||
|
||||
if failures > 0 && !*warn {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func reportText(findings []Finding, failures, groups int, verbose bool) {
|
||||
filesWithFail := map[string]bool{}
|
||||
for _, f := range findings {
|
||||
if f.Pass && !verbose {
|
||||
continue
|
||||
}
|
||||
if !f.Pass {
|
||||
filesWithFail[f.File] = true
|
||||
}
|
||||
fmt.Println(formatFinding(f))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
if failures == 0 {
|
||||
fmt.Printf("aria-check: no contrast failures (%d class groups scanned)\n", groups)
|
||||
return
|
||||
}
|
||||
fmt.Printf("aria-check: %d contrast failure(s) across %d file(s) — %d class groups scanned\n",
|
||||
failures, len(filesWithFail), groups)
|
||||
}
|
||||
|
||||
func formatFinding(f Finding) string {
|
||||
verdict := "FAIL"
|
||||
if f.Pass {
|
||||
verdict = "ok "
|
||||
}
|
||||
size := "normal"
|
||||
if f.Large {
|
||||
size = "large"
|
||||
}
|
||||
bg := f.BG
|
||||
if bg == "" {
|
||||
bg = "surface"
|
||||
}
|
||||
rel, err := filepath.Rel(".", f.File)
|
||||
if err != nil {
|
||||
rel = f.File
|
||||
}
|
||||
return fmt.Sprintf("%s:%d: %s %s %.2f:1 (need %.1f:1) %s on %s [%s %s]\n %s → %s %q",
|
||||
rel, f.Line, verdict, f.ThemeName(), f.Ratio, f.Required,
|
||||
f.FG, bg, f.ThemeName(), size,
|
||||
hexOf(f.FGColor), hexOf(f.BGColor), truncate(f.Snip, 90))
|
||||
}
|
||||
|
||||
type jsonFinding struct {
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Theme string `json:"theme"`
|
||||
FG string `json:"fg"`
|
||||
BG string `json:"bg"`
|
||||
FGColor string `json:"fgColor"`
|
||||
BGColor string `json:"bgColor"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
Required float64 `json:"required"`
|
||||
Large bool `json:"large"`
|
||||
Pass bool `json:"pass"`
|
||||
}
|
||||
|
||||
func reportJSON(findings []Finding, verbose bool) {
|
||||
out := make([]jsonFinding, 0, len(findings))
|
||||
for _, f := range findings {
|
||||
if f.Pass && !verbose {
|
||||
continue
|
||||
}
|
||||
bg := f.BG
|
||||
if bg == "" {
|
||||
bg = "surface"
|
||||
}
|
||||
out = append(out, jsonFinding{
|
||||
File: f.File, Line: f.Line, Theme: f.ThemeName(),
|
||||
FG: f.FG, BG: bg, FGColor: hexOf(f.FGColor), BGColor: hexOf(f.BGColor),
|
||||
Ratio: round2(f.Ratio), Required: f.Required, Large: f.Large, Pass: f.Pass,
|
||||
})
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
}
|
||||
|
||||
func hexOf(c RGBA) string {
|
||||
to := func(v float64) int { return int(clamp01(v)*255 + 0.5) }
|
||||
if c.A < 1 {
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", to(c.R), to(c.G), to(c.B), to(c.A))
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x", to(c.R), to(c.G), to(c.B))
|
||||
}
|
||||
|
||||
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n-1] + "…"
|
||||
}
|
||||
|
||||
func splitExts(s string) []string {
|
||||
var out []string
|
||||
for _, e := range strings.Split(s, ",") {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(e, ".") {
|
||||
e = "." + e
|
||||
}
|
||||
out = append(out, strings.ToLower(e))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "aria-check:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
Reference in New Issue
Block a user