509 lines
15 KiB
Go
509 lines
15 KiB
Go
|
|
// 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 <html> or <body> 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
|
||
|
|
}
|