a simpler package manager for no-build websites
0

Configure Feed

Select the types of activity you want to include in your feed.

Add module graph abstraction

+310 -142
+105 -112
internal/inspect/inspect.go
··· 4 4 // 5 5 // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/unpm 6 6 7 - // Package inspect analyzes an already-vendored output tree: it powers the 8 - // `check` and `why` commands by walking the relative-import graph that the 9 - // vendoring engine writes to disk. It reads files and importmap.json; it never 10 - // fetches anything. 7 + // Package inspect powers the `check` and `why` commands. It analyzes an 8 + // already-vendored output tree by walking the module graph that the vendoring 9 + // engine wrote to disk (via internal/modgraph); it never fetches anything. 11 10 package inspect 12 11 13 12 import ( ··· 20 19 "strings" 21 20 22 21 "tangled.org/jakelazaroff.com/unpm/internal/cfg" 23 - "tangled.org/jakelazaroff.com/unpm/internal/imports" 24 22 "tangled.org/jakelazaroff.com/unpm/internal/layout" 23 + "tangled.org/jakelazaroff.com/unpm/internal/modgraph" 25 24 ) 26 25 27 26 func Check(c *cfg.Config) error { 28 27 l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root} 28 + g := modgraph.New(l) 29 29 var errors []string 30 30 31 31 // 1. Error: bare module specifiers in vendored files not in the import map. ··· 34 34 } 35 35 36 36 // 2. Error: import map entries with no corresponding file on disk. 37 - // 3. Warning: files on disk not reachable from any import map entry. 38 37 entryPoints, err := readEntryPoints(c) 39 38 if err != nil { 40 39 return err 41 40 } 42 41 for key := range c.Imports { 43 - relPath, ok := entryPoints[key] 42 + rel, ok := entryPoints[key] 44 43 if !ok { 45 44 errors = append(errors, fmt.Sprintf("%q: not found in importmap.json (run 'unpm vendor')", key)) 46 45 continue 47 46 } 48 - absPath := l.DiskPath(relPath) 49 - if _, err := os.Stat(absPath); os.IsNotExist(err) { 50 - errors = append(errors, fmt.Sprintf("%q: expected file %s not found on disk", key, relPath)) 47 + if !g.Exists(rel) { 48 + errors = append(errors, fmt.Sprintf("%q: expected file %s not found on disk", key, rel)) 51 49 } 52 50 } 53 51 54 - reachable := map[string]bool{ 55 - l.DiskPath("importmap.js"): true, 56 - l.DiskPath("importmap.json"): true, 57 - l.DiskPath("jsconfig.json"): true, 58 - } 59 - for key, relPath := range entryPoints { 60 - if err := walkImports(c.Unpm.Out, relPath, reachable); err != nil { 52 + // 3. Warning: files on disk not reachable from any import map entry. 53 + reachable := map[layout.RelativePath]bool{} 54 + for key, rel := range entryPoints { 55 + if err := markReachable(g, rel, reachable); err != nil { 61 56 fmt.Fprintf(os.Stderr, "\033[33mwarning:\033[0m could not walk imports for %q: %v\n", key, err) 62 57 } 63 58 } 64 - var unreachable []string 65 - filepath.Walk(c.Unpm.Out, func(p string, info os.FileInfo, err error) error { 66 - if err != nil || info.IsDir() { 67 - return nil 68 - } 69 - if !reachable[filepath.Clean(p)] { 70 - rel, _ := filepath.Rel(c.Unpm.Out, p) 71 - unreachable = append(unreachable, filepath.ToSlash(rel)) 59 + var unreachable []layout.RelativePath 60 + g.Walk(func(rel layout.RelativePath) error { 61 + if !isManifest(rel) && !reachable[rel] { 62 + unreachable = append(unreachable, rel) 72 63 } 73 64 return nil 74 65 }) ··· 91 82 return nil 92 83 } 93 84 94 - // FindBareImports walks all vendored JS files and returns bare import specifiers 95 - // (e.g. "preact/hooks") that are not present in the import map. 85 + // markReachable records rel and everything it transitively imports in the 86 + // reachable set. Source maps are reachable through the file that names them but 87 + // are not themselves walked; bare specifiers resolve through the import map 88 + // rather than by location, so they have no on-disk target to follow. 89 + func markReachable(g modgraph.Graph, rel layout.RelativePath, reachable map[layout.RelativePath]bool) error { 90 + if reachable[rel] { 91 + return nil 92 + } 93 + reachable[rel] = true 94 + 95 + imports, err := g.Imports(rel) 96 + if err != nil { 97 + return err 98 + } 99 + for _, imp := range imports { 100 + switch { 101 + case imp.SourceMap: 102 + if imp.Target != "" && g.Exists(imp.Target) { 103 + reachable[imp.Target] = true 104 + } 105 + case imp.Bare || imp.Target == "": 106 + // nothing to follow 107 + default: 108 + if err := markReachable(g, imp.Target, reachable); err != nil { 109 + return err 110 + } 111 + } 112 + } 113 + return nil 114 + } 115 + 116 + // FindBareImports walks the vendored JS files and returns, for each bare import 117 + // specifier (e.g. "preact/hooks") not present in importMap, the files that 118 + // import it. 96 119 func FindBareImports(outDir string, importMap map[string]string) map[string][]string { 120 + g := modgraph.New(layout.Layout{Out: outDir}) 97 121 missing := map[string][]string{} 98 122 99 - filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error { 100 - if err != nil || info.IsDir() { 101 - return nil 102 - } 103 - 104 - ext := strings.ToLower(filepath.Ext(p)) 105 - if ext != ".js" && ext != ".mjs" { 123 + g.Walk(func(rel layout.RelativePath) error { 124 + switch strings.ToLower(path.Ext(string(rel))) { 125 + case ".js", ".mjs": 126 + default: 106 127 return nil 107 128 } 108 - 109 - data, err := os.ReadFile(p) 129 + imports, err := g.Imports(rel) 110 130 if err != nil { 111 131 return nil 112 132 } 113 - 114 - relPath, _ := filepath.Rel(outDir, p) 115 - relPath = filepath.ToSlash(relPath) 116 - 117 - for _, spec := range imports.Scan(string(data)) { 118 - if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") || strings.Contains(spec, "://") { 119 - continue 120 - } 121 - if _, ok := importMap[spec]; !ok { 122 - missing[spec] = append(missing[spec], relPath) 133 + for _, imp := range imports { 134 + if imp.Bare { 135 + if _, ok := importMap[imp.Spec]; !ok { 136 + missing[imp.Spec] = append(missing[imp.Spec], string(rel)) 137 + } 123 138 } 124 139 } 125 140 return nil ··· 129 144 } 130 145 131 146 func Why(c *cfg.Config, target string) error { 132 - // Normalize target to a relative path within outDir 133 - target = filepath.ToSlash(target) 134 - target = strings.TrimPrefix(target, filepath.ToSlash(c.Unpm.Out)+"/") 147 + l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root} 148 + g := modgraph.New(l) 149 + 150 + rel := normalizeTarget(l, target) 135 151 136 152 entryPoints, err := readEntryPoints(c) 137 153 if err != nil { 138 154 return err 139 155 } 140 156 141 - // BFS from each entry point to find the shortest import chain to target 142 - for key, relPath := range entryPoints { 143 - 144 - chain := findImportChain(c.Unpm.Out, relPath, target) 157 + // Find the first import chain from an entry point to the target. 158 + for key, start := range entryPoints { 159 + chain := findImportChain(g, start, rel) 145 160 if chain != nil { 146 - fmt.Printf("%s", key) 161 + fmt.Print(key) 147 162 for _, link := range chain { 148 163 fmt.Printf(" -> %s", link) 149 164 } ··· 152 167 } 153 168 } 154 169 155 - return fmt.Errorf("%s is not imported by any entry point", target) 170 + return fmt.Errorf("%s is not imported by any entry point", rel) 156 171 } 157 172 158 - // findImportChain does a BFS from start to find target, returning the chain of files. 159 - func findImportChain(outDir, start, target string) []string { 173 + // findImportChain does a BFS from start to find target, returning the chain of 174 + // files (beginning with start). It returns nil when target is unreachable. 175 + func findImportChain(g modgraph.Graph, start, target layout.RelativePath) []layout.RelativePath { 160 176 type node struct { 161 - relPath string 162 - chain []string 177 + rel layout.RelativePath 178 + chain []layout.RelativePath 163 179 } 164 180 165 - visited := map[string]bool{} 166 - queue := []node{{relPath: start, chain: []string{start}}} 181 + visited := map[layout.RelativePath]bool{} 182 + queue := []node{{rel: start, chain: []layout.RelativePath{start}}} 167 183 168 184 for len(queue) > 0 { 169 185 cur := queue[0] 170 186 queue = queue[1:] 171 187 172 - if cur.relPath == target { 188 + if cur.rel == target { 173 189 return cur.chain 174 190 } 175 - 176 - absPath := filepath.Clean(filepath.Join(outDir, cur.relPath)) 177 - if visited[absPath] { 191 + if visited[cur.rel] { 178 192 continue 179 193 } 180 - visited[absPath] = true 194 + visited[cur.rel] = true 181 195 182 - data, err := os.ReadFile(absPath) 196 + imports, err := g.Imports(cur.rel) 183 197 if err != nil { 184 198 continue 185 199 } 186 - 187 - for _, spec := range imports.Scan(string(data)) { 188 - if !strings.HasPrefix(spec, ".") { 200 + for _, imp := range imports { 201 + if imp.Bare || imp.SourceMap || imp.Target == "" || visited[imp.Target] { 189 202 continue 190 203 } 191 - depPath := path.Join(path.Dir(cur.relPath), spec) 192 - if !visited[filepath.Clean(filepath.Join(outDir, depPath))] { 193 - queue = append(queue, node{ 194 - relPath: depPath, 195 - chain: append(append([]string{}, cur.chain...), depPath), 196 - }) 197 - } 204 + chain := append(append([]layout.RelativePath{}, cur.chain...), imp.Target) 205 + queue = append(queue, node{rel: imp.Target, chain: chain}) 198 206 } 199 207 } 200 208 201 209 return nil 202 210 } 203 211 212 + // normalizeTarget turns a user-supplied path (absolute, or relative to the 213 + // output directory) into a vendored RelativePath. 214 + func normalizeTarget(l layout.Layout, target string) layout.RelativePath { 215 + target = filepath.ToSlash(target) 216 + target = strings.TrimPrefix(target, filepath.ToSlash(l.Out)+"/") 217 + return layout.RelativePath(target) 218 + } 219 + 204 220 // readEntryPoints reads importmap.json from the vendor directory and returns a 205 - // map of import key -> relative path within outDir. Since vendor rewrites all 206 - // imports to relative paths, walking from these entry points is sufficient to 207 - // reach every vendored file. 208 - func readEntryPoints(c *cfg.Config) (map[string]string, error) { 221 + // map of import key -> vendored RelativePath. Since vendor rewrites all imports 222 + // to relative paths, walking from these entry points reaches every vendored 223 + // file. 224 + func readEntryPoints(c *cfg.Config) (map[string]layout.RelativePath, error) { 209 225 l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root} 210 226 data, err := os.ReadFile(l.DiskPath("importmap.json")) 211 227 if err != nil { ··· 221 237 222 238 // Convert import-map URL paths (e.g. "/vendor/esm.sh/...") back to paths 223 239 // relative to the output directory by stripping the web root. 224 - result := make(map[string]string) 240 + result := make(map[string]layout.RelativePath, len(im.Imports)) 225 241 for key, urlPath := range im.Imports { 226 242 result[key] = l.RelFromWeb(urlPath) 227 243 } ··· 229 245 return result, nil 230 246 } 231 247 232 - // walkImports recursively follows relative imports from a file, adding each to the reachable set. 233 - func walkImports(outDir, relPath string, reachable map[string]bool) error { 234 - absPath := filepath.Clean(filepath.Join(outDir, relPath)) 235 - if reachable[absPath] { 236 - return nil 237 - } 238 - reachable[absPath] = true 239 - 240 - data, err := os.ReadFile(absPath) 241 - if err != nil { 242 - return fmt.Errorf("reading %s: %w", absPath, err) 248 + // isManifest reports whether rel is one of the generated files that is never 249 + // itself an import target. 250 + func isManifest(rel layout.RelativePath) bool { 251 + switch rel { 252 + case "importmap.js", "importmap.json", "jsconfig.json": 253 + return true 243 254 } 244 - 245 - for _, spec := range imports.Scan(string(data)) { 246 - if !strings.HasPrefix(spec, ".") { 247 - continue 248 - } 249 - // Resolve relative to the current file's directory 250 - depPath := path.Join(path.Dir(relPath), spec) 251 - if err := walkImports(outDir, depPath, reachable); err != nil { 252 - return err 253 - } 254 - } 255 - 256 - // Mark source maps referenced by //# sourceMappingURL=... as reachable 257 - for _, mapRel := range imports.ScanSourceMaps(string(data)) { 258 - mapPath := filepath.Clean(filepath.Join(outDir, path.Join(path.Dir(relPath), mapRel))) 259 - reachable[mapPath] = true 260 - } 261 - 262 - return nil 255 + return false 263 256 }
+37 -20
internal/layout/layout.go
··· 21 21 "strings" 22 22 ) 23 23 24 + // RelativePath is the slash-separated path of a vendored file relative to the 25 + // output directory, e.g. "esm.sh/preact@10/index.js". Because it encodes the 26 + // original host and path, it is the canonical identifier for a vendored file 27 + // across the codebase; giving it its own type keeps it from being confused with 28 + // disk paths, web paths, or raw import specifiers, which are all plain strings. 29 + type RelativePath string 30 + 31 + // Dir returns the directory containing r. 32 + func (r RelativePath) Dir() RelativePath { return RelativePath(path.Dir(string(r))) } 33 + 34 + // Base returns the final element of r (its filename). 35 + func (r RelativePath) Base() string { return path.Base(string(r)) } 36 + 37 + // Join extends r with additional slash-separated elements, cleaning the result. 38 + func (r RelativePath) Join(elems ...string) RelativePath { 39 + return RelativePath(path.Join(append([]string{string(r)}, elems...)...)) 40 + } 41 + 24 42 // Layout describes where vendored files live and how they are served. 25 43 type Layout struct { 26 44 // Out is the output directory on disk. ··· 29 47 Root string 30 48 } 31 49 32 - // Rel returns the slash-separated path, relative to the output directory, at 33 - // which the resource for u is vendored: its host joined with its path. A query 34 - // string is folded into a suffix (inserted before the file extension, so 35 - // extension-based handling still works) to keep URLs that differ only by their 36 - // query from colliding on disk. 37 - func Rel(u *url.URL) string { 50 + // Rel returns the RelativePath at which the resource for u is vendored: its 51 + // host joined with its path. A query string is folded into a suffix (inserted 52 + // before the file extension, so extension-based handling still works) to keep 53 + // URLs that differ only by their query from colliding on disk. 54 + func Rel(u *url.URL) RelativePath { 38 55 p := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(u.Path, "/")), "/") 39 56 if p == "" || p == "." { 40 57 p = "index" ··· 43 60 ext := path.Ext(p) 44 61 p = strings.TrimSuffix(p, ext) + "__" + safeQuery(u.RawQuery) + ext 45 62 } 46 - return path.Join(u.Host, p) 63 + return RelativePath(path.Join(u.Host, p)) 47 64 } 48 65 49 - // DiskPath returns the on-disk location of a slash-relative vendored path. 50 - func (l Layout) DiskPath(rel string) string { 51 - return filepath.Join(l.Out, filepath.FromSlash(rel)) 66 + // DiskPath returns the on-disk location of a vendored path. 67 + func (l Layout) DiskPath(rel RelativePath) string { 68 + return filepath.Join(l.Out, filepath.FromSlash(string(rel))) 52 69 } 53 70 54 - // WebPath returns the import-map URL for a slash-relative vendored path, e.g. 71 + // WebPath returns the import-map URL for a vendored path, e.g. 55 72 // "/vendor/esm.sh/preact@10/index.js". 56 - func (l Layout) WebPath(rel string) string { 57 - return path.Join(l.Root, rel) 73 + func (l Layout) WebPath(rel RelativePath) string { 74 + return path.Join(l.Root, string(rel)) 58 75 } 59 76 60 77 // RelFromWeb converts an import-map URL produced by WebPath back into its 61 - // slash-relative vendored path by stripping the web root. 62 - func (l Layout) RelFromWeb(webPath string) string { 78 + // vendored RelativePath by stripping the web root. 79 + func (l Layout) RelFromWeb(webPath string) RelativePath { 63 80 root := l.Root 64 81 if !strings.HasSuffix(root, "/") { 65 82 root += "/" 66 83 } 67 - return strings.TrimPrefix(webPath, root) 84 + return RelativePath(strings.TrimPrefix(webPath, root)) 68 85 } 69 86 70 87 // JSConfigPath returns the path used in jsconfig.json's compilerOptions.paths. 71 88 // It is relative to the output directory (where jsconfig.json lives) and so 72 89 // never includes the web root. 73 - func JSConfigPath(rel string) string { 74 - return "./" + rel 90 + func JSConfigPath(rel RelativePath) string { 91 + return "./" + string(rel) 75 92 } 76 93 77 94 // Relative returns the browser-relative specifier that, from the vendored file 78 95 // at fromRel, points to the vendored file at toRel (e.g. "./dep.js", 79 96 // "../lib/x.js"). 80 - func Relative(fromRel, toRel string) string { 81 - rel := relSlash(path.Dir(fromRel), toRel) 97 + func Relative(fromRel, toRel RelativePath) string { 98 + rel := relSlash(path.Dir(string(fromRel)), string(toRel)) 82 99 if !strings.HasPrefix(rel, ".") { 83 100 rel = "./" + rel 84 101 }
+16 -3
internal/layout/layout_test.go
··· 39 39 } 40 40 for _, tt := range tests { 41 41 t.Run(tt.name, func(t *testing.T) { 42 - if got := Rel(mustParse(t, tt.url)); got != tt.want { 42 + if got := string(Rel(mustParse(t, tt.url))); got != tt.want { 43 43 t.Errorf("Rel(%q) = %q, want %q", tt.url, got, tt.want) 44 44 } 45 45 }) ··· 54 54 } 55 55 } 56 56 57 + func TestRelativePathHelpers(t *testing.T) { 58 + r := RelativePath("esm.sh/preact@10/es2022/preact.mjs") 59 + if got, want := r.Dir(), RelativePath("esm.sh/preact@10/es2022"); got != want { 60 + t.Errorf("Dir() = %q, want %q", got, want) 61 + } 62 + if got, want := r.Base(), "preact.mjs"; got != want { 63 + t.Errorf("Base() = %q, want %q", got, want) 64 + } 65 + if got, want := r.Dir().Join("../hooks.mjs"), RelativePath("esm.sh/preact@10/hooks.mjs"); got != want { 66 + t.Errorf("Dir().Join() = %q, want %q", got, want) 67 + } 68 + } 69 + 57 70 func TestDiskAndWebPaths(t *testing.T) { 58 71 l := Layout{Out: "/project/vendor", Root: "/vendor"} 59 72 ··· 68 81 func TestRelFromWebRoundTrips(t *testing.T) { 69 82 for _, root := range []string{"/vendor", "/vendor/", "/", "/assets/vendor"} { 70 83 l := Layout{Root: root} 71 - rel := "esm.sh/preact@10/index.js" 84 + rel := RelativePath("esm.sh/preact@10/index.js") 72 85 if got := l.RelFromWeb(l.WebPath(rel)); got != rel { 73 86 t.Errorf("root %q: RelFromWeb(WebPath(%q)) = %q", root, rel, got) 74 87 } ··· 95 108 } 96 109 for _, tt := range tests { 97 110 t.Run(tt.name, func(t *testing.T) { 98 - if got := Relative(tt.from, tt.to); got != tt.want { 111 + if got := Relative(RelativePath(tt.from), RelativePath(tt.to)); got != tt.want { 99 112 t.Errorf("Relative(%q, %q) = %q, want %q", tt.from, tt.to, got, tt.want) 100 113 } 101 114 })
+145
internal/modgraph/modgraph.go
··· 1 + // This Source Code Form is subject to the terms of the Mozilla Public 2 + // License, v. 2.0. If a copy of the MPL was not distributed with this 3 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 + // 5 + // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/unpm 6 + 7 + // Package modgraph reads the graph of already-vendored modules from disk. It is 8 + // the shared foundation for the `check` and `why` commands, which both need to 9 + // walk imports starting from the import-map entries. 10 + // 11 + // Vendored files are identified by their layout.RelativePath. Because that path 12 + // encodes the original host and path, references can be resolved with plain 13 + // path arithmetic, without reconstructing URLs. modgraph never fetches anything. 14 + package modgraph 15 + 16 + import ( 17 + "io/fs" 18 + "net/url" 19 + "os" 20 + "path" 21 + "path/filepath" 22 + "strings" 23 + 24 + "tangled.org/jakelazaroff.com/unpm/internal/imports" 25 + "tangled.org/jakelazaroff.com/unpm/internal/layout" 26 + ) 27 + 28 + // Import is a single reference found inside a vendored file. 29 + type Import struct { 30 + // Spec is the specifier exactly as it appears in the source. 31 + Spec string 32 + // Bare is true for bare module specifiers, which are resolved by the 33 + // import map rather than by location. 34 + Bare bool 35 + // SourceMap is true for a //# sourceMappingURL= reference. 36 + SourceMap bool 37 + // Target is the resolved location of the referenced file (empty when Bare, 38 + // or when the reference is an external URL that can't be placed on disk). 39 + Target layout.RelativePath 40 + } 41 + 42 + // Graph reads the vendored module graph rooted at a layout. 43 + type Graph struct { 44 + layout layout.Layout 45 + } 46 + 47 + // New returns a Graph that reads files under l. 48 + func New(l layout.Layout) Graph { 49 + return Graph{layout: l} 50 + } 51 + 52 + // Imports reads the vendored file at rel and returns its references. Files that 53 + // are not JavaScript/TypeScript have no references. 54 + func (g Graph) Imports(rel layout.RelativePath) ([]Import, error) { 55 + if !isJavaScript(rel) { 56 + return nil, nil 57 + } 58 + data, err := os.ReadFile(g.layout.DiskPath(rel)) 59 + if err != nil { 60 + return nil, err 61 + } 62 + src := string(data) 63 + 64 + var imps []Import 65 + for _, spec := range imports.Scan(src) { 66 + if isBare(spec) { 67 + imps = append(imps, Import{Spec: spec, Bare: true}) 68 + } else { 69 + imps = append(imps, Import{Spec: spec, Target: resolve(rel, spec)}) 70 + } 71 + } 72 + for _, spec := range imports.ScanSourceMaps(src) { 73 + imps = append(imps, Import{Spec: spec, SourceMap: true, Target: resolve(rel, spec)}) 74 + } 75 + return imps, nil 76 + } 77 + 78 + // Exists reports whether the vendored file at rel is present on disk. 79 + func (g Graph) Exists(rel layout.RelativePath) bool { 80 + info, err := os.Stat(g.layout.DiskPath(rel)) 81 + return err == nil && !info.IsDir() 82 + } 83 + 84 + // Walk calls fn for every vendored file under the output directory, identified 85 + // by its RelativePath. It is a no-op when the output directory is absent. 86 + func (g Graph) Walk(fn func(rel layout.RelativePath) error) error { 87 + out := g.layout.Out 88 + if _, err := os.Stat(out); os.IsNotExist(err) { 89 + return nil 90 + } 91 + return filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error { 92 + if err != nil || d.IsDir() { 93 + return err 94 + } 95 + rel, err := filepath.Rel(out, p) 96 + if err != nil { 97 + return err 98 + } 99 + return fn(layout.RelativePath(filepath.ToSlash(rel))) 100 + }) 101 + } 102 + 103 + // isBare reports whether spec is a bare module specifier — one resolved by the 104 + // import map rather than by location (not "./", "../", "/", or a full URL). 105 + func isBare(spec string) bool { 106 + return spec != "" && 107 + !strings.HasPrefix(spec, ".") && 108 + !strings.HasPrefix(spec, "/") && 109 + !strings.Contains(spec, "://") 110 + } 111 + 112 + // resolve turns a non-bare specifier found in the file at rel into the location 113 + // it points to: a full URL maps to its own host/path; a root-relative specifier 114 + // maps under the file's host; a relative specifier is joined to the file's 115 + // directory. An empty result means the reference can't be placed on disk. 116 + func resolve(rel layout.RelativePath, spec string) layout.RelativePath { 117 + switch { 118 + case strings.Contains(spec, "://"): 119 + u, err := url.Parse(spec) 120 + if err != nil || u.Host == "" { 121 + return "" 122 + } 123 + return layout.RelativePath(path.Clean(u.Host + u.Path)) 124 + case strings.HasPrefix(spec, "/"): 125 + host := string(rel) 126 + if i := strings.IndexByte(host, '/'); i >= 0 { 127 + host = host[:i] 128 + } 129 + return layout.RelativePath(path.Clean(host + spec)) 130 + default: 131 + return rel.Dir().Join(spec) 132 + } 133 + } 134 + 135 + var jsExts = map[string]bool{ 136 + ".js": true, ".mjs": true, ".cjs": true, ".jsx": true, 137 + ".ts": true, ".tsx": true, ".mts": true, ".cts": true, 138 + } 139 + 140 + // isJavaScript reports whether rel should be scanned for module references. 141 + // Extensionless paths (such as esm.sh entry points) are treated as JavaScript. 142 + func isJavaScript(rel layout.RelativePath) bool { 143 + ext := path.Ext(string(rel)) 144 + return ext == "" || jsExts[ext] 145 + }
+7 -7
internal/vendor/vendor.go
··· 53 53 // file is the cached result of pass 1 for a single URL. 54 54 type file struct { 55 55 content []byte 56 - path string // slash path relative to the output directory (see layout.Rel) 57 - deps map[string]*file // import spec as written in source -> dep file 58 - sourceMap *file // referenced source map file 59 - types *file // the file's types 56 + path layout.RelativePath // location relative to the output directory (see layout.Rel) 57 + deps map[string]*file // import spec as written in source -> dep file 58 + sourceMap *file // referenced source map file 59 + types *file // the file's types 60 60 } 61 61 62 62 // filename returns the base name of the file's vendored path. 63 - func (f *file) filename() string { return path.Base(f.path) } 63 + func (f *file) filename() string { return f.path.Base() } 64 64 65 65 func Vendor(c *cfg.Config) ([]string, error) { 66 66 // clean the output directory, preserving pinned files ··· 394 394 return v.writeFile(canon, f.path, content) 395 395 } 396 396 397 - func (v *vendorer) writeFile(canon, rel string, content []byte) error { 398 - if v.config.IsPinned(rel) { 397 + func (v *vendorer) writeFile(canon string, rel layout.RelativePath, content []byte) error { 398 + if v.config.IsPinned(string(rel)) { 399 399 if v.config.Unpm.Verbose { 400 400 fmt.Printf("%s -> %s (pinned)\n", canon, rel) 401 401 }