From 0d8dba90df2332f4444a2ddbf335fc1fd9f04736 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:35:55 -0700 Subject: [PATCH] web: make a purged CSS class fail the build instead of the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/web/css_purge_test.go | 240 +++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 internal/web/css_purge_test.go diff --git a/internal/web/css_purge_test.go b/internal/web/css_purge_test.go new file mode 100644 index 0000000..fb8b2c3 --- /dev/null +++ b/internal/web/css_purge_test.go @@ -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") + } +}