web: make a purged CSS class fail the build instead of the page
tailwind.config.js has input.css in its own content glob, so a hand-written component class survives the purge only if its literal name can be extracted from that file. A rule written solely as `.foo::before` cannot be, and Tailwind drops it silently: no error, just an unstyled element on one page. That has cost three phases. The mitigation everybody reached for — grep output.css after `make css` — is the discipline that failed, and it is worse than it looks, because Tailwind escapes class names in its output and a raw-name grep reports a false negative that looks exactly like a purge failure. So parse the `@layer components` blocks for declared class names and assert each one reaches output.css as a selector, comparing escaped forms. No list to maintain: the list is input.css. 220 classes covered today. Two self-tests pin that the check catches a pseudo-only class and is not fooled by escaping. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Tailwind purge trap, made loud.
|
||||
//
|
||||
// tailwind.config.js has input.css in its OWN content glob, so a hand-written
|
||||
// component class survives the purge only if its literal name can be *extracted*
|
||||
// from that file. A name that only ever appears glued to something else — the
|
||||
// canonical case is a rule written solely as `.foo::before` — is not extractable
|
||||
// and Tailwind drops the rule from output.css. Nothing errors. The page just
|
||||
// renders unstyled, and it is invisible until somebody looks at that exact
|
||||
// element on that exact page.
|
||||
//
|
||||
// That has now cost three phases: flagged twice, and actually bitten once when
|
||||
// `.firsts-entry-zone::before` was silently dropped. The mitigation everybody
|
||||
// reached for — "remember to grep output.css after make css" — is the discipline
|
||||
// that failed, and it is worse than it looks because Tailwind ESCAPES class
|
||||
// names in its output (`.text-[color:var(--warn)]` is written
|
||||
// `.text-\[color\:var\(--warn\)\]`), so a naive grep for the literal name
|
||||
// reports a false negative that looks exactly like a purge failure.
|
||||
//
|
||||
// So: a test. It needs no list to maintain — the list IS input.css — and it
|
||||
// turns a silent styling failure into a red build.
|
||||
//
|
||||
// A Tailwind `safelist` was the other option and is worse: a list somebody has
|
||||
// to remember to add to is the same failure mode one level up.
|
||||
|
||||
// cssClassInSelector matches a class name in a selector. The leading dot must
|
||||
// not be preceded by an identifier character, so `1.5rem` in a declaration is
|
||||
// never mistaken for a class.
|
||||
var cssClassInSelector = regexp.MustCompile(`\.(-?[A-Za-z_][-\w]*)`)
|
||||
|
||||
// cssComment strips /* ... */ so a class name mentioned in prose can't be read
|
||||
// as a declaration. Several of the component blocks have long explanatory
|
||||
// comments that name other classes.
|
||||
var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||
|
||||
// componentClasses returns every class name declared inside an `@layer
|
||||
// components` block of input.css.
|
||||
//
|
||||
// It walks the file rather than regexing whole rules because a component block
|
||||
// can contain nested at-rules (`@media`, `@supports`) and because a selector can
|
||||
// be a list spanning several lines. The walk collects each *prelude* — the text
|
||||
// between one brace and the next — and reads class names out of it. An at-rule
|
||||
// prelude (`@media ...`) is skipped; a declaration body is never a prelude
|
||||
// because it is followed by `}`, not `{`.
|
||||
func componentClasses(t *testing.T, css string) []string {
|
||||
t.Helper()
|
||||
css = cssComment.ReplaceAllString(css, " ")
|
||||
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
|
||||
// Find each `@layer components` block by brace-counting from its opening
|
||||
// brace, then walk only inside it.
|
||||
const marker = "@layer components"
|
||||
for idx := 0; ; {
|
||||
i := strings.Index(css[idx:], marker)
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
i += idx
|
||||
open := strings.Index(css[i:], "{")
|
||||
if open < 0 {
|
||||
break
|
||||
}
|
||||
open += i
|
||||
|
||||
depth := 0
|
||||
var prelude strings.Builder
|
||||
end := len(css)
|
||||
for j := open; j < len(css); j++ {
|
||||
switch css[j] {
|
||||
case '{':
|
||||
depth++
|
||||
// depth 1 is the @layer's own brace; anything deeper opened on a
|
||||
// prelude we have been buffering.
|
||||
if depth > 1 {
|
||||
sel := strings.TrimSpace(prelude.String())
|
||||
if !strings.HasPrefix(sel, "@") {
|
||||
for _, m := range cssClassInSelector.FindAllStringSubmatch(sel, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prelude.Reset()
|
||||
case '}':
|
||||
depth--
|
||||
prelude.Reset()
|
||||
if depth == 0 {
|
||||
end = j
|
||||
}
|
||||
default:
|
||||
prelude.WriteByte(css[j])
|
||||
}
|
||||
if depth == 0 && j > open {
|
||||
break
|
||||
}
|
||||
}
|
||||
idx = end + 1
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// cssEscape renders a class name the way Tailwind writes it into output.css:
|
||||
// every character outside [A-Za-z0-9_-] is backslash-escaped. This is the half
|
||||
// of the check that a grep gets wrong.
|
||||
func cssEscape(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if r == '-' || r == '_' || (r >= '0' && r <= '9') ||
|
||||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r > 127 {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// selectorPresent reports whether `.name` appears in the (minified) stylesheet
|
||||
// as a selector rather than as a prefix of a longer class name. Tailwind's
|
||||
// output has no line breaks, so the boundary check is the whole test.
|
||||
func selectorPresent(css, name string) bool {
|
||||
needle := "." + cssEscape(name)
|
||||
for i := 0; ; {
|
||||
j := strings.Index(css[i:], needle)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
j += i
|
||||
i = j + 1
|
||||
// A match must not be the head of a longer name: `.map` inside
|
||||
// `.map-svg` ends on '-', which is an identifier character.
|
||||
if k := j + len(needle); k < len(css) {
|
||||
c := css[k]
|
||||
if c == '-' || c == '_' || c == '\\' ||
|
||||
(c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryComponentClassSurvivesThePurge is the whole point of this file. If it
|
||||
// fails, run `make css` first — a stale output.css looks identical to a purged
|
||||
// class from here, and that is deliberate: shipping a stylesheet that predates
|
||||
// the rule you just wrote is the same bug wearing a different hat.
|
||||
func TestEveryComponentClassSurvivesThePurge(t *testing.T) {
|
||||
inRaw, err := os.ReadFile("static/css/input.css")
|
||||
if err != nil {
|
||||
t.Fatalf("read input.css: %v", err)
|
||||
}
|
||||
outRaw, err := os.ReadFile("static/css/output.css")
|
||||
if err != nil {
|
||||
t.Fatalf("read output.css: %v — run `make css`", err)
|
||||
}
|
||||
in, out := string(inRaw), string(outRaw)
|
||||
|
||||
classes := componentClasses(t, in)
|
||||
if len(classes) < 50 {
|
||||
t.Fatalf("only found %d component classes in input.css — the parser has stopped working, "+
|
||||
"which would make this test pass for the wrong reason", len(classes))
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, c := range classes {
|
||||
if !selectorPresent(out, c) {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("%d class(es) declared in input.css's @layer components are absent from output.css: %s\n"+
|
||||
"Either run `make css`, or the name is not extractable from input.css — a rule written only as "+
|
||||
"`.foo::before` or only inside a nested selector cannot be extracted, and Tailwind purges it "+
|
||||
"silently. Give it a plain `.foo { ... }` declaration (a custom property is enough).",
|
||||
len(missing), strings.Join(missing, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeCheckCatchesAPseudoOnlyClass proves the check would have caught the
|
||||
// W4 bug, using a synthetic pair rather than trusting that the real stylesheet
|
||||
// happens to exercise the path. Without this, a parser that silently found
|
||||
// nothing would leave the real test green forever.
|
||||
func TestPurgeCheckCatchesAPseudoOnlyClass(t *testing.T) {
|
||||
in := `@layer components {
|
||||
/* a comment naming .decoy-class, which must not be collected */
|
||||
.kept { color: red; }
|
||||
.pseudo-only::before { content: ""; }
|
||||
@media (min-width: 40rem) {
|
||||
.nested { display: none; }
|
||||
}
|
||||
}`
|
||||
got := componentClasses(t, in)
|
||||
want := []string{"kept", "nested", "pseudo-only"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("componentClasses = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// Tailwind's output as it would be if `.pseudo-only` were not extractable.
|
||||
out := `.kept{color:red}.nested-thing{display:block}`
|
||||
if !selectorPresent(out, "kept") {
|
||||
t.Error("kept should be present")
|
||||
}
|
||||
if selectorPresent(out, "pseudo-only") {
|
||||
t.Error("pseudo-only should be reported missing — this is the W4 bug")
|
||||
}
|
||||
// The boundary check: `.nested` must not match inside `.nested-thing`.
|
||||
if selectorPresent(out, "nested") {
|
||||
t.Error("nested matched the prefix of .nested-thing — the boundary check is broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPurgeCheckComparesEscapedForms is the W6 lesson as a test: a raw-name grep
|
||||
// reports a false negative on any class Tailwind had to escape, which reads
|
||||
// exactly like a purge failure and once cost a session's time "fixing" a class
|
||||
// that was never broken.
|
||||
func TestPurgeCheckComparesEscapedForms(t *testing.T) {
|
||||
out := `.text-\[color\:var\(--warn\)\]{color:var(--warn)}`
|
||||
if !selectorPresent(out, "text-[color:var(--warn)]") {
|
||||
t.Error("escaped class reported missing — the check must escape before comparing")
|
||||
}
|
||||
if strings.Contains(out, ".text-[color:var(--warn)]") {
|
||||
t.Error("fixture is wrong: the raw form should not appear in Tailwind output")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user