|
1 | 1 | import type { Format } from "../types/format.js"; |
2 | 2 |
|
3 | 3 | /** |
4 | | - * Lazily merges an ordered array of DTCG {@link Format} objects into a single |
5 | | - * combined token tree via a {@link Proxy}. Sources are applied left-to-right; |
| 4 | + * Merges an ordered array of DTCG {@link Format} objects into a single |
| 5 | + * combined token tree using lazy getters. Sources are applied left-to-right; |
6 | 6 | * later entries override earlier ones at leaf (non-object) positions, while |
7 | 7 | * nested groups are recursively merged so siblings from different sources are |
8 | | - * preserved. No allocation occurs until a property is actually accessed. |
| 8 | + * preserved. Token aliases (`"{dot.path}"` strings in `$value`) are resolved |
| 9 | + * on access against the root of the merged tree, and `$type` is inherited from |
| 10 | + * ancestor groups when not declared on the token itself. |
9 | 11 | * |
10 | 12 | * @example |
11 | 13 | * mergeFormats([ |
12 | 14 | * { color: { red: { $type: "color", $value: "…" } } }, |
13 | 15 | * { color: { blue: { $type: "color", $value: "…" } } }, |
14 | 16 | * ]) |
15 | | - * // → proxy that lazily produces { color: { red: { … }, blue: { … } } } |
| 17 | + * // → { color: { red: { … }, blue: { … } } } |
16 | 18 | */ |
17 | | -export const mergeFormats = (formats: Format[]): Format => { |
18 | | - const handler: ProxyHandler<object> = { |
19 | | - get(_target, key) { |
20 | | - if (typeof key !== "string") return undefined; |
21 | | - |
22 | | - const subFormats: Format[] = []; |
23 | | - let leafValue: unknown; |
24 | | - let hasLeaf = false; |
25 | | - |
26 | | - for (const format of formats) { |
27 | | - const val = (format as Record<string, unknown>)[key]; |
28 | | - if (val === undefined) continue; |
29 | | - |
30 | | - if (isPlainObject(val)) { |
31 | | - subFormats.push(val as Format); |
32 | | - } else { |
33 | | - // A later leaf (primitive or array) wins; discard accumulated sub-objects. |
34 | | - subFormats.length = 0; |
35 | | - leafValue = val; |
36 | | - hasLeaf = true; |
37 | | - } |
38 | | - } |
39 | | - |
40 | | - if (subFormats.length > 0) return mergeFormats(subFormats); |
41 | | - if (hasLeaf) return leafValue; |
42 | | - return undefined; |
43 | | - }, |
| 19 | +export const mergeFormats = (formats: Format[], root?: RawObject, inheritedType?: string): Format => { |
| 20 | + const merged = Object.create(null) as RawObject; |
| 21 | + const effectiveRoot = root ?? merged; |
| 22 | + |
| 23 | + // ── Collect every key that appears in any source ─────────────────────────── |
| 24 | + const keys = new Set<string>(); |
| 25 | + for (const format of formats) { |
| 26 | + for (const key in format as RawObject) keys.add(key); |
| 27 | + } |
44 | 28 |
|
45 | | - has(_target, key) { |
46 | | - if (typeof key !== "string") return false; |
47 | | - return formats.some((f) => key in (f as object)); |
48 | | - }, |
| 29 | + // Expose inherited $type even if no source in this node defines one. |
| 30 | + if (!keys.has("$type") && inheritedType !== undefined) keys.add("$type"); |
| 31 | + |
| 32 | + // ── Helper: effective $type for this node (own wins over inherited) ──────── |
| 33 | + const getNodeType = (): string | undefined => { |
| 34 | + let ownType: string | undefined; |
| 35 | + for (const format of formats) { |
| 36 | + const t = (format as RawObject).$type; |
| 37 | + if (typeof t === "string") ownType = t; |
| 38 | + } |
| 39 | + return ownType ?? inheritedType; |
| 40 | + }; |
49 | 41 |
|
50 | | - ownKeys() { |
51 | | - const keys = new Set<string>(); |
| 42 | + // ── Define a lazy getter for every key ──────────────────────────────────── |
| 43 | + for (const key of keys) { |
| 44 | + Object.defineProperty(merged, key, { |
| 45 | + get(): unknown { |
| 46 | + // $type: own value, or fall back to the inherited type. |
| 47 | + if (key === "$type") return getNodeType(); |
| 48 | + |
| 49 | + const subFormats: RawObject[] = []; |
| 50 | + let leafValue: unknown; |
| 51 | + let hasLeaf = false; |
| 52 | + |
| 53 | + for (const format of formats) { |
| 54 | + const val = (format as RawObject)[key]; |
| 55 | + if (val === undefined) continue; |
| 56 | + |
| 57 | + if (isPlainObject(val)) { |
| 58 | + subFormats.push(val); |
| 59 | + } else { |
| 60 | + // A later leaf (primitive or array) wins; reset sub-objects. |
| 61 | + subFormats.length = 0; |
| 62 | + leafValue = val; |
| 63 | + hasLeaf = true; |
| 64 | + } |
| 65 | + } |
52 | 66 |
|
53 | | - for (const format of formats) { |
54 | | - for (const key in format) { |
55 | | - keys.add(key); |
| 67 | + if (subFormats.length > 0) { |
| 68 | + // Pass along the current node's effective type so children can inherit it. |
| 69 | + return mergeFormats(subFormats as Format[], effectiveRoot, getNodeType()); |
56 | 70 | } |
57 | | - } |
58 | 71 |
|
59 | | - return [...keys]; |
60 | | - }, |
| 72 | + if (hasLeaf) { |
| 73 | + // Resolve DTCG alias strings in $value lazily against the merged root. |
| 74 | + if (key === "$value" && isAlias(leafValue)) { |
| 75 | + return resolveAlias(leafValue, effectiveRoot); |
| 76 | + } |
| 77 | + return leafValue; |
| 78 | + } |
61 | 79 |
|
62 | | - getOwnPropertyDescriptor(_target, key) { |
63 | | - if (typeof key !== "string") return undefined; |
64 | | - const exists = formats.some((f) => Object.hasOwn(f as object, key)); |
65 | | - if (!exists) return undefined; |
66 | | - return { configurable: true, enumerable: true, writable: false, value: undefined }; |
67 | | - }, |
68 | | - }; |
| 80 | + return undefined; |
| 81 | + }, |
| 82 | + enumerable: true, |
| 83 | + configurable: true, |
| 84 | + }); |
| 85 | + } |
69 | 86 |
|
70 | | - // Each call gets a fresh subclass so its prototype slot is independent. |
71 | | - return NullProxy.from(handler) as unknown as Format; |
| 87 | + return merged as unknown as Format; |
72 | 88 | }; |
73 | 89 |
|
74 | | -// ─── NullProxy ──────────────────────────────────────────────────────────────── |
75 | | - |
76 | | -/** |
77 | | - * A base class whose instances have no default own properties and whose |
78 | | - * prototype chain routes all property access through a caller-supplied |
79 | | - * {@link ProxyHandler}. Callers should subclass and call `.from(handler)` on |
80 | | - * the subclass so each merged view gets an isolated prototype slot. |
81 | | - */ |
| 90 | +// ─── Internal ───────────────────────────────────────────────────────────────── |
82 | 91 |
|
83 | | -class NullProxy { |
84 | | - static { |
85 | | - // @ts-expect-error to fully nullify the prototype |
86 | | - delete NullProxy.prototype.constructor; |
87 | | - } |
| 92 | +type RawObject = Record<string, unknown>; |
88 | 93 |
|
89 | | - static from(handler: ProxyHandler<object>): NullProxy { |
90 | | - return new Proxy(Object.create(null) as object, handler); |
91 | | - // Object.setPrototypeOf(this.prototype, new Proxy(Object.create(null) as object, handler)); |
| 94 | +const isPlainObject = (v: unknown): v is RawObject => |
| 95 | + v !== null && typeof v === "object" && !Array.isArray(v); |
92 | 96 |
|
93 | | - // return new this; |
94 | | - } |
95 | | -} |
| 97 | +const isAlias = (v: unknown): v is string => |
| 98 | + typeof v === "string" && v.startsWith("{") && v.endsWith("}"); |
96 | 99 |
|
97 | | -// ─── Internal ───────────────────────────────────────────────────────────────── |
| 100 | +// Module-level cycle-detection set (safe because JS is single-threaded). |
| 101 | +const resolvingAliases = new Set<string>(); |
98 | 102 |
|
99 | | -const isPlainObject = (value: unknown): value is Record<string, unknown> => |
100 | | - value !== null && typeof value === "object" && !Array.isArray(value); |
| 103 | +/** |
| 104 | + * Resolves a DTCG alias string (e.g. `"{color.blue.800}"`) against the merged |
| 105 | + * root, returning the target token's `$value`. Returns `undefined` on missing |
| 106 | + * paths or detected cycles. |
| 107 | + */ |
| 108 | +const resolveAlias = (alias: string, root: RawObject): unknown => { |
| 109 | + const path = alias.slice(1, -1); // strip { } |
| 110 | + if (resolvingAliases.has(path)) return undefined; // cycle guard |
| 111 | + resolvingAliases.add(path); |
| 112 | + try { |
| 113 | + let node: unknown = root; |
| 114 | + for (const seg of path.split(".")) { |
| 115 | + if (!isPlainObject(node)) return undefined; |
| 116 | + node = node[seg]; |
| 117 | + } |
| 118 | + // Read $value from the target node — this triggers that node's own getter, |
| 119 | + // so chains of aliases resolve automatically. |
| 120 | + return isPlainObject(node) && "$value" in node ? node.$value : undefined; |
| 121 | + } finally { |
| 122 | + resolvingAliases.delete(path); |
| 123 | + } |
| 124 | +}; |
0 commit comments