11import type { Format } from "../types/format.js" ;
2+ import { parsePointer } from "./pointer.js" ;
23
34// ─── Public API ───────────────────────────────────────────────────────────────
45
5- /**
6- * Merges an ordered array of DTCG {@link Format} objects into a single token
7- * tree with lazy resolution. Sources are applied left-to-right — later entries
8- * override earlier ones at leaf positions while nested groups are recursively
9- * merged so siblings from different sources coexist.
10- *
11- * **Lazy getters** — every property in the returned tree is a self-memoizing
12- * getter. On first access it computes the value (resolving aliases, inheriting
13- * `$type`, unwrapping `$value`), replaces itself with a plain data property,
14- * and is never called again. The result is fully compatible with
15- * `JSON.stringify` and `Object.keys`.
16- *
17- * **Alias resolution** — `"{dot.path}"` strings in `$value` are resolved
18- * against the merged root. Chains resolve naturally because each getter fires
19- * in turn as the tree is walked. Circular references return `undefined`.
20- *
21- * **Type inheritance** — `$type` declared on a group is automatically visible
22- * on every descendant token that does not declare its own `$type`.
23- *
24- * **Unwrapping** — accessing a token path (e.g. `tokens.color.blue`) returns
25- * its resolved value directly, not the `{ $type, $value }` wrapper object.
26- *
27- * @example
28- * mergeFormats([
29- * { color: { red: { $type: "color", $value: "…" } } },
30- * { color: { blue: { $type: "color", $value: "…" } } },
31- * ])
32- * // → { color: { red: "…", blue: "…" } }
33- */
34- export const mergeFormats = ( formats : Format [ ] ) : Format => buildNode ( formats as RawObject [ ] , undefined , undefined ) as unknown as Format ;
6+ /** Merges ordered formats into a single lazily resolved token tree. */
7+ export const mergeFormats = ( formats : Format [ ] ) : Format =>
8+ buildNode ( formats as RawObject [ ] , formats as RawObject [ ] , undefined , undefined , [ ] ) as unknown as Format ;
359
3610// ─── Internal ─────────────────────────────────────────────────────────────────
3711
3812type RawObject = Record < string , unknown > ;
3913
14+ /** Returns `true` when a value is a plain object. */
4015const isPlainObject = ( v : unknown ) : v is RawObject => v !== null && typeof v === "object" && ! Array . isArray ( v ) ;
16+ /** Returns `true` when a value is a DTCG alias string. */
4117const isAlias = ( v : unknown ) : v is string => typeof v === "string" && v . startsWith ( "{" ) && v . endsWith ( "}" ) ;
42- const isRef = ( v : unknown ) : v is { $ref : string } => isPlainObject ( v ) && typeof ( v as RawObject ) . $ref === "string" ;
43-
44- /**
45- * Builds a merged node from `formats`. The `root` parameter is the top-level
46- * merged object shared by all recursive calls (used as the alias resolution
47- * root). On the first call `root` is `undefined`; `buildNode` sets itself as
48- * the root and propagates it downward.
49- */
50- const buildNode = ( formats : RawObject [ ] , root : RawObject | undefined , inheritedType : string | undefined ) : RawObject => {
18+ /** Returns `true` when a value is a JSON reference object. */
19+ const isRef = ( v : unknown ) : v is { $ref : string } => isPlainObject ( v ) && typeof v . $ref === "string" ;
20+ /** Converts a path array into a stable cycle-detection key. */
21+ const pathToId = ( path : readonly string [ ] ) : string => path . join ( "." ) || "#" ;
22+
23+ /** Collects nested object values for a key across merged formats. */
24+ const getSubFormats = ( formats : readonly RawObject [ ] , key : string ) : RawObject [ ] => {
25+ const subFormats : RawObject [ ] = [ ] ;
26+
27+ for ( const format of formats ) {
28+ const value = format [ key ] ;
29+ if ( value === undefined ) continue ;
30+ if ( isPlainObject ( value ) ) {
31+ subFormats . push ( value ) ;
32+ } else {
33+ subFormats . length = 0 ;
34+ }
35+ }
36+
37+ return subFormats ;
38+ } ;
39+
40+ /** Resolves all format objects that exist at a nested path. */
41+ const getFormatsAtPath = ( formats : readonly RawObject [ ] , path : readonly string [ ] ) : RawObject [ ] => {
42+ let currentFormats = formats as RawObject [ ] ;
43+
44+ for ( const segment of path ) {
45+ currentFormats = getSubFormats ( currentFormats , segment ) ;
46+ if ( currentFormats . length === 0 ) return [ ] ;
47+ }
48+
49+ return currentFormats . some ( ( format ) => "$value" in format ) ? [ ] : currentFormats ;
50+ } ;
51+
52+ /** Parses either an alias path or JSON Pointer path into segments. */
53+ const parseReferencePath = ( reference : string ) : string [ ] | undefined => {
54+ if ( isAlias ( reference ) ) return reference . slice ( 1 , - 1 ) . split ( "." ) ;
55+ try {
56+ return parsePointer ( reference ) ;
57+ } catch {
58+ return undefined ;
59+ }
60+ } ;
61+
62+ const resolvingExtends = new Set < string > ( ) ;
63+
64+ /** Expands `$extends` references before a node is merged. */
65+ const expandFormats = ( formats : readonly RawObject [ ] , rootFormats : readonly RawObject [ ] , path : readonly string [ ] ) : RawObject [ ] => {
66+ const pathId = pathToId ( path ) ;
67+ if ( resolvingExtends . has ( pathId ) ) return [ ] ;
68+
69+ resolvingExtends . add ( pathId ) ;
70+ try {
71+ const expandedFormats : RawObject [ ] = [ ] ;
72+
73+ for ( const format of formats ) {
74+ const reference = typeof format . $extends === "string" ? format . $extends : undefined ;
75+ if ( reference ) {
76+ const targetPath = parseReferencePath ( reference ) ;
77+ if ( targetPath ) {
78+ const targetFormats = getFormatsAtPath ( rootFormats , targetPath ) ;
79+ expandedFormats . push ( ...expandFormats ( targetFormats , rootFormats , targetPath ) ) ;
80+ }
81+ }
82+
83+ expandedFormats . push ( format ) ;
84+ }
85+
86+ return expandedFormats ;
87+ } finally {
88+ resolvingExtends . delete ( pathId ) ;
89+ }
90+ } ;
91+
92+ /** Builds a merged lazy node for a specific format path. */
93+ const buildNode = (
94+ formats : RawObject [ ] ,
95+ rootFormats : RawObject [ ] ,
96+ root : RawObject | undefined ,
97+ inheritedType : string | undefined ,
98+ path : string [ ] ,
99+ ) : RawObject => {
51100 const node = Object . create ( null ) as RawObject ;
52101 // At the top level the node itself IS the resolution root; recursive calls
53102 // receive the already-established root so all aliases resolve to the same tree.
54103 const effectiveRoot = root ?? node ;
104+ const effectiveFormats = expandFormats ( formats , rootFormats , path ) ;
55105
56106 // Collect every key that appears across all sources.
57107 const keys = new Set < string > ( ) ;
58- for ( const format of formats ) {
108+ for ( const format of effectiveFormats ) {
59109 for ( const key in format ) keys . add ( key ) ;
60110 }
61111
@@ -64,14 +114,18 @@ const buildNode = (formats: RawObject[], root: RawObject | undefined, inheritedT
64114 // only defined on the parent group.
65115 if ( ! keys . has ( "$type" ) && inheritedType !== undefined ) keys . add ( "$type" ) ;
66116
67- // Lazily compute the effective $type for this node.
68- // The last source to declare a string $type wins; falls back to inherited.
117+ /** Returns the effective `$type` for the current node. */
118+ let nodeTypeResolved = false ;
119+ let nodeType : string | undefined ;
69120 const getNodeType = ( ) : string | undefined => {
70- let ownType : string | undefined ;
71- for ( const format of formats ) {
72- if ( typeof format . $type === "string" ) ownType = format . $type ;
121+ if ( ! nodeTypeResolved ) {
122+ nodeTypeResolved = true ;
123+ for ( const format of effectiveFormats ) {
124+ if ( typeof format . $type === "string" ) nodeType = format . $type ;
125+ }
126+ nodeType ??= inheritedType ;
73127 }
74- return ownType ?? inheritedType ;
128+ return nodeType ;
75129 } ;
76130
77131 // Define a self-memoizing getter for every key.
@@ -84,14 +138,14 @@ const buildNode = (formats: RawObject[], root: RawObject | undefined, inheritedT
84138 let value : unknown = key === "$type" ? getNodeType ( ) : undefined ;
85139
86140 if ( value === undefined && key !== "$type" ) {
87- // Partition sources for this key into sub-objects (groups/tokens)
88- // and leaf values (primitives/arrays) . A later leaf resets any
89- // previously accumulated sub-objects because it fully overrides them .
141+ // Single pass: partition sources for this key into sub-objects
142+ // (groups/tokens) and leaf values. A later leaf resets any
143+ // previously accumulated sub-objects (full override) .
90144 const subFormats : RawObject [ ] = [ ] ;
91145 let leafValue : unknown ;
92146 let hasLeaf = false ;
93147
94- for ( const format of formats ) {
148+ for ( const format of effectiveFormats ) {
95149 const v = format [ key ] ;
96150 if ( v === undefined ) continue ;
97151 if ( isPlainObject ( v ) ) {
@@ -110,7 +164,7 @@ const buildNode = (formats: RawObject[], root: RawObject | undefined, inheritedT
110164 // the value object itself (e.g. a border's width inherits
111165 // "border" from its parent, which is wrong).
112166 const childType = key === "$value" ? undefined : getNodeType ( ) ;
113- const sub = buildNode ( subFormats , effectiveRoot , childType ) ;
167+ const sub = buildNode ( subFormats , rootFormats , effectiveRoot , childType , [ ... path , key ] ) ;
114168
115169 if ( "$value" in sub ) {
116170 // Leaf token: auto-unwrap so callers get the value directly
@@ -150,15 +204,7 @@ const buildNode = (formats: RawObject[], root: RawObject | undefined, inheritedT
150204// never leaves stale entries that would permanently block re-resolution.
151205const resolvingAliases = new Set < string > ( ) ;
152206
153- /**
154- * Resolves a DTCG alias string (e.g. `"{color.blue.800}"`) by walking the
155- * dot-separated path against the merged root. Returns `undefined` when:
156- * - the path does not exist in the tree, or
157- * - a circular reference is detected.
158- *
159- * Chained aliases resolve naturally because each token's getter fires in
160- * turn as we descend — there is no need for explicit multi-hop logic.
161- */
207+ /** Resolves a DTCG alias string against the merged token tree. */
162208const resolveAlias = ( alias : string , root : RawObject ) : unknown => {
163209 const path = alias . slice ( 1 , - 1 ) ; // strip surrounding { }
164210 if ( resolvingAliases . has ( path ) ) return undefined ; // circular reference guard
@@ -177,31 +223,34 @@ const resolveAlias = (alias: string, root: RawObject): unknown => {
177223 }
178224} ;
179225
180- /**
181- * Resolves a JSON Reference string (e.g. `"#/base/alpha/dark/$value/components"`)
182- * by walking the slash-separated path against the merged root. Returns `undefined`
183- * for any missing segment. Silently skips `/$value/` segments that no longer
184- * exist in the merged tree (DTCG document-pointer style vs. unwrapped runtime tree).
185- */
226+ /** Resolves a JSON Pointer reference against the merged token tree. */
186227const resolveRef = ( ref : string , root : RawObject ) : unknown => {
187- if ( ! ref . startsWith ( "#/" ) ) return undefined ;
228+ let path : string [ ] ;
229+ try {
230+ path = parsePointer ( ref ) ;
231+ } catch {
232+ return undefined ;
233+ }
234+
188235 let node : unknown = root ;
189- for ( const seg of ref . slice ( 2 ) . split ( "/" ) ) {
190- if ( ! isPlainObject ( node ) ) return undefined ;
236+ for ( const seg of path ) {
191237 // DTCG $ref paths are often written with "/$value/" as a literal path
192238 // segment. Since the merged tree auto-unwraps $value, that key no longer
193239 // exists at runtime — skip it so the walk lands in the resolved value.
194- if ( seg === "$value" && ! ( seg in node ) ) continue ;
195- node = node [ seg ] ;
240+ if ( isPlainObject ( node ) ) {
241+ if ( seg === "$value" && ! ( seg in node ) ) continue ;
242+ node = node [ seg ] ;
243+ } else if ( Array . isArray ( node ) ) {
244+ const index = Number ( seg ) ;
245+ node = Number . isInteger ( index ) ? node [ index ] : undefined ;
246+ } else {
247+ return undefined ;
248+ }
196249 }
197250 return node ;
198251} ;
199252
200- /**
201- * Recursively resolves DTCG aliases and `$ref` objects wherever they appear
202- * inside a `$value` — including inside arrays and nested composite objects
203- * such as shadows, borders, and gradients.
204- */
253+ /** Recursively resolves aliases and `$ref` objects inside a `$value`. */
205254const resolveDeep = ( value : unknown , root : RawObject ) : unknown => {
206255 if ( isAlias ( value ) ) return resolveAlias ( value , root ) ;
207256 if ( isRef ( value ) ) return resolveRef ( value . $ref , root ) ;
0 commit comments