a simpler package manager for no-build websites
0

Configure Feed

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

Add tests and refactor

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