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