Files
Pete/internal/web/css_purge_test.go
T
prosolis 0d8dba90df 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
2026-07-24 20:35:55 -07:00

241 lines
8.3 KiB
Go

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")
}
}