a simpler package manager for no-build websites
0

Configure Feed

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

Add layout abstraction

+259 -72
+11 -14
internal/inspect/inspect.go
··· 21 21 22 22 "tangled.org/jakelazaroff.com/unpm/internal/cfg" 23 23 "tangled.org/jakelazaroff.com/unpm/internal/imports" 24 + "tangled.org/jakelazaroff.com/unpm/internal/layout" 24 25 ) 25 26 26 27 func Check(c *cfg.Config) error { 28 + l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root} 27 29 var errors []string 28 30 29 31 // 1. Error: bare module specifiers in vendored files not in the import map. ··· 43 45 errors = append(errors, fmt.Sprintf("%q: not found in importmap.json (run 'unpm vendor')", key)) 44 46 continue 45 47 } 46 - absPath := filepath.Join(c.Unpm.Out, filepath.FromSlash(relPath)) 48 + absPath := l.DiskPath(relPath) 47 49 if _, err := os.Stat(absPath); os.IsNotExist(err) { 48 50 errors = append(errors, fmt.Sprintf("%q: expected file %s not found on disk", key, relPath)) 49 51 } 50 52 } 51 53 52 54 reachable := map[string]bool{ 53 - filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.js")): true, 54 - filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.json")): true, 55 - filepath.Clean(filepath.Join(c.Unpm.Out, "jsconfig.json")): true, 55 + l.DiskPath("importmap.js"): true, 56 + l.DiskPath("importmap.json"): true, 57 + l.DiskPath("jsconfig.json"): true, 56 58 } 57 59 for key, relPath := range entryPoints { 58 60 if err := walkImports(c.Unpm.Out, relPath, reachable); err != nil { ··· 204 206 // imports to relative paths, walking from these entry points is sufficient to 205 207 // reach every vendored file. 206 208 func readEntryPoints(c *cfg.Config) (map[string]string, error) { 207 - outDir := c.Unpm.Out 208 - data, err := os.ReadFile(filepath.Join(outDir, "importmap.json")) 209 + l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root} 210 + data, err := os.ReadFile(l.DiskPath("importmap.json")) 209 211 if err != nil { 210 212 return nil, fmt.Errorf("reading importmap.json: %w (run 'unpm vendor' first)", err) 211 213 } ··· 217 219 return nil, fmt.Errorf("parsing importmap.json: %w", err) 218 220 } 219 221 220 - // Convert absolute URL paths (e.g. "/vendor/esm.sh/...") to paths relative to outDir 221 - // by stripping the root URL prefix 222 - root := c.Unpm.Root 223 - if !strings.HasSuffix(root, "/") { 224 - root += "/" 225 - } 222 + // Convert import-map URL paths (e.g. "/vendor/esm.sh/...") back to paths 223 + // relative to the output directory by stripping the web root. 226 224 result := make(map[string]string) 227 225 for key, urlPath := range im.Imports { 228 - rel := strings.TrimPrefix(urlPath, root) 229 - result[key] = rel 226 + result[key] = l.RelFromWeb(urlPath) 230 227 } 231 228 232 229 return result, nil
+123
internal/layout/layout.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 layout maps remote module URLs to their vendored locations. 8 + // 9 + // Every URL is mirrored under the output directory using its host and path, 10 + // so https://esm.sh/preact@10/index.js becomes esm.sh/preact@10/index.js. The 11 + // same scheme yields disk paths, import-map URLs, jsconfig paths, and the 12 + // browser-relative specifiers used when rewriting imports between vendored 13 + // files. Centralizing it here keeps every path derivation consistent. 14 + package layout 15 + 16 + import ( 17 + "net/url" 18 + "path" 19 + "path/filepath" 20 + "regexp" 21 + "strings" 22 + ) 23 + 24 + // Layout describes where vendored files live and how they are served. 25 + type Layout struct { 26 + // Out is the output directory on disk. 27 + Out string 28 + // Root is the URL path prefix under which Out is served, e.g. "/vendor". 29 + Root string 30 + } 31 + 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 { 38 + p := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(u.Path, "/")), "/") 39 + if p == "" || p == "." { 40 + p = "index" 41 + } 42 + if u.RawQuery != "" { 43 + ext := path.Ext(p) 44 + p = strings.TrimSuffix(p, ext) + "__" + safeQuery(u.RawQuery) + ext 45 + } 46 + return path.Join(u.Host, p) 47 + } 48 + 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)) 52 + } 53 + 54 + // WebPath returns the import-map URL for a slash-relative vendored path, e.g. 55 + // "/vendor/esm.sh/preact@10/index.js". 56 + func (l Layout) WebPath(rel string) string { 57 + return path.Join(l.Root, rel) 58 + } 59 + 60 + // 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 { 63 + root := l.Root 64 + if !strings.HasSuffix(root, "/") { 65 + root += "/" 66 + } 67 + return strings.TrimPrefix(webPath, root) 68 + } 69 + 70 + // JSConfigPath returns the path used in jsconfig.json's compilerOptions.paths. 71 + // It is relative to the output directory (where jsconfig.json lives) and so 72 + // never includes the web root. 73 + func JSConfigPath(rel string) string { 74 + return "./" + rel 75 + } 76 + 77 + // Relative returns the browser-relative specifier that, from the vendored file 78 + // at fromRel, points to the vendored file at toRel (e.g. "./dep.js", 79 + // "../lib/x.js"). 80 + func Relative(fromRel, toRel string) string { 81 + rel := relSlash(path.Dir(fromRel), toRel) 82 + if !strings.HasPrefix(rel, ".") { 83 + rel = "./" + rel 84 + } 85 + return rel 86 + } 87 + 88 + // relSlash computes a relative slash path from base (a directory) to target. 89 + // Unlike filepath.Rel it always operates on forward slashes, so results don't 90 + // depend on the host operating system. 91 + func relSlash(base, target string) string { 92 + bs := splitPath(base) 93 + ts := splitPath(target) 94 + 95 + i := 0 96 + for i < len(bs) && i < len(ts) && bs[i] == ts[i] { 97 + i++ 98 + } 99 + 100 + var parts []string 101 + for range bs[i:] { 102 + parts = append(parts, "..") 103 + } 104 + parts = append(parts, ts[i:]...) 105 + if len(parts) == 0 { 106 + return "." 107 + } 108 + return strings.Join(parts, "/") 109 + } 110 + 111 + func splitPath(p string) []string { 112 + if p == "" || p == "." { 113 + return nil 114 + } 115 + return strings.Split(p, "/") 116 + } 117 + 118 + var unsafeQueryChar = regexp.MustCompile(`[^A-Za-z0-9._-]+`) 119 + 120 + // safeQuery turns a raw query string into a filesystem-safe suffix. 121 + func safeQuery(raw string) string { 122 + return strings.Trim(unsafeQueryChar.ReplaceAllString(raw, "_"), "_") 123 + }
+103
internal/layout/layout_test.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 layout 8 + 9 + import ( 10 + "net/url" 11 + "testing" 12 + ) 13 + 14 + func mustParse(t *testing.T, raw string) *url.URL { 15 + t.Helper() 16 + u, err := url.Parse(raw) 17 + if err != nil { 18 + t.Fatalf("parsing %q: %v", raw, err) 19 + } 20 + return u 21 + } 22 + 23 + func TestRel(t *testing.T) { 24 + tests := []struct { 25 + name string 26 + url string 27 + want string 28 + }{ 29 + {"file with extension", "https://esm.sh/preact@10/index.js", "esm.sh/preact@10/index.js"}, 30 + {"nested path", "https://esm.sh/preact@10/es2022/preact.mjs", "esm.sh/preact@10/es2022/preact.mjs"}, 31 + {"root path becomes index", "https://example.com/", "example.com/index"}, 32 + {"empty path becomes index", "https://example.com", "example.com/index"}, 33 + {"extensionless segment kept", "https://esm.sh/preact", "esm.sh/preact"}, 34 + {"dot segments cleaned", "https://esm.sh/a/../b/c.js", "esm.sh/b/c.js"}, 35 + {"query folded before extension", "https://esm.sh/preact.mjs?bundle", "esm.sh/preact__bundle.mjs"}, 36 + {"query sanitized", "https://esm.sh/x.js?target=es2022&dev", "esm.sh/x__target_es2022_dev.js"}, 37 + {"query on extensionless segment", "https://esm.sh/preact?bundle", "esm.sh/preact__bundle"}, 38 + {"distinct queries do not collide", "https://esm.sh/x.js?a", "esm.sh/x__a.js"}, 39 + } 40 + for _, tt := range tests { 41 + t.Run(tt.name, func(t *testing.T) { 42 + if got := Rel(mustParse(t, tt.url)); got != tt.want { 43 + t.Errorf("Rel(%q) = %q, want %q", tt.url, got, tt.want) 44 + } 45 + }) 46 + } 47 + } 48 + 49 + func TestRelQueryNoCollision(t *testing.T) { 50 + a := Rel(mustParse(t, "https://esm.sh/x.js?dev")) 51 + b := Rel(mustParse(t, "https://esm.sh/x.js?prod")) 52 + if a == b { 53 + t.Fatalf("URLs differing only by query collided: both -> %q", a) 54 + } 55 + } 56 + 57 + func TestDiskAndWebPaths(t *testing.T) { 58 + l := Layout{Out: "/project/vendor", Root: "/vendor"} 59 + 60 + if got, want := l.DiskPath("esm.sh/preact.mjs"), "/project/vendor/esm.sh/preact.mjs"; got != want { 61 + t.Errorf("DiskPath = %q, want %q", got, want) 62 + } 63 + if got, want := l.WebPath("esm.sh/preact.mjs"), "/vendor/esm.sh/preact.mjs"; got != want { 64 + t.Errorf("WebPath = %q, want %q", got, want) 65 + } 66 + } 67 + 68 + func TestRelFromWebRoundTrips(t *testing.T) { 69 + for _, root := range []string{"/vendor", "/vendor/", "/", "/assets/vendor"} { 70 + l := Layout{Root: root} 71 + rel := "esm.sh/preact@10/index.js" 72 + if got := l.RelFromWeb(l.WebPath(rel)); got != rel { 73 + t.Errorf("root %q: RelFromWeb(WebPath(%q)) = %q", root, rel, got) 74 + } 75 + } 76 + } 77 + 78 + func TestJSConfigPath(t *testing.T) { 79 + if got, want := JSConfigPath("esm.sh/mylib.d.ts"), "./esm.sh/mylib.d.ts"; got != want { 80 + t.Errorf("JSConfigPath = %q, want %q", got, want) 81 + } 82 + } 83 + 84 + func TestRelative(t *testing.T) { 85 + tests := []struct { 86 + name string 87 + from string 88 + to string 89 + want string 90 + }{ 91 + {"sibling", "esm.sh/preact@10/es2022/preact.mjs", "esm.sh/preact@10/es2022/hooks.mjs", "./hooks.mjs"}, 92 + {"parent", "esm.sh/a/b.js", "esm.sh/c.js", "../c.js"}, 93 + {"child", "esm.sh/index.js", "esm.sh/lib/dep.js", "./lib/dep.js"}, 94 + {"cross host", "esm.sh/a/b.js", "cdn.example.com/c.js", "../../cdn.example.com/c.js"}, 95 + } 96 + for _, tt := range tests { 97 + t.Run(tt.name, func(t *testing.T) { 98 + if got := Relative(tt.from, tt.to); got != tt.want { 99 + t.Errorf("Relative(%q, %q) = %q, want %q", tt.from, tt.to, got, tt.want) 100 + } 101 + }) 102 + } 103 + }
+22 -58
internal/vendor/vendor.go
··· 26 26 "tangled.org/jakelazaroff.com/unpm/internal/cfg" 27 27 "tangled.org/jakelazaroff.com/unpm/internal/imports" 28 28 "tangled.org/jakelazaroff.com/unpm/internal/inspect" 29 + "tangled.org/jakelazaroff.com/unpm/internal/layout" 29 30 ) 30 31 31 32 type vendorer struct { 32 33 config *cfg.Config 34 + l layout.Layout 33 35 files map[string]*file // canonical URL -> fetched file 34 36 aliases map[string]*file // requested URL -> fetched file 35 37 misses map[string]error // requested URL -> error, to skip retrying known-bad URLs ··· 51 53 // file is the cached result of pass 1 for a single URL. 52 54 type file struct { 53 55 content []byte 54 - filename string 55 - path string // path on disk relative to v.config.Unpm.Out (host/dir/filename) 56 + path string // slash path relative to the output directory (see layout.Rel) 56 57 deps map[string]*file // import spec as written in source -> dep file 57 58 sourceMap *file // referenced source map file 58 59 types *file // the file's types 59 60 } 61 + 62 + // filename returns the base name of the file's vendored path. 63 + func (f *file) filename() string { return path.Base(f.path) } 60 64 61 65 func Vendor(c *cfg.Config) ([]string, error) { 62 66 // clean the output directory, preserving pinned files ··· 66 70 67 71 v := &vendorer{ 68 72 config: c, 73 + l: layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root}, 69 74 files: make(map[string]*file), 70 75 aliases: make(map[string]*file), 71 76 misses: make(map[string]error), ··· 116 121 if f == nil { 117 122 continue 118 123 } 119 - importMap[key] = path.Join(c.Unpm.Root, f.path) 124 + importMap[key] = v.l.WebPath(f.path) 120 125 121 126 // types: explicit config entry / x-typescript-types header / sidecar .d.ts > entry file itself 122 127 if f.types != nil && f.types != f { 123 - typesMap[key] = "./" + f.types.path 128 + typesMap[key] = layout.JSConfigPath(f.types.path) 124 129 } else { 125 - typesMap[key] = "./" + f.path 130 + typesMap[key] = layout.JSConfigPath(f.path) 126 131 } 127 132 } 128 133 ··· 245 250 return nil, fmt.Errorf("reading %s: %w", rawURL, err) 246 251 } 247 252 248 - // derive local file path from the original URL's host + path. 249 - // if the URL path has a recognized file extension, the filename is the basename; 250 - // otherwise, treat the whole path as a directory and derive the filename from the response. 251 - var localDir, filename string 252 - if path.Ext(strings.ToLower(u.Path)) != "" { 253 - localDir = path.Join(u.Host, path.Dir(u.Path)) 254 - filename = path.Base(u.Path) 255 - } else { 256 - localDir = path.Join(u.Host, u.Path) 257 - 258 - // use the final (post-redirect) URL to determine filename 259 - filename = path.Base(resp.Request.URL.Path) 260 - if filename == "" || filename == "/" || filename == "." { 261 - filename = "index.js" 262 - } 263 - 264 - if path.Ext(strings.ToLower(filename)) == "" { 265 - filename += ".js" 266 - } 267 - } 268 - 269 253 canon := u.String() 270 254 f := &file{ 271 - content: body, 272 - filename: filename, 273 - path: path.Join(localDir, filename), 274 - deps: make(map[string]*file), 255 + content: body, 256 + path: layout.Rel(u), 257 + deps: make(map[string]*file), 275 258 } 276 259 v.files[canon] = f 277 260 v.aliases[rawURL] = f ··· 284 267 // the same URL with the extension replaced (e.g. foo.mjs -> foo.d.ts). 404s 285 268 // are expected and silent. 286 269 if f.types == nil { 287 - if sURL := sidecarURL(u, f.filename); sURL != "" { 270 + if sURL := sidecarURL(u, f.filename()); sURL != "" { 288 271 if sf, err := v.fetch(sURL); err == nil { 289 272 f.types = sf 290 273 } ··· 298 281 // source map — fetching each and recording the edges on f. Source maps are 299 282 // skipped; all other files use regex-based import scanning. 300 283 func (v *vendorer) discover(u *url.URL, f *file) { 301 - if !isSourceMap(f.filename) { 284 + if !isSourceMap(f.filename()) { 302 285 v.discoverRegex(u, f) 303 286 } 304 287 v.discoverSourceMap(u, f) ··· 403 386 } 404 387 405 388 var content []byte 406 - if isSourceMap(f.filename) { 389 + if isSourceMap(f.filename()) { 407 390 content = f.content 408 391 } else { 409 392 content = v.rewrite(f) ··· 418 401 } 419 402 return nil 420 403 } 421 - dest := filepath.Join(v.config.Unpm.Out, filepath.FromSlash(rel)) 404 + dest := v.l.DiskPath(rel) 422 405 if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { 423 406 return fmt.Errorf("creating directory %s: %w", filepath.Dir(dest), err) 424 407 } ··· 432 415 } 433 416 434 417 // rewrite substitutes import specs and the sourceMappingURL in f.content with 435 - // paths relative to f.vendorRel using the vendor locations of f's deps. 418 + // paths relative to f's vendored location using the vendor locations of f's deps. 436 419 func (v *vendorer) rewrite(f *file) []byte { 437 - currentDir := path.Dir(f.path) 438 - 439 420 rewrites := make(map[string]string, len(f.deps)) 440 421 for spec, dep := range f.deps { 441 - rewrites[spec] = relPath(currentDir, dep.path) 422 + rewrites[spec] = layout.Relative(f.path, dep.path) 442 423 } 443 424 444 425 result := imports.Rewrite(string(f.content), func(spec string) (string, bool) { ··· 447 428 }) 448 429 449 430 if f.sourceMap != nil { 450 - result = rewriteSourceMapURL(result, currentDir, f.sourceMap) 431 + result = imports.RewriteSourceMap(result, func(string) string { 432 + return layout.Relative(f.path, f.sourceMap.path) 433 + }) 451 434 } 452 435 453 436 return []byte(result) 454 - } 455 - 456 - // rewriteSourceMapURL points the //# sourceMappingURL comment at the vendored 457 - // copy of the map, leaving inline data: URLs untouched. 458 - func rewriteSourceMapURL(content, currentDir string, sourceMap *file) string { 459 - return imports.RewriteSourceMap(content, func(string) string { 460 - return relPath(currentDir, sourceMap.path) 461 - }) 462 - } 463 - 464 - // relPath expresses target relative to fromDir in slash form, prefixing "./" so 465 - // it never reads as a bare specifier. 466 - func relPath(fromDir, target string) string { 467 - rel, _ := filepath.Rel(fromDir, target) 468 - rel = filepath.ToSlash(rel) 469 - if !strings.HasPrefix(rel, ".") { 470 - rel = "./" + rel 471 - } 472 - return rel 473 437 } 474 438 475 439 func writeImportMap(outDir string, rewritten map[string]string, verbose bool) error {