From b5d170817c7933af74abcc0689d3f64ff2d6efca Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 20:16:26 +0000 Subject: [PATCH] fix(html): scope selector lists correctly Co-Authored-By: Virgil --- responsive.go | 65 +++++++++++++++++++++++++++++++++++++++++++++- responsive_test.go | 8 ++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/responsive.go b/responsive.go index 28d614e..b9944a3 100644 --- a/responsive.go +++ b/responsive.go @@ -81,7 +81,7 @@ func ScopeVariant(name, selector string) string { return scope } - parts := strings.Split(selector, ",") + parts := splitSelectorList(selector) scoped := make([]string, 0, len(parts)) for i := range parts { part := strings.TrimSpace(parts[i]) @@ -96,6 +96,69 @@ func ScopeVariant(name, selector string) string { return strings.Join(scoped, ", ") } +// splitSelectorList splits a CSS selector list on top-level commas only. +// Commas inside brackets, parentheses, braces, or quoted strings are preserved. +func splitSelectorList(selector string) []string { + if selector == "" { + return nil + } + + parts := make([]string, 0, 1) + var b strings.Builder + var quote rune + depthParen := 0 + depthBracket := 0 + depthBrace := 0 + + for _, r := range selector { + switch { + case quote != 0: + b.WriteRune(r) + if r == quote { + quote = 0 + } else if r == '\\' { + // Keep escaped characters inside quoted strings intact. + continue + } + case r == '"' || r == '\'': + quote = r + b.WriteRune(r) + case r == '(': + depthParen++ + b.WriteRune(r) + case r == ')': + if depthParen > 0 { + depthParen-- + } + b.WriteRune(r) + case r == '[': + depthBracket++ + b.WriteRune(r) + case r == ']': + if depthBracket > 0 { + depthBracket-- + } + b.WriteRune(r) + case r == '{': + depthBrace++ + b.WriteRune(r) + case r == '}': + if depthBrace > 0 { + depthBrace-- + } + b.WriteRune(r) + case r == ',' && depthParen == 0 && depthBracket == 0 && depthBrace == 0: + parts = append(parts, b.String()) + b.Reset() + default: + b.WriteRune(r) + } + } + + parts = append(parts, b.String()) + return parts +} + // responsive.go: Variant adds a named layout variant (e.g., "desktop", "tablet", "mobile"). // Example: r.Variant("mobile", NewLayout("C").C(Raw("body"))). // Variants render in insertion order. diff --git a/responsive_test.go b/responsive_test.go index 7182b12..6e12857 100644 --- a/responsive_test.go +++ b/responsive_test.go @@ -215,3 +215,11 @@ func TestScopeVariant_IgnoresEmptySelectorSegments(t *testing.T) { t.Fatalf("ScopeVariant should skip empty selector segments = %q, want %q", got, want) } } + +func TestScopeVariant_PreservesNestedCommas(t *testing.T) { + got := ScopeVariant("desktop", `:is(.nav, .sidebar), .footer`) + want := `[data-variant="desktop"] :is(.nav, .sidebar), [data-variant="desktop"] .footer` + if got != want { + t.Fatalf("ScopeVariant should preserve nested commas = %q, want %q", got, want) + } +}