Manually merge from 'pre-kjol' into 'master'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -42,6 +42,71 @@ func TestEngineEdgeCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// @source inline(...) safelists candidates that aren't present in the scanned
|
||||
// source files at all (brace expansion covers the color/shade cross product).
|
||||
func TestSourceInlineSafelist(t *testing.T) {
|
||||
css, _, err := twCompile(
|
||||
`@import "tailwindcss";
|
||||
@source inline("{text,bg}-{red,blue}-{100,500}");`,
|
||||
".", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("twCompile error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"color: var(--color-red-100)",
|
||||
"color: var(--color-red-500)",
|
||||
"color: var(--color-blue-100)",
|
||||
"color: var(--color-blue-500)",
|
||||
"background-color: var(--color-red-100)",
|
||||
"background-color: var(--color-blue-500)",
|
||||
} {
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("expected safelisted output to contain %q\n---\n%s", want, css)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `not inline(...)` removes a candidate even if the scanner found it in source.
|
||||
func TestSourceNotInlineRemoves(t *testing.T) {
|
||||
css, _, err := twCompile(
|
||||
`@import "tailwindcss";
|
||||
@source not inline("bg-red-500");`,
|
||||
".", []string{"bg-red-500", "bg-blue-500"})
|
||||
if err != nil {
|
||||
t.Fatalf("twCompile error: %v", err)
|
||||
}
|
||||
if strings.Contains(css, "background-color: var(--color-red-500)") {
|
||||
t.Errorf("expected bg-red-500 to be excluded by `not inline`\n---\n%s", css)
|
||||
}
|
||||
if !strings.Contains(css, "background-color: var(--color-blue-500)") {
|
||||
t.Errorf("expected bg-blue-500 to remain\n---\n%s", css)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBraces(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"flex", []string{"flex"}},
|
||||
{"{a,b}", []string{"a", "b"}},
|
||||
{"{1..3}", []string{"1", "2", "3"}},
|
||||
{"{text,bg}-{red,blue}", []string{"text-red", "text-blue", "bg-red", "bg-blue"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := expandBraces(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("expandBraces(%q) = %v, want %v", c.in, got, c.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("expandBraces(%q)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentTopLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -20,11 +20,11 @@ import (
|
||||
mincss "github.com/tdewolff/minify/v2/css"
|
||||
)
|
||||
|
||||
var min *minify.M
|
||||
var minifier *minify.M
|
||||
|
||||
func init() {
|
||||
min = minify.New()
|
||||
min.AddFunc("text/css", mincss.Minify)
|
||||
minifier = minify.New()
|
||||
minifier.AddFunc("text/css", mincss.Minify)
|
||||
}
|
||||
|
||||
// Scan extracts candidate utility class names from the files matched by patterns
|
||||
@@ -56,4 +56,4 @@ func CompileFiles(entryCSS, baseDir string, sourceGlobs []string) (string, error
|
||||
}
|
||||
|
||||
// Minify shrinks compiled CSS.
|
||||
func Minify(css string) (string, error) { return min.String("text/css", css) }
|
||||
func Minify(css string) (string, error) { return minifier.String("text/css", css) }
|
||||
|
||||
Reference in New Issue
Block a user