Add landing page for kjol, documentation

This commit is contained in:
2026-07-13 16:51:21 -04:00
parent 5230bd6702
commit fec8ef4a3e
54 changed files with 3529 additions and 547 deletions

View File

@@ -0,0 +1,52 @@
package tw
import (
"strings"
"testing"
)
// @custom-variant is how a project redefines `dark:` as a CLASS toggle. The built-in
// dark variant is a prefers-color-scheme media query, which a site with its own theme
// switch cannot use: the OS says one thing and the switch says another, and the media
// query wins.
func TestCustomVariantDarkClass(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
".", []string{"dark:bg-black", "bg-white"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(css, "prefers-color-scheme") {
t.Error("dark: still compiled to a media query — the @custom-variant was ignored")
}
if !strings.Contains(css, ".dark") {
t.Errorf("dark: did not compile to a class selector:\n%s", css)
}
}
// The shorthand's selector may itself contain commas — &:where(.dark, .dark *) is ONE
// selector, and splitting it on that comma yields two broken halves.
func TestCustomVariantKeepsNestedCommas(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant dark (&:where(.dark, .dark *));\n",
".", []string{"dark:bg-black"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(css, ".dark *") {
t.Errorf("the descendant half of the selector was lost:\n%s", css)
}
}
// The block form, with an explicit @slot.
func TestCustomVariantBlockForm(t *testing.T) {
css, _, err := Compile(
"@import \"tailwindcss\";\n@custom-variant tall {\n @media (min-height: 800px) { @slot; }\n}\n",
".", []string{"tall:bg-black"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(css, "min-height: 800px") && !strings.Contains(css, "min-height:800px") {
t.Errorf("the block-form variant did not compile:\n%s", css)
}
}

View File

@@ -1261,7 +1261,8 @@ 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 not yet wired. -mta
// @utility/--value()). @custom-variant IS wired (both the shorthand and block
// forms) — see parseCustomVariant. -mta
//go:embed tw_theme.css
var defaultThemeCSS string
@@ -1304,6 +1305,7 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
var keyframes []*AstNode
var passthrough []*AstNode
var customUtilities []*AstNode
var customVariants []*AstNode
hasPreflight := false
hasUtilities := false
@@ -1359,6 +1361,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)
default:
passthrough = append(passthrough, node)
}
@@ -1373,6 +1377,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 +8831,95 @@ 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