From c3e08b861effcc33c6127c8a23dd2405513700df Mon Sep 17 00:00:00 2001 From: Arturo Silva Date: Thu, 19 Mar 2026 02:26:31 -0400 Subject: [PATCH] feat: add var() resolution and mode-* descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve var(--X) references in initial-value by chasing through the token graph until a literal is found. Handles chains (A→B→C) and circular references (kept as-is). Original unresolved value preserved in RawInitialValue for CSS output. - Add mode-* extended descriptors (e.g., mode-dark, mode-high-contrast) to express color mode overrides directly in @property blocks. Mode values also get var() resolution with originals in RawModes. - JSON output: initialValue and modes contain resolved literals. - CSS output: :root preserves var() references, mode blocks generated as :root[data-color-mode=''] selectors. --- src/internal/builder/css.go | 45 ++++- src/internal/parser/parser.go | 71 +++++++ src/internal/parser/parser_test.go | 315 +++++++++++++++++++++++++++++ src/internal/parser/token.go | 25 ++- 4 files changed, 443 insertions(+), 13 deletions(-) diff --git a/src/internal/builder/css.go b/src/internal/builder/css.go index b35cf73..d7334c9 100644 --- a/src/internal/builder/css.go +++ b/src/internal/builder/css.go @@ -56,15 +56,56 @@ func ToCSS(tokens []parser.Token) string { }) for _, t := range catTokens { + // Use the original var() reference if available, resolved literal otherwise + value := t.InitialValue + if t.RawInitialValue != "" { + value = t.RawInitialValue + } + if t.Deprecated { - sb.WriteString(fmt.Sprintf(" /* @deprecated */ %s: %s;\n", t.Name, t.InitialValue)) + sb.WriteString(fmt.Sprintf(" /* @deprecated */ %s: %s;\n", t.Name, value)) } else { - sb.WriteString(fmt.Sprintf(" %s: %s;\n", t.Name, t.InitialValue)) + sb.WriteString(fmt.Sprintf(" %s: %s;\n", t.Name, value)) } } } sb.WriteString("}\n") + // Collect mode overrides across all tokens + modeTokens := make(map[string][]parser.Token) // modeName → tokens with that mode + var modeOrder []string + + for _, t := range tokens { + for modeName := range t.Modes { + if _, exists := modeTokens[modeName]; !exists { + modeOrder = append(modeOrder, modeName) + } + modeTokens[modeName] = append(modeTokens[modeName], t) + } + } + + sort.Strings(modeOrder) + + for _, modeName := range modeOrder { + sb.WriteString(fmt.Sprintf("\n:root[data-color-mode='%s'] {\n", modeName)) + + mTokens := modeTokens[modeName] + sort.Slice(mTokens, func(i, j int) bool { + return mTokens[i].Name < mTokens[j].Name + }) + + for _, t := range mTokens { + // Use the original var() reference if available + value := t.Modes[modeName] + if raw, exists := t.RawModes[modeName]; exists { + value = raw + } + sb.WriteString(fmt.Sprintf(" %s: %s;\n", t.Name, value)) + } + + sb.WriteString("}\n") + } + return sb.String() } diff --git a/src/internal/parser/parser.go b/src/internal/parser/parser.go index 1ed27f6..b2dfc85 100644 --- a/src/internal/parser/parser.go +++ b/src/internal/parser/parser.go @@ -12,6 +12,7 @@ var ( descriptorRe = regexp.MustCompile(`([\w-]+)\s*:\s*("[^"]*"|[^;]+);?`) rootBlockRe = regexp.MustCompile(`:root\s*\{([^}]+)\}`) customPropRe = regexp.MustCompile(`(--[\w-]+)\s*:\s*([^;]+);`) + varRefRe = regexp.MustCompile(`var\((--[\w-]+)\)`) ) // ParseFiles parses multiple CSS files and returns all tokens @@ -84,9 +85,68 @@ func Parse(css string, filename string) ([]Token, error) { result = append(result, *token) } + // Resolve var() references in initial values + resolveVarReferences(result) + return result, nil } +// resolveVarReferences resolves var(--X) references in InitialValue fields. +// When a token's initial-value contains var(--other-token), the reference is +// chased through the token graph until a literal value is found. +// The original unresolved value is preserved in RawInitialValue for CSS output. +func resolveVarReferences(tokens []Token) { + byName := make(map[string]*Token) + for i := range tokens { + byName[tokens[i].Name] = &tokens[i] + } + + for i := range tokens { + if varRefRe.MatchString(tokens[i].InitialValue) { + tokens[i].RawInitialValue = tokens[i].InitialValue + tokens[i].InitialValue = resolveVarValue(tokens[i].InitialValue, byName, nil) + } + + for modeName, modeValue := range tokens[i].Modes { + if varRefRe.MatchString(modeValue) { + if tokens[i].RawModes == nil { + tokens[i].RawModes = make(map[string]string) + } + tokens[i].RawModes[modeName] = modeValue + tokens[i].Modes[modeName] = resolveVarValue(modeValue, byName, nil) + } + } + } +} + +func resolveVarValue(value string, byName map[string]*Token, seen map[string]bool) string { + if seen == nil { + seen = make(map[string]bool) + } + + return varRefRe.ReplaceAllStringFunc(value, func(match string) string { + sub := varRefRe.FindStringSubmatch(match) + name := sub[1] + + if seen[name] { + return match // circular reference — keep as-is + } + seen[name] = true + + target, exists := byName[name] + if !exists { + return match // unknown var — keep as-is + } + + resolved := target.InitialValue + if varRefRe.MatchString(resolved) { + resolved = resolveVarValue(resolved, byName, seen) + } + + return resolved + }) +} + func parseDescriptors(body string, token *Token) { matches := descriptorRe.FindAllStringSubmatch(body, -1) @@ -126,6 +186,17 @@ func parseDescriptors(body string, token *Token) { } } token.Examples = cleaned + default: + // mode- descriptors (e.g., mode-dark, mode-high-contrast) + if strings.HasPrefix(key, "mode-") { + modeName := strings.TrimPrefix(key, "mode-") + if modeName != "" { + if token.Modes == nil { + token.Modes = make(map[string]string) + } + token.Modes[modeName] = value + } + } } } } diff --git a/src/internal/parser/parser_test.go b/src/internal/parser/parser_test.go index f5736a5..2dee6f3 100644 --- a/src/internal/parser/parser_test.go +++ b/src/internal/parser/parser_test.go @@ -123,6 +123,321 @@ func TestRootOverridesInitialValue(t *testing.T) { } } +func TestVarResolutionDirect(t *testing.T) { + css := ` +@property --color-blue { + syntax: ""; + inherits: false; + initial-value: #0055ff; +} + +@property --color-primary { + syntax: ""; + inherits: false; + initial-value: var(--color-blue); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + var primary *Token + for i := range tokens { + if tokens[i].Name == "--color-primary" { + primary = &tokens[i] + } + } + + if primary == nil { + t.Fatal("--color-primary not found") + } + if primary.InitialValue != "#0055ff" { + t.Errorf("Expected resolved value #0055ff, got %s", primary.InitialValue) + } + if primary.RawInitialValue != "var(--color-blue)" { + t.Errorf("Expected raw value var(--color-blue), got %s", primary.RawInitialValue) + } +} + +func TestVarResolutionChain(t *testing.T) { + css := ` +@property --color-hex { + syntax: ""; + inherits: false; + initial-value: #ff0000; +} + +@property --color-brand { + syntax: ""; + inherits: false; + initial-value: var(--color-hex); +} + +@property --color-action { + syntax: ""; + inherits: false; + initial-value: var(--color-brand); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + var action *Token + for i := range tokens { + if tokens[i].Name == "--color-action" { + action = &tokens[i] + } + } + + if action == nil { + t.Fatal("--color-action not found") + } + if action.InitialValue != "#ff0000" { + t.Errorf("Expected resolved chain value #ff0000, got %s", action.InitialValue) + } + if action.RawInitialValue != "var(--color-brand)" { + t.Errorf("Expected raw value var(--color-brand), got %s", action.RawInitialValue) + } +} + +func TestVarResolutionCircular(t *testing.T) { + css := ` +@property --a { + syntax: ""; + inherits: false; + initial-value: var(--b); +} + +@property --b { + syntax: ""; + inherits: false; + initial-value: var(--a); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + // Should not panic — circular refs are kept as-is + for _, tok := range tokens { + if tok.Name == "--a" && tok.RawInitialValue == "" { + t.Error("Expected --a to have RawInitialValue set") + } + } +} + +func TestVarResolutionUnknownRef(t *testing.T) { + css := ` +@property --color-primary { + syntax: ""; + inherits: false; + initial-value: var(--unknown-token); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + if tokens[0].InitialValue != "var(--unknown-token)" { + t.Errorf("Expected unknown ref kept as-is, got %s", tokens[0].InitialValue) + } +} + +func TestVarResolutionWithRootOverride(t *testing.T) { + css := ` +@property --color-blue { + syntax: ""; + inherits: false; + initial-value: #0055ff; +} + +@property --color-primary { + syntax: ""; + inherits: false; + initial-value: #000000; +} + +:root { + --color-primary: var(--color-blue); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + var primary *Token + for i := range tokens { + if tokens[i].Name == "--color-primary" { + primary = &tokens[i] + } + } + + if primary == nil { + t.Fatal("--color-primary not found") + } + // :root override with var() should be resolved + if primary.InitialValue != "#0055ff" { + t.Errorf("Expected resolved value #0055ff, got %s", primary.InitialValue) + } + if primary.RawInitialValue != "var(--color-blue)" { + t.Errorf("Expected raw value var(--color-blue), got %s", primary.RawInitialValue) + } +} + +func TestVarResolutionLiteralUnchanged(t *testing.T) { + css := ` +@property --color-literal { + syntax: ""; + inherits: false; + initial-value: #ff0000; +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + if tokens[0].RawInitialValue != "" { + t.Errorf("Expected empty RawInitialValue for literal, got %s", tokens[0].RawInitialValue) + } + if tokens[0].InitialValue != "#ff0000" { + t.Errorf("Expected #ff0000, got %s", tokens[0].InitialValue) + } +} + +func TestModeDescriptor(t *testing.T) { + css := ` +@property --color-bg { + syntax: ""; + inherits: false; + initial-value: #ffffff; + mode-dark: #1a1a2e; +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + tok := tokens[0] + if tok.InitialValue != "#ffffff" { + t.Errorf("Expected initial-value #ffffff, got %s", tok.InitialValue) + } + if tok.Modes == nil { + t.Fatal("Expected Modes to be set") + } + if tok.Modes["dark"] != "#1a1a2e" { + t.Errorf("Expected mode-dark #1a1a2e, got %s", tok.Modes["dark"]) + } +} + +func TestModeWithVarResolution(t *testing.T) { + css := ` +@property --color-blue-300 { + syntax: ""; + inherits: false; + initial-value: #94B4FF; +} + +@property --color-blue-450 { + syntax: ""; + inherits: false; + initial-value: #2C49EF; +} + +@property --color-primary { + syntax: ""; + inherits: false; + initial-value: var(--color-blue-450); + mode-dark: var(--color-blue-300); +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + var primary *Token + for i := range tokens { + if tokens[i].Name == "--color-primary" { + primary = &tokens[i] + } + } + + if primary == nil { + t.Fatal("--color-primary not found") + } + + // initial-value resolved + if primary.InitialValue != "#2C49EF" { + t.Errorf("Expected resolved initial-value #2C49EF, got %s", primary.InitialValue) + } + if primary.RawInitialValue != "var(--color-blue-450)" { + t.Errorf("Expected raw initial-value var(--color-blue-450), got %s", primary.RawInitialValue) + } + + // mode-dark resolved + if primary.Modes["dark"] != "#94B4FF" { + t.Errorf("Expected resolved mode-dark #94B4FF, got %s", primary.Modes["dark"]) + } + if primary.RawModes["dark"] != "var(--color-blue-300)" { + t.Errorf("Expected raw mode-dark var(--color-blue-300), got %s", primary.RawModes["dark"]) + } +} + +func TestMultipleModes(t *testing.T) { + css := ` +@property --color-bg { + syntax: ""; + inherits: false; + initial-value: #ffffff; + mode-dark: #1a1a2e; + mode-high-contrast: #000000; +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + tok := tokens[0] + if len(tok.Modes) != 2 { + t.Fatalf("Expected 2 modes, got %d", len(tok.Modes)) + } + if tok.Modes["dark"] != "#1a1a2e" { + t.Errorf("Expected dark #1a1a2e, got %s", tok.Modes["dark"]) + } + if tok.Modes["high-contrast"] != "#000000" { + t.Errorf("Expected high-contrast #000000, got %s", tok.Modes["high-contrast"]) + } +} + +func TestModeLiteralNoRawModes(t *testing.T) { + css := ` +@property --color-bg { + syntax: ""; + inherits: false; + initial-value: #ffffff; + mode-dark: #1a1a2e; +} +` + tokens, err := ParseString(css) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + tok := tokens[0] + if tok.RawModes != nil { + t.Errorf("Expected nil RawModes for literal mode values, got %v", tok.RawModes) + } +} + func TestValidateDuplicates(t *testing.T) { tokens := []Token{ {Name: "--color-a", Source: Source{File: "a.css", Line: 1}}, diff --git a/src/internal/parser/token.go b/src/internal/parser/token.go index 9f279bf..0729723 100644 --- a/src/internal/parser/token.go +++ b/src/internal/parser/token.go @@ -2,17 +2,20 @@ package parser // Token represents a parsed CSS @property design token type Token struct { - Name string `json:"name"` - Syntax string `json:"syntax,omitempty"` - Inherits bool `json:"inherits"` - InitialValue string `json:"initialValue,omitempty"` - Description string `json:"description,omitempty"` - Category string `json:"category,omitempty"` - Type string `json:"type,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Deprecated bool `json:"deprecated,omitempty"` - Examples []string `json:"examples,omitempty"` - Source Source `json:"source"` + Name string `json:"name"` + Syntax string `json:"syntax,omitempty"` + Inherits bool `json:"inherits"` + InitialValue string `json:"initialValue,omitempty"` + RawInitialValue string `json:"-"` // original var() reference before resolution + Modes map[string]string `json:"modes,omitempty"` + RawModes map[string]string `json:"-"` // original var() references before resolution + Description string `json:"description,omitempty"` + Category string `json:"category,omitempty"` + Type string `json:"type,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Deprecated bool `json:"deprecated,omitempty"` + Examples []string `json:"examples,omitempty"` + Source Source `json:"source"` } // Source tracks where a token was defined