Skip to content

Commit ecd3c5c

Browse files
committed
checkpoint
1 parent 6f34089 commit ecd3c5c

5 files changed

Lines changed: 119 additions & 189 deletions

File tree

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
"exports": {
1010
".": "./dist/index.js",
1111
"./loader": "./dist/loader/index.js",
12-
"./tree": "./dist/tree/index.js",
1312
"./types": "./dist/types.js",
1413
"./types/*": "./dist/types/*.js",
1514
"./test/*": "./test/*.ts"

src/loader/merge.ts

Lines changed: 98 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,100 +1,124 @@
11
import type { Format } from "../types/format.js";
22

33
/**
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;
66
* later entries override earlier ones at leaf (non-object) positions, while
77
* 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.
911
*
1012
* @example
1113
* mergeFormats([
1214
* { color: { red: { $type: "color", $value: "…" } } },
1315
* { color: { blue: { $type: "color", $value: "…" } } },
1416
* ])
15-
* // → proxy that lazily produces { color: { red: { … }, blue: { … } } }
17+
* // → { color: { red: { … }, blue: { … } } }
1618
*/
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+
}
4428

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+
};
4941

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

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());
5670
}
57-
}
5871

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

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

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;
7288
};
7389

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 ─────────────────────────────────────────────────────────────────
8291

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>;
8893

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);
9296

93-
// return new this;
94-
}
95-
}
97+
const isAlias = (v: unknown): v is string =>
98+
typeof v === "string" && v.startsWith("{") && v.endsWith("}");
9699

97-
// ─── Internal ─────────────────────────────────────────────────────────────────
100+
// Module-level cycle-detection set (safe because JS is single-threaded).
101+
const resolvingAliases = new Set<string>();
98102

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

src/scratch.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { load } from "./loader/index.js";
2+
3+
const resolverURL = new URL("../src/test/example/design-tokens.resolver.json", import.meta.url);
4+
5+
const { tokens } = load(resolverURL);
6+
7+
// Direct access — no createTree wrapper needed
8+
console.dir(tokens["z-index"], { depth: null });
9+
10+
// Alias resolution: color.primary.light.$value should resolve to a concrete color value
11+
// console.log((tokens as any).color?.primary?.light?.$value);
12+
13+
// $type inheritance: a token without its own $type should show the group's type
14+
// console.log((tokens as any)["border-radius"]?.full?.$type); // → "dimension"

src/tree/index.ts

Lines changed: 0 additions & 98 deletions
This file was deleted.

test/loader.test.ts

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,6 @@ import type { Resolver } from "../src/types/resolver.ts";
99
const exampleDir = new URL("../src/test/example/", import.meta.url);
1010
const resolverURL = new URL("design-tokens.resolver.json", exampleDir);
1111

12-
/** Collects all keys visible via for…in (own + inherited enumerable), including proxy traps. */
13-
const keysOf = (obj: object): string[] => {
14-
const keys: string[] = [];
15-
for (const key in obj) keys.push(key);
16-
return keys;
17-
};
18-
1912
// ─── parsePointer ─────────────────────────────────────────────────────────────
2013

2114
describe("parsePointer", () => {
@@ -62,36 +55,34 @@ describe("getAtPath", () => {
6255
// ─── mergeFormats ─────────────────────────────────────────────────────────────
6356

6457
describe("mergeFormats", () => {
65-
it("returns an empty proxy for an empty array", () => {
66-
// The proxy has no own keys, so it looks identical to {} for key-based equality.
67-
const result = mergeFormats([]);
68-
expect(keysOf(result)).toEqual([]);
58+
it("returns an empty object for an empty array", () => {
59+
expect(Object.keys(mergeFormats([]))).toEqual([]);
6960
});
7061

7162
it("passes a single source through without mutation", () => {
7263
const format = { spacing: { md: { $value: 8 } } } as Format;
73-
expect(mergeFormats([format])).toMatchObject(format);
64+
expect(mergeFormats([format])).toEqual(format);
7465
expect(mergeFormats([format])).not.toBe(format);
7566
});
7667

7768
it("deeply merges sibling groups from different sources", () => {
7869
const a = { color: { red: { $value: "#f00" } } } as Format;
7970
const b = { color: { blue: { $value: "#00f" } } } as Format;
80-
expect(mergeFormats([a, b])).toMatchObject({
71+
expect(mergeFormats([a, b])).toEqual({
8172
color: { red: { $value: "#f00" }, blue: { $value: "#00f" } },
8273
});
8374
});
8475

8576
it("later sources override leaf values", () => {
8677
const a = { spacing: { md: { $value: 8 } } } as Format;
8778
const b = { spacing: { md: { $value: 16 } } } as Format;
88-
expect(mergeFormats([a, b])).toMatchObject({ spacing: { md: { $value: 16 } } });
79+
expect(mergeFormats([a, b])).toEqual({ spacing: { md: { $value: 16 } } });
8980
});
9081

9182
it("replaces arrays rather than merging them", () => {
9283
const a = { font: { family: { $value: ["Arial"] } } } as Format;
9384
const b = { font: { family: { $value: ["Helvetica", "Arial"] } } } as Format;
94-
expect(mergeFormats([a, b])).toMatchObject({
85+
expect(mergeFormats([a, b])).toEqual({
9586
font: { family: { $value: ["Helvetica", "Arial"] } },
9687
});
9788
});
@@ -176,7 +167,7 @@ describe("load", () => {
176167
it("returns identically to new LoaderHost().load()", () => {
177168
const a = load(resolverURL);
178169
const b = new LoaderHost().load(resolverURL);
179-
expect(keysOf(a.tokens).sort()).toEqual(keysOf(b.tokens).sort());
170+
expect(Object.keys(a.tokens).sort()).toEqual(Object.keys(b.tokens).sort());
180171
expect(a.sources.map((s) => s.href)).toEqual(b.sources.map((s) => s.href));
181172
});
182173
});

0 commit comments

Comments
 (0)