go-bananas/internal/tokenengine/json.go

166 lines
5.4 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
package tokenengine
import (
"sort"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// ResolvedTheme is the JSON payload served to runtime consumers. It carries
// both a flat variable map (what a browser or a design tool wants) and the
// nested resolved document (what an application that maps tokens onto component
// props wants), so consumers never have to choose.
type ResolvedTheme struct {
Theme ThemeRef `json:"theme"`
Tokens domain.TokenDoc `json:"tokens"`
Variable map[string]string `json:"variables"`
Meta domain.ThemeMeta `json:"meta,omitempty"`
}
// ThemeRef is the identity block repeated in every runtime payload.
type ThemeRef struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int64 `json:"version"`
Appearance domain.Appearance `json:"appearance"`
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
UpdatedAt string `json:"updatedAt"`
}
// JSONPayload builds the runtime JSON payload for one appearance.
func JSONPayload(theme *domain.Theme, appearance domain.Appearance) ResolvedTheme {
if appearance == "" {
appearance = theme.DefaultAppearance
}
doc := Resolve(theme, appearance)
return ResolvedTheme{
Theme: ThemeRef{
Slug: theme.Slug,
Name: theme.Name,
Description: theme.Description,
Version: theme.Version,
Appearance: appearance,
DefaultAppearance: theme.DefaultAppearance,
UpdatedAt: theme.UpdatedAt.UTC().Format(domain.TimeFormat),
},
Tokens: resolvedDocument(doc),
Variable: doc.Variables(),
Meta: theme.Meta,
}
}
// resolvedDocument renders the resolved tokens back into nested form, keeping
// the DTCG `$type`/`$value` shape so that the output can be fed straight into
// any DTCG-aware tool.
func resolvedDocument(doc *Doc) domain.TokenDoc {
out := make(domain.TokenDoc, len(Groups)+1)
for _, t := range doc.Tokens {
insertResolved(out, strings.Split(t.Path, "."), t)
}
return out
}
// insertResolved places a token into the nested output document, creating the
// intermediate group objects on the way down.
func insertResolved(root domain.TokenDoc, segments []string, t FlatToken) {
current := root
for _, seg := range segments[:len(segments)-1] {
child, ok := current[seg].(map[string]any)
if !ok {
child = map[string]any{}
current[seg] = child
}
// Re-widen so that the next iteration can index it as a TokenDoc.
current = child
}
leaf := map[string]any{ValueKey: t.Value, TypeKey: string(t.Kind)}
if t.Description != "" {
leaf[DescKey] = t.Description
}
current[segments[len(segments)-1]] = leaf
}
// ExportDocument produces a portable, self-describing token document for a
// theme. Unlike the runtime payload it keeps the appearance overrides intact,
// because the point of an export is that it can be re-imported losslessly.
func ExportDocument(theme *domain.Theme) domain.TokenDoc {
out := domain.TokenDoc{SchemaKey: SchemaID}
merged := make(domain.TokenDoc, len(theme.Tokens)+1)
for k, v := range theme.Tokens {
merged[k] = v
}
if _, ok := merged[SchemaKey]; !ok {
out[SchemaKey] = SchemaID
}
delete(merged, SchemaKey)
for k, v := range merged {
out[k] = v
}
return out
}
// ManifestEntry describes one theme in the runtime manifest: the small payload
// a loader needs in order to build a theme picker without fetching every theme.
type ManifestEntry struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
Version int64 `json:"version"`
Tags []string `json:"tags,omitempty"`
CSS string `json:"css"`
Tokens string `json:"tokens"`
}
// Manifest is the discovery document served from the runtime API.
type Manifest struct {
// APIVersion is the runtime contract version. Consumers should refuse to
// load a manifest whose major version they do not understand.
APIVersion string `json:"apiVersion"`
// Default is the slug applied when a consumer does not choose.
Default string `json:"default,omitempty"`
// BaseURL is the absolute runtime base the entries are relative to.
BaseURL string `json:"baseUrl"`
Themes []ManifestEntry `json:"themes"`
Extra map[string]string `json:"extra,omitempty"`
}
// RuntimeAPIVersion is the current runtime contract version.
const RuntimeAPIVersion = "1"
// Tags extracts theme tags from the opaque meta block. Tags are the one piece
// of meta the engine understands, because the manifest needs them.
func Tags(meta domain.ThemeMeta) []string {
raw, ok := meta["tags"]
if !ok {
return nil
}
switch t := raw.(type) {
case []string:
return t
case []any:
out := make([]string, 0, len(t))
for _, v := range t {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
}
}
sort.Strings(out)
return out
case string:
parts := strings.Split(t, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
sort.Strings(out)
return out
default:
return nil
}
}