229 lines
6.8 KiB
Go
229 lines
6.8 KiB
Go
// 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)
|
|
}
|