Skip to content

Commit 5ae1135

Browse files
committed
checkpoint
1 parent 897bf22 commit 5ae1135

8 files changed

Lines changed: 202 additions & 141 deletions

File tree

src/loader/index.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,12 @@ export class LoaderHost {
5858
/** Parsed JSON values, keyed by URL href. */
5959
#cache = new Map<string, unknown>();
6060

61+
/** Stores the loader system implementation. */
6162
constructor(sys: LoaderSys) {
6263
this.sys = sys;
6364
}
6465

65-
/** Reads and JSON-parses the file at `url`, caching the result by href. */
66+
/** Reads and caches parsed JSON from a URL. */
6667
readJSON<T>(url: URL): T {
6768
const { href } = url;
6869
if (!this.#cache.has(href)) {
@@ -71,18 +72,7 @@ export class LoaderHost {
7172
return this.#cache.get(href) as T;
7273
}
7374

74-
/**
75-
* Loads a DTCG resolver document and returns the merged token tree plus the
76-
* list of external source files that were fetched.
77-
*
78-
* `input` may be:
79-
* - A string path or {@link URL} pointing to a resolver JSON file on disk (or network).
80-
* - An inline {@link Resolver} object — pair with `options.base` so that relative
81-
* `$ref` paths inside it resolve to the right location.
82-
*
83-
* Resolution-order items that reference modifiers are skipped; only `set`
84-
* entries contribute to the merged token tree.
85-
*/
75+
/** Loads a resolver into merged tokens and resolved source URLs. */
8676
load(input: string | URL | Resolver, options?: LoadOptions): LoadResult {
8777
const defaultBase = this.sys.currentDirectory();
8878
let resolver: Resolver;
@@ -126,19 +116,15 @@ export class LoaderHost {
126116
return { tokens: mergeFormats(formats), sources };
127117
}
128118

129-
/** Clears the file cache, forcing the next `load()` call to re-read every file. */
119+
/** Clears the cached parsed JSON files. */
130120
clearCache(): void {
131121
this.#cache.clear();
132122
}
133123
}
134124

135125
// ─── Internal helpers ─────────────────────────────────────────────────────────
136126

137-
/**
138-
* Resolves an item from `resolutionOrder` to a concrete {@link Set} by
139-
* dereferencing any JSON Pointer. Returns `null` for modifier references and
140-
* any `$ref` that does not point to a set.
141-
*/
127+
/** Resolves a resolution-order item to a concrete set or `null`. */
142128
const resolveSet = (item: Resolver["resolutionOrder"][number], resolver: Resolver): Set | null => {
143129
if ("$ref" in item) {
144130
const target = getAtPath(resolver, parsePointer(item.$ref));
@@ -147,16 +133,13 @@ const resolveSet = (item: Resolver["resolutionOrder"][number], resolver: Resolve
147133
return item.type === "set" ? item : null;
148134
};
149135

150-
const isSet = (v: unknown): v is Set => isObject(v) && Array.isArray((v as { sources?: unknown }).sources);
136+
/** Returns `true` when a value looks like a resolver set. */
137+
const isSet = (v: unknown): v is Set => isObject(v) && Array.isArray(v.sources);
151138

139+
/** Returns `true` when a value is a non-array object. */
152140
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
153141

154-
/**
155-
* Converts `base` to a {@link URL}, falling back to `fallback` when `base` is
156-
* absent. String values are resolved against `fallback` so that both absolute
157-
* URLs (`"https://…"`) and absolute file paths (`"/Users/…"`) work correctly
158-
* without requiring Node's `pathToFileURL`.
159-
*/
142+
/** Converts an optional base value into a resolved base URL. */
160143
const toBaseURL = (base: URL | string | undefined, fallback: URL): URL => {
161144
if (base == null) return fallback;
162145
if (base instanceof URL) return base;

src/loader/merge.ts

Lines changed: 125 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,111 @@
11
import 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

3812
type RawObject = Record<string, unknown>;
3913

14+
/** Returns `true` when a value is a plain object. */
4015
const 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. */
4117
const 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.
151205
const 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. */
162208
const 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. */
186227
const 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`. */
205254
const resolveDeep = (value: unknown, root: RawObject): unknown => {
206255
if (isAlias(value)) return resolveAlias(value, root);
207256
if (isRef(value)) return resolveRef(value.$ref, root);

src/loader/node.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,13 @@ import { LoaderHost, type LoaderSys, type LoadOptions, type LoadResult } from ".
2020
* };
2121
*/
2222
export const nodeSys: LoaderSys = {
23+
/** Reads a UTF-8 file from disk. */
2324
readFile: (url) => readFileSync(url, "utf8"),
25+
/** Returns the current working directory as a file URL. */
2426
currentDirectory: () => pathToFileURL(`${process.cwd()}/`),
2527
};
2628

2729
// ─── Convenience export ───────────────────────────────────────────────────────
2830

29-
/**
30-
* One-shot helper that loads and resolves a DTCG resolver document in Node.js.
31-
*
32-
* For repeated loads (e.g. a watch-mode build tool), prefer constructing a
33-
* {@link LoaderHost} directly and reusing it across calls to share the
34-
* internal file-read cache:
35-
*
36-
* ```ts
37-
* import { nodeSys } from "./node.js";
38-
* import { LoaderHost } from "./index.js";
39-
*
40-
* const host = new LoaderHost(nodeSys);
41-
* const result1 = host.load(resolverURL);
42-
* const result2 = host.load(otherResolverURL); // cache is shared
43-
* ```
44-
*/
31+
/** Loads a resolver once using the default Node.js loader system. */
4532
export const load = (input: string | URL | Resolver, options?: LoadOptions): LoadResult => new LoaderHost(nodeSys).load(input, options);

0 commit comments

Comments
 (0)