···2121 "strings"
2222)
23232424+// RelativePath is the slash-separated path of a vendored file relative to the
2525+// output directory, e.g. "esm.sh/preact@10/index.js". Because it encodes the
2626+// original host and path, it is the canonical identifier for a vendored file
2727+// across the codebase; giving it its own type keeps it from being confused with
2828+// disk paths, web paths, or raw import specifiers, which are all plain strings.
2929+type RelativePath string
3030+3131+// Dir returns the directory containing r.
3232+func (r RelativePath) Dir() RelativePath { return RelativePath(path.Dir(string(r))) }
3333+3434+// Base returns the final element of r (its filename).
3535+func (r RelativePath) Base() string { return path.Base(string(r)) }
3636+3737+// Join extends r with additional slash-separated elements, cleaning the result.
3838+func (r RelativePath) Join(elems ...string) RelativePath {
3939+ return RelativePath(path.Join(append([]string{string(r)}, elems...)...))
4040+}
4141+2442// Layout describes where vendored files live and how they are served.
2543type Layout struct {
2644 // Out is the output directory on disk.
···2947 Root string
3048}
31493232-// Rel returns the slash-separated path, relative to the output directory, at
3333-// which the resource for u is vendored: its host joined with its path. A query
3434-// string is folded into a suffix (inserted before the file extension, so
3535-// extension-based handling still works) to keep URLs that differ only by their
3636-// query from colliding on disk.
3737-func Rel(u *url.URL) string {
5050+// Rel returns the RelativePath at which the resource for u is vendored: its
5151+// host joined with its path. A query string is folded into a suffix (inserted
5252+// before the file extension, so extension-based handling still works) to keep
5353+// URLs that differ only by their query from colliding on disk.
5454+func Rel(u *url.URL) RelativePath {
3855 p := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(u.Path, "/")), "/")
3956 if p == "" || p == "." {
4057 p = "index"
···4360 ext := path.Ext(p)
4461 p = strings.TrimSuffix(p, ext) + "__" + safeQuery(u.RawQuery) + ext
4562 }
4646- return path.Join(u.Host, p)
6363+ return RelativePath(path.Join(u.Host, p))
4764}
48654949-// DiskPath returns the on-disk location of a slash-relative vendored path.
5050-func (l Layout) DiskPath(rel string) string {
5151- return filepath.Join(l.Out, filepath.FromSlash(rel))
6666+// DiskPath returns the on-disk location of a vendored path.
6767+func (l Layout) DiskPath(rel RelativePath) string {
6868+ return filepath.Join(l.Out, filepath.FromSlash(string(rel)))
5269}
53705454-// WebPath returns the import-map URL for a slash-relative vendored path, e.g.
7171+// WebPath returns the import-map URL for a vendored path, e.g.
5572// "/vendor/esm.sh/preact@10/index.js".
5656-func (l Layout) WebPath(rel string) string {
5757- return path.Join(l.Root, rel)
7373+func (l Layout) WebPath(rel RelativePath) string {
7474+ return path.Join(l.Root, string(rel))
5875}
59766077// RelFromWeb converts an import-map URL produced by WebPath back into its
6161-// slash-relative vendored path by stripping the web root.
6262-func (l Layout) RelFromWeb(webPath string) string {
7878+// vendored RelativePath by stripping the web root.
7979+func (l Layout) RelFromWeb(webPath string) RelativePath {
6380 root := l.Root
6481 if !strings.HasSuffix(root, "/") {
6582 root += "/"
6683 }
6767- return strings.TrimPrefix(webPath, root)
8484+ return RelativePath(strings.TrimPrefix(webPath, root))
6885}
69867087// JSConfigPath returns the path used in jsconfig.json's compilerOptions.paths.
7188// It is relative to the output directory (where jsconfig.json lives) and so
7289// never includes the web root.
7373-func JSConfigPath(rel string) string {
7474- return "./" + rel
9090+func JSConfigPath(rel RelativePath) string {
9191+ return "./" + string(rel)
7592}
76937794// Relative returns the browser-relative specifier that, from the vendored file
7895// at fromRel, points to the vendored file at toRel (e.g. "./dep.js",
7996// "../lib/x.js").
8080-func Relative(fromRel, toRel string) string {
8181- rel := relSlash(path.Dir(fromRel), toRel)
9797+func Relative(fromRel, toRel RelativePath) string {
9898+ rel := relSlash(path.Dir(string(fromRel)), string(toRel))
8299 if !strings.HasPrefix(rel, ".") {
83100 rel = "./" + rel
84101 }
···11+// This Source Code Form is subject to the terms of the Mozilla Public
22+// License, v. 2.0. If a copy of the MPL was not distributed with this
33+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
44+//
55+// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/unpm
66+77+// Package modgraph reads the graph of already-vendored modules from disk. It is
88+// the shared foundation for the `check` and `why` commands, which both need to
99+// walk imports starting from the import-map entries.
1010+//
1111+// Vendored files are identified by their layout.RelativePath. Because that path
1212+// encodes the original host and path, references can be resolved with plain
1313+// path arithmetic, without reconstructing URLs. modgraph never fetches anything.
1414+package modgraph
1515+1616+import (
1717+ "io/fs"
1818+ "net/url"
1919+ "os"
2020+ "path"
2121+ "path/filepath"
2222+ "strings"
2323+2424+ "tangled.org/jakelazaroff.com/unpm/internal/imports"
2525+ "tangled.org/jakelazaroff.com/unpm/internal/layout"
2626+)
2727+2828+// Import is a single reference found inside a vendored file.
2929+type Import struct {
3030+ // Spec is the specifier exactly as it appears in the source.
3131+ Spec string
3232+ // Bare is true for bare module specifiers, which are resolved by the
3333+ // import map rather than by location.
3434+ Bare bool
3535+ // SourceMap is true for a //# sourceMappingURL= reference.
3636+ SourceMap bool
3737+ // Target is the resolved location of the referenced file (empty when Bare,
3838+ // or when the reference is an external URL that can't be placed on disk).
3939+ Target layout.RelativePath
4040+}
4141+4242+// Graph reads the vendored module graph rooted at a layout.
4343+type Graph struct {
4444+ layout layout.Layout
4545+}
4646+4747+// New returns a Graph that reads files under l.
4848+func New(l layout.Layout) Graph {
4949+ return Graph{layout: l}
5050+}
5151+5252+// Imports reads the vendored file at rel and returns its references. Files that
5353+// are not JavaScript/TypeScript have no references.
5454+func (g Graph) Imports(rel layout.RelativePath) ([]Import, error) {
5555+ if !isJavaScript(rel) {
5656+ return nil, nil
5757+ }
5858+ data, err := os.ReadFile(g.layout.DiskPath(rel))
5959+ if err != nil {
6060+ return nil, err
6161+ }
6262+ src := string(data)
6363+6464+ var imps []Import
6565+ for _, spec := range imports.Scan(src) {
6666+ if isBare(spec) {
6767+ imps = append(imps, Import{Spec: spec, Bare: true})
6868+ } else {
6969+ imps = append(imps, Import{Spec: spec, Target: resolve(rel, spec)})
7070+ }
7171+ }
7272+ for _, spec := range imports.ScanSourceMaps(src) {
7373+ imps = append(imps, Import{Spec: spec, SourceMap: true, Target: resolve(rel, spec)})
7474+ }
7575+ return imps, nil
7676+}
7777+7878+// Exists reports whether the vendored file at rel is present on disk.
7979+func (g Graph) Exists(rel layout.RelativePath) bool {
8080+ info, err := os.Stat(g.layout.DiskPath(rel))
8181+ return err == nil && !info.IsDir()
8282+}
8383+8484+// Walk calls fn for every vendored file under the output directory, identified
8585+// by its RelativePath. It is a no-op when the output directory is absent.
8686+func (g Graph) Walk(fn func(rel layout.RelativePath) error) error {
8787+ out := g.layout.Out
8888+ if _, err := os.Stat(out); os.IsNotExist(err) {
8989+ return nil
9090+ }
9191+ return filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error {
9292+ if err != nil || d.IsDir() {
9393+ return err
9494+ }
9595+ rel, err := filepath.Rel(out, p)
9696+ if err != nil {
9797+ return err
9898+ }
9999+ return fn(layout.RelativePath(filepath.ToSlash(rel)))
100100+ })
101101+}
102102+103103+// isBare reports whether spec is a bare module specifier — one resolved by the
104104+// import map rather than by location (not "./", "../", "/", or a full URL).
105105+func isBare(spec string) bool {
106106+ return spec != "" &&
107107+ !strings.HasPrefix(spec, ".") &&
108108+ !strings.HasPrefix(spec, "/") &&
109109+ !strings.Contains(spec, "://")
110110+}
111111+112112+// resolve turns a non-bare specifier found in the file at rel into the location
113113+// it points to: a full URL maps to its own host/path; a root-relative specifier
114114+// maps under the file's host; a relative specifier is joined to the file's
115115+// directory. An empty result means the reference can't be placed on disk.
116116+func resolve(rel layout.RelativePath, spec string) layout.RelativePath {
117117+ switch {
118118+ case strings.Contains(spec, "://"):
119119+ u, err := url.Parse(spec)
120120+ if err != nil || u.Host == "" {
121121+ return ""
122122+ }
123123+ return layout.RelativePath(path.Clean(u.Host + u.Path))
124124+ case strings.HasPrefix(spec, "/"):
125125+ host := string(rel)
126126+ if i := strings.IndexByte(host, '/'); i >= 0 {
127127+ host = host[:i]
128128+ }
129129+ return layout.RelativePath(path.Clean(host + spec))
130130+ default:
131131+ return rel.Dir().Join(spec)
132132+ }
133133+}
134134+135135+var jsExts = map[string]bool{
136136+ ".js": true, ".mjs": true, ".cjs": true, ".jsx": true,
137137+ ".ts": true, ".tsx": true, ".mts": true, ".cts": true,
138138+}
139139+140140+// isJavaScript reports whether rel should be scanned for module references.
141141+// Extensionless paths (such as esm.sh entry points) are treated as JavaScript.
142142+func isJavaScript(rel layout.RelativePath) bool {
143143+ ext := path.Ext(string(rel))
144144+ return ext == "" || jsExts[ext]
145145+}