@@ -8,8 +8,11 @@ license that can be found in the LICENSE file.
88package load
99
1010import (
11+ "context"
12+ "errors"
1113 "fmt"
1214 "path/filepath"
15+ "time"
1316
1417 "bennypowers.dev/asimonim/config"
1518 "bennypowers.dev/asimonim/fs"
@@ -20,6 +23,14 @@ import (
2023 "bennypowers.dev/asimonim/token"
2124)
2225
26+ var (
27+ // ErrLocalResolution indicates that local filesystem resolution failed.
28+ ErrLocalResolution = errors .New ("local resolution failed" )
29+
30+ // ErrNetworkFallback indicates that the CDN network fallback also failed.
31+ ErrNetworkFallback = errors .New ("network fallback failed" )
32+ )
33+
2334// Options configures how tokens are loaded.
2435type Options struct {
2536 // Root is the directory for local specifier resolution (required for local/npm: paths).
@@ -39,6 +50,16 @@ type Options struct {
3950 // SchemaVersion overrides auto-detection from file content.
4051 // Takes precedence over config file if set.
4152 SchemaVersion schema.Version
53+
54+ // Fetcher enables opt-in network fallback for npm: specifiers.
55+ // When set, if local resolution fails for an npm: specifier,
56+ // Load will attempt to fetch the content from unpkg.com.
57+ // Nil means no network fallback (default).
58+ Fetcher Fetcher
59+
60+ // FetchTimeout is the maximum time to wait for a network fetch.
61+ // Defaults to DefaultTimeout when zero. Has no effect if Fetcher is nil.
62+ FetchTimeout time.Duration
4263}
4364
4465// Load loads design tokens from a specifier with full resolution.
@@ -48,16 +69,19 @@ type Options struct {
4869// - npm package: "npm:@scope/pkg/tokens.json" (requires node_modules)
4970// - jsr package: "jsr:@scope/pkg/tokens.json" (requires node_modules)
5071//
72+ // When Options.Fetcher is set, npm: specifiers that fail local resolution
73+ // will fall back to fetching from unpkg.com.
74+ //
5175// The loading process:
5276// 1. Optionally loads config from .config/design-tokens.yaml
5377// 2. Applies Options values (they take precedence over config)
54- // 3. Resolves specifier to file content via filesystem
78+ // 3. Resolves specifier to file content via filesystem (with optional CDN fallback)
5579// 4. Detects schema version (if not specified)
5680// 5. Parses tokens
5781// 6. Resolves $extends (v2025.10)
5882// 7. Resolves aliases
5983// 8. Returns *token.Map
60- func Load (spec string , opts Options ) (* token.Map , error ) {
84+ func Load (ctx context. Context , spec string , opts Options ) (* token.Map , error ) {
6185 // Set up filesystem
6286 filesystem := opts .FS
6387 if filesystem == nil {
@@ -97,7 +121,11 @@ func Load(spec string, opts Options) (*token.Map, error) {
97121 }
98122
99123 // Resolve specifier to content
100- content , err := resolveContent (spec , root , filesystem )
124+ fetchTimeout := opts .FetchTimeout
125+ if fetchTimeout == 0 {
126+ fetchTimeout = DefaultTimeout
127+ }
128+ content , err := resolveContent (ctx , spec , root , filesystem , opts .Fetcher , fetchTimeout )
101129 if err != nil {
102130 return nil , fmt .Errorf ("failed to resolve specifier %q: %w" , spec , err )
103131 }
@@ -136,8 +164,10 @@ func Load(spec string, opts Options) (*token.Map, error) {
136164 return token .NewMap (tokens , prefix ), nil
137165}
138166
139- // resolveContent resolves a specifier to file content via filesystem.
140- func resolveContent (spec , root string , filesystem fs.FileSystem ) ([]byte , error ) {
167+ // resolveContent resolves a specifier to file content.
168+ // Tries local resolution first. If that fails and a Fetcher is provided,
169+ // falls back to CDN for npm: specifiers.
170+ func resolveContent (ctx context.Context , spec , root string , filesystem fs.FileSystem , fetcher Fetcher , fetchTimeout time.Duration ) ([]byte , error ) {
141171 // Create resolver chain
142172 res , err := specifier .NewDefaultResolver (filesystem , root )
143173 if err != nil {
@@ -147,7 +177,8 @@ func resolveContent(spec, root string, filesystem fs.FileSystem) ([]byte, error)
147177 // Resolve specifier to path
148178 resolved , err := res .Resolve (spec )
149179 if err != nil {
150- return nil , err
180+ // Local resolution failed — try CDN fallback
181+ return fetchFromCDN (ctx , spec , fetcher , fetchTimeout , err )
151182 }
152183
153184 // Make local paths absolute relative to root
@@ -157,9 +188,36 @@ func resolveContent(spec, root string, filesystem fs.FileSystem) ([]byte, error)
157188 }
158189
159190 // Read file content
160- content , err := filesystem .ReadFile (path )
161- if err != nil {
162- return nil , fmt .Errorf ("failed to read %s: %w" , path , err )
191+ content , readErr := filesystem .ReadFile (path )
192+ if readErr != nil {
193+ // File read failed — try CDN fallback (npm: specifiers only;
194+ // non-npm specifiers return localErr unchanged via CDNURL check)
195+ localErr := fmt .Errorf ("failed to read %s: %w" , path , readErr )
196+ return fetchFromCDN (ctx , spec , fetcher , fetchTimeout , localErr )
197+ }
198+
199+ return content , nil
200+ }
201+
202+ // fetchFromCDN attempts to fetch content from CDN as a fallback.
203+ // Returns the original localErr if no fetcher is provided or the specifier
204+ // has no CDN URL.
205+ func fetchFromCDN (ctx context.Context , spec string , fetcher Fetcher , fetchTimeout time.Duration , localErr error ) ([]byte , error ) {
206+ if fetcher == nil {
207+ return nil , localErr
208+ }
209+
210+ cdnURL , ok := specifier .CDNURL (spec )
211+ if ! ok {
212+ return nil , localErr
213+ }
214+
215+ ctx , cancel := context .WithTimeout (ctx , fetchTimeout )
216+ defer cancel ()
217+
218+ content , fetchErr := fetcher .Fetch (ctx , cdnURL )
219+ if fetchErr != nil {
220+ return nil , fmt .Errorf ("%w (%w), %w: %w" , ErrLocalResolution , localErr , ErrNetworkFallback , fetchErr )
163221 }
164222
165223 return content , nil
0 commit comments