go-bananas/internal/tokenengine/schema.go

509 lines
15 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
// 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
}