diff --git a/go/tw/tailwind.go b/go/tw/tailwind.go index 533c83a8..66d844b6 100644 --- a/go/tw/tailwind.go +++ b/go/tw/tailwind.go @@ -1304,6 +1304,7 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) var keyframes []*AstNode var passthrough []*AstNode var customUtilities []*AstNode + var customVariants []*AstNode var safelistAdd []string var safelistRemove []string hasPreflight := false @@ -1361,6 +1362,8 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) processTheme(node) case node.Kind == nAtRule && node.Name == "@utility": customUtilities = append(customUtilities, node) + case node.Kind == nAtRule && node.Name == "@custom-variant": + customVariants = append(customVariants, node) case node.Kind == nAtRule && node.Name == "@source": literals, negate := parseSourceDirective(node.Params) if negate { @@ -1399,6 +1402,20 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error) ds := buildDesignSystem(theme) + // Register @custom-variant blocks. This is how a project defines `dark:` as a CLASS + // toggle rather than a media query — the built-in dark variant follows the OS, which + // a site with a theme switch cannot use: + // + // @custom-variant dark (&:where(.dark, .dark *)); + // + // Both of Tailwind's forms are accepted: the shorthand above, and the block form + // with an explicit @slot. + for _, cv := range customVariants { + if name, body, ok := parseCustomVariant(cv); ok { + ds.variants.fromAst(name, body, ds) + } + } + // Register @utility blocks as static utilities. for _, u := range customUtilities { name := strings.TrimSpace(u.Params) @@ -8813,6 +8830,95 @@ func (v *Variants) kind(name string) variantKind { func (v *Variants) keys() []string { return v.order } +// parseCustomVariant reads an @custom-variant at-rule into a name and the AST body that +// fromAst expects (a body whose rules contain an @slot where the utility goes). +// +// Two forms, both from Tailwind: +// +// @custom-variant dark (&:where(.dark, .dark *)); // shorthand +// +// @custom-variant dark { // block, explicit slot +// &:where(.dark, .dark *) { @slot; } +// } +// +// In the shorthand, a parenthesised selector starting with '@' is an at-rule +// (`@custom-variant any-hover (@media (any-hover: hover))`), and anything else is a +// selector. Several may be given, comma-separated at the top level. +func parseCustomVariant(node *AstNode) (name string, body []*AstNode, ok bool) { + params := strings.TrimSpace(node.Params) + if params == "" { + return "", nil, false + } + + // The name is the first token; whatever follows is the shorthand's parenthesised part. + i := strings.IndexAny(params, " \t(") + if i < 0 { + // No shorthand: it must be the block form, which carries its own @slot. + if len(node.Nodes) == 0 { + return "", nil, false + } + return params, node.Nodes, true + } + name = strings.TrimSpace(params[:i]) + rest := strings.TrimSpace(params[i:]) + + if rest == "" { + if len(node.Nodes) == 0 { + return "", nil, false + } + return name, node.Nodes, true + } + if !strings.HasPrefix(rest, "(") || !strings.HasSuffix(rest, ")") { + return "", nil, false + } + inner := strings.TrimSpace(rest[1 : len(rest)-1]) + if inner == "" { + return "", nil, false + } + + for _, sel := range splitTopLevel(inner, ',') { + sel = strings.TrimSpace(sel) + if sel == "" { + continue + } + slot := atRule("@slot", "") + if strings.HasPrefix(sel, "@") { + // "@media (any-hover: hover)" -> name "@media", params "(any-hover: hover)" + at, params, _ := strings.Cut(sel, " ") + body = append(body, atRule(at, strings.TrimSpace(params), slot)) + continue + } + body = append(body, styleRule(sel, slot)) + } + if len(body) == 0 { + return "", nil, false + } + return name, body, true +} + +// splitTopLevel splits on sep, ignoring separators nested inside brackets — a selector +// list like `&:where(.dark, .dark *)` is ONE selector, and splitting it on its inner +// comma would produce two broken halves. +func splitTopLevel(s string, sep byte) []string { + var parts []string + depth := 0 + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '(', '[': + depth++ + case ')', ']': + depth-- + case sep: + if depth == 0 { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + } + return append(parts, s[start:]) +} + func (v *Variants) compoundsWith(parent string, child *Variant) bool { parentInfo, ok := v.variants[parent] if !ok {