166 lines
5.4 KiB
Go
166 lines
5.4 KiB
Go
|
|
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
|
||
|
|
}
|
||
|
|
}
|