Skip to content

Commit 897bf22

Browse files
committed
checkpoint
1 parent fd8cb2f commit 897bf22

11 files changed

Lines changed: 259 additions & 174 deletions

File tree

package.json

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,22 @@
1515
},
1616
"main": "./dist/index.js",
1717
"types": "./dist/index.d.ts",
18-
"files": ["dist"],
18+
"files": [
19+
"dist"
20+
],
1921
"repository": "https://github.com/jsxtools/dtcg-tools",
2022
"bugs": "https://github.com/jsxtools/dtcg-tools/issues",
2123
"publishConfig": {
2224
"access": "public"
2325
},
2426
"sideEffects": false,
25-
"keywords": ["design-tokens", "dtcg", "typescript", "types", "schema"],
27+
"keywords": [
28+
"design-tokens",
29+
"dtcg",
30+
"typescript",
31+
"types",
32+
"schema"
33+
],
2634
"scripts": {
2735
"build": "tsc",
2836
"clean": "rm -rf dist coverage *.tsbuildinfo",

src/loader/index.ts

Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { readFileSync } from "node:fs";
2-
import { pathToFileURL } from "node:url";
3-
41
import type { Format } from "../types/format.js";
52
import type { Set } from "../types/resolver/set.js";
63
import type { Resolver } from "../types/resolver.js";
@@ -10,33 +7,30 @@ import { getAtPath, parsePointer } from "./pointer.js";
107
// ─── Public Types ─────────────────────────────────────────────────────────────
118

129
/**
13-
* The I/O interface consumed by {@link LoaderHost}. Swap this out to run in
10+
* The I/O interface consumed by {@link LoaderHost}. Implement this to run in
1411
* any environment — browser, Deno, test sandbox, etc.
1512
*
13+
* The Node.js implementation (`nodeSys`) lives in `./node.ts` to keep this
14+
* module free of Node-only imports and safe to bundle for the browser.
15+
*
1616
* @example Browser virtual filesystem
1717
* const browserSys: LoaderSys = {
1818
* readFile: (url) => fileMap.get(url.href) ?? (() => { throw new Error(`Not found: ${url}`) })(),
1919
* currentDirectory: () => new URL("./", location.href),
20-
* }
20+
* };
2121
*/
2222
export interface LoaderSys {
2323
/** Reads the contents of the file at `url` and returns it as a UTF-8 string. */
2424
readFile(url: URL): string;
2525

26-
/** Returns the base URL used when no explicit base is provided to {@link LoaderHost.load}. */
26+
/** Returns the base URL used when no explicit `base` is provided to {@link LoaderHost.load}. */
2727
currentDirectory(): URL;
2828
}
2929

30-
/** Default {@link LoaderSys} for Node.js — reads files with `readFileSync`. */
31-
export const nodeSys: LoaderSys = {
32-
readFile: (url) => readFileSync(url, "utf8"),
33-
currentDirectory: () => pathToFileURL(`${process.cwd()}/`),
34-
};
35-
3630
export interface LoadOptions {
3731
/**
38-
* Base URL (or absolute path) used to resolve relative file references inside
39-
* a resolver document. Defaults to {@link LoaderSys.currentDirectory} when omitted.
32+
* Base URL (or string path/URL) used to resolve relative `$ref` paths inside
33+
* the resolver document. Defaults to {@link LoaderSys.currentDirectory} when omitted.
4034
*/
4135
base?: URL | string;
4236
}
@@ -45,67 +39,72 @@ export interface LoadResult {
4539
/** The fully merged DTCG token tree, combining all resolved sources in order. */
4640
tokens: Format;
4741

48-
/** Resolved URLs of every external token file that was fetched. */
42+
/** Resolved URLs of every external token file that was loaded. */
4943
sources: URL[];
5044
}
5145

5246
// ─── LoaderHost ───────────────────────────────────────────────────────────────
5347

5448
/**
55-
* A stateful DTCG loader that caches every JSON file it reads.
56-
* Reuse a single `LoaderHost` instance across multiple `load()` calls to share
57-
* the cache and avoid re-reading the same files.
49+
* A stateful DTCG loader. Provide a {@link LoaderSys} appropriate for your
50+
* runtime environment (e.g. `nodeSys` from `./node.ts` for Node.js).
5851
*
59-
* Pass a custom {@link LoaderSys} to run outside of Node.js.
52+
* Reuse a single instance across multiple `load()` calls to share the internal
53+
* file-read cache and avoid parsing the same JSON files more than once.
6054
*/
6155
export class LoaderHost {
6256
readonly sys: LoaderSys;
6357

6458
/** Parsed JSON values, keyed by URL href. */
6559
#cache = new Map<string, unknown>();
6660

67-
constructor(sys: LoaderSys = nodeSys) {
61+
constructor(sys: LoaderSys) {
6862
this.sys = sys;
6963
}
7064

71-
/** Reads and JSON-parses a file at `url`, caching the result by href. */
65+
/** Reads and JSON-parses the file at `url`, caching the result by href. */
7266
readJSON<T>(url: URL): T {
7367
const { href } = url;
74-
7568
if (!this.#cache.has(href)) {
7669
this.#cache.set(href, JSON.parse(this.sys.readFile(url)));
7770
}
78-
7971
return this.#cache.get(href) as T;
8072
}
8173

8274
/**
83-
* Loads a DTCG resolver and returns the merged token tree plus source metadata.
75+
* Loads a DTCG resolver document and returns the merged token tree plus the
76+
* list of external source files that were fetched.
8477
*
8578
* `input` may be:
86-
* - A file-path string or `URL` pointing to a resolver JSON file.
87-
* - An inline {@link Resolver} object (pair with `options.base` so that
88-
* relative `$ref` paths inside it can be resolved).
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.
8982
*
90-
* Resolution order items that reference modifiers are skipped; only sets are
91-
* merged into the final token tree.
83+
* Resolution-order items that reference modifiers are skipped; only `set`
84+
* entries contribute to the merged token tree.
9285
*/
9386
load(input: string | URL | Resolver, options?: LoadOptions): LoadResult {
9487
const defaultBase = this.sys.currentDirectory();
9588
let resolver: Resolver;
9689
let resolverBase: URL;
9790

9891
if (typeof input === "string" || input instanceof URL) {
99-
const resolverURL = new URL(input.toString(), resolveBase(options?.base, defaultBase));
92+
// Resolve the path/URL relative to the sys's current directory (or the
93+
// caller-supplied base). Using new URL(str, base) handles both absolute
94+
// file paths (e.g. "/Users/…") and relative strings correctly on all
95+
// platforms without requiring Node's pathToFileURL.
96+
const resolverURL = new URL(input.toString(), toBaseURL(options?.base, defaultBase));
10097
resolver = this.readJSON<Resolver>(resolverURL);
101-
// Derive a directory-level base so relative $refs in the document resolve correctly.
98+
// Strip the filename so sibling $refs inside the resolver resolve correctly.
10299
resolverBase = new URL(".", resolverURL);
103100
} else {
104101
resolver = input;
105-
resolverBase = resolveBase(options?.base, defaultBase);
102+
resolverBase = toBaseURL(options?.base, defaultBase);
106103
}
107104

108-
// ── Assemble formats in resolution order ──────────────────────────────
105+
// Walk resolutionOrder, collect Set sources in order.
106+
// Modifier entries are intentionally skipped — they are context-dependent
107+
// and cannot be statically merged into a single flat token tree.
109108
const sources: URL[] = [];
110109
const formats: Format[] = [];
111110

@@ -127,51 +126,41 @@ export class LoaderHost {
127126
return { tokens: mergeFormats(formats), sources };
128127
}
129128

130-
/** Drops all cached reads, forcing subsequent loads to re-read every file. */
129+
/** Clears the file cache, forcing the next `load()` call to re-read every file. */
131130
clearCache(): void {
132131
this.#cache.clear();
133132
}
134133
}
135134

136-
// ─── Convenience export ───────────────────────────────────────────────────────
137-
138-
/**
139-
* One-shot helper that loads and resolves a DTCG resolver document.
140-
* For repeated loads, prefer {@link LoaderHost} to share the internal file cache.
141-
*/
142-
export const load = (input: string | URL | Resolver, options?: LoadOptions): LoadResult =>
143-
new LoaderHost(nodeSys).load(input, options);
144-
145135
// ─── Internal helpers ─────────────────────────────────────────────────────────
146136

147137
/**
148-
* Resolves an item from `resolutionOrder` to a concrete {@link Set}, dereferencing
149-
* JSON Pointers as needed. Returns `null` for modifiers and unresolvable refs.
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.
150141
*/
151142
const resolveSet = (item: Resolver["resolutionOrder"][number], resolver: Resolver): Set | null => {
152143
if ("$ref" in item) {
153-
const resolved = getAtPath(resolver, parsePointer(item.$ref));
154-
return isSet(resolved) ? resolved : null;
144+
const target = getAtPath(resolver, parsePointer(item.$ref));
145+
return isSet(target) ? target : null;
155146
}
156-
157147
return item.type === "set" ? item : null;
158148
};
159149

160-
const isSet = (value: unknown): value is Set =>
161-
isPlainObject(value) && Array.isArray((value as { sources?: unknown }).sources);
150+
const isSet = (v: unknown): v is Set => isObject(v) && Array.isArray((v as { sources?: unknown }).sources);
162151

163-
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
164-
value !== null && typeof value === "object" && !Array.isArray(value);
152+
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
165153

166-
const resolveBase = (base: URL | string | undefined, fallback: URL): URL => {
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+
*/
160+
const toBaseURL = (base: URL | string | undefined, fallback: URL): URL => {
167161
if (base == null) return fallback;
168162
if (base instanceof URL) return base;
169-
170-
try {
171-
return new URL(base);
172-
} catch {
173-
// Treat as a local file-system path.
174-
const path = base.endsWith("/") || base.endsWith("\\") ? base : `${base}/`;
175-
return pathToFileURL(path);
176-
}
163+
// new URL(string, fallback) handles absolute paths (e.g. "/Users/…") correctly
164+
// when fallback is a file:// URL: the path replaces the fallback's path component.
165+
return new URL(base.endsWith("/") ? base : `${base}/`, fallback);
177166
};

0 commit comments

Comments
 (0)