initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
508
internal/tokenengine/schema.go
Normal file
508
internal/tokenengine/schema.go
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue