package tokenengine import ( "encoding/json" "fmt" "math" "sort" "strconv" "strings" "github.com/urpi/go-bananas/internal/domain" ) // FlatToken is one resolved token: its dotted path, its resolved type and the // exact CSS-ready value. type FlatToken struct { // Path is the dotted token path, e.g. "color.semantic.background". Path string // Kind is the resolved token type. Kind Kind // Value is the raw author-supplied value. Value any // CSS is the value as it appears in a custom property declaration. CSS string // Description is the token's $description, if any. Description string // Derived marks tokens computed by the engine rather than authored. Derived bool } // Doc is a parsed, resolved token document for one appearance. type Doc struct { // Theme is the theme the document was derived from. Theme *domain.Theme // Appearance is the colour scheme that was resolved. Appearance domain.Appearance // Tokens are the leaves in deterministic path order. Tokens []FlatToken // byPath indexes Tokens for O(1) lookup. byPath map[string]FlatToken // palettes lists palette names discovered in the document. palettes []string } // Lookup returns a token by dotted path. func (d *Doc) Lookup(path string) (FlatToken, bool) { t, ok := d.byPath[path] return t, ok } // Palettes returns the palette names present in the document. func (d *Doc) Palettes() []string { return d.palettes } // PalettesOf returns the shade steps defined for one palette, sorted so that // scale output is deterministic. func (d *Doc) PalettesOf(palette string) []string { var steps []string prefix := "color." + palette + "." for _, t := range d.Tokens { if strings.HasPrefix(t.Path, prefix) { steps = append(steps, strings.TrimPrefix(t.Path, prefix)) } } sort.Slice(steps, func(i, j int) bool { return shadeLess(steps[i], steps[j]) }) return steps } // Variables renders the document as CSS custom property name to value. func (d *Doc) Variables() map[string]string { out := make(map[string]string, len(d.Tokens)) for _, t := range d.Tokens { out[VariableName(t.Path)] = t.CSS } return out } // VariableOrder returns the CSS variable names in deterministic order, which is // the order the generated stylesheet uses. func (d *Doc) VariableOrder() []string { out := make([]string, 0, len(d.Tokens)) for _, t := range d.Tokens { out = append(out, VariableName(t.Path)) } return out } // shadeLess orders numeric shades numerically and anything else lexically, so // 50, 100, 200 ... 950 and custom names both come out sensibly. func shadeLess(a, b string) bool { an, aerr := strconv.Atoi(a) bn, berr := strconv.Atoi(b) if aerr == nil && berr == nil { return an < bn } if aerr == nil { return true } if berr == nil { return false } return a < b } // Resolve merges the base token document with the overrides for the requested // appearance and returns a flat, ordered token list. // // Overrides are a partial document of the same shape as the base, deep-merged // one level at a time. A leaf in the override replaces the corresponding leaf // in the base; a nested object in the override merges with its counterpart. func Resolve(theme *domain.Theme, appearance domain.Appearance) *Doc { base := theme.Tokens.Clone() if base == nil { base = domain.TokenDoc{} } merged := applyOverrides(base, overridesFor(base, appearance)) doc := &Doc{ Theme: theme, Appearance: appearance, byPath: make(map[string]FlatToken), palettes: paletteNames(merged), } collect(merged, "", nil, doc) sort.Slice(doc.Tokens, func(i, j int) bool { return doc.Tokens[i].Path < doc.Tokens[j].Path }) for _, t := range doc.Tokens { doc.byPath[t.Path] = t } return doc } // overridesFor extracts the override sub-document for one appearance. func overridesFor(doc domain.TokenDoc, appearance domain.Appearance) map[string]any { appearances, ok := asMap(doc[AppearancesKey]) if !ok { return nil } override, _ := asMap(appearances[string(appearance)]) return override } // applyOverrides deep-merges override into base, returning a new document. func applyOverrides(base domain.TokenDoc, override map[string]any) domain.TokenDoc { out := make(domain.TokenDoc, len(base)) for k, v := range base { out[k] = v } for k, v := range override { if overrideMap, ok := asMap(v); ok { if baseMap, ok := asMap(out[k]); ok { out[k] = map[string]any(applyOverrides(domain.TokenDoc(baseMap), overrideMap)) continue } } out[k] = v } return out } // inheritedKind carries the type down the tree so that a group may declare // "$type" once and have every leaf inherit it, as DTCG specifies. func collect(node map[string]any, prefix string, inherited *Kind, doc *Doc) { if node == nil { return } if isLeaf(node) { collectLeaf(node, prefix, inherited, doc) return } if raw, ok := node[TypeKey].(string); ok { k := Kind(raw) inherited = &k } keys := make([]string, 0, len(node)) for k := range node { if strings.HasPrefix(k, "$") { continue } keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { child, ok := asMap(node[k]) if !ok { continue } path := k if prefix != "" { path = prefix + "." + k } collect(child, path, inherited, doc) } } // collectLeaf records a single token leaf. func collectLeaf(node map[string]any, path string, inherited *Kind, doc *Doc) { kind := Kind("") if inherited != nil { kind = *inherited } if raw, ok := node[TypeKey].(string); ok { kind = Kind(raw) } value := node[ValueKey] if kind == "" { kind = inferKind(value) } if kind == "" { return } t := FlatToken{ Path: path, Kind: kind, Value: value, CSS: renderValue(kind, value), } if d, ok := node[DescKey].(string); ok { t.Description = d } doc.Tokens = append(doc.Tokens, t) doc.byPath[path] = t } // inferKind guesses a leaf's type when neither the leaf nor an ancestor // declared one. Guessing keeps hand-written documents usable; validation still // reports the inferred type so authors can make it explicit. func inferKind(v any) Kind { switch t := v.(type) { case string: if _, err := ParseColor(t); err == nil && strings.HasPrefix(strings.TrimSpace(t), "#") { return KindColor } if strings.ContainsAny(t, "()") { return KindShadow } return KindFontFamily case float64, int, json.Number: return KindNumber case []any: return KindCubicBezier default: return "" } } // renderValue converts a token value into its CSS form, appending the // canonical unit for unit-bearing kinds when the author omitted it. func renderValue(kind Kind, value any) string { switch kind { case KindDimension, KindFontSize, KindLetterSpacing, KindDuration: if n, ok := toFloat(value); ok { return FormatNumber(n) + kind.UnitSuffix() } case KindNumber, KindFontWeight, KindLineHeight: if n, ok := toFloat(value); ok { return FormatNumber(n) } } return stringify(value) } // stringify renders any supported value as a CSS literal. func stringify(value any) string { switch t := value.(type) { case nil: return "" case string: return t case bool: return strconv.FormatBool(t) case float64: return FormatNumber(t) case int: return FormatNumber(float64(t)) case json.Number: return t.String() case []any: parts := make([]string, len(t)) for i, p := range t { parts[i] = stringify(p) } return strings.Join(parts, ", ") default: // Complex values are stored as pre-formatted CSS strings by the editor, // so this is the only safe rendering. return fmt.Sprint(t) } } func toFloat(v any) (float64, bool) { switch t := v.(type) { case float64: return t, true case int: return float64(t), true case int64: return float64(t), true case json.Number: f, err := t.Float64() return f, err == nil case string: f, err := strconv.ParseFloat(strings.TrimSpace(t), 64) return f, err == nil default: return 0, false } } // validateNumeric accepts a JSON number or a numeric string. func validateNumeric(v any) error { switch t := v.(type) { case float64: if math.IsNaN(t) || math.IsInf(t, 0) { return fmt.Errorf("value must be finite") } return nil case int, int64: return nil case json.Number: f, err := t.Float64() if err != nil { return fmt.Errorf("value %q is not numeric", t.String()) } if math.IsNaN(f) || math.IsInf(f, 0) { return fmt.Errorf("value must be finite") } return nil case string: if _, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err != nil { return fmt.Errorf("value %q is not numeric", t) } return nil default: return fmt.Errorf("expected a number, got %T", v) } } // cssLengthUnits are the units accepted for a bare dimension value. var cssLengthUnits = []string{"px", "rem", "em", "%", "vh", "vw", "vmin", "vmax", "ch", "ex", "cm", "mm", "in", "pt", "pc", "svh", "lvh", "dvh", "ms", "s", "fr"} // validateCSSLength accepts a number, a numeric string with a unit, or one of // the CSS global keywords. func validateCSSLength(v any) error { if err := validateNumeric(v); err != nil { if s, ok := v.(string); ok { trimmed := strings.ToLower(strings.TrimSpace(s)) switch trimmed { case "auto", "min-content", "max-content", "fit-content", "inherit", "initial", "unset", "normal": return nil } for _, unit := range cssLengthUnits { if strings.HasSuffix(trimmed, unit) { if _, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, unit), 64); err == nil { return nil } } } if strings.HasPrefix(trimmed, "clamp(") || strings.HasPrefix(trimmed, "calc(") || strings.HasPrefix(trimmed, "min(") || strings.HasPrefix(trimmed, "max(") { // Function-based values are opaque to the validator; the browser // is the authority and will discard anything malformed. return nil } return fmt.Errorf("value %q is not a valid length", s) } return err } return nil } // Validate checks a token document and returns every finding. func Validate(doc domain.TokenDoc) ValidationResult { result := ValidationResult{Valid: true, Issues: []Issue{}} issues := make([]violation, 0, 32) known := make(map[string]GroupSpec, len(Groups)) for _, g := range Groups { known[g.Name] = g } for key, raw := range doc { if _, reserved := ReservedKeys[key]; reserved { continue } node, ok := asMap(raw) if !ok { issues = append(issues, violation{ Path: key, Severity: SeverityError, Message: fmt.Sprintf("group %q must be an object", key), Hint: "top-level keys are token groups, or one of " + strings.Join(sortedReservedKeys(), ", "), }) continue } spec, ok := known[key] if !ok { issues = append(issues, violation{ Path: key, Severity: SeverityError, Message: fmt.Sprintf("unknown token group %q", key), Hint: "known groups: " + strings.Join(GroupNames(), ", "), }) continue } validateGroup(spec, node, key, &issues) } if appearances, ok := asMap(doc[AppearancesKey]); ok { for name, raw := range appearances { if name != string(domain.AppearanceLight) && name != string(domain.AppearanceDark) { issues = append(issues, violation{ Path: AppearancesKey + "." + name, Severity: SeverityError, Message: fmt.Sprintf("unknown appearance %q", name), Hint: "use \"light\" or \"dark\"", }) continue } node, ok := asMap(raw) if !ok { issues = append(issues, violation{ Path: AppearancesKey + "." + name, Severity: SeverityError, Message: "appearance overrides must be an object", }) continue } for group, groupRaw := range node { spec, ok := known[group] if !ok { issues = append(issues, violation{ Path: AppearancesKey + "." + name + "." + group, Severity: SeverityError, Message: fmt.Sprintf("unknown token group %q", group), }) continue } groupNode, ok := asMap(groupRaw) if !ok { continue } // Overrides are partial by design: only value validity is // checked, never completeness. validateLeaves(spec, groupNode, spec.Kind, group, AppearancesKey+"."+name+"."+group, &issues) } } } else if _, present := doc[AppearancesKey]; present { issues = append(issues, violation{ Path: AppearancesKey, Severity: SeverityError, Message: AppearancesKey + " must be an object keyed by appearance", Hint: `example: {"dark": {"color": {"semantic": {"background": {"$value": "#0b0f19"}}}}}`, }) } // A theme with no dark overrides will render identically in both schemes, // which is almost always a mistake for a theme that declares a dark // appearance as its default. appearancesMap, _ := asMap(doc[AppearancesKey]) if _, hasDark := asMap(appearancesMap[string(domain.AppearanceDark)]); !hasDark { issues = append(issues, violation{ Path: AppearancesKey, Severity: SeverityWarning, Message: "no dark appearance overrides are defined", Hint: `add {"$appearances": {"dark": {...}}} so the dark scheme is intentional rather than accidental`, }) } resolved := countLeaves(doc) result.TokenCount = resolved result.Issues = toIssues(issues) for _, i := range result.Issues { if i.Severity == SeverityError { result.Valid = false break } } return result } // toIssues converts and deterministically orders internal violations. func toIssues(in []violation) []Issue { out := make([]Issue, 0, len(in)) for _, v := range in { out = append(out, Issue(v)) } sort.SliceStable(out, func(i, j int) bool { if ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity); ri != rj { return ri < rj } return out[i].Path < out[j].Path }) return out } // sortedReservedKeys returns the reserved document keys in sorted order. func sortedReservedKeys() []string { out := make([]string, 0, len(ReservedKeys)) for k := range ReservedKeys { out = append(out, k) } sort.Strings(out) return out } // validateGroup checks one group's required and recommended tokens. func validateGroup(spec GroupSpec, node map[string]any, prefix string, issues *[]violation) { validateLeaves(spec, node, spec.Kind, prefix, prefix, issues) for _, path := range spec.Recommended { if _, err := LookupIn(node, path); err != nil { *issues = append(*issues, violation{ Path: prefix + "." + path, Severity: SeverityWarning, Message: fmt.Sprintf("recommended token %q is missing", path), Hint: spec.Description, }) } } } // validateLeaves walks a group tree checking every leaf value's validity. func validateLeaves(spec GroupSpec, node map[string]any, kindDefault Kind, prefix, path string, issues *[]violation) { if isLeaf(node) { kind := kindDefault if raw, ok := node[TypeKey].(string); ok { kind = Kind(raw) } else if inferred := inferKind(node[ValueKey]); inferred != "" && kind == kindDefault { kind = inferred } if !kind.Valid() { *issues = append(*issues, violation{ Path: path, Severity: SeverityError, Message: fmt.Sprintf("unknown $type %q", kind), Hint: "omit $type to inherit the group's type", }) return } if err := validateKindValue(kind, node[ValueKey]); err != nil { *issues = append(*issues, violation{ Path: path, Severity: SeverityError, Message: err.Error(), Hint: fmt.Sprintf("expected a %s value", kind), }) } return } inherited := kindDefault if raw, ok := node[TypeKey].(string); ok { inherited = Kind(raw) } for _, key := range sortedMapKeys(node) { if strings.HasPrefix(key, "$") { continue } child, ok := asMap(node[key]) if !ok { *issues = append(*issues, violation{ Path: path + "." + key, Severity: SeverityError, Message: fmt.Sprintf("token must be an object with a %q property, got %T", ValueKey, node[key]), Hint: `example: {"$type": "color", "$value": "#6366f1"}`, }) continue } validateLeaves(spec, child, inherited, prefix, path+"."+key, issues) } } // sortedMapKeys returns the keys of m in sorted order. func sortedMapKeys(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // LookupIn walks a token document by dotted path and returns the leaf. func LookupIn(doc map[string]any, path string) (map[string]any, error) { var current any = doc for _, segment := range strings.Split(path, ".") { node, ok := asMap(current) if !ok { return nil, fmt.Errorf("path %q: %q is not a group", path, segment) } next, exists := node[segment] if !exists { return nil, fmt.Errorf("path %q: %q not found", path, segment) } current = next } leaf, ok := asMap(current) if !ok || !isLeaf(leaf) { return nil, fmt.Errorf("path %q is not a token leaf", path) } return leaf, nil } // SetIn writes a token leaf at a dotted path, creating intermediate groups. func SetIn(doc domain.TokenDoc, path string, value any, kind Kind, description string) error { segments := strings.Split(path, ".") if len(segments) == 0 || segments[0] == "" { return fmt.Errorf("%w: empty token path", domain.ErrInvalid) } // Reject paths that would collide with structural keys. for i, seg := range segments { if _, reserved := ReservedKeys[seg]; reserved && i == 0 { return fmt.Errorf("%w: %q is a reserved key", domain.ErrInvalid, seg) } if seg == "" { return fmt.Errorf("%w: token path %q has an empty segment", domain.ErrInvalid, path) } } if _, ok := LookupGroup(segments[0]); !ok { return fmt.Errorf("%w: unknown token group %q", domain.ErrInvalid, segments[0]) } current := doc for _, seg := range segments[:len(segments)-1] { child, ok := asMap(current[seg]) if !ok { child = map[string]any{} current[seg] = child } current = child } leaf := map[string]any{ValueKey: value, TypeKey: string(kind)} if description != "" { leaf[DescKey] = description } current[segments[len(segments)-1]] = leaf return nil } // DeleteIn removes a token leaf at a dotted path. It reports whether the token // existed so callers can distinguish a no-op delete from a real one. func DeleteIn(doc domain.TokenDoc, path string) (bool, error) { segments := strings.Split(path, ".") if len(segments) < 2 { return false, fmt.Errorf("%w: refusing to delete top-level group %q", domain.ErrInvalid, path) } current := any(doc) for _, seg := range segments[:len(segments)-1] { node, ok := asMap(current) if !ok { return false, nil } child, exists := node[seg] if !exists { return false, nil } current = child } node, ok := asMap(current) if !ok { return false, nil } last := segments[len(segments)-1] if _, exists := node[last]; !exists { return false, nil } delete(node, last) pruneEmpty(node) return true, nil } // pruneEmpty removes group objects left empty by a delete, so that the stored // document does not accumulate `{}` noise that would fail validation. func pruneEmpty(node map[string]any) { for key, raw := range node { child, ok := asMap(raw) if !ok || len(child) == 0 { if ok && len(child) == 0 { delete(node, key) } continue } pruneEmpty(child) if len(child) == 0 { delete(node, key) } } } // countLeaves counts leaf tokens, excluding appearance overrides. func countLeaves(node map[string]any) int { total := 0 for key, raw := range node { if key == AppearancesKey || strings.HasPrefix(key, "$") { continue } child, ok := asMap(raw) if !ok { continue } if isLeaf(child) { total++ continue } total += countLeaves(child) } return total } // RenameToken moves a token leaf to a new dotted path, preserving its value and // description. It refuses to clobber an existing token unless overwrite is set. func RenameToken(doc domain.TokenDoc, from, to string, overwrite bool) error { leaf, err := LookupIn(doc, from) if err != nil { return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error()) } if _, err := LookupIn(doc, to); err == nil && !overwrite { return fmt.Errorf("%w: token %q already exists", domain.ErrConflict, to) } if _, err := DeleteIn(doc, from); err != nil { return err } value := leaf[ValueKey] description, _ := leaf[DescKey].(string) kind, ok := leaf[TypeKey].(string) if !ok { kind = string(inferKind(value)) } return SetIn(doc, to, value, Kind(kind), description) } // EnsureDefaults fills any missing recommended token with the supplied default. // It is used when duplicating a theme so the copy is immediately usable, and is // deliberately not used on the request path for existing themes. func EnsureDefaults(doc domain.TokenDoc, defaults domain.TokenDoc) int { applied := 0 for key, raw := range defaults { if _, exists := doc[key]; exists { continue } if err := deepMergeMissing(doc, key, raw); err == nil { applied++ } } return applied } // deepMergeMissing merges src into dst only where dst has no value. func deepMergeMissing(dst domain.TokenDoc, key string, src any) error { srcMap, srcOK := asMap(src) dstMap, dstOK := asMap(dst[key]) if srcOK && dstOK && !isLeaf(srcMap) { for k, v := range srcMap { if err := deepMergeMissing(dstMap, k, v); err != nil { return err } } return nil } dst[key] = src return nil }