go-bananas/internal/tokenengine/tokens.go
Daniel Noulet 951dd8d26b
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
initial commit
2026-10-07 15:11:35 +02:00

751 lines
21 KiB
Go

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
}