752 lines
21 KiB
Go
752 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
|
||
|
|
}
|