// Package tokenengine turns a Go Bananas!! design-token document into the // artefacts that browsers and applications consume: CSS custom properties, // resolved JSON tokens, and a flat variable map. // // The package is deliberately pure. It performs no I/O, holds no global state // and is safe for concurrent use, which lets the HTTP layer cache rendered // themes aggressively and pre-warm them at start-up. package tokenengine import ( "fmt" "sort" "strings" "github.com/urpi/go-bananas/internal/domain" ) // Kind is a token value type. The set mirrors the W3C Design Tokens Community // Group format so that exported JSON is portable to other token tooling. type Kind string const ( KindColor Kind = "color" KindDimension Kind = "dimension" KindFontFamily Kind = "fontFamily" KindFontSize Kind = "fontSize" KindFontWeight Kind = "fontWeight" KindLineHeight Kind = "lineHeight" KindLetterSpacing Kind = "letterSpacing" KindDuration Kind = "duration" KindCubicBezier Kind = "cubicBezier" KindNumber Kind = "number" KindShadow Kind = "shadow" KindFontStyle Kind = "fontStyle" ) // Valid reports whether k is a known kind. func (k Kind) Valid() bool { switch k { case KindColor, KindDimension, KindFontFamily, KindFontSize, KindFontWeight, KindLineHeight, KindLetterSpacing, KindDuration, KindCubicBezier, KindNumber, KindShadow, KindFontStyle: return true default: return false } } // UnitSuffix is appended to raw numeric values of unit-bearing kinds when // rendering CSS, so a bare `4` in a `space` group becomes `4px`. func (k Kind) UnitSuffix() string { switch k { case KindDimension, KindFontSize, KindLetterSpacing: return "px" case KindDuration: return "ms" default: return "" } } // ValueKey and TypeKey are the DTCG property names. const ( ValueKey = "$value" TypeKey = "$type" // DescKey carries human documentation for a token. DescKey = "$description" // ExtensionsKey holds non-standard metadata that the engine ignores. ExtensionsKey = "$extensions" // SchemaKey identifies the token document format. SchemaKey = "$schema" // AppearancesKey holds per-appearance token overrides. It is the only // structural key that is not itself a token group. AppearancesKey = "$appearances" ) // SchemaID identifies the canonical document format. const SchemaID = "https://urpi.be/schemas/go-bananas/tokens/v1" // VariablePrefix namespaces every custom property this engine emits so that a // consuming application can never collide with go-bananas' own CSS. const VariablePrefix = "--bn" // ThemeAttribute is the data attribute that scopes a theme to a subtree. // Setting it on or is the supported way to apply a theme. const ThemeAttribute = "data-bananas-theme" // AppearanceAttribute selects the colour scheme within a themed subtree. const AppearanceAttribute = "data-bananas-appearance" // darkClass is the conventional class name used by Tailwind and most design // systems for "this subtree is dark". The generated CSS honours it in addition // to the explicit appearance attribute so that consumers do not have to choose. const darkClass = "dark" // GroupSpec describes one top-level token group. type GroupSpec struct { // Name is the group key as it appears in the document. Name string // Kind is the default type applied to leaves that omit "$type". Kind Kind // Freeform allows an arbitrary number of nested levels below the group. Freeform bool // Required is the minimum depth: paths listed here must exist. Required []string // Recommended paths are validated when present but never required. They // drive warnings in the editor rather than hard failures. Recommended []string // Description is shown in the editor UI and emitted as $description. Description string } // paletteShades is the canonical 11-step scale every generated palette defines. var paletteShades = []string{"50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"} // StandardPaletteShades returns a copy of the canonical scale steps. func StandardPaletteShades() []string { out := make([]string, len(paletteShades)) copy(out, paletteShades) return out } // Groups is the canonical group registry, in the order the editor renders them. var Groups = []GroupSpec{ { Name: "color", Kind: KindColor, Freeform: true, Description: "Raw palettes and semantic colour roles. Semantic roles are what components consume.", Recommended: []string{ "palette.brand." + "500", "palette.brand.600", "palette.neutral.500", "palette.neutral.900", "semantic.background", "semantic.surface", "semantic.surface-raised", "semantic.border", "semantic.border-strong", "semantic.text", "semantic.text-muted", "semantic.text-subtle", "semantic.text-inverted", "semantic.primary", "semantic.primary-hover", "semantic.primary-active", "semantic.on-primary", "semantic.secondary", "semantic.accent", "semantic.on-accent", "semantic.success", "semantic.warning", "semantic.danger", "semantic.info", "semantic.overlay", "semantic.focus-ring", }, }, { Name: "font", Kind: KindDimension, Freeform: true, Recommended: []string{"family.sans", "family.mono", "size.sm", "size.md", "size.lg", "size.xl", "weight.normal", "weight.medium", "weight.semibold", "weight.bold", "lineHeight.tight", "lineHeight.normal", "lineHeight.relaxed", "letterSpacing.tight", "letterSpacing.normal", "letterSpacing.wide"}, }, { Name: "space", Kind: KindDimension, Freeform: true, Recommended: []string{"0", "1", "2", "3", "4", "5", "6", "8", "10", "12", "16"}, }, { Name: "radius", Kind: KindDimension, Freeform: true, Recommended: []string{"none", "sm", "md", "lg", "xl", "full"}, }, { Name: "border", Kind: KindDimension, Freeform: true, Recommended: []string{"width.thin", "width.default", "width.thick", "color.default", "color.strong"}, }, { Name: "shadow", Kind: KindShadow, Freeform: true, Recommended: []string{"none", "sm", "md", "lg", "xl"}, }, { Name: "duration", Kind: KindDuration, Freeform: true, Recommended: []string{"instant", "fast", "normal", "slow"}, }, { Name: "ease", Kind: KindCubicBezier, Freeform: true, Recommended: []string{"linear", "standard", "entrance", "exit"}, }, { Name: "zIndex", Kind: KindNumber, Freeform: true, Recommended: []string{"base", "dropdown", "sticky", "overlay", "modal", "toast"}, }, { Name: "opacity", Kind: KindNumber, Freeform: true, Recommended: []string{"disabled", "muted", "strong"}, }, { Name: "breakpoint", Kind: KindDimension, Freeform: true, Recommended: []string{"sm", "md", "lg", "xl", "2xl"}, }, } // ReservedKeys are document-level keys that are not token groups. var ReservedKeys = map[string]struct{}{ SchemaKey: {}, AppearancesKey: {}, DescKey: {}, ExtensionsKey: {}, } // LookupGroup returns the spec for a group name. func LookupGroup(name string) (GroupSpec, bool) { for _, g := range Groups { if g.Name == name { return g, true } } return GroupSpec{}, false } // GroupNames returns the canonical group names in render order. func GroupNames() []string { out := make([]string, len(Groups)) for i, g := range Groups { out[i] = g.Name } return out } // paletteNames returns the palette identifiers defined in a document, sorted so // that generated CSS is byte-stable. func paletteNames(doc map[string]any) []string { colorGroup, ok := asMap(doc["color"]) if !ok { return nil } var names []string for key := range colorGroup { if key == "semantic" { continue } if _, isGroup := asMap(colorGroup[key]); isGroup { names = append(names, key) } } sort.Strings(names) return names } // violation is an internal validation finding. type violation struct { // Path is the dotted token path, e.g. "color.semantic.background". Path string // Severity separates blocking problems from style advice. Severity Severity // Message explains the problem in one sentence. Message string // Hint suggests a concrete fix. Hint string } // Severity classifies a validation finding. type Severity string const ( // SeverityError blocks publication. SeverityError Severity = "error" // SeverityWarning does not block publication but should be addressed. SeverityWarning Severity = "warning" // SeverityInfo is purely advisory. SeverityInfo Severity = "info" ) // Issue is a single validation finding, safe to serialise straight to the UI. type Issue struct { Path string `json:"path"` Severity Severity `json:"severity"` Message string `json:"message"` Hint string `json:"hint,omitempty"` } // ValidationResult aggregates every finding for a document. type ValidationResult struct { // Valid is true when there are no error-severity issues. Valid bool `json:"valid"` // TokenCount is the number of leaf tokens discovered. TokenCount int `json:"tokenCount"` // Issues is ordered by severity then path so output is deterministic. Issues []Issue `json:"issues"` } // Errors returns only the blocking issues. func (v ValidationResult) Errors() []Issue { out := make([]Issue, 0, len(v.Issues)) for _, i := range v.Issues { if i.Severity == SeverityError { out = append(out, i) } } return out } // Warnings returns only the advisory issues. func (v ValidationResult) Warnings() []Issue { out := make([]Issue, 0, len(v.Issues)) for _, i := range v.Issues { if i.Severity == SeverityWarning { out = append(out, i) } } return out } // severityRank orders severities for deterministic sorting. func severityRank(s Severity) int { switch s { case SeverityError: return 0 case SeverityWarning: return 1 default: return 2 } } // asMap extracts a nested object from an untyped token document. // // Both a plain map and a domain.TokenDoc are accepted. The distinction matters: // a type assertion to map[string]any fails for a named map type, so without this // second case every document handed to the engine as a domain.TokenDoc would be // silently reported as "not a group". func asMap(v any) (map[string]any, bool) { switch t := v.(type) { case map[string]any: return t, true case domain.TokenDoc: return map[string]any(t), true default: return nil, false } } // keyAliases maps authored camelCase token keys onto their kebab-case CSS // equivalents. Only keys the DTCG spec spells in camelCase need an entry; the // mapping is an explicit allow-list rather than a generic camelCase splitter so // that a token genuinely named "brandDark" cannot be silently rewritten. var keyAliases = map[string]string{ "lineHeight": "line-height", "fontSize": "font-size", "letterSpacing": "letter-spacing", "fontFamily": "font-family", "fontWeight": "font-weight", "cubicBezier": "cubic-bezier", "borderRadius": "border-radius", "zIndex": "z-index", "surfaceMuted": "surface-muted", "fontStyle": "font-style", "lineScale": "line-scale", "seedVersion": "seed-version", } // normalizeKey applies the alias table to one authored token key. func normalizeKey(key string) string { if alias, ok := keyAliases[key]; ok { return alias } return key } // isLeaf reports whether a node is a token leaf rather than a nested group. func isLeaf(node map[string]any) bool { _, hasValue := node[ValueKey] return hasValue } // cssVarSegment normalises one path segment for use in a custom property name. func cssVarSegment(s string) string { s = normalizeKey(s) var b strings.Builder b.Grow(len(s)) prevDash := false for _, r := range strings.ToLower(s) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) prevDash = false case r == '-' || r == '_' || r == ' ' || r == '.' || r == '/': // Collapse every separator class into a single dash so that // "line Height" and "line-height" cannot produce two variables. if !prevDash && b.Len() > 0 { b.WriteByte('-') prevDash = true } default: if !prevDash && b.Len() > 0 { b.WriteByte('-') prevDash = true } } } return strings.Trim(b.String(), "-") } // VariableName builds the CSS custom property for a dotted token path. func VariableName(path string) string { segments := strings.Split(path, ".") cleaned := make([]string, 0, len(segments)) for _, s := range segments { if c := cssVarSegment(s); c != "" { cleaned = append(cleaned, c) } } if len(cleaned) == 0 { return VariablePrefix } return VariablePrefix + "-" + strings.Join(cleaned, "-") } // selectorSafe converts a token path into something usable inside a CSS // attribute selector value. func selectorSafe(s string) string { return strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': return r default: return '-' } }, s) } // NumberHint returns the canonical shade list, exposed for the editor's palette // builder so the UI and the validator never disagree. func NumberHint() []string { return StandardPaletteShades() } // validateKindValue checks a single leaf value against its kind, returning an // error describing the problem or nil when the value is acceptable. func validateKindValue(kind Kind, value any) error { switch kind { case KindColor: s, ok := value.(string) if !ok { return fmt.Errorf("expected a colour string, got %T", value) } if _, err := ParseColor(s); err != nil { return err } case KindDimension, KindFontSize, KindLetterSpacing: if err := validateCSSLength(value); err != nil { return err } case KindDuration: if err := validateCSSLength(value); err != nil { return err } case KindNumber, KindFontWeight, KindLineHeight: if err := validateNumeric(value); err != nil { return err } case KindCubicBezier: parts, ok := value.([]any) if !ok { return fmt.Errorf("expected a 4-number array, got %T", value) } if len(parts) != 4 { return fmt.Errorf("expected 4 numbers, got %d", len(parts)) } for i, p := range parts { if err := validateNumeric(p); err != nil { return fmt.Errorf("component %d: %w", i, err) } } case KindShadow: if _, ok := value.(string); !ok { return fmt.Errorf("expected a CSS box-shadow string, got %T", value) } case KindFontFamily: if _, ok := value.(string); !ok { return fmt.Errorf("expected a font stack string, got %T", value) } case KindFontStyle: if _, ok := value.(string); !ok { return fmt.Errorf("expected a font style keyword, got %T", value) } default: // Unknown kinds are tolerated so that consumers can introduce their own // token types without the engine needing a release first. } return nil }