···
1
1
.DS_Store
2
2
example
3
3
+
/unpm
···
12
12
"os"
13
13
"strings"
14
14
15
15
+
"github.com/jakelazaroff/unpm/pkg/cfg"
15
16
"github.com/jakelazaroff/unpm/pkg/unpm"
16
17
)
17
18
···
24
25
return nil
25
26
}
26
27
27
27
-
const defaultOut = "vendor"
28
28
-
const defaultRoot = "."
29
29
-
30
28
func main() {
31
31
-
var fetchConfigVal, fetchOutVal, fetchRootVal string
32
32
-
var fetchPinVal stringSlice
33
33
-
fetchCmd := flag.NewFlagSet("fetch", flag.ExitOnError)
34
34
-
fetchCmd.StringVar(&fetchConfigVal, "config", "unpm.json", "path to config JSON file")
35
35
-
fetchCmd.StringVar(&fetchOutVal, "out", defaultOut, "output directory")
36
36
-
fetchCmd.StringVar(&fetchRootVal, "root", defaultRoot, "root directory for import map paths")
37
37
-
fetchCmd.Var(&fetchPinVal, "pin", "pin a module specifier (can be repeated)")
29
29
+
if len(os.Args) < 2 {
30
30
+
fmt.Fprintf(os.Stderr, "usage: unpm <command> [flags]\n\ncommands:\n fetch download and vendor imports\n check warn about bare imports missing from import map\n prune remove unreachable vendored files\n why explain why a transitive dependency is imported\n")
31
31
+
os.Exit(1)
32
32
+
}
38
33
39
39
-
var checkConfigVal, checkOutVal string
40
40
-
checkCmd := flag.NewFlagSet("check", flag.ExitOnError)
41
41
-
checkCmd.StringVar(&checkConfigVal, "config", "unpm.json", "path to config JSON file")
42
42
-
checkCmd.StringVar(&checkOutVal, "out", defaultOut, "vendor directory to check")
34
34
+
cmd := os.Args[1]
43
35
44
44
-
var pruneConfigVal, pruneOutVal string
45
45
-
var prunePinVal stringSlice
46
46
-
pruneCmd := flag.NewFlagSet("prune", flag.ExitOnError)
47
47
-
pruneCmd.StringVar(&pruneConfigVal, "config", "unpm.json", "path to config JSON file")
48
48
-
pruneCmd.StringVar(&pruneOutVal, "out", defaultOut, "vendor directory to prune")
49
49
-
pruneCmd.Var(&prunePinVal, "pin", "pin a module specifier (can be repeated)")
36
36
+
var configVal, outVal, rootVal string
37
37
+
var pinVal stringSlice
38
38
+
fs := flag.NewFlagSet(cmd, flag.ExitOnError)
39
39
+
fs.StringVar(&configVal, "config", "unpm.json", "path to config JSON file")
40
40
+
fs.StringVar(&outVal, "out", "", "output directory")
41
41
+
fs.StringVar(&rootVal, "root", "", "root directory for import map paths")
42
42
+
fs.Var(&pinVal, "pin", "pin a module specifier (can be repeated)")
43
43
+
fs.Parse(os.Args[2:])
50
44
51
51
-
var whyConfigVal, whyOutVal string
52
52
-
whyCmd := flag.NewFlagSet("why", flag.ExitOnError)
53
53
-
whyCmd.StringVar(&whyConfigVal, "config", "unpm.json", "path to config JSON file")
54
54
-
whyCmd.StringVar(&whyOutVal, "out", defaultOut, "vendor directory")
45
45
+
switch cmd {
46
46
+
case "fetch", "check", "prune", "why":
47
47
+
default:
48
48
+
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
49
49
+
os.Exit(1)
50
50
+
}
55
51
56
56
-
if len(os.Args) < 2 {
57
57
-
fmt.Fprintf(os.Stderr, "usage: unpm <command> [flags]\n\ncommands:\n fetch download and vendor imports\n check warn about bare imports missing from import map\n prune remove unreachable vendored files\n why explain why a transitive dependency is imported\n")
52
52
+
c, err := cfg.ReadConfig(configVal)
53
53
+
if err != nil {
54
54
+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
58
55
os.Exit(1)
59
56
}
57
57
+
if outVal != "" {
58
58
+
c.Unpm.Out = outVal
59
59
+
}
60
60
+
if rootVal != "" {
61
61
+
c.Unpm.Root = rootVal
62
62
+
}
63
63
+
c.Unpm.Pin = append(c.Unpm.Pin, pinVal...)
60
64
61
61
-
switch os.Args[1] {
65
65
+
switch cmd {
62
66
case "fetch":
63
63
-
fetchCmd.Parse(os.Args[2:])
64
64
-
cfg, err := unpm.ReadConfig(fetchConfigVal)
65
65
-
if err != nil {
66
66
-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
67
67
-
os.Exit(1)
68
68
-
}
69
69
-
unpm.MergePin(cfg, []string(fetchPinVal))
70
70
-
outDir := unpm.OutDir(cfg, fetchConfigVal, fetchOutVal, defaultOut)
71
71
-
root := unpm.Root(cfg, fetchConfigVal, fetchRootVal, defaultRoot)
72
67
fmt.Println("unpm: fetching imports...")
73
73
-
if err := unpm.Fetch(cfg, outDir, root); err != nil {
68
68
+
if err := unpm.Fetch(c); err != nil {
74
69
fmt.Fprintf(os.Stderr, "error: %v\n", err)
75
70
os.Exit(1)
76
71
}
77
72
fmt.Println("done.")
78
73
79
74
case "check":
80
80
-
checkCmd.Parse(os.Args[2:])
81
81
-
cfg, err := unpm.ReadConfig(checkConfigVal)
82
82
-
if err != nil {
83
83
-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
84
84
-
os.Exit(1)
85
85
-
}
86
86
-
outDir := unpm.OutDir(cfg, checkConfigVal, checkOutVal, defaultOut)
87
75
fmt.Println("unpm: checking imports...")
88
88
-
if err := unpm.Check(cfg, outDir); err != nil {
76
76
+
if err := unpm.Check(c); err != nil {
89
77
fmt.Fprintf(os.Stderr, "error: %v\n", err)
90
78
os.Exit(1)
91
79
}
92
80
fmt.Println("done.")
93
81
94
82
case "prune":
95
95
-
pruneCmd.Parse(os.Args[2:])
96
96
-
cfg, err := unpm.ReadConfig(pruneConfigVal)
97
97
-
if err != nil {
98
98
-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
99
99
-
os.Exit(1)
100
100
-
}
101
101
-
unpm.MergePin(cfg, []string(prunePinVal))
102
102
-
outDir := unpm.OutDir(cfg, pruneConfigVal, pruneOutVal, defaultOut)
103
83
fmt.Println("unpm: pruning vendor...")
104
104
-
if err := unpm.Prune(cfg, outDir); err != nil {
84
84
+
if err := unpm.Prune(c); err != nil {
105
85
fmt.Fprintf(os.Stderr, "error: %v\n", err)
106
86
os.Exit(1)
107
87
}
108
88
fmt.Println("done.")
109
89
110
90
case "why":
111
111
-
whyCmd.Parse(os.Args[2:])
112
112
-
if whyCmd.NArg() < 1 {
91
91
+
if fs.NArg() < 1 {
113
92
fmt.Fprintf(os.Stderr, "usage: unpm why <file>\n")
114
93
os.Exit(1)
115
94
}
116
116
-
cfg, err := unpm.ReadConfig(whyConfigVal)
117
117
-
if err != nil {
118
118
-
fmt.Fprintf(os.Stderr, "error: %v\n", err)
119
119
-
os.Exit(1)
120
120
-
}
121
121
-
outDir := unpm.OutDir(cfg, whyConfigVal, whyOutVal, defaultOut)
122
122
-
if err := unpm.Why(cfg, outDir, whyCmd.Arg(0)); err != nil {
95
95
+
if err := unpm.Why(c, fs.Arg(0)); err != nil {
123
96
fmt.Fprintf(os.Stderr, "error: %v\n", err)
124
97
os.Exit(1)
125
98
}
126
126
-
127
127
-
default:
128
128
-
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
129
129
-
os.Exit(1)
130
99
}
131
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://github.com/jakelazaroff/unpm
6
6
+
7
7
+
package cfg
8
8
+
9
9
+
import (
10
10
+
"encoding/json"
11
11
+
"fmt"
12
12
+
"os"
13
13
+
"path/filepath"
14
14
+
"slices"
15
15
+
"strings"
16
16
+
)
17
17
+
18
18
+
type Config struct {
19
19
+
Imports map[string]string `json:"imports"`
20
20
+
Unpm Options `json:"$unpm"`
21
21
+
}
22
22
+
23
23
+
type Options struct {
24
24
+
Out string `json:"out,omitempty"`
25
25
+
Root string `json:"root,omitempty"`
26
26
+
Pin []string `json:"pin,omitempty"`
27
27
+
}
28
28
+
29
29
+
func ReadConfig(configPath string) (*Config, error) {
30
30
+
data, err := os.ReadFile(configPath)
31
31
+
if err != nil {
32
32
+
return nil, fmt.Errorf("reading config: %w", err)
33
33
+
}
34
34
+
35
35
+
var cfg Config
36
36
+
if err := json.Unmarshal(data, &cfg); err != nil {
37
37
+
return nil, fmt.Errorf("parsing config: %w", err)
38
38
+
}
39
39
+
40
40
+
if len(cfg.Imports) == 0 {
41
41
+
return nil, fmt.Errorf("no imports found in %s", configPath)
42
42
+
}
43
43
+
44
44
+
for key, url := range cfg.Imports {
45
45
+
if strings.HasSuffix(key, "/") {
46
46
+
return nil, fmt.Errorf("trailing-slash prefix entries are not supported: %q (list each subpath explicitly)", key)
47
47
+
}
48
48
+
if !strings.HasPrefix(url, "https://") {
49
49
+
return nil, fmt.Errorf("URL for %q must be an https:// URL, got: %s", key, url)
50
50
+
}
51
51
+
}
52
52
+
53
53
+
// Apply defaults, resolved relative to the config file's directory.
54
54
+
dir := filepath.Dir(configPath)
55
55
+
if cfg.Unpm.Out == "" {
56
56
+
cfg.Unpm.Out = filepath.Join(dir, "vendor")
57
57
+
} else {
58
58
+
cfg.Unpm.Out = filepath.Join(dir, cfg.Unpm.Out)
59
59
+
}
60
60
+
if cfg.Unpm.Root == "" {
61
61
+
cfg.Unpm.Root = dir
62
62
+
} else {
63
63
+
cfg.Unpm.Root = filepath.Join(dir, cfg.Unpm.Root)
64
64
+
}
65
65
+
66
66
+
return &cfg, nil
67
67
+
}
68
68
+
69
69
+
// IsPinned reports whether the given import key is pinned.
70
70
+
func (c *Config) IsPinned(key string) bool {
71
71
+
return slices.Contains(c.Unpm.Pin, key)
72
72
+
}
···
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://github.com/jakelazaroff/unpm
6
6
+
7
7
+
package cfg_test
8
8
+
9
9
+
import (
10
10
+
"os"
11
11
+
"path/filepath"
12
12
+
"strings"
13
13
+
"testing"
14
14
+
15
15
+
"github.com/jakelazaroff/unpm/pkg/cfg"
16
16
+
)
17
17
+
18
18
+
func TestReadConfig(t *testing.T) {
19
19
+
t.Run("valid", func(t *testing.T) {
20
20
+
dir := t.TempDir()
21
21
+
p := filepath.Join(dir, "unpm.json")
22
22
+
os.WriteFile(p, []byte(`{"imports":{"foo":"https://example.com/foo.js"}}`), 0o644)
23
23
+
24
24
+
c, err := cfg.ReadConfig(p)
25
25
+
if err != nil {
26
26
+
t.Fatal(err)
27
27
+
}
28
28
+
if c.Imports["foo"] != "https://example.com/foo.js" {
29
29
+
t.Fatalf("unexpected import: %v", c.Imports)
30
30
+
}
31
31
+
})
32
32
+
33
33
+
t.Run("no imports", func(t *testing.T) {
34
34
+
dir := t.TempDir()
35
35
+
p := filepath.Join(dir, "unpm.json")
36
36
+
os.WriteFile(p, []byte(`{}`), 0o644)
37
37
+
38
38
+
_, err := cfg.ReadConfig(p)
39
39
+
if err == nil || !strings.Contains(err.Error(), "no imports") {
40
40
+
t.Fatalf("expected 'no imports' error, got: %v", err)
41
41
+
}
42
42
+
})
43
43
+
44
44
+
t.Run("trailing slash", func(t *testing.T) {
45
45
+
dir := t.TempDir()
46
46
+
p := filepath.Join(dir, "unpm.json")
47
47
+
os.WriteFile(p, []byte(`{"imports":{"foo/":"https://example.com/foo.js"}}`), 0o644)
48
48
+
49
49
+
_, err := cfg.ReadConfig(p)
50
50
+
if err == nil || !strings.Contains(err.Error(), "trailing-slash") {
51
51
+
t.Fatalf("expected trailing-slash error, got: %v", err)
52
52
+
}
53
53
+
})
54
54
+
55
55
+
t.Run("non-https", func(t *testing.T) {
56
56
+
dir := t.TempDir()
57
57
+
p := filepath.Join(dir, "unpm.json")
58
58
+
os.WriteFile(p, []byte(`{"imports":{"foo":"http://example.com/foo.js"}}`), 0o644)
59
59
+
60
60
+
_, err := cfg.ReadConfig(p)
61
61
+
if err == nil || !strings.Contains(err.Error(), "https://") {
62
62
+
t.Fatalf("expected https error, got: %v", err)
63
63
+
}
64
64
+
})
65
65
+
66
66
+
t.Run("default out and root", func(t *testing.T) {
67
67
+
dir := t.TempDir()
68
68
+
p := filepath.Join(dir, "unpm.json")
69
69
+
os.WriteFile(p, []byte(`{"imports":{"foo":"https://example.com/foo.js"}}`), 0o644)
70
70
+
71
71
+
c, err := cfg.ReadConfig(p)
72
72
+
if err != nil {
73
73
+
t.Fatal(err)
74
74
+
}
75
75
+
if c.Unpm.Out != filepath.Join(dir, "vendor") {
76
76
+
t.Fatalf("expected Out=%q, got %q", filepath.Join(dir, "vendor"), c.Unpm.Out)
77
77
+
}
78
78
+
if c.Unpm.Root != dir {
79
79
+
t.Fatalf("expected Root=%q, got %q", dir, c.Unpm.Root)
80
80
+
}
81
81
+
})
82
82
+
83
83
+
t.Run("custom out and root", func(t *testing.T) {
84
84
+
dir := t.TempDir()
85
85
+
p := filepath.Join(dir, "unpm.json")
86
86
+
os.WriteFile(p, []byte(`{"imports":{"foo":"https://example.com/foo.js"},"$unpm":{"out":"dist","root":"public"}}`), 0o644)
87
87
+
88
88
+
c, err := cfg.ReadConfig(p)
89
89
+
if err != nil {
90
90
+
t.Fatal(err)
91
91
+
}
92
92
+
if c.Unpm.Out != filepath.Join(dir, "dist") {
93
93
+
t.Fatalf("expected Out=%q, got %q", filepath.Join(dir, "dist"), c.Unpm.Out)
94
94
+
}
95
95
+
if c.Unpm.Root != filepath.Join(dir, "public") {
96
96
+
t.Fatalf("expected Root=%q, got %q", filepath.Join(dir, "public"), c.Unpm.Root)
97
97
+
}
98
98
+
})
99
99
+
}
100
100
+
101
101
+
func TestIsPinned(t *testing.T) {
102
102
+
c := &cfg.Config{
103
103
+
Imports: map[string]string{
104
104
+
"a": "https://x.com/a.js",
105
105
+
"b": "https://x.com/b.js",
106
106
+
},
107
107
+
Unpm: cfg.Options{Pin: []string{"b"}},
108
108
+
}
109
109
+
110
110
+
if c.IsPinned("a") {
111
111
+
t.Fatal("a should not be pinned")
112
112
+
}
113
113
+
if !c.IsPinned("b") {
114
114
+
t.Fatal("b should be pinned")
115
115
+
}
116
116
+
}
···
18
18
"regexp"
19
19
"sort"
20
20
"strings"
21
21
-
)
22
21
23
23
-
type Config struct {
24
24
-
Imports map[string]string `json:"imports"`
25
25
-
Unpm *Options `json:"$unpm,omitempty"`
26
26
-
}
27
27
-
28
28
-
type Options struct {
29
29
-
Out string `json:"out,omitempty"`
30
30
-
Root string `json:"root,omitempty"`
31
31
-
Pin []string `json:"pin,omitempty"`
32
32
-
}
22
22
+
"github.com/jakelazaroff/unpm/pkg/cfg"
23
23
+
)
33
24
34
25
type vendorer struct {
35
26
outDir string
···
38
29
esmPaths map[string]string // X-ESM-Path value -> path relative to outDir
39
30
}
40
31
41
41
-
// MergePin appends CLI pin values into the config, deduplicating.
42
42
-
func MergePin(cfg *Config, pins []string) {
43
43
-
if len(pins) == 0 {
44
44
-
return
45
45
-
}
46
46
-
if cfg.Unpm == nil {
47
47
-
cfg.Unpm = &Options{}
48
48
-
}
49
49
-
existing := map[string]bool{}
50
50
-
for _, p := range cfg.Unpm.Pin {
51
51
-
existing[p] = true
52
52
-
}
53
53
-
for _, p := range pins {
54
54
-
if !existing[p] {
55
55
-
cfg.Unpm.Pin = append(cfg.Unpm.Pin, p)
56
56
-
existing[p] = true
57
57
-
}
58
58
-
}
59
59
-
}
60
60
-
61
61
-
func (c *Config) isPinned(key string) bool {
62
62
-
if c.Unpm == nil {
63
63
-
return false
64
64
-
}
65
65
-
for _, p := range c.Unpm.Pin {
66
66
-
if p == key {
67
67
-
return true
68
68
-
}
69
69
-
}
70
70
-
return false
71
71
-
}
72
72
-
73
73
-
func ReadConfig(configPath string) (*Config, error) {
74
74
-
data, err := os.ReadFile(configPath)
75
75
-
if err != nil {
76
76
-
return nil, fmt.Errorf("reading config: %w", err)
77
77
-
}
78
78
-
79
79
-
var cfg Config
80
80
-
if err := json.Unmarshal(data, &cfg); err != nil {
81
81
-
return nil, fmt.Errorf("parsing config: %w", err)
82
82
-
}
83
83
-
84
84
-
if len(cfg.Imports) == 0 {
85
85
-
return nil, fmt.Errorf("no imports found in %s", configPath)
86
86
-
}
87
87
-
88
88
-
for key, url := range cfg.Imports {
89
89
-
if strings.HasSuffix(key, "/") {
90
90
-
return nil, fmt.Errorf("trailing-slash prefix entries are not supported: %q (list each subpath explicitly)", key)
91
91
-
}
92
92
-
if !strings.HasPrefix(url, "https://") {
93
93
-
return nil, fmt.Errorf("URL for %q must be an https:// URL, got: %s", key, url)
94
94
-
}
95
95
-
}
96
96
-
97
97
-
return &cfg, nil
98
98
-
}
99
99
-
100
100
-
// OutDir returns the output directory. The flag takes precedence if set;
101
101
-
// otherwise the config's "out" field is used (resolved relative to configPath);
102
102
-
// otherwise the default is returned.
103
103
-
func OutDir(cfg *Config, configPath, flagVal, flagDefault string) string {
104
104
-
if flagVal != flagDefault {
105
105
-
return flagVal
106
106
-
}
107
107
-
if cfg.Unpm != nil && cfg.Unpm.Out != "" {
108
108
-
return filepath.Join(filepath.Dir(configPath), cfg.Unpm.Out)
109
109
-
}
110
110
-
return flagDefault
111
111
-
}
112
112
-
113
113
-
// Root returns the root directory for import map paths. The flag takes precedence
114
114
-
// if set; otherwise the config's "root" field is used (resolved relative to
115
115
-
// configPath); otherwise the default (parent of outDir) is returned.
116
116
-
func Root(cfg *Config, configPath, flagVal, flagDefault string) string {
117
117
-
if flagVal != flagDefault {
118
118
-
return flagVal
119
119
-
}
120
120
-
if cfg.Unpm != nil && cfg.Unpm.Root != "" {
121
121
-
return filepath.Join(filepath.Dir(configPath), cfg.Unpm.Root)
122
122
-
}
123
123
-
return flagDefault
124
124
-
}
125
125
-
126
126
-
func Fetch(cfg *Config, outDir, root string) error {
32
32
+
func Fetch(c *cfg.Config) error {
127
33
v := &vendorer{
128
128
-
outDir: outDir,
34
34
+
outDir: c.Unpm.Out,
129
35
downloaded: make(map[string]string),
130
36
types: make(map[string]string),
131
37
esmPaths: make(map[string]string),
···
133
39
134
40
// Download all imports; relPath is relative to outDir
135
41
downloaded := make(map[string]string) // import key -> relPath within outDir
136
136
-
for key, url := range cfg.Imports {
137
137
-
if cfg.isPinned(key) {
42
42
+
for key, rawURL := range c.Imports {
43
43
+
if c.IsPinned(key) {
138
44
continue
139
45
}
140
140
-
relPath, err := v.download(url)
46
46
+
relPath, err := v.download(rawURL)
141
47
if err != nil {
142
48
return fmt.Errorf("downloading %q: %w", key, err)
143
49
}
···
147
53
// Compute absolute import map paths from root
148
54
rewritten := make(map[string]string)
149
55
for key, relPath := range downloaded {
150
150
-
absPath := filepath.Join(outDir, filepath.FromSlash(relPath))
151
151
-
fromRoot, err := filepath.Rel(root, absPath)
56
56
+
absPath := filepath.Join(c.Unpm.Out, filepath.FromSlash(relPath))
57
57
+
fromRoot, err := filepath.Rel(c.Unpm.Root, absPath)
152
58
if err != nil {
153
59
return fmt.Errorf("computing relative path for %q: %w", key, err)
154
60
}
155
61
rewritten[key] = "/" + filepath.ToSlash(fromRoot)
156
62
}
157
63
158
158
-
if err := writeImportMap(outDir, rewritten); err != nil {
64
64
+
if err := writeImportMap(c.Unpm.Out, rewritten); err != nil {
159
65
return err
160
66
}
161
67
162
68
// Build types mapping: import key -> types path relative to outDir.
163
69
// Use x-typescript-types if available, otherwise fall back to the JS file itself.
164
70
typesMap := make(map[string]string)
165
165
-
for key, url := range cfg.Imports {
166
166
-
if cfg.isPinned(key) {
71
71
+
for key, rawURL := range c.Imports {
72
72
+
if c.IsPinned(key) {
167
73
continue
168
74
}
169
169
-
if typesRel, ok := v.types[url]; ok {
75
75
+
if typesRel, ok := v.types[rawURL]; ok {
170
76
typesMap[key] = "./" + typesRel
171
77
} else {
172
78
typesMap[key] = "./" + downloaded[key]
173
79
}
174
80
}
175
81
176
176
-
if err := writeTypesDts(outDir, typesMap); err != nil {
82
82
+
if err := writeTypesDts(c.Unpm.Out, typesMap); err != nil {
177
83
return err
178
84
}
179
85
180
180
-
checkBareImports(outDir, cfg.Imports)
86
86
+
checkBareImports(c.Unpm.Out, c.Imports)
181
87
182
88
return nil
183
89
}
184
90
185
185
-
func Check(cfg *Config, outDir string) error {
91
91
+
func Check(c *cfg.Config) error {
186
92
var errors []string
187
93
188
94
// 1. Error: bare module specifiers in vendored files not in the import map.
189
95
missing := map[string][]string{}
190
190
-
filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error {
96
96
+
filepath.Walk(c.Unpm.Out, func(p string, info os.FileInfo, err error) error {
191
97
if err != nil || info.IsDir() {
192
98
return nil
193
99
}
···
199
105
if err != nil {
200
106
return nil
201
107
}
202
202
-
relPath, _ := filepath.Rel(outDir, p)
108
108
+
relPath, _ := filepath.Rel(c.Unpm.Out, p)
203
109
relPath = filepath.ToSlash(relPath)
204
110
for _, m := range allImportRe.FindAllStringSubmatch(string(data), -1) {
205
111
spec := m[1]
206
112
if strings.HasPrefix(spec, ".") || strings.HasPrefix(spec, "/") || strings.Contains(spec, "://") {
207
113
continue
208
114
}
209
209
-
if _, ok := cfg.Imports[spec]; !ok {
115
115
+
if _, ok := c.Imports[spec]; !ok {
210
116
missing[spec] = append(missing[spec], relPath)
211
117
}
212
118
}
···
217
123
}
218
124
219
125
// 2. Error: import map entries with no corresponding file on disk.
220
220
-
for key, rawURL := range cfg.Imports {
221
221
-
relPath, err := resolveVendorPath(rawURL, outDir)
126
126
+
for key, rawURL := range c.Imports {
127
127
+
relPath, err := resolveVendorPath(rawURL, c.Unpm.Out)
222
128
if err != nil {
223
129
errors = append(errors, fmt.Sprintf("%q (%s): %v", key, rawURL, err))
224
130
continue
225
131
}
226
226
-
absPath := filepath.Join(outDir, filepath.FromSlash(relPath))
132
132
+
absPath := filepath.Join(c.Unpm.Out, filepath.FromSlash(relPath))
227
133
if _, err := os.Stat(absPath); os.IsNotExist(err) {
228
134
errors = append(errors, fmt.Sprintf("%q: expected file %s not found on disk", key, relPath))
229
135
}
···
231
137
232
138
// 3. Warning: files on disk not reachable from any import map entry.
233
139
reachable := map[string]bool{
234
234
-
filepath.Clean(filepath.Join(outDir, "importmap.js")): true,
235
235
-
filepath.Clean(filepath.Join(outDir, "importmap.json")): true,
236
236
-
filepath.Clean(filepath.Join(outDir, "jsconfig.json")): true,
140
140
+
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.js")): true,
141
141
+
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.json")): true,
142
142
+
filepath.Clean(filepath.Join(c.Unpm.Out, "jsconfig.json")): true,
237
143
}
238
238
-
for key, rawURL := range cfg.Imports {
239
239
-
relPath, err := resolveVendorPath(rawURL, outDir)
144
144
+
for key, rawURL := range c.Imports {
145
145
+
relPath, err := resolveVendorPath(rawURL, c.Unpm.Out)
240
146
if err != nil {
241
147
continue // already reported above
242
148
}
243
243
-
if err := walkImports(outDir, relPath, reachable); err != nil {
149
149
+
if err := walkImports(c.Unpm.Out, relPath, reachable); err != nil {
244
150
fmt.Fprintf(os.Stderr, " \033[33mwarning:\033[0m could not walk imports for %q: %v\n", key, err)
245
151
}
246
152
}
247
153
var unreachable []string
248
248
-
filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error {
154
154
+
filepath.Walk(c.Unpm.Out, func(p string, info os.FileInfo, err error) error {
249
155
if err != nil || info.IsDir() {
250
156
return nil
251
157
}
252
158
if !reachable[filepath.Clean(p)] {
253
253
-
rel, _ := filepath.Rel(outDir, p)
159
159
+
rel, _ := filepath.Rel(c.Unpm.Out, p)
254
160
unreachable = append(unreachable, filepath.ToSlash(rel))
255
161
}
256
162
return nil
···
319
225
}
320
226
}
321
227
322
322
-
func Why(cfg *Config, outDir, target string) error {
228
228
+
func Why(c *cfg.Config, target string) error {
323
229
// Normalize target to a relative path within outDir
324
230
target = filepath.ToSlash(target)
325
325
-
target = strings.TrimPrefix(target, filepath.ToSlash(outDir)+"/")
231
231
+
target = strings.TrimPrefix(target, filepath.ToSlash(c.Unpm.Out)+"/")
326
232
327
233
// BFS from each entry point to find the shortest import chain to target
328
328
-
for key, rawURL := range cfg.Imports {
329
329
-
relPath, err := resolveVendorPath(rawURL, outDir)
234
234
+
for key, rawURL := range c.Imports {
235
235
+
relPath, err := resolveVendorPath(rawURL, c.Unpm.Out)
330
236
if err != nil {
331
237
continue
332
238
}
333
239
334
334
-
chain := findImportChain(outDir, relPath, target)
240
240
+
chain := findImportChain(c.Unpm.Out, relPath, target)
335
241
if chain != nil {
336
242
fmt.Printf(" %s", key)
337
243
for _, link := range chain {
···
394
300
// allImportRe matches all import/export specifiers.
395
301
var allImportRe = regexp.MustCompile(`\b(?:import|export)\s*(?:[^"']*\bfrom\s*|)["']([^"']+)["']`)
396
302
397
397
-
func Prune(cfg *Config, outDir string) error {
303
303
+
func Prune(c *cfg.Config) error {
398
304
// Walk the import graph from entry points to find all reachable files.
399
305
reachable := map[string]bool{
400
306
// Generated outputs of fetch are always reachable.
401
401
-
filepath.Clean(filepath.Join(outDir, "importmap.js")): true,
402
402
-
filepath.Clean(filepath.Join(outDir, "importmap.json")): true,
403
403
-
filepath.Clean(filepath.Join(outDir, "jsconfig.json")): true,
307
307
+
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.js")): true,
308
308
+
filepath.Clean(filepath.Join(c.Unpm.Out, "importmap.json")): true,
309
309
+
filepath.Clean(filepath.Join(c.Unpm.Out, "jsconfig.json")): true,
404
310
}
405
405
-
for key, rawURL := range cfg.Imports {
406
406
-
relPath, err := resolveVendorPath(rawURL, outDir)
311
311
+
for key, rawURL := range c.Imports {
312
312
+
relPath, err := resolveVendorPath(rawURL, c.Unpm.Out)
407
313
if err != nil {
408
314
return fmt.Errorf("resolving %q: %w", key, err)
409
315
}
410
410
-
if err := walkImports(outDir, relPath, reachable); err != nil {
316
316
+
if err := walkImports(c.Unpm.Out, relPath, reachable); err != nil {
411
317
return err
412
318
}
413
319
}
414
320
415
321
// Collect all files in the vendor directory
416
322
var toDelete []string
417
417
-
err := filepath.Walk(outDir, func(p string, info os.FileInfo, err error) error {
323
323
+
err := filepath.Walk(c.Unpm.Out, func(p string, info os.FileInfo, err error) error {
418
324
if err != nil {
419
325
return err
420
326
}
···
438
344
}
439
345
440
346
// Remove empty directories (walk bottom-up)
441
441
-
if err := removeEmptyDirs(outDir); err != nil {
347
347
+
if err := removeEmptyDirs(c.Unpm.Out); err != nil {
442
348
return fmt.Errorf("cleaning empty directories: %w", err)
443
349
}
444
350
···
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://github.com/jakelazaroff/unpm
6
6
+
7
7
+
package unpm_test
8
8
+
9
9
+
import (
10
10
+
"encoding/json"
11
11
+
"net/http"
12
12
+
"net/http/httptest"
13
13
+
"os"
14
14
+
"path/filepath"
15
15
+
"strings"
16
16
+
"testing"
17
17
+
18
18
+
"github.com/jakelazaroff/unpm/pkg/cfg"
19
19
+
"github.com/jakelazaroff/unpm/pkg/unpm"
20
20
+
)
21
21
+
22
22
+
// testFile defines a file served by the test server.
23
23
+
type testFile struct {
24
24
+
body string
25
25
+
headers map[string]string
26
26
+
}
27
27
+
28
28
+
// newTestServer creates an httptest.Server that serves the given files by path.
29
29
+
func newTestServer(files map[string]testFile) *httptest.Server {
30
30
+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31
31
+
f, ok := files[r.URL.Path]
32
32
+
if !ok {
33
33
+
http.NotFound(w, r)
34
34
+
return
35
35
+
}
36
36
+
for k, v := range f.headers {
37
37
+
w.Header().Set(k, v)
38
38
+
}
39
39
+
w.Write([]byte(f.body))
40
40
+
}))
41
41
+
}
42
42
+
43
43
+
func TestFetch_SingleFile(t *testing.T) {
44
44
+
srv := newTestServer(map[string]testFile{
45
45
+
"/mylib.js": {body: `export function hello() { return "hi"; }`},
46
46
+
})
47
47
+
defer srv.Close()
48
48
+
49
49
+
outDir := filepath.Join(t.TempDir(), "vendor")
50
50
+
root := t.TempDir()
51
51
+
52
52
+
c := &cfg.Config{
53
53
+
Imports: map[string]string{"mylib": srv.URL + "/mylib.js"},
54
54
+
Unpm: cfg.Options{Out: outDir, Root: root},
55
55
+
}
56
56
+
if err := unpm.Fetch(c); err != nil {
57
57
+
t.Fatal(err)
58
58
+
}
59
59
+
60
60
+
// Check the JS file exists
61
61
+
host := strings.TrimPrefix(srv.URL, "http://")
62
62
+
jsPath := filepath.Join(outDir, host, "mylib.js")
63
63
+
data, err := os.ReadFile(jsPath)
64
64
+
if err != nil {
65
65
+
t.Fatalf("expected file at %s: %v", jsPath, err)
66
66
+
}
67
67
+
if !strings.Contains(string(data), "hello") {
68
68
+
t.Fatalf("unexpected content: %s", data)
69
69
+
}
70
70
+
71
71
+
// Check importmap.json
72
72
+
imData, err := os.ReadFile(filepath.Join(outDir, "importmap.json"))
73
73
+
if err != nil {
74
74
+
t.Fatal(err)
75
75
+
}
76
76
+
var im struct {
77
77
+
Imports map[string]string `json:"imports"`
78
78
+
}
79
79
+
if err := json.Unmarshal(imData, &im); err != nil {
80
80
+
t.Fatal(err)
81
81
+
}
82
82
+
if _, ok := im.Imports["mylib"]; !ok {
83
83
+
t.Fatalf("importmap.json missing 'mylib': %v", im.Imports)
84
84
+
}
85
85
+
86
86
+
// Check importmap.js exists
87
87
+
if _, err := os.Stat(filepath.Join(outDir, "importmap.js")); err != nil {
88
88
+
t.Fatal("importmap.js not found")
89
89
+
}
90
90
+
91
91
+
// Check jsconfig.json
92
92
+
if _, err := os.Stat(filepath.Join(outDir, "jsconfig.json")); err != nil {
93
93
+
t.Fatal("jsconfig.json not found")
94
94
+
}
95
95
+
}
96
96
+
97
97
+
func TestFetch_TransitiveDeps(t *testing.T) {
98
98
+
srv := newTestServer(map[string]testFile{
99
99
+
"/a.js": {body: `import { b } from "./b.js"; export const a = b;`},
100
100
+
"/b.js": {body: `export const b = 42;`},
101
101
+
})
102
102
+
defer srv.Close()
103
103
+
104
104
+
outDir := filepath.Join(t.TempDir(), "vendor")
105
105
+
c := &cfg.Config{
106
106
+
Imports: map[string]string{"a": srv.URL + "/a.js"},
107
107
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir()},
108
108
+
}
109
109
+
if err := unpm.Fetch(c); err != nil {
110
110
+
t.Fatal(err)
111
111
+
}
112
112
+
113
113
+
host := strings.TrimPrefix(srv.URL, "http://")
114
114
+
115
115
+
// Both files should exist
116
116
+
for _, name := range []string{"a.js", "b.js"} {
117
117
+
p := filepath.Join(outDir, host, name)
118
118
+
if _, err := os.Stat(p); err != nil {
119
119
+
t.Fatalf("expected %s to exist: %v", name, err)
120
120
+
}
121
121
+
}
122
122
+
123
123
+
// a.js should have rewritten import (still relative)
124
124
+
data, _ := os.ReadFile(filepath.Join(outDir, host, "a.js"))
125
125
+
if !strings.Contains(string(data), `"./b.js"`) {
126
126
+
t.Fatalf("expected rewritten import to ./b.js, got: %s", data)
127
127
+
}
128
128
+
}
129
129
+
130
130
+
func TestFetch_OriginRelativeImport(t *testing.T) {
131
131
+
srv := newTestServer(map[string]testFile{
132
132
+
"/entry.js": {body: `import { dep } from "/lib/dep.js"; export default dep;`},
133
133
+
"/lib/dep.js": {body: `export const dep = "ok";`},
134
134
+
})
135
135
+
defer srv.Close()
136
136
+
137
137
+
outDir := filepath.Join(t.TempDir(), "vendor")
138
138
+
c := &cfg.Config{
139
139
+
Imports: map[string]string{"entry": srv.URL + "/entry.js"},
140
140
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir()},
141
141
+
}
142
142
+
if err := unpm.Fetch(c); err != nil {
143
143
+
t.Fatal(err)
144
144
+
}
145
145
+
146
146
+
host := strings.TrimPrefix(srv.URL, "http://")
147
147
+
148
148
+
// dep.js should be downloaded
149
149
+
depPath := filepath.Join(outDir, host, "lib", "dep.js")
150
150
+
if _, err := os.Stat(depPath); err != nil {
151
151
+
t.Fatalf("dep.js not downloaded: %v", err)
152
152
+
}
153
153
+
154
154
+
// entry.js should have rewritten import to relative path
155
155
+
data, _ := os.ReadFile(filepath.Join(outDir, host, "entry.js"))
156
156
+
content := string(data)
157
157
+
if strings.Contains(content, `"/lib/dep.js"`) {
158
158
+
t.Fatalf("origin-relative import was not rewritten: %s", content)
159
159
+
}
160
160
+
if !strings.Contains(content, `"./lib/dep.js"`) {
161
161
+
t.Fatalf("expected rewritten import to ./lib/dep.js, got: %s", content)
162
162
+
}
163
163
+
}
164
164
+
165
165
+
func TestFetch_ESMPath(t *testing.T) {
166
166
+
srv := newTestServer(map[string]testFile{
167
167
+
"/preact": {
168
168
+
body: `/* shim */`,
169
169
+
headers: map[string]string{"X-ESM-Path": "/es2022/preact.mjs"},
170
170
+
},
171
171
+
"/es2022/preact.mjs": {body: `export function h() {}`},
172
172
+
})
173
173
+
defer srv.Close()
174
174
+
175
175
+
outDir := filepath.Join(t.TempDir(), "vendor")
176
176
+
c := &cfg.Config{
177
177
+
Imports: map[string]string{"preact": srv.URL + "/preact"},
178
178
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir()},
179
179
+
}
180
180
+
if err := unpm.Fetch(c); err != nil {
181
181
+
t.Fatal(err)
182
182
+
}
183
183
+
184
184
+
host := strings.TrimPrefix(srv.URL, "http://")
185
185
+
186
186
+
// The canonical file should be downloaded
187
187
+
canonPath := filepath.Join(outDir, host, "es2022", "preact.mjs")
188
188
+
if _, err := os.Stat(canonPath); err != nil {
189
189
+
t.Fatalf("canonical ESM file not downloaded: %v", err)
190
190
+
}
191
191
+
192
192
+
// The shim directory should NOT have a file (unpm skips the shim)
193
193
+
shimDir := filepath.Join(outDir, host, "preact")
194
194
+
if _, err := os.Stat(shimDir); err == nil {
195
195
+
entries, _ := os.ReadDir(shimDir)
196
196
+
for _, e := range entries {
197
197
+
data, _ := os.ReadFile(filepath.Join(shimDir, e.Name()))
198
198
+
if strings.Contains(string(data), "shim") {
199
199
+
t.Fatal("shim file should not have been written")
200
200
+
}
201
201
+
}
202
202
+
}
203
203
+
}
204
204
+
205
205
+
func TestFetch_TypeScriptTypes(t *testing.T) {
206
206
+
srv := newTestServer(map[string]testFile{
207
207
+
"/mylib.js": {
208
208
+
body: `export function hello() {}`,
209
209
+
headers: map[string]string{"X-Typescript-Types": "/mylib.d.ts"},
210
210
+
},
211
211
+
"/mylib.d.ts": {body: `export declare function hello(): void;`},
212
212
+
})
213
213
+
defer srv.Close()
214
214
+
215
215
+
outDir := filepath.Join(t.TempDir(), "vendor")
216
216
+
c := &cfg.Config{
217
217
+
Imports: map[string]string{"mylib": srv.URL + "/mylib.js"},
218
218
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir()},
219
219
+
}
220
220
+
if err := unpm.Fetch(c); err != nil {
221
221
+
t.Fatal(err)
222
222
+
}
223
223
+
224
224
+
host := strings.TrimPrefix(srv.URL, "http://")
225
225
+
226
226
+
// .d.ts file should be downloaded
227
227
+
dtsPath := filepath.Join(outDir, host, "mylib.d.ts")
228
228
+
if _, err := os.Stat(dtsPath); err != nil {
229
229
+
t.Fatalf(".d.ts file not downloaded: %v", err)
230
230
+
}
231
231
+
232
232
+
// jsconfig.json should map to the .d.ts file
233
233
+
data, _ := os.ReadFile(filepath.Join(outDir, "jsconfig.json"))
234
234
+
content := string(data)
235
235
+
if !strings.Contains(content, "mylib.d.ts") {
236
236
+
t.Fatalf("jsconfig.json should reference .d.ts file, got: %s", content)
237
237
+
}
238
238
+
}
239
239
+
240
240
+
func TestFetch_SourceMap(t *testing.T) {
241
241
+
srv := newTestServer(map[string]testFile{
242
242
+
"/app.js": {body: "export const x = 1;\n//# sourceMappingURL=app.js.map"},
243
243
+
"/app.js.map": {body: `{"version":3,"sources":["app.ts"]}`},
244
244
+
})
245
245
+
defer srv.Close()
246
246
+
247
247
+
outDir := filepath.Join(t.TempDir(), "vendor")
248
248
+
c := &cfg.Config{
249
249
+
Imports: map[string]string{"app": srv.URL + "/app.js"},
250
250
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir()},
251
251
+
}
252
252
+
if err := unpm.Fetch(c); err != nil {
253
253
+
t.Fatal(err)
254
254
+
}
255
255
+
256
256
+
host := strings.TrimPrefix(srv.URL, "http://")
257
257
+
258
258
+
// .map file should be downloaded
259
259
+
mapPath := filepath.Join(outDir, host, "app.js.map")
260
260
+
if _, err := os.Stat(mapPath); err != nil {
261
261
+
t.Fatalf("source map not downloaded: %v", err)
262
262
+
}
263
263
+
264
264
+
// The sourceMappingURL should be rewritten to a relative path
265
265
+
data, _ := os.ReadFile(filepath.Join(outDir, host, "app.js"))
266
266
+
content := string(data)
267
267
+
if !strings.Contains(content, "sourceMappingURL=./app.js.map") {
268
268
+
t.Fatalf("sourceMappingURL not rewritten, got: %s", content)
269
269
+
}
270
270
+
}
271
271
+
272
272
+
func TestFetch_Pin(t *testing.T) {
273
273
+
srv := newTestServer(map[string]testFile{
274
274
+
"/a.js": {body: `export const a = 1;`},
275
275
+
"/b.js": {body: `export const b = 2;`},
276
276
+
})
277
277
+
defer srv.Close()
278
278
+
279
279
+
outDir := filepath.Join(t.TempDir(), "vendor")
280
280
+
c := &cfg.Config{
281
281
+
Imports: map[string]string{
282
282
+
"a": srv.URL + "/a.js",
283
283
+
"b": srv.URL + "/b.js",
284
284
+
},
285
285
+
Unpm: cfg.Options{Out: outDir, Root: t.TempDir(), Pin: []string{"b"}},
286
286
+
}
287
287
+
if err := unpm.Fetch(c); err != nil {
288
288
+
t.Fatal(err)
289
289
+
}
290
290
+
291
291
+
host := strings.TrimPrefix(srv.URL, "http://")
292
292
+
293
293
+
// a.js should exist
294
294
+
if _, err := os.Stat(filepath.Join(outDir, host, "a.js")); err != nil {
295
295
+
t.Fatal("a.js should be downloaded")
296
296
+
}
297
297
+
298
298
+
// b.js should NOT exist (pinned)
299
299
+
if _, err := os.Stat(filepath.Join(outDir, host, "b.js")); err == nil {
300
300
+
t.Fatal("b.js should not be downloaded (pinned)")
301
301
+
}
302
302
+
}
303
303
+
304
304
+
func TestCheck(t *testing.T) {
305
305
+
outDir := t.TempDir()
306
306
+
host := "example.com"
307
307
+
308
308
+
// Set up vendor files
309
309
+
dir := filepath.Join(outDir, host)
310
310
+
os.MkdirAll(dir, 0o755)
311
311
+
os.WriteFile(filepath.Join(dir, "a.js"), []byte(`import { b } from "./b.js"; export const a = b;`), 0o644)
312
312
+
os.WriteFile(filepath.Join(dir, "b.js"), []byte(`export const b = 1;`), 0o644)
313
313
+
314
314
+
// Write generated files so check doesn't warn about them
315
315
+
os.WriteFile(filepath.Join(outDir, "importmap.js"), []byte(""), 0o644)
316
316
+
os.WriteFile(filepath.Join(outDir, "importmap.json"), []byte("{}"), 0o644)
317
317
+
os.WriteFile(filepath.Join(outDir, "jsconfig.json"), []byte("{}"), 0o644)
318
318
+
319
319
+
t.Run("passing", func(t *testing.T) {
320
320
+
c := &cfg.Config{
321
321
+
Imports: map[string]string{"a": "https://example.com/a.js"},
322
322
+
Unpm: cfg.Options{Out: outDir},
323
323
+
}
324
324
+
if err := unpm.Check(c); err != nil {
325
325
+
t.Fatalf("expected check to pass: %v", err)
326
326
+
}
327
327
+
})
328
328
+
329
329
+
t.Run("missing file on disk", func(t *testing.T) {
330
330
+
c := &cfg.Config{
331
331
+
Imports: map[string]string{
332
332
+
"a": "https://example.com/a.js",
333
333
+
"missing": "https://example.com/missing.js",
334
334
+
},
335
335
+
Unpm: cfg.Options{Out: outDir},
336
336
+
}
337
337
+
err := unpm.Check(c)
338
338
+
if err == nil {
339
339
+
t.Fatal("expected check to fail for missing file")
340
340
+
}
341
341
+
})
342
342
+
343
343
+
t.Run("bare import not in map", func(t *testing.T) {
344
344
+
os.WriteFile(filepath.Join(dir, "c.js"), []byte(`import { x } from "unknown-pkg"; export const c = x;`), 0o644)
345
345
+
c := &cfg.Config{
346
346
+
Imports: map[string]string{"c": "https://example.com/c.js"},
347
347
+
Unpm: cfg.Options{Out: outDir},
348
348
+
}
349
349
+
err := unpm.Check(c)
350
350
+
if err == nil {
351
351
+
t.Fatal("expected check to fail for bare import not in map")
352
352
+
}
353
353
+
os.Remove(filepath.Join(dir, "c.js"))
354
354
+
})
355
355
+
}
356
356
+
357
357
+
func TestPrune(t *testing.T) {
358
358
+
outDir := t.TempDir()
359
359
+
host := "example.com"
360
360
+
361
361
+
dir := filepath.Join(outDir, host)
362
362
+
os.MkdirAll(dir, 0o755)
363
363
+
364
364
+
// a.js imports b.js (reachable); c.js is unreachable
365
365
+
os.WriteFile(filepath.Join(dir, "a.js"), []byte(`import { b } from "./b.js"; export const a = b;`), 0o644)
366
366
+
os.WriteFile(filepath.Join(dir, "b.js"), []byte(`export const b = 1;`), 0o644)
367
367
+
os.WriteFile(filepath.Join(dir, "c.js"), []byte(`export const c = "orphan";`), 0o644)
368
368
+
369
369
+
// Create an unreachable file in a subdirectory (to test empty dir removal)
370
370
+
subDir := filepath.Join(dir, "sub")
371
371
+
os.MkdirAll(subDir, 0o755)
372
372
+
os.WriteFile(filepath.Join(subDir, "orphan.js"), []byte(`export default 0;`), 0o644)
373
373
+
374
374
+
// Write generated files
375
375
+
os.WriteFile(filepath.Join(outDir, "importmap.js"), []byte(""), 0o644)
376
376
+
os.WriteFile(filepath.Join(outDir, "importmap.json"), []byte("{}"), 0o644)
377
377
+
os.WriteFile(filepath.Join(outDir, "jsconfig.json"), []byte("{}"), 0o644)
378
378
+
379
379
+
c := &cfg.Config{
380
380
+
Imports: map[string]string{"a": "https://example.com/a.js"},
381
381
+
Unpm: cfg.Options{Out: outDir},
382
382
+
}
383
383
+
if err := unpm.Prune(c); err != nil {
384
384
+
t.Fatal(err)
385
385
+
}
386
386
+
387
387
+
// a.js and b.js should remain
388
388
+
if _, err := os.Stat(filepath.Join(dir, "a.js")); err != nil {
389
389
+
t.Fatal("a.js should not be pruned")
390
390
+
}
391
391
+
if _, err := os.Stat(filepath.Join(dir, "b.js")); err != nil {
392
392
+
t.Fatal("b.js should not be pruned")
393
393
+
}
394
394
+
395
395
+
// c.js and sub/orphan.js should be removed
396
396
+
if _, err := os.Stat(filepath.Join(dir, "c.js")); !os.IsNotExist(err) {
397
397
+
t.Fatal("c.js should be pruned")
398
398
+
}
399
399
+
if _, err := os.Stat(filepath.Join(subDir, "orphan.js")); !os.IsNotExist(err) {
400
400
+
t.Fatal("sub/orphan.js should be pruned")
401
401
+
}
402
402
+
403
403
+
// sub/ directory should be removed (now empty)
404
404
+
if _, err := os.Stat(subDir); !os.IsNotExist(err) {
405
405
+
t.Fatal("empty sub/ directory should be removed")
406
406
+
}
407
407
+
}
408
408
+
409
409
+
func TestWhy(t *testing.T) {
410
410
+
outDir := t.TempDir()
411
411
+
host := "example.com"
412
412
+
413
413
+
dir := filepath.Join(outDir, host)
414
414
+
os.MkdirAll(dir, 0o755)
415
415
+
os.WriteFile(filepath.Join(dir, "entry.js"), []byte(`import { dep } from "./dep.js"; export default dep;`), 0o644)
416
416
+
os.WriteFile(filepath.Join(dir, "dep.js"), []byte(`export const dep = 1;`), 0o644)
417
417
+
418
418
+
c := &cfg.Config{
419
419
+
Imports: map[string]string{"entry": "https://example.com/entry.js"},
420
420
+
Unpm: cfg.Options{Out: outDir},
421
421
+
}
422
422
+
423
423
+
// Should find the chain
424
424
+
if err := unpm.Why(c, "example.com/dep.js"); err != nil {
425
425
+
t.Fatalf("expected Why to find chain: %v", err)
426
426
+
}
427
427
+
428
428
+
// Should fail for a file not in the chain
429
429
+
if err := unpm.Why(c, "example.com/nonexistent.js"); err == nil {
430
430
+
t.Fatal("expected Why to fail for nonexistent target")
431
431
+
}
432
432
+
}