package tw import ( "strings" "testing" ) func twTestCompile(t *testing.T, candidates ...string) string { t.Helper() css, _, err := twCompile(`@import "tailwindcss";`, ".", candidates) if err != nil { t.Fatalf("twCompile error: %v", err) } return css } // Edge cases that the modifier/fraction rewrite fixes. func TestEngineEdgeCases(t *testing.T) { cases := []struct { candidate string contains string }{ // Improper fraction read as a fraction (not an opacity modifier). {"aspect-16/9", "aspect-ratio: 16/9"}, // Proper fraction. {"w-1/2", "width: calc(1 / 2 * 100%)"}, // Arbitrary opacity modifier decoded (not "[0.5]%"). {"bg-white/[0.5]", "color-mix(in oklab, var(--color-white) 50%, transparent)"}, // Bare spacing. {"m-4", "margin: calc(var(--spacing) * 4)"}, {"p-4", "padding: calc(var(--spacing) * 4)"}, // text-{size}/{leading}: font-size AND line-height (the dropped-modifier fix). {"text-sm/6", "line-height: calc(var(--spacing) * 6)"}, // Theme color via @theme namespace. {"bg-red-500", "background-color: var(--color-red-500)"}, } for _, c := range cases { css := twTestCompile(t, c.candidate) if !strings.Contains(css, c.contains) { t.Errorf("compile(%q): expected to contain %q\n---\n%s", c.candidate, c.contains, css) } } } func TestSegmentTopLevel(t *testing.T) { cases := []struct { in string sep string want []string }{ {"a:b:c", ":", []string{"a", "b", "c"}}, {"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0)", ",", []string{"var(--a, 0 0 1px rgb(0, 0, 0))", " 0 0 1px rgb(0, 0, 0)"}}, {"display:grid", ":", []string{"display", "grid"}}, {"[display:grid]", ":", []string{"[display:grid]"}}, {"red-500/50", "/", []string{"red-500", "50"}}, {"calc(1/2)/3", "/", []string{"calc(1/2)", "3"}}, } for _, c := range cases { got := segment(c.in, c.sep) if len(got) != len(c.want) { t.Errorf("segment(%q,%q) = %v, want %v", c.in, c.sep, got, c.want) continue } for i := range got { if got[i] != c.want[i] { t.Errorf("segment(%q,%q)[%d] = %q, want %q", c.in, c.sep, i, got[i], c.want[i]) } } } } func TestEscapeIdentifier(t *testing.T) { cases := []struct{ in, want string }{ {"flex", "flex"}, {"hover:bg-red-500", `hover\:bg-red-500`}, {"w-1/2", `w-1\/2`}, {"bg-[#fff]", `bg-\[\#fff\]`}, {"2xl", `\32 xl`}, } for _, c := range cases { if got := escape(c.in); got != c.want { t.Errorf("escape(%q) = %q, want %q", c.in, got, c.want) } } } func TestDecodeArbitraryValue(t *testing.T) { cases := []struct{ in, want string }{ {"100%_!important", "100% !important"}, {"calc(100dvh_-_5rem)", "calc(100dvh - 5rem)"}, {`url(/a_b.png)`, "url(/a_b.png)"}, {`var(--my_var)`, "var(--my_var)"}, {"calc(var(--spacing)*4_+_env(safe-area-inset-bottom,0px))", "calc(var(--spacing) * 4 + env(safe-area-inset-bottom,0px))"}, } for _, c := range cases { if got := decodeArbitraryValue(c.in); got != c.want { t.Errorf("decodeArbitraryValue(%q) = %q, want %q", c.in, got, c.want) } } } func TestInferDataType(t *testing.T) { cases := []struct { value string types []string want string }{ {"#ff0000", []string{dtColor, dtLength}, dtColor}, {"10rem", []string{dtColor, dtLength}, dtLength}, {"50%", []string{dtLength, dtPercentage}, dtPercentage}, {"16/9", []string{dtRatio, dtColor}, dtRatio}, {"var(--x)", []string{dtColor, dtLength}, ""}, {"calc(1px+2px)", []string{dtLength}, dtLength}, {"red", []string{dtColor}, dtColor}, } for _, c := range cases { if got := inferDataType(c.value, c.types); got != c.want { t.Errorf("inferDataType(%q,%v) = %q, want %q", c.value, c.types, got, c.want) } } } func TestNumericPredicates(t *testing.T) { if !isValidSpacingMultiplier("0.5") { t.Error("0.5 should be a valid spacing multiplier") } if isValidSpacingMultiplier("0.3") { t.Error("0.3 should NOT be a valid spacing multiplier") } if !isPositiveInteger("3") || isPositiveInteger("3.0") || isPositiveInteger("03") { t.Error("isPositiveInteger canonical-form check failed") } } // Apostrophes inside JS comments (e.g. "don't", "button's") must not desync the // quote-based class scanner. Before comments were skipped, a stray apostrophe // flipped quote parity and swallowed every class literal until the next quote — // silently dropping singly-used utilities like the lineup card's switch and // drag styles. func TestExtractCandidatesSkipsComments(t *testing.T) { src := "// we don't need reactivity here\n" + "const cur = locked ? \"cursor-not-allowed\" : \"cursor-move\";\n" + "/* the button's thumb: it's offset */\n" + "const thumb = on ? \"translate-x-3\" : \"\";\n" + "const tpl = ``;\n" got := map[string]bool{} for _, c := range extractCandidates(src) { got[c] = true } for _, want := range []string{"cursor-not-allowed", "cursor-move", "translate-x-3", "sr-only"} { if !got[want] { t.Errorf("candidate %q was not extracted past comment apostrophes", want) } } } // scan returns the set of candidates extracted from src. func scan(src string) map[string]bool { got := map[string]bool{} for _, c := range extractCandidates(src) { got[c] = true } return got } // Quotes inside a regex literal must not be read as a string (which would // desync the scanner and drop following class literals), and a division `/` // must not be mistaken for a regex (which would swallow the code after it). func TestExtractCandidatesRegexLiterals(t *testing.T) { cases := []struct { name string src string want []string }{ { "apostrophe in regex", `const r = /it's/; const c = "cursor-move";`, []string{"cursor-move"}, }, { "regex after return keyword", `function f(){ return /a"b/; } const c = "p-4";`, []string{"p-4"}, }, { "char class containing slash and quote", `const r = /[/']/g; const c = "block";`, []string{"block"}, }, { "division is not a regex (string between divisions survives)", `const w = a / 2; cls = "text-lg"; const h = b / 4; cls2 = "m-2";`, []string{"text-lg", "m-2"}, }, { "regex inside a template interpolation, class after it", "const t = html`${y ? \"p-5\" : \"p-6\"}`;", []string{"p-5", "p-6"}, }, { "regex with braces in template interpolation keeps ${} balanced", "const t = html``;", []string{"flex", "hidden"}, }, } for _, tc := range cases { got := scan(tc.src) for _, w := range tc.want { if !got[w] { t.Errorf("%s: candidate %q not extracted (src=%q)", tc.name, w, tc.src) } } } } // The preflight is written against Tailwind's compile-time CSS functions, e.g. // `font-family: --theme(--default-font-family, …)`. If those are not resolved, they // reach the browser verbatim; the browser cannot parse `--theme(…)` and DROPS THE // WHOLE DECLARATION. The symptom is subtle and global: every page silently loses its // font-family and falls back to the browser default, and no @theme override of // --font-sans can ever take effect. That shipped for a while, so pin it. func TestPreflightResolvesThemeFunctions(t *testing.T) { css, _, err := twCompile(`@import "tailwindcss";`, ".", nil) if err != nil { t.Fatal(err) } flat := strings.Join(strings.Fields(css), " ") if strings.Contains(css, "--theme(") { t.Error("the compiled CSS still contains an unresolved --theme(…); the browser will drop those declarations") } if !strings.Contains(flat, "font-family: var(--default-font-family)") { t.Error("preflight did not resolve html's font-family to a var()") } } // And the whole point of resolving it: an app's @theme override of --font-sans must // actually reach the page. func TestThemeFontOverrideReachesHTML(t *testing.T) { css, _, err := twCompile(` @import "tailwindcss"; @font-face { font-family: "Lora"; src: url("/fonts/lora.woff2") format("woff2"); } @theme { --font-sans: "Lora", serif; } `, ".", nil) if err != nil { t.Fatal(err) } // The chain the browser walks: html -> --default-font-family -> --font-sans. flat := strings.Join(strings.Fields(css), " ") for _, want := range []string{ "font-family: var(--default-font-family)", "--default-font-family: var(--font-sans)", `--font-sans: "Lora", serif`, } { if !strings.Contains(flat, want) { t.Errorf("missing %q — the font override does not reach the page", want) } } // A vendored face must survive the build; dropping it would leave the family // declared but never loaded. if !strings.Contains(css, "@font-face") || !strings.Contains(css, "lora.woff2") { t.Error("@font-face was dropped from the output") } }