1- import { readFileSync } from "node:fs" ;
2- import { pathToFileURL } from "node:url" ;
3-
41import type { Format } from "../types/format.js" ;
52import type { Set } from "../types/resolver/set.js" ;
63import 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 */
2222export 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-
3630export 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 */
6155export 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 */
151142const 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