Manually merge from 'pre-kjol' into 'master'

This commit is contained in:
2026-07-15 15:11:29 -04:00
parent f2e0d22255
commit 342de9e18a
9 changed files with 365 additions and 188 deletions

View File

@@ -1261,8 +1261,7 @@ func compileCandidates(rawCandidates []string, ds *DesignSystem, onInvalid func(
// the candidate list.
//
// @INCOMPLETE Only static @utility blocks are supported (no functional
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
// forms) — see parseCustomVariant. -mta
// @utility/--value()); @custom-variant is not yet wired. -mta
//go:embed tw_theme.css
var defaultThemeCSS string
@@ -1305,7 +1304,8 @@ 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
hasUtilities := false
@@ -1361,8 +1361,13 @@ 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 {
safelistRemove = append(safelistRemove, literals...)
} else {
safelistAdd = append(safelistAdd, literals...)
}
default:
passthrough = append(passthrough, node)
}
@@ -1375,21 +1380,24 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
}
processInput(inAst, baseDir)
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)
}
if len(safelistAdd) > 0 {
candidates = append(candidates, safelistAdd...)
}
if len(safelistRemove) > 0 {
remove := make(map[string]bool, len(safelistRemove))
for _, c := range safelistRemove {
remove[c] = true
}
filtered := candidates[:0:0]
for _, c := range candidates {
if !remove[c] {
filtered = append(filtered, c)
}
}
candidates = filtered
}
ds := buildDesignSystem(theme)
// Register @utility blocks as static utilities.
for _, u := range customUtilities {
@@ -1438,14 +1446,6 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
if e != nil {
return "", 0, e
}
// The preflight is written against Tailwind's compile-time CSS functions —
// `font-family: --theme(--default-font-family, …)` and five more like it. They
// have to be resolved here, exactly as the theme's own declarations are above.
// Left in, `--theme(…)` reaches the browser verbatim, which cannot parse it and
// so DROPS THE WHOLE DECLARATION: html ends up with no font-family at all and
// falls back to the browser default, and no @theme override of --font-sans can
// ever take effect.
substituteFunctions(pfAst, ds)
out = append(out, atRule("@layer", "base", pfAst...))
}
@@ -1459,6 +1459,95 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
return toCss(out), len(astNodes), nil
}
// parseSourceDirective parses the params of an `@source` at-rule, supporting
// the safelist form `@source inline("<pattern>")` / `@source not inline("<pattern>")`
// (mirrors upstream Tailwind v4's inline source safelist). Each quoted string
// literal is brace-expanded (e.g. `{text,bg}-{red,blue}-{100,200}`) into the
// literal candidate classes it names. File-glob `@source "./path/**/*.html"`
// directives are not supported and are ignored.
func parseSourceDirective(params string) (literals []string, negate bool) {
trimmed := strings.TrimSpace(params)
if rest, ok := strings.CutPrefix(trimmed, "not "); ok {
negate = true
trimmed = strings.TrimSpace(rest)
}
if !strings.HasPrefix(trimmed, "inline(") || !strings.HasSuffix(trimmed, ")") {
return nil, false
}
inner := trimmed[len("inline(") : len(trimmed)-1]
for _, lit := range extractQuotedLiterals(inner) {
literals = append(literals, expandBraces(lit)...)
}
return literals, negate
}
// extractQuotedLiterals returns the contents of every single- or
// double-quoted string literal found in s.
func extractQuotedLiterals(s string) []string {
var out []string
for i := 0; i < len(s); i++ {
quote := s[i]
if quote != '\'' && quote != '"' {
continue
}
j := i + 1
for j < len(s) && s[j] != quote {
if s[j] == '\\' && j+1 < len(s) {
j++
}
j++
}
out = append(out, s[i+1:min(j, len(s))])
i = j
}
return out
}
// expandBraces expands shell-style brace groups in pattern, e.g.
// "{text,bg}-red-{100,200}" -> ["text-red-100", "text-red-200",
// "bg-red-100", "bg-red-200"]. A brace group may also be a numeric range
// ("{1..3}" -> "1", "2", "3"). Groups are expanded left to right; a pattern
// with no braces expands to itself.
func expandBraces(pattern string) []string {
start := strings.IndexByte(pattern, '{')
if start == -1 {
return []string{pattern}
}
relEnd := strings.IndexByte(pattern[start:], '}')
if relEnd == -1 {
return []string{pattern}
}
end := start + relEnd
prefix, inner, suffix := pattern[:start], pattern[start+1:end], pattern[end+1:]
var parts []string
if a, b, ok := strings.Cut(inner, ".."); ok && !strings.Contains(inner, ",") {
lo, loErr := strconv.Atoi(strings.TrimSpace(a))
hi, hiErr := strconv.Atoi(strings.TrimSpace(b))
if loErr == nil && hiErr == nil {
step := 1
if lo > hi {
step = -1
}
for n := lo; ; n += step {
parts = append(parts, strconv.Itoa(n))
if n == hi {
break
}
}
}
}
if parts == nil {
parts = strings.Split(inner, ",")
}
var out []string
for _, p := range parts {
out = append(out, expandBraces(prefix+p+suffix)...)
}
return out
}
// scanSources scans the given glob/** patterns (relative to baseDir) for
// candidate class names using the bundler's scanner.
func scanSources(baseDir string, patterns []string) []string {
@@ -8831,95 +8920,6 @@ func (v *Variants) compare(a, z *Variant) int {
return 1
}
// 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:])
}
// fromAst registers a variant whose body comes from CSS (@custom-variant).
func (v *Variants) fromAst(name string, ast []*AstNode, ds *DesignSystem) {
var selectors []string