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