···
21
21
22
22
"tangled.org/jakelazaroff.com/unpm/internal/cfg"
23
23
"tangled.org/jakelazaroff.com/unpm/internal/imports"
24
24
+
"tangled.org/jakelazaroff.com/unpm/internal/layout"
24
25
)
25
26
26
27
func Check(c *cfg.Config) error {
28
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
46
-
absPath := filepath.Join(c.Unpm.Out, filepath.FromSlash(relPath))
48
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
53
-
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.js")): true,
54
54
-
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.json")): true,
55
55
-
filepath.Clean(filepath.Join(c.Unpm.Out, "jsconfig.json")): true,
55
55
+
l.DiskPath("importmap.js"): true,
56
56
+
l.DiskPath("importmap.json"): true,
57
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
207
-
outDir := c.Unpm.Out
208
208
-
data, err := os.ReadFile(filepath.Join(outDir, "importmap.json"))
209
209
+
l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root}
210
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
220
-
// Convert absolute URL paths (e.g. "/vendor/esm.sh/...") to paths relative to outDir
221
221
-
// by stripping the root URL prefix
222
222
-
root := c.Unpm.Root
223
223
-
if !strings.HasSuffix(root, "/") {
224
224
-
root += "/"
225
225
-
}
222
222
+
// Convert import-map URL paths (e.g. "/vendor/esm.sh/...") back to paths
223
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
228
-
rel := strings.TrimPrefix(urlPath, root)
229
229
-
result[key] = rel
226
226
+
result[key] = l.RelFromWeb(urlPath)
230
227
}
231
228
232
229
return result, nil
···
1
1
+
// This Source Code Form is subject to the terms of the Mozilla Public
2
2
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
+
//
5
5
+
// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/unpm
6
6
+
7
7
+
// Package layout maps remote module URLs to their vendored locations.
8
8
+
//
9
9
+
// Every URL is mirrored under the output directory using its host and path,
10
10
+
// so https://esm.sh/preact@10/index.js becomes esm.sh/preact@10/index.js. The
11
11
+
// same scheme yields disk paths, import-map URLs, jsconfig paths, and the
12
12
+
// browser-relative specifiers used when rewriting imports between vendored
13
13
+
// files. Centralizing it here keeps every path derivation consistent.
14
14
+
package layout
15
15
+
16
16
+
import (
17
17
+
"net/url"
18
18
+
"path"
19
19
+
"path/filepath"
20
20
+
"regexp"
21
21
+
"strings"
22
22
+
)
23
23
+
24
24
+
// Layout describes where vendored files live and how they are served.
25
25
+
type Layout struct {
26
26
+
// Out is the output directory on disk.
27
27
+
Out string
28
28
+
// Root is the URL path prefix under which Out is served, e.g. "/vendor".
29
29
+
Root string
30
30
+
}
31
31
+
32
32
+
// Rel returns the slash-separated path, relative to the output directory, at
33
33
+
// which the resource for u is vendored: its host joined with its path. A query
34
34
+
// string is folded into a suffix (inserted before the file extension, so
35
35
+
// extension-based handling still works) to keep URLs that differ only by their
36
36
+
// query from colliding on disk.
37
37
+
func Rel(u *url.URL) string {
38
38
+
p := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(u.Path, "/")), "/")
39
39
+
if p == "" || p == "." {
40
40
+
p = "index"
41
41
+
}
42
42
+
if u.RawQuery != "" {
43
43
+
ext := path.Ext(p)
44
44
+
p = strings.TrimSuffix(p, ext) + "__" + safeQuery(u.RawQuery) + ext
45
45
+
}
46
46
+
return path.Join(u.Host, p)
47
47
+
}
48
48
+
49
49
+
// DiskPath returns the on-disk location of a slash-relative vendored path.
50
50
+
func (l Layout) DiskPath(rel string) string {
51
51
+
return filepath.Join(l.Out, filepath.FromSlash(rel))
52
52
+
}
53
53
+
54
54
+
// WebPath returns the import-map URL for a slash-relative vendored path, e.g.
55
55
+
// "/vendor/esm.sh/preact@10/index.js".
56
56
+
func (l Layout) WebPath(rel string) string {
57
57
+
return path.Join(l.Root, rel)
58
58
+
}
59
59
+
60
60
+
// RelFromWeb converts an import-map URL produced by WebPath back into its
61
61
+
// slash-relative vendored path by stripping the web root.
62
62
+
func (l Layout) RelFromWeb(webPath string) string {
63
63
+
root := l.Root
64
64
+
if !strings.HasSuffix(root, "/") {
65
65
+
root += "/"
66
66
+
}
67
67
+
return strings.TrimPrefix(webPath, root)
68
68
+
}
69
69
+
70
70
+
// JSConfigPath returns the path used in jsconfig.json's compilerOptions.paths.
71
71
+
// It is relative to the output directory (where jsconfig.json lives) and so
72
72
+
// never includes the web root.
73
73
+
func JSConfigPath(rel string) string {
74
74
+
return "./" + rel
75
75
+
}
76
76
+
77
77
+
// Relative returns the browser-relative specifier that, from the vendored file
78
78
+
// at fromRel, points to the vendored file at toRel (e.g. "./dep.js",
79
79
+
// "../lib/x.js").
80
80
+
func Relative(fromRel, toRel string) string {
81
81
+
rel := relSlash(path.Dir(fromRel), toRel)
82
82
+
if !strings.HasPrefix(rel, ".") {
83
83
+
rel = "./" + rel
84
84
+
}
85
85
+
return rel
86
86
+
}
87
87
+
88
88
+
// relSlash computes a relative slash path from base (a directory) to target.
89
89
+
// Unlike filepath.Rel it always operates on forward slashes, so results don't
90
90
+
// depend on the host operating system.
91
91
+
func relSlash(base, target string) string {
92
92
+
bs := splitPath(base)
93
93
+
ts := splitPath(target)
94
94
+
95
95
+
i := 0
96
96
+
for i < len(bs) && i < len(ts) && bs[i] == ts[i] {
97
97
+
i++
98
98
+
}
99
99
+
100
100
+
var parts []string
101
101
+
for range bs[i:] {
102
102
+
parts = append(parts, "..")
103
103
+
}
104
104
+
parts = append(parts, ts[i:]...)
105
105
+
if len(parts) == 0 {
106
106
+
return "."
107
107
+
}
108
108
+
return strings.Join(parts, "/")
109
109
+
}
110
110
+
111
111
+
func splitPath(p string) []string {
112
112
+
if p == "" || p == "." {
113
113
+
return nil
114
114
+
}
115
115
+
return strings.Split(p, "/")
116
116
+
}
117
117
+
118
118
+
var unsafeQueryChar = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
119
119
+
120
120
+
// safeQuery turns a raw query string into a filesystem-safe suffix.
121
121
+
func safeQuery(raw string) string {
122
122
+
return strings.Trim(unsafeQueryChar.ReplaceAllString(raw, "_"), "_")
123
123
+
}
···
1
1
+
// This Source Code Form is subject to the terms of the Mozilla Public
2
2
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
+
//
5
5
+
// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/unpm
6
6
+
7
7
+
package layout
8
8
+
9
9
+
import (
10
10
+
"net/url"
11
11
+
"testing"
12
12
+
)
13
13
+
14
14
+
func mustParse(t *testing.T, raw string) *url.URL {
15
15
+
t.Helper()
16
16
+
u, err := url.Parse(raw)
17
17
+
if err != nil {
18
18
+
t.Fatalf("parsing %q: %v", raw, err)
19
19
+
}
20
20
+
return u
21
21
+
}
22
22
+
23
23
+
func TestRel(t *testing.T) {
24
24
+
tests := []struct {
25
25
+
name string
26
26
+
url string
27
27
+
want string
28
28
+
}{
29
29
+
{"file with extension", "https://esm.sh/preact@10/index.js", "esm.sh/preact@10/index.js"},
30
30
+
{"nested path", "https://esm.sh/preact@10/es2022/preact.mjs", "esm.sh/preact@10/es2022/preact.mjs"},
31
31
+
{"root path becomes index", "https://example.com/", "example.com/index"},
32
32
+
{"empty path becomes index", "https://example.com", "example.com/index"},
33
33
+
{"extensionless segment kept", "https://esm.sh/preact", "esm.sh/preact"},
34
34
+
{"dot segments cleaned", "https://esm.sh/a/../b/c.js", "esm.sh/b/c.js"},
35
35
+
{"query folded before extension", "https://esm.sh/preact.mjs?bundle", "esm.sh/preact__bundle.mjs"},
36
36
+
{"query sanitized", "https://esm.sh/x.js?target=es2022&dev", "esm.sh/x__target_es2022_dev.js"},
37
37
+
{"query on extensionless segment", "https://esm.sh/preact?bundle", "esm.sh/preact__bundle"},
38
38
+
{"distinct queries do not collide", "https://esm.sh/x.js?a", "esm.sh/x__a.js"},
39
39
+
}
40
40
+
for _, tt := range tests {
41
41
+
t.Run(tt.name, func(t *testing.T) {
42
42
+
if got := 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
+
})
46
46
+
}
47
47
+
}
48
48
+
49
49
+
func TestRelQueryNoCollision(t *testing.T) {
50
50
+
a := Rel(mustParse(t, "https://esm.sh/x.js?dev"))
51
51
+
b := Rel(mustParse(t, "https://esm.sh/x.js?prod"))
52
52
+
if a == b {
53
53
+
t.Fatalf("URLs differing only by query collided: both -> %q", a)
54
54
+
}
55
55
+
}
56
56
+
57
57
+
func TestDiskAndWebPaths(t *testing.T) {
58
58
+
l := Layout{Out: "/project/vendor", Root: "/vendor"}
59
59
+
60
60
+
if got, want := l.DiskPath("esm.sh/preact.mjs"), "/project/vendor/esm.sh/preact.mjs"; got != want {
61
61
+
t.Errorf("DiskPath = %q, want %q", got, want)
62
62
+
}
63
63
+
if got, want := l.WebPath("esm.sh/preact.mjs"), "/vendor/esm.sh/preact.mjs"; got != want {
64
64
+
t.Errorf("WebPath = %q, want %q", got, want)
65
65
+
}
66
66
+
}
67
67
+
68
68
+
func TestRelFromWebRoundTrips(t *testing.T) {
69
69
+
for _, root := range []string{"/vendor", "/vendor/", "/", "/assets/vendor"} {
70
70
+
l := Layout{Root: root}
71
71
+
rel := "esm.sh/preact@10/index.js"
72
72
+
if got := l.RelFromWeb(l.WebPath(rel)); got != rel {
73
73
+
t.Errorf("root %q: RelFromWeb(WebPath(%q)) = %q", root, rel, got)
74
74
+
}
75
75
+
}
76
76
+
}
77
77
+
78
78
+
func TestJSConfigPath(t *testing.T) {
79
79
+
if got, want := JSConfigPath("esm.sh/mylib.d.ts"), "./esm.sh/mylib.d.ts"; got != want {
80
80
+
t.Errorf("JSConfigPath = %q, want %q", got, want)
81
81
+
}
82
82
+
}
83
83
+
84
84
+
func TestRelative(t *testing.T) {
85
85
+
tests := []struct {
86
86
+
name string
87
87
+
from string
88
88
+
to string
89
89
+
want string
90
90
+
}{
91
91
+
{"sibling", "esm.sh/preact@10/es2022/preact.mjs", "esm.sh/preact@10/es2022/hooks.mjs", "./hooks.mjs"},
92
92
+
{"parent", "esm.sh/a/b.js", "esm.sh/c.js", "../c.js"},
93
93
+
{"child", "esm.sh/index.js", "esm.sh/lib/dep.js", "./lib/dep.js"},
94
94
+
{"cross host", "esm.sh/a/b.js", "cdn.example.com/c.js", "../../cdn.example.com/c.js"},
95
95
+
}
96
96
+
for _, tt := range tests {
97
97
+
t.Run(tt.name, func(t *testing.T) {
98
98
+
if got := Relative(tt.from, tt.to); got != tt.want {
99
99
+
t.Errorf("Relative(%q, %q) = %q, want %q", tt.from, tt.to, got, tt.want)
100
100
+
}
101
101
+
})
102
102
+
}
103
103
+
}
···
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
29
+
"tangled.org/jakelazaroff.com/unpm/internal/layout"
29
30
)
30
31
31
32
type vendorer struct {
32
33
config *cfg.Config
34
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
54
-
filename string
55
55
-
path string // path on disk relative to v.config.Unpm.Out (host/dir/filename)
56
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
61
+
62
62
+
// filename returns the base name of the file's vendored path.
63
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
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
119
-
importMap[key] = path.Join(c.Unpm.Root, f.path)
124
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
123
-
typesMap[key] = "./" + f.types.path
128
128
+
typesMap[key] = layout.JSConfigPath(f.types.path)
124
129
} else {
125
125
-
typesMap[key] = "./" + f.path
130
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
248
-
// derive local file path from the original URL's host + path.
249
249
-
// if the URL path has a recognized file extension, the filename is the basename;
250
250
-
// otherwise, treat the whole path as a directory and derive the filename from the response.
251
251
-
var localDir, filename string
252
252
-
if path.Ext(strings.ToLower(u.Path)) != "" {
253
253
-
localDir = path.Join(u.Host, path.Dir(u.Path))
254
254
-
filename = path.Base(u.Path)
255
255
-
} else {
256
256
-
localDir = path.Join(u.Host, u.Path)
257
257
-
258
258
-
// use the final (post-redirect) URL to determine filename
259
259
-
filename = path.Base(resp.Request.URL.Path)
260
260
-
if filename == "" || filename == "/" || filename == "." {
261
261
-
filename = "index.js"
262
262
-
}
263
263
-
264
264
-
if path.Ext(strings.ToLower(filename)) == "" {
265
265
-
filename += ".js"
266
266
-
}
267
267
-
}
268
268
-
269
253
canon := u.String()
270
254
f := &file{
271
271
-
content: body,
272
272
-
filename: filename,
273
273
-
path: path.Join(localDir, filename),
274
274
-
deps: make(map[string]*file),
255
255
+
content: body,
256
256
+
path: layout.Rel(u),
257
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
287
-
if sURL := sidecarURL(u, f.filename); sURL != "" {
270
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
301
-
if !isSourceMap(f.filename) {
284
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
406
-
if isSourceMap(f.filename) {
389
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
421
-
dest := filepath.Join(v.config.Unpm.Out, filepath.FromSlash(rel))
404
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
435
-
// paths relative to f.vendorRel using the vendor locations of f's deps.
418
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
437
-
currentDir := path.Dir(f.path)
438
438
-
439
420
rewrites := make(map[string]string, len(f.deps))
440
421
for spec, dep := range f.deps {
441
441
-
rewrites[spec] = relPath(currentDir, dep.path)
422
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
450
-
result = rewriteSourceMapURL(result, currentDir, f.sourceMap)
431
431
+
result = imports.RewriteSourceMap(result, func(string) string {
432
432
+
return layout.Relative(f.path, f.sourceMap.path)
433
433
+
})
451
434
}
452
435
453
436
return []byte(result)
454
454
-
}
455
455
-
456
456
-
// rewriteSourceMapURL points the //# sourceMappingURL comment at the vendored
457
457
-
// copy of the map, leaving inline data: URLs untouched.
458
458
-
func rewriteSourceMapURL(content, currentDir string, sourceMap *file) string {
459
459
-
return imports.RewriteSourceMap(content, func(string) string {
460
460
-
return relPath(currentDir, sourceMap.path)
461
461
-
})
462
462
-
}
463
463
-
464
464
-
// relPath expresses target relative to fromDir in slash form, prefixing "./" so
465
465
-
// it never reads as a bare specifier.
466
466
-
func relPath(fromDir, target string) string {
467
467
-
rel, _ := filepath.Rel(fromDir, target)
468
468
-
rel = filepath.ToSlash(rel)
469
469
-
if !strings.HasPrefix(rel, ".") {
470
470
-
rel = "./" + rel
471
471
-
}
472
472
-
return rel
473
437
}
474
438
475
439
func writeImportMap(outDir string, rewritten map[string]string, verbose bool) error {