···
786
786
}
787
787
}
788
788
789
789
+
func TestBinary_FailedVendorDoesNotCleanOldFiles(t *testing.T) {
790
790
+
srv := newTestServer(map[string]testFile{})
791
791
+
defer srv.Close()
792
792
+
793
793
+
dir := t.TempDir()
794
794
+
vendorDir := filepath.Join(dir, "vendor")
795
795
+
staleDir := filepath.Join(vendorDir, "old-host.com")
796
796
+
staleFile := filepath.Join(staleDir, "stale.js")
797
797
+
os.MkdirAll(staleDir, 0o755)
798
798
+
os.WriteFile(staleFile, []byte(`export const stale = 1;`), 0o644)
799
799
+
800
800
+
configPath := writeConfig(t, dir, map[string]any{
801
801
+
"imports": map[string]string{"missing": srv.URL + "/missing.js"},
802
802
+
})
803
803
+
804
804
+
_, _, code := runCmd(binary, "vendor", "--config", configPath)
805
805
+
if code == 0 {
806
806
+
t.Fatal("expected vendor to fail")
807
807
+
}
808
808
+
if _, err := os.Stat(staleFile); err != nil {
809
809
+
t.Fatalf("stale file should be preserved after failed vendor: %v", err)
810
810
+
}
811
811
+
}
812
812
+
789
813
// --- check and why tests ---
790
814
791
815
func setupCheckDir(t *testing.T) (dir, vendorDir string) {
···
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 vendor
8
8
+
9
9
+
import (
10
10
+
"errors"
11
11
+
"fmt"
12
12
+
"io"
13
13
+
"mime"
14
14
+
"net/http"
15
15
+
"net/url"
16
16
+
"path"
17
17
+
"slices"
18
18
+
"strings"
19
19
+
20
20
+
"tangled.org/jakelazaroff.com/unpm/internal/imports"
21
21
+
"tangled.org/jakelazaroff.com/unpm/internal/layout"
22
22
+
)
23
23
+
24
24
+
type crawler struct {
25
25
+
files map[string]*file // canonical URL -> fetched file
26
26
+
aliases map[string]*file // requested URL -> fetched file
27
27
+
misses map[string]error // requested URL -> error, to skip retrying known-bad URLs
28
28
+
warnings []string
29
29
+
}
30
30
+
31
31
+
func newCrawler() *crawler {
32
32
+
return &crawler{
33
33
+
files: make(map[string]*file),
34
34
+
aliases: make(map[string]*file),
35
35
+
misses: make(map[string]error),
36
36
+
}
37
37
+
}
38
38
+
39
39
+
// errSkip signals that a URL was reachable but served content we don't vendor
40
40
+
// (typically text/html from a wrong URL). Callers should treat it as a soft
41
41
+
// skip rather than a hard failure.
42
42
+
var errSkip = errors.New("unsupported response")
43
43
+
44
44
+
var knownContentTypes = map[string][]string{
45
45
+
".js": {"text/javascript", "application/javascript"},
46
46
+
".mjs": {"text/javascript", "application/javascript"},
47
47
+
".cjs": {"text/javascript", "application/javascript"},
48
48
+
".map": {"application/json"},
49
49
+
}
50
50
+
51
51
+
// file is the cached result of pass 1 for a single URL.
52
52
+
type file struct {
53
53
+
content []byte
54
54
+
path layout.RelativePath // location relative to the output directory (see layout.Rel)
55
55
+
deps map[string]*file // import spec as written in source -> dep file
56
56
+
sourceMap *file // referenced source map file
57
57
+
types *file // the file's types
58
58
+
}
59
59
+
60
60
+
// filename returns the base name of the file's vendored path.
61
61
+
func (f *file) filename() string { return f.path.Base() }
62
62
+
63
63
+
// fetch downloads a URL, follows x-esm-path shims, caches the bytes in memory,
64
64
+
// and recursively enumerates the URLs referenced by the file. It returns the
65
65
+
// fetched file.
66
66
+
func (c *crawler) fetch(rawURL string) (*file, error) {
67
67
+
var err error
68
68
+
69
69
+
if f, ok := c.aliases[rawURL]; ok {
70
70
+
return f, nil
71
71
+
}
72
72
+
if cached, ok := c.misses[rawURL]; ok {
73
73
+
return nil, cached
74
74
+
}
75
75
+
defer func() {
76
76
+
if err != nil {
77
77
+
c.misses[rawURL] = err
78
78
+
}
79
79
+
}()
80
80
+
81
81
+
u, err := url.Parse(rawURL)
82
82
+
if err != nil {
83
83
+
return nil, fmt.Errorf("parsing URL %s: %w", rawURL, err)
84
84
+
}
85
85
+
86
86
+
resp, err := http.Get(rawURL)
87
87
+
if err != nil {
88
88
+
return nil, fmt.Errorf("fetching %s: %w", rawURL, err)
89
89
+
}
90
90
+
defer resp.Body.Close()
91
91
+
92
92
+
if resp.StatusCode != http.StatusOK {
93
93
+
return nil, fmt.Errorf("fetching %s: status %d", rawURL, resp.StatusCode)
94
94
+
}
95
95
+
96
96
+
// warn if the content-type header does not match the file extension
97
97
+
// (e.g. a .js file served as text/html)
98
98
+
ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
99
99
+
if strings.HasSuffix(strings.ToLower(u.Path), ".d.ts") {
100
100
+
if ct != "text/plain" && ct != "application/typescript" {
101
101
+
c.warnings = append(c.warnings, fmt.Sprintf("%s: content-type %q does not match extension", rawURL, ct))
102
102
+
}
103
103
+
} else if expected, ok := knownContentTypes[strings.ToLower(path.Ext(u.Path))]; ok && !slices.Contains(expected, ct) {
104
104
+
c.warnings = append(c.warnings, fmt.Sprintf("%s: content-type %q does not match extension", rawURL, ct))
105
105
+
}
106
106
+
107
107
+
// esm.sh returns x-esm-path with the canonical resolved path;
108
108
+
// skip the shim and fetch the resolved module directly
109
109
+
if esmPath := resp.Header.Get("x-esm-path"); esmPath != "" {
110
110
+
canonURL := u.Scheme + "://" + u.Host + esmPath
111
111
+
f, err := c.fetch(canonURL)
112
112
+
if err != nil {
113
113
+
return nil, fmt.Errorf("downloading canonical path for %s: %w", rawURL, err)
114
114
+
}
115
115
+
c.aliases[rawURL] = f
116
116
+
117
117
+
// x-typescript-types is on this response, not the canonical one
118
118
+
c.attachTypesHeader(u, resp, rawURL, f)
119
119
+
120
120
+
return f, nil
121
121
+
}
122
122
+
123
123
+
body, err := io.ReadAll(resp.Body)
124
124
+
if err != nil {
125
125
+
return nil, fmt.Errorf("reading %s: %w", rawURL, err)
126
126
+
}
127
127
+
128
128
+
canon := u.String()
129
129
+
f := &file{
130
130
+
content: body,
131
131
+
path: layout.Rel(u),
132
132
+
deps: make(map[string]*file),
133
133
+
}
134
134
+
c.files[canon] = f
135
135
+
c.aliases[rawURL] = f
136
136
+
137
137
+
c.discover(u, f)
138
138
+
139
139
+
c.attachTypesHeader(u, resp, rawURL, f)
140
140
+
141
141
+
// if the response didn't already point us at types, try a "sidecar" .d.ts at
142
142
+
// the same URL with the extension replaced (e.g. foo.mjs -> foo.d.ts). 404s
143
143
+
// are expected and silent.
144
144
+
if f.types == nil {
145
145
+
if sURL := sidecarURL(u, f.filename()); sURL != "" {
146
146
+
if sf, err := c.fetch(sURL); err == nil {
147
147
+
f.types = sf
148
148
+
}
149
149
+
}
150
150
+
}
151
151
+
152
152
+
return f, nil
153
153
+
}
154
154
+
155
155
+
// discover enumerates the URLs a fetched file references — its imports and any
156
156
+
// source map — fetching each and recording the edges on f. Source maps are
157
157
+
// skipped; all other files use regex-based import scanning.
158
158
+
func (c *crawler) discover(u *url.URL, f *file) {
159
159
+
if !isSourceMap(f.filename()) {
160
160
+
c.discoverRegex(u, f)
161
161
+
}
162
162
+
c.discoverSourceMap(u, f)
163
163
+
}
164
164
+
165
165
+
// discoverSourceMap follows a //# sourceMappingURL= comment, fetching the
166
166
+
// referenced map and recording it on f.sourceMap. Inline data: URLs are skipped.
167
167
+
func (c *crawler) discoverSourceMap(u *url.URL, f *file) {
168
168
+
for _, mapPath := range imports.ScanSourceMaps(string(f.content)) {
169
169
+
ref, err := url.Parse(mapPath)
170
170
+
if err != nil {
171
171
+
continue
172
172
+
}
173
173
+
mapURL := u.ResolveReference(ref).String()
174
174
+
if sm, err := c.fetch(mapURL); err != nil {
175
175
+
c.warnings = append(c.warnings, fmt.Sprintf("failed to download source map %s: %v", mapURL, err))
176
176
+
} else {
177
177
+
f.sourceMap = sm
178
178
+
}
179
179
+
}
180
180
+
}
181
181
+
182
182
+
// isSourceMap reports whether filename is a .map sidecar.
183
183
+
func isSourceMap(filename string) bool {
184
184
+
return strings.HasSuffix(strings.ToLower(filename), ".map")
185
185
+
}
186
186
+
187
187
+
// attachTypesHeader follows the x-typescript-types header on resp and records
188
188
+
// the result on f.types. The header is authoritative, so it overwrites any
189
189
+
// sidecar already found. Errors are non-fatal — a warning is emitted.
190
190
+
func (c *crawler) attachTypesHeader(u *url.URL, resp *http.Response, rawURL string, f *file) {
191
191
+
typesURL := resp.Header.Get("x-typescript-types")
192
192
+
if typesURL == "" {
193
193
+
return
194
194
+
}
195
195
+
if strings.HasPrefix(typesURL, "/") {
196
196
+
typesURL = u.Scheme + "://" + u.Host + typesURL
197
197
+
}
198
198
+
tf, err := c.fetch(typesURL)
199
199
+
if err != nil {
200
200
+
c.warnings = append(c.warnings, fmt.Sprintf("failed to download types for %s: %v", rawURL, err))
201
201
+
return
202
202
+
}
203
203
+
if f != nil {
204
204
+
f.types = tf
205
205
+
}
206
206
+
}
207
207
+
208
208
+
// isScript reports whether filename is a script file (JS or TS, but not .d.ts).
209
209
+
func isScript(filename string) bool {
210
210
+
f := strings.ToLower(filename)
211
211
+
if strings.HasSuffix(f, ".d.ts") {
212
212
+
return false
213
213
+
}
214
214
+
switch path.Ext(f) {
215
215
+
case ".ts", ".mts", ".tsx", ".jsx", ".js", ".mjs":
216
216
+
return true
217
217
+
}
218
218
+
return false
219
219
+
}
220
220
+
221
221
+
// sidecarURL returns the URL of the .d.ts file that may live alongside a script
222
222
+
// file (e.g. foo.mjs -> foo.d.ts), or "" if no sidecar lookup is appropriate.
223
223
+
func sidecarURL(u *url.URL, filename string) string {
224
224
+
if !isScript(filename) {
225
225
+
return ""
226
226
+
}
227
227
+
ext := path.Ext(u.Path)
228
228
+
if ext == "" {
229
229
+
return ""
230
230
+
}
231
231
+
s := *u
232
232
+
s.Path = strings.TrimSuffix(u.Path, ext) + ".d.ts"
233
233
+
return s.String()
234
234
+
}
235
235
+
236
236
+
// discoverRegex enumerates imports by scanning the source with a regex.
237
237
+
// Bare specifiers are skipped because imports.Resolve returns "" for anything
238
238
+
// without a scheme or a "/", "./", "../" prefix.
239
239
+
func (c *crawler) discoverRegex(u *url.URL, f *file) {
240
240
+
for _, spec := range imports.Scan(string(f.content)) {
241
241
+
if _, seen := f.deps[spec]; seen {
242
242
+
continue
243
243
+
}
244
244
+
depURL := imports.Resolve(u, spec)
245
245
+
if depURL == "" {
246
246
+
continue
247
247
+
}
248
248
+
df, err := c.fetch(depURL)
249
249
+
if err != nil {
250
250
+
c.warnings = append(c.warnings, fmt.Sprintf("failed to download %s: %v", depURL, err))
251
251
+
continue
252
252
+
}
253
253
+
f.deps[spec] = df
254
254
+
}
255
255
+
}
···
7
7
package vendor
8
8
9
9
import (
10
10
-
"encoding/json"
11
10
"errors"
12
11
"fmt"
13
13
-
"io"
14
14
-
"io/fs"
15
15
-
"mime"
16
16
-
"net/http"
17
17
-
"net/url"
18
18
-
"os"
19
19
-
"path"
20
20
-
"path/filepath"
21
21
-
"slices"
22
22
-
"sort"
23
12
"strings"
24
24
-
"syscall"
25
13
26
14
"tangled.org/jakelazaroff.com/unpm/internal/cfg"
27
27
-
"tangled.org/jakelazaroff.com/unpm/internal/imports"
28
15
"tangled.org/jakelazaroff.com/unpm/internal/inspect"
29
16
"tangled.org/jakelazaroff.com/unpm/internal/layout"
30
17
)
31
18
32
32
-
type vendorer struct {
33
33
-
config *cfg.Config
34
34
-
l layout.Layout
35
35
-
files map[string]*file // canonical URL -> fetched file
36
36
-
aliases map[string]*file // requested URL -> fetched file
37
37
-
misses map[string]error // requested URL -> error, to skip retrying known-bad URLs
38
38
-
warnings []string
39
39
-
}
40
40
-
41
41
-
// errSkip signals that a URL was reachable but served content we don't vendor
42
42
-
// (typically text/html from a wrong URL). Callers should treat it as a soft
43
43
-
// skip rather than a hard failure.
44
44
-
var errSkip = errors.New("unsupported response")
45
45
-
46
46
-
var knownContentTypes = map[string][]string{
47
47
-
".js": {"text/javascript", "application/javascript"},
48
48
-
".mjs": {"text/javascript", "application/javascript"},
49
49
-
".cjs": {"text/javascript", "application/javascript"},
50
50
-
".map": {"application/json"},
51
51
-
}
52
52
-
53
53
-
// file is the cached result of pass 1 for a single URL.
54
54
-
type file struct {
55
55
-
content []byte
56
56
-
path layout.RelativePath // location relative to the output directory (see layout.Rel)
57
57
-
deps map[string]*file // import spec as written in source -> dep file
58
58
-
sourceMap *file // referenced source map file
59
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
63
-
func (f *file) filename() string { return f.path.Base() }
64
64
-
65
19
func Vendor(c *cfg.Config) ([]string, error) {
66
66
-
// clean the output directory, preserving pinned files
67
67
-
if _, err := clean(c, c.Unpm.Out); err != nil && !errors.Is(err, fs.ErrNotExist) {
68
68
-
return []string{}, err
69
69
-
}
70
70
-
71
71
-
v := &vendorer{
72
72
-
config: c,
73
73
-
l: layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root},
74
74
-
files: make(map[string]*file),
75
75
-
aliases: make(map[string]*file),
76
76
-
misses: make(map[string]error),
77
77
-
}
20
20
+
l := layout.Layout{Out: c.Unpm.Out, Root: c.Unpm.Root}
21
21
+
cr := newCrawler()
78
22
79
23
// pass 1: fetch every reachable URL and discover dep edges in memory
80
24
entries := make(map[string]*file) // import key -> entry file
81
25
for key, rawURL := range c.Imports {
82
82
-
f, err := v.fetch(rawURL)
26
26
+
f, err := cr.fetch(rawURL)
83
27
if err != nil {
84
28
if errors.Is(err, errSkip) {
85
29
continue
86
30
}
87
87
-
return v.warnings, fmt.Errorf("fetching %q: %w", key, err)
31
31
+
return cr.warnings, fmt.Errorf("fetching %q: %w", key, err)
88
32
}
89
33
90
34
entries[key] = f
···
97
41
if !ok {
98
42
continue
99
43
}
100
100
-
tf, err := v.fetch(typesURL)
44
44
+
tf, err := cr.fetch(typesURL)
101
45
if err != nil {
102
102
-
v.warnings = append(v.warnings, fmt.Sprintf("failed to download types for %q: %v", key, err))
46
46
+
cr.warnings = append(cr.warnings, fmt.Sprintf("failed to download types for %q: %v", key, err))
103
47
continue
104
48
}
105
49
if f != nil {
···
109
53
110
54
// pass 2: write each fetched file to disk at host/dir/filename, rewriting
111
55
// imports to relative paths between vendor locations
112
112
-
for canon := range v.files {
113
113
-
if err := v.writeOne(canon); err != nil {
114
114
-
return v.warnings, fmt.Errorf("writing %s: %w", canon, err)
56
56
+
w := &writer{
57
57
+
config: c,
58
58
+
l: l,
59
59
+
written: make(map[layout.RelativePath]bool),
60
60
+
}
61
61
+
for canon, f := range cr.files {
62
62
+
if err := w.writeOne(canon, f); err != nil {
63
63
+
return cr.warnings, fmt.Errorf("writing %s: %w", canon, err)
115
64
}
116
65
}
117
66
···
121
70
if f == nil {
122
71
continue
123
72
}
124
124
-
importMap[key] = v.l.WebPath(f.path)
73
73
+
importMap[key] = l.WebPath(f.path)
125
74
126
75
// types: explicit config entry / x-typescript-types header / sidecar .d.ts > entry file itself
127
76
if f.types != nil && f.types != f {
···
131
80
}
132
81
}
133
82
134
134
-
if err := writeImportMap(c.Unpm.Out, importMap, c.Unpm.Verbose); err != nil {
135
135
-
return v.warnings, err
136
136
-
}
137
137
-
138
138
-
if err := writeTypesDts(c.Unpm.Out, typesMap, c.Unpm.Verbose); err != nil {
139
139
-
return v.warnings, err
140
140
-
}
141
141
-
142
142
-
for spec, files := range inspect.FindBareImports(c.Unpm.Out, c.Imports) {
143
143
-
fmt.Printf("\033[33mwarning:\033[0m %q is imported by %s but missing from import map\n", spec, strings.Join(files, ", "))
144
144
-
}
145
145
-
146
146
-
return v.warnings, nil
147
147
-
}
148
148
-
149
149
-
// recursively remove a directory's children while leaving pinned files,
150
150
-
// returning whether the cleaned directory should be deleted
151
151
-
func clean(config *cfg.Config, path string) (bool, error) {
152
152
-
// if the file is pinned, don't clean it
153
153
-
relPath, _ := filepath.Rel(config.Unpm.Out, path)
154
154
-
if config.IsPinned(filepath.ToSlash(relPath)) {
155
155
-
return false, nil
156
156
-
}
157
157
-
158
158
-
// if the path is a leaf file, it should be deleted
159
159
-
entries, err := os.ReadDir(path)
160
160
-
if err != nil {
161
161
-
if errors.Is(err, syscall.ENOTDIR) {
162
162
-
return true, nil
163
163
-
}
164
164
-
165
165
-
return false, err
166
166
-
}
167
167
-
168
168
-
// iterate through the directory, removing any files and now-empty child directories
169
169
-
empty := true
170
170
-
for _, entry := range entries {
171
171
-
child := filepath.Join(path, entry.Name())
172
172
-
delete, err := clean(config, child)
173
173
-
if err != nil {
174
174
-
return false, err
175
175
-
}
176
176
-
177
177
-
empty = empty && delete
178
178
-
if delete {
179
179
-
if err := os.Remove(child); err != nil {
180
180
-
return false, err
181
181
-
}
182
182
-
}
183
183
-
}
184
184
-
185
185
-
return empty, nil
186
186
-
}
187
187
-
188
188
-
// fetch downloads a URL, follows x-esm-path shims, caches the bytes in memory,
189
189
-
// and recursively enumerates the URLs referenced by the file. It returns the
190
190
-
// fetched file.
191
191
-
func (v *vendorer) fetch(rawURL string) (*file, error) {
192
192
-
var err error
193
193
-
194
194
-
if f, ok := v.aliases[rawURL]; ok {
195
195
-
return f, nil
196
196
-
}
197
197
-
if cached, ok := v.misses[rawURL]; ok {
198
198
-
return nil, cached
199
199
-
}
200
200
-
defer func() {
201
201
-
if err != nil {
202
202
-
v.misses[rawURL] = err
203
203
-
}
204
204
-
}()
205
205
-
206
206
-
u, err := url.Parse(rawURL)
207
207
-
if err != nil {
208
208
-
return nil, fmt.Errorf("parsing URL %s: %w", rawURL, err)
209
209
-
}
210
210
-
211
211
-
resp, err := http.Get(rawURL)
212
212
-
if err != nil {
213
213
-
return nil, fmt.Errorf("fetching %s: %w", rawURL, err)
214
214
-
}
215
215
-
defer resp.Body.Close()
216
216
-
217
217
-
if resp.StatusCode != http.StatusOK {
218
218
-
return nil, fmt.Errorf("fetching %s: status %d", rawURL, resp.StatusCode)
219
219
-
}
220
220
-
221
221
-
// warn if the content-type header does not match the file extension
222
222
-
// (e.g. a .js file served as text/html)
223
223
-
ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
224
224
-
if strings.HasSuffix(strings.ToLower(u.Path), ".d.ts") {
225
225
-
if ct != "text/plain" && ct != "application/typescript" {
226
226
-
v.warnings = append(v.warnings, fmt.Sprintf("%s: content-type %q does not match extension", rawURL, ct))
227
227
-
}
228
228
-
} else if expected, ok := knownContentTypes[strings.ToLower(path.Ext(u.Path))]; ok && !slices.Contains(expected, ct) {
229
229
-
v.warnings = append(v.warnings, fmt.Sprintf("%s: content-type %q does not match extension", rawURL, ct))
230
230
-
}
231
231
-
232
232
-
// esm.sh returns x-esm-path with the canonical resolved path;
233
233
-
// skip the shim and fetch the resolved module directly
234
234
-
if esmPath := resp.Header.Get("x-esm-path"); esmPath != "" {
235
235
-
canonURL := u.Scheme + "://" + u.Host + esmPath
236
236
-
f, err := v.fetch(canonURL)
237
237
-
if err != nil {
238
238
-
return nil, fmt.Errorf("downloading canonical path for %s: %w", rawURL, err)
239
239
-
}
240
240
-
v.aliases[rawURL] = f
241
241
-
242
242
-
// x-typescript-types is on this response, not the canonical one
243
243
-
v.attachTypesHeader(u, resp, rawURL, f)
244
244
-
245
245
-
return f, nil
246
246
-
}
247
247
-
248
248
-
body, err := io.ReadAll(resp.Body)
249
249
-
if err != nil {
250
250
-
return nil, fmt.Errorf("reading %s: %w", rawURL, err)
251
251
-
}
252
252
-
253
253
-
canon := u.String()
254
254
-
f := &file{
255
255
-
content: body,
256
256
-
path: layout.Rel(u),
257
257
-
deps: make(map[string]*file),
258
258
-
}
259
259
-
v.files[canon] = f
260
260
-
v.aliases[rawURL] = f
261
261
-
262
262
-
v.discover(u, f)
263
263
-
264
264
-
v.attachTypesHeader(u, resp, rawURL, f)
265
265
-
266
266
-
// if the response didn't already point us at types, try a "sidecar" .d.ts at
267
267
-
// the same URL with the extension replaced (e.g. foo.mjs -> foo.d.ts). 404s
268
268
-
// are expected and silent.
269
269
-
if f.types == nil {
270
270
-
if sURL := sidecarURL(u, f.filename()); sURL != "" {
271
271
-
if sf, err := v.fetch(sURL); err == nil {
272
272
-
f.types = sf
273
273
-
}
274
274
-
}
275
275
-
}
276
276
-
277
277
-
return f, nil
278
278
-
}
279
279
-
280
280
-
// discover enumerates the URLs a fetched file references — its imports and any
281
281
-
// source map — fetching each and recording the edges on f. Source maps are
282
282
-
// skipped; all other files use regex-based import scanning.
283
283
-
func (v *vendorer) discover(u *url.URL, f *file) {
284
284
-
if !isSourceMap(f.filename()) {
285
285
-
v.discoverRegex(u, f)
286
286
-
}
287
287
-
v.discoverSourceMap(u, f)
288
288
-
}
289
289
-
290
290
-
// discoverSourceMap follows a //# sourceMappingURL= comment, fetching the
291
291
-
// referenced map and recording it on f.sourceMap. Inline data: URLs are skipped.
292
292
-
func (v *vendorer) discoverSourceMap(u *url.URL, f *file) {
293
293
-
for _, mapPath := range imports.ScanSourceMaps(string(f.content)) {
294
294
-
ref, err := url.Parse(mapPath)
295
295
-
if err != nil {
296
296
-
continue
297
297
-
}
298
298
-
mapURL := u.ResolveReference(ref).String()
299
299
-
if sm, err := v.fetch(mapURL); err != nil {
300
300
-
v.warnings = append(v.warnings, fmt.Sprintf("failed to download source map %s: %v", mapURL, err))
301
301
-
} else {
302
302
-
f.sourceMap = sm
303
303
-
}
304
304
-
}
305
305
-
}
306
306
-
307
307
-
// isSourceMap reports whether filename is a .map sidecar.
308
308
-
func isSourceMap(filename string) bool {
309
309
-
return strings.HasSuffix(strings.ToLower(filename), ".map")
310
310
-
}
311
311
-
312
312
-
// attachTypesHeader follows the x-typescript-types header on resp and records
313
313
-
// the result on f.types. The header is authoritative, so it overwrites any
314
314
-
// sidecar already found. Errors are non-fatal — a warning is emitted.
315
315
-
func (v *vendorer) attachTypesHeader(u *url.URL, resp *http.Response, rawURL string, f *file) {
316
316
-
typesURL := resp.Header.Get("x-typescript-types")
317
317
-
if typesURL == "" {
318
318
-
return
319
319
-
}
320
320
-
if strings.HasPrefix(typesURL, "/") {
321
321
-
typesURL = u.Scheme + "://" + u.Host + typesURL
322
322
-
}
323
323
-
tf, err := v.fetch(typesURL)
324
324
-
if err != nil {
325
325
-
v.warnings = append(v.warnings, fmt.Sprintf("failed to download types for %s: %v", rawURL, err))
326
326
-
return
327
327
-
}
328
328
-
if f != nil {
329
329
-
f.types = tf
330
330
-
}
331
331
-
}
332
332
-
333
333
-
// isScript reports whether filename is a script file (JS or TS, but not .d.ts).
334
334
-
func isScript(filename string) bool {
335
335
-
f := strings.ToLower(filename)
336
336
-
if strings.HasSuffix(f, ".d.ts") {
337
337
-
return false
338
338
-
}
339
339
-
switch path.Ext(f) {
340
340
-
case ".ts", ".mts", ".tsx", ".jsx", ".js", ".mjs":
341
341
-
return true
83
83
+
if err := w.writeImportMap(importMap); err != nil {
84
84
+
return cr.warnings, err
342
85
}
343
343
-
return false
344
344
-
}
345
86
346
346
-
// sidecarURL returns the URL of the .d.ts file that may live alongside a script
347
347
-
// file (e.g. foo.mjs -> foo.d.ts), or "" if no sidecar lookup is appropriate.
348
348
-
func sidecarURL(u *url.URL, filename string) string {
349
349
-
if !isScript(filename) {
350
350
-
return ""
351
351
-
}
352
352
-
ext := path.Ext(u.Path)
353
353
-
if ext == "" {
354
354
-
return ""
355
355
-
}
356
356
-
s := *u
357
357
-
s.Path = strings.TrimSuffix(u.Path, ext) + ".d.ts"
358
358
-
return s.String()
359
359
-
}
360
360
-
361
361
-
// discoverRegex enumerates imports by scanning the source with a regex.
362
362
-
// Bare specifiers are skipped because imports.Resolve returns "" for anything
363
363
-
// without a scheme or a "/", "./", "../" prefix.
364
364
-
func (v *vendorer) discoverRegex(u *url.URL, f *file) {
365
365
-
for _, spec := range imports.Scan(string(f.content)) {
366
366
-
if _, seen := f.deps[spec]; seen {
367
367
-
continue
368
368
-
}
369
369
-
depURL := imports.Resolve(u, spec)
370
370
-
if depURL == "" {
371
371
-
continue
372
372
-
}
373
373
-
df, err := v.fetch(depURL)
374
374
-
if err != nil {
375
375
-
v.warnings = append(v.warnings, fmt.Sprintf("failed to download %s: %v", depURL, err))
376
376
-
continue
377
377
-
}
378
378
-
f.deps[spec] = df
87
87
+
if err := w.writeTypesDts(typesMap); err != nil {
88
88
+
return cr.warnings, err
379
89
}
380
380
-
}
381
90
382
382
-
func (v *vendorer) writeOne(canon string) error {
383
383
-
f := v.files[canon]
384
384
-
if f == nil {
385
385
-
return nil
91
91
+
if err := w.clean(); err != nil {
92
92
+
return cr.warnings, err
386
93
}
387
94
388
388
-
var content []byte
389
389
-
if isSourceMap(f.filename()) {
390
390
-
content = f.content
391
391
-
} else {
392
392
-
content = v.rewrite(f)
95
95
+
for spec, files := range inspect.FindBareImports(c.Unpm.Out, c.Imports) {
96
96
+
fmt.Printf("\033[33mwarning:\033[0m %q is imported by %s but missing from import map\n", spec, strings.Join(files, ", "))
393
97
}
394
394
-
return v.writeFile(canon, f.path, content)
395
395
-
}
396
98
397
397
-
func (v *vendorer) writeFile(canon string, rel layout.RelativePath, content []byte) error {
398
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
-
}
402
402
-
return nil
403
403
-
}
404
404
-
dest := v.l.DiskPath(rel)
405
405
-
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
406
406
-
return fmt.Errorf("creating directory %s: %w", filepath.Dir(dest), err)
407
407
-
}
408
408
-
if err := os.WriteFile(dest, content, 0o644); err != nil {
409
409
-
return fmt.Errorf("writing %s: %w", dest, err)
410
410
-
}
411
411
-
if v.config.Unpm.Verbose {
412
412
-
fmt.Printf("%s -> %s\n", canon, rel)
413
413
-
}
414
414
-
return nil
415
415
-
}
416
416
-
417
417
-
// rewrite substitutes import specs and the sourceMappingURL in f.content with
418
418
-
// paths relative to f's vendored location using the vendor locations of f's deps.
419
419
-
func (v *vendorer) rewrite(f *file) []byte {
420
420
-
rewrites := make(map[string]string, len(f.deps))
421
421
-
for spec, dep := range f.deps {
422
422
-
rewrites[spec] = layout.Relative(f.path, dep.path)
423
423
-
}
424
424
-
425
425
-
result := imports.Rewrite(string(f.content), func(spec string) (string, bool) {
426
426
-
rewritten, ok := rewrites[spec]
427
427
-
return rewritten, ok
428
428
-
})
429
429
-
430
430
-
if f.sourceMap != nil {
431
431
-
result = imports.RewriteSourceMap(result, func(string) string {
432
432
-
return layout.Relative(f.path, f.sourceMap.path)
433
433
-
})
434
434
-
}
435
435
-
436
436
-
return []byte(result)
437
437
-
}
438
438
-
439
439
-
func writeImportMap(outDir string, rewritten map[string]string, verbose bool) error {
440
440
-
// Build the imports object as JS key-value pairs
441
441
-
var entries []string
442
442
-
for key, val := range rewritten {
443
443
-
entries = append(entries, fmt.Sprintf(" %q: %q", key, val))
444
444
-
}
445
445
-
sort.Strings(entries)
446
446
-
447
447
-
js := fmt.Sprintf(`const importmap = document.createElement("script");
448
448
-
importmap.type = "importmap";
449
449
-
importmap.textContent = JSON.stringify({
450
450
-
imports: {
451
451
-
%s,
452
452
-
},
453
453
-
});
454
454
-
document.currentScript.after(importmap);
455
455
-
`, strings.Join(entries, ",\n"))
456
456
-
457
457
-
dest := filepath.Join(outDir, "importmap.js")
458
458
-
if err := os.WriteFile(dest, []byte(js), 0o644); err != nil {
459
459
-
return fmt.Errorf("writing importmap.js: %w", err)
460
460
-
}
461
461
-
462
462
-
if verbose {
463
463
-
fmt.Printf("wrote %s\n", dest)
464
464
-
}
465
465
-
466
466
-
// Also write importmap.json for use with Node or SSR
467
467
-
jsonData, err := json.MarshalIndent(map[string]any{
468
468
-
"imports": rewritten,
469
469
-
}, "", " ")
470
470
-
if err != nil {
471
471
-
return fmt.Errorf("marshaling importmap.json: %w", err)
472
472
-
}
473
473
-
474
474
-
jsonDest := filepath.Join(outDir, "importmap.json")
475
475
-
if err := os.WriteFile(jsonDest, append(jsonData, '\n'), 0o644); err != nil {
476
476
-
return fmt.Errorf("writing importmap.json: %w", err)
477
477
-
}
478
478
-
479
479
-
if verbose {
480
480
-
fmt.Printf("wrote %s\n", jsonDest)
481
481
-
}
482
482
-
return nil
483
483
-
}
484
484
-
485
485
-
func writeTypesDts(dir string, types map[string]string, verbose bool) error {
486
486
-
if len(types) == 0 {
487
487
-
return nil
488
488
-
}
489
489
-
490
490
-
paths := make(map[string][]string)
491
491
-
for key, path := range types {
492
492
-
paths[key] = []string{path}
493
493
-
}
494
494
-
495
495
-
data, err := json.MarshalIndent(map[string]any{
496
496
-
"compilerOptions": map[string]any{
497
497
-
"paths": paths,
498
498
-
},
499
499
-
"exclude": []string{"."},
500
500
-
}, "", " ")
501
501
-
if err != nil {
502
502
-
return fmt.Errorf("marshaling jsconfig.json: %w", err)
503
503
-
}
504
504
-
505
505
-
dest := filepath.Join(dir, "jsconfig.json")
506
506
-
if err := os.WriteFile(dest, append(data, '\n'), 0o644); err != nil {
507
507
-
return fmt.Errorf("writing jsconfig.json: %w", err)
508
508
-
}
509
509
-
510
510
-
if verbose {
511
511
-
fmt.Printf("wrote %s\n", dest)
512
512
-
}
513
513
-
return nil
99
99
+
return cr.warnings, nil
514
100
}
···
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 vendor
8
8
+
9
9
+
import (
10
10
+
"encoding/json"
11
11
+
"errors"
12
12
+
"fmt"
13
13
+
"io/fs"
14
14
+
"os"
15
15
+
"path/filepath"
16
16
+
"sort"
17
17
+
"strings"
18
18
+
19
19
+
"tangled.org/jakelazaroff.com/unpm/internal/cfg"
20
20
+
"tangled.org/jakelazaroff.com/unpm/internal/imports"
21
21
+
"tangled.org/jakelazaroff.com/unpm/internal/layout"
22
22
+
)
23
23
+
24
24
+
type writer struct {
25
25
+
config *cfg.Config
26
26
+
l layout.Layout
27
27
+
written map[layout.RelativePath]bool
28
28
+
}
29
29
+
30
30
+
func (w *writer) writeOne(canon string, f *file) error {
31
31
+
var content []byte
32
32
+
if isSourceMap(f.filename()) {
33
33
+
content = f.content
34
34
+
} else {
35
35
+
content = w.rewrite(f)
36
36
+
}
37
37
+
return w.writeFile(canon, f.path, content)
38
38
+
}
39
39
+
40
40
+
func (w *writer) writeFile(canon string, rel layout.RelativePath, content []byte) error {
41
41
+
if w.config.IsPinned(string(rel)) {
42
42
+
if w.config.Unpm.Verbose {
43
43
+
fmt.Printf("%s -> %s (pinned)\n", canon, rel)
44
44
+
}
45
45
+
return nil
46
46
+
}
47
47
+
dest := w.l.DiskPath(rel)
48
48
+
if err := writeBytes(dest, content); err != nil {
49
49
+
return fmt.Errorf("writing %s: %w", dest, err)
50
50
+
}
51
51
+
w.written[rel] = true
52
52
+
if w.config.Unpm.Verbose {
53
53
+
fmt.Printf("%s -> %s\n", canon, rel)
54
54
+
}
55
55
+
return nil
56
56
+
}
57
57
+
58
58
+
// rewrite substitutes import specs and the sourceMappingURL in f.content with
59
59
+
// paths relative to f's vendored location using the vendor locations of f's deps.
60
60
+
func (w *writer) rewrite(f *file) []byte {
61
61
+
rewrites := make(map[string]string, len(f.deps))
62
62
+
for spec, dep := range f.deps {
63
63
+
rewrites[spec] = layout.Relative(f.path, dep.path)
64
64
+
}
65
65
+
66
66
+
result := imports.Rewrite(string(f.content), func(spec string) (string, bool) {
67
67
+
rewritten, ok := rewrites[spec]
68
68
+
return rewritten, ok
69
69
+
})
70
70
+
71
71
+
if f.sourceMap != nil {
72
72
+
result = imports.RewriteSourceMap(result, func(string) string {
73
73
+
return layout.Relative(f.path, f.sourceMap.path)
74
74
+
})
75
75
+
}
76
76
+
77
77
+
return []byte(result)
78
78
+
}
79
79
+
80
80
+
func (w *writer) writeImportMap(rewritten map[string]string) error {
81
81
+
// Build the imports object as JS key-value pairs
82
82
+
var entries []string
83
83
+
for key, val := range rewritten {
84
84
+
entries = append(entries, fmt.Sprintf(" %q: %q", key, val))
85
85
+
}
86
86
+
sort.Strings(entries)
87
87
+
88
88
+
js := fmt.Sprintf(`const importmap = document.createElement("script");
89
89
+
importmap.type = "importmap";
90
90
+
importmap.textContent = JSON.stringify({
91
91
+
imports: {
92
92
+
%s,
93
93
+
},
94
94
+
});
95
95
+
document.currentScript.after(importmap);
96
96
+
`, strings.Join(entries, ",\n"))
97
97
+
98
98
+
if err := w.writeGenerated("importmap.js", []byte(js)); err != nil {
99
99
+
return fmt.Errorf("writing importmap.js: %w", err)
100
100
+
}
101
101
+
102
102
+
if w.config.Unpm.Verbose {
103
103
+
fmt.Printf("wrote %s\n", w.l.DiskPath("importmap.js"))
104
104
+
}
105
105
+
106
106
+
// Also write importmap.json for use with Node or SSR
107
107
+
jsonData, err := json.MarshalIndent(map[string]any{
108
108
+
"imports": rewritten,
109
109
+
}, "", " ")
110
110
+
if err != nil {
111
111
+
return fmt.Errorf("marshaling importmap.json: %w", err)
112
112
+
}
113
113
+
114
114
+
if err := w.writeGenerated("importmap.json", append(jsonData, '\n')); err != nil {
115
115
+
return fmt.Errorf("writing importmap.json: %w", err)
116
116
+
}
117
117
+
118
118
+
if w.config.Unpm.Verbose {
119
119
+
fmt.Printf("wrote %s\n", w.l.DiskPath("importmap.json"))
120
120
+
}
121
121
+
return nil
122
122
+
}
123
123
+
124
124
+
func (w *writer) writeTypesDts(types map[string]string) error {
125
125
+
if len(types) == 0 {
126
126
+
return nil
127
127
+
}
128
128
+
129
129
+
paths := make(map[string][]string)
130
130
+
for key, path := range types {
131
131
+
paths[key] = []string{path}
132
132
+
}
133
133
+
134
134
+
data, err := json.MarshalIndent(map[string]any{
135
135
+
"compilerOptions": map[string]any{
136
136
+
"paths": paths,
137
137
+
},
138
138
+
"exclude": []string{"."},
139
139
+
}, "", " ")
140
140
+
if err != nil {
141
141
+
return fmt.Errorf("marshaling jsconfig.json: %w", err)
142
142
+
}
143
143
+
144
144
+
if err := w.writeGenerated("jsconfig.json", append(data, '\n')); err != nil {
145
145
+
return fmt.Errorf("writing jsconfig.json: %w", err)
146
146
+
}
147
147
+
148
148
+
if w.config.Unpm.Verbose {
149
149
+
fmt.Printf("wrote %s\n", w.l.DiskPath("jsconfig.json"))
150
150
+
}
151
151
+
return nil
152
152
+
}
153
153
+
154
154
+
func (w *writer) writeGenerated(rel layout.RelativePath, content []byte) error {
155
155
+
if err := writeBytes(w.l.DiskPath(rel), content); err != nil {
156
156
+
return err
157
157
+
}
158
158
+
w.written[rel] = true
159
159
+
return nil
160
160
+
}
161
161
+
162
162
+
func writeBytes(dest string, content []byte) error {
163
163
+
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
164
164
+
return fmt.Errorf("creating directory %s: %w", filepath.Dir(dest), err)
165
165
+
}
166
166
+
return os.WriteFile(dest, content, 0o644)
167
167
+
}
168
168
+
169
169
+
// clean removes files that were not written by this run, preserving pinned
170
170
+
// files, then prunes any empty directories left behind.
171
171
+
func (w *writer) clean() error {
172
172
+
out := w.config.Unpm.Out
173
173
+
if _, err := os.Stat(out); errors.Is(err, fs.ErrNotExist) {
174
174
+
return nil
175
175
+
}
176
176
+
177
177
+
if err := filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error {
178
178
+
if err != nil || d.IsDir() {
179
179
+
return err
180
180
+
}
181
181
+
rel, err := filepath.Rel(out, p)
182
182
+
if err != nil {
183
183
+
return err
184
184
+
}
185
185
+
relPath := layout.RelativePath(filepath.ToSlash(rel))
186
186
+
if w.written[relPath] || w.config.IsPinned(string(relPath)) {
187
187
+
return nil
188
188
+
}
189
189
+
return os.Remove(p)
190
190
+
}); err != nil {
191
191
+
return err
192
192
+
}
193
193
+
194
194
+
return pruneEmptyDirs(out)
195
195
+
}
196
196
+
197
197
+
func pruneEmptyDirs(root string) error {
198
198
+
var dirs []string
199
199
+
if err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
200
200
+
if err != nil {
201
201
+
return err
202
202
+
}
203
203
+
if d.IsDir() && p != root {
204
204
+
dirs = append(dirs, p)
205
205
+
}
206
206
+
return nil
207
207
+
}); err != nil {
208
208
+
return err
209
209
+
}
210
210
+
211
211
+
for i := len(dirs) - 1; i >= 0; i-- {
212
212
+
entries, err := os.ReadDir(dirs[i])
213
213
+
if err == nil && len(entries) == 0 {
214
214
+
if err := os.Remove(dirs[i]); err != nil {
215
215
+
return err
216
216
+
}
217
217
+
}
218
218
+
}
219
219
+
return nil
220
220
+
}