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 } }