fix(esm): Vendor 支持子路径,不然 es-toolkit/compat 这种装不了
有些包把东西放在子路径下(es-toolkit 的 toString 只在 compat 里,主入口没有),
而摊平之后原包的 exports 映射就没了,import "es-toolkit/compat" 解析不到。
现在直接写子路径就行:
esm.Install(ctx, "es-toolkit/compat", "app/node_modules")
拉的是根包,摊平的是子路径,落到 node_modules/es-toolkit/compat/。根包和子路径
可以共存——子路径目录嵌在根包目录里,而最小 package.json 不写 exports,
所以解析器认得出来。
npm.SplitPath 负责拆名字,scoped 包名自带一个斜杠所以前两段才是包名。
This commit is contained in:
@@ -230,6 +230,16 @@ res, err := esm.Install(ctx, "qs", "app/node_modules")
|
||||
res, err := esm.Vendor(".", "qs", "app/node_modules")
|
||||
```
|
||||
|
||||
包把东西放在**子路径**下的(`es-toolkit/compat`、`lodash/isString`),直接写子路径:
|
||||
|
||||
```go
|
||||
esm.Install(ctx, "es-toolkit/compat", "app/node_modules")
|
||||
```
|
||||
|
||||
拉的是根包,摊平的是那个子路径,落到 `node_modules/es-toolkit/compat/`。
|
||||
**必须这么写**——摊平之后原包的 `exports` 映射就没了,`import "es-toolkit/compat"`
|
||||
在只有根包的情况下解析不到。
|
||||
|
||||
`esm/npm` 只做「把包和依赖弄到磁盘上」,**刻意不是 npm**:不跑安装脚本(供应链攻击
|
||||
的主要入口,而纯 JS 库根本不需要)、不管 devDependencies、semver 只实现 `^`/`~`/精确/
|
||||
`x`/`>=` 这个子集、只平铺不嵌套。碰上支持不了的(复合版本范围、主版本冲突)会**明确
|
||||
|
||||
@@ -323,6 +323,26 @@ func SplitSpec(spec string) (name, rng string) {
|
||||
return spec, ""
|
||||
}
|
||||
|
||||
// SplitPath 把带子路径的名字拆成包名和子路径。
|
||||
//
|
||||
// es-toolkit → es-toolkit, ""
|
||||
// es-toolkit/compat → es-toolkit, "compat"
|
||||
// @scope/pkg → @scope/pkg, ""
|
||||
// @scope/pkg/sub/dir → @scope/pkg, "sub/dir"
|
||||
//
|
||||
// scoped 包名自带一个斜杠,所以前两段是包名。
|
||||
func SplitPath(name string) (pkg, subpath string) {
|
||||
segs := strings.Split(name, "/")
|
||||
n := 1
|
||||
if strings.HasPrefix(name, "@") {
|
||||
n = 2
|
||||
}
|
||||
if len(segs) <= n {
|
||||
return name, ""
|
||||
}
|
||||
return strings.Join(segs[:n], "/"), strings.Join(segs[n:], "/")
|
||||
}
|
||||
|
||||
func keysOf(m map[string]release) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
|
||||
@@ -220,3 +220,22 @@ func pick(versions []string, r versionRange) (string, error) {
|
||||
}
|
||||
return bestRaw, nil
|
||||
}
|
||||
|
||||
// Satisfies 判断某个版本满不满足一个范围。
|
||||
//
|
||||
// 给「已经装过了,还用不用重装」这类判断用:配置里写 qs@^6,装着的是 6.16.0
|
||||
// 就跳过,是 5.x 就得换。
|
||||
//
|
||||
// 范围的写法支持范围见本文件开头;解析不了会返回错误,调用方可以选择当作
|
||||
// 「需要重装」还是直接报错。
|
||||
func Satisfies(version, rangeSpec string) (bool, error) {
|
||||
v, err := parseVersion(version)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
r, err := parseRange(rangeSpec)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r.allows(v), nil
|
||||
}
|
||||
|
||||
@@ -121,3 +121,46 @@ func TestSplitSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSatisfies(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
ver, rng string
|
||||
want bool
|
||||
}{
|
||||
{"6.16.0", "^6", true},
|
||||
{"5.9.0", "^6", false},
|
||||
{"6.16.0", "", true}, // 空范围=任意
|
||||
{"6.16.0", "*", true},
|
||||
{"1.2.9", "~1.2", true},
|
||||
{"1.3.0", "~1.2", false},
|
||||
{"1.2.3", "1.2.3", true},
|
||||
} {
|
||||
got, err := Satisfies(c.ver, c.rng)
|
||||
if err != nil {
|
||||
t.Errorf("Satisfies(%q, %q): %v", c.ver, c.rng, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("Satisfies(%q, %q) = %v", c.ver, c.rng, got)
|
||||
}
|
||||
}
|
||||
if _, err := Satisfies("1.0.0", "^1 || ^2"); err == nil {
|
||||
t.Error("支持不了的范围该报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPath(t *testing.T) {
|
||||
for _, c := range []struct{ in, pkg, sub string }{
|
||||
{"es-toolkit", "es-toolkit", ""},
|
||||
{"es-toolkit/compat", "es-toolkit", "compat"},
|
||||
{"lodash/isString", "lodash", "isString"},
|
||||
{"@scope/pkg", "@scope/pkg", ""},
|
||||
{"@scope/pkg/sub", "@scope/pkg", "sub"},
|
||||
{"@scope/pkg/a/b", "@scope/pkg", "a/b"},
|
||||
} {
|
||||
pkg, sub := SplitPath(c.in)
|
||||
if pkg != c.pkg || sub != c.sub {
|
||||
t.Errorf("SplitPath(%q) = %q, %q,该是 %q, %q", c.in, pkg, sub, c.pkg, c.sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -30,6 +30,11 @@ import (
|
||||
// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules);
|
||||
// outRoot 一般传脚本目录旁边的 node_modules。
|
||||
//
|
||||
// name 可以带子路径,比如 es-toolkit/compat——有些包把东西放在子路径下,
|
||||
// 而摊平之后原包的 exports 映射就没了,子路径解析不到。这时把子路径本身
|
||||
// 当成一个包摊平,落到 node_modules/es-toolkit/compat/,脚本照常
|
||||
// `import { toString } from "es-toolkit/compat"`。
|
||||
//
|
||||
// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法——
|
||||
// 用 WithTarget 改过 Loader 的话这里也要改。
|
||||
func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult, error) {
|
||||
@@ -39,7 +44,11 @@ func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult,
|
||||
}
|
||||
|
||||
var res VendorResult
|
||||
version, err := installedVersion(fromDir, name)
|
||||
|
||||
// name 可以带子路径(es-toolkit/compat)。版本从**根包**读,
|
||||
// 但打包入口和输出目录都用完整的名字。
|
||||
pkg, _ := npm.SplitPath(name)
|
||||
version, err := installedVersion(fromDir, pkg)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
@@ -85,11 +94,17 @@ func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (V
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
if _, err := npm.Fetch(ctx, tmp, spec); err != nil {
|
||||
return VendorResult{}, err
|
||||
// spec 可能带子路径(es-toolkit/compat@^1):拉的是根包,摊的是子路径
|
||||
name, rng := npm.SplitSpec(spec)
|
||||
pkg, _ := npm.SplitPath(name)
|
||||
fetchSpec := pkg
|
||||
if rng != "" {
|
||||
fetchSpec = pkg + "@" + rng
|
||||
}
|
||||
|
||||
name, _ := npm.SplitSpec(spec)
|
||||
if _, err := npm.Fetch(ctx, tmp, fetchSpec); err != nil {
|
||||
return VendorResult{}, err
|
||||
}
|
||||
return Vendor(tmp, name, outRoot, opts...)
|
||||
}
|
||||
|
||||
|
||||
@@ -191,3 +191,71 @@ func TestVendor_写出最小的package_json(t *testing.T) {
|
||||
t.Error("不该写 exports 字段")
|
||||
}
|
||||
}
|
||||
|
||||
// 带子路径的名字要能摊平:有些包把东西放在子路径下(es-toolkit/compat),
|
||||
// 而摊平之后原包的 exports 映射就没了,子路径本来解析不到。
|
||||
func TestVendor_子路径当成独立的包摊平(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// 摆一个带子目录的包:根入口和 sub 入口导出不同的东西
|
||||
writePkg(t, dir, "multi", "1.0.0", "index.js", `export const root = "根"`, nil)
|
||||
subDir := filepath.Join(dir, "node_modules", "multi", "sub")
|
||||
if err := os.MkdirAll(subDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subDir, "index.js"),
|
||||
[]byte(`export const only = "只在子路径里"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := t.TempDir()
|
||||
out := filepath.Join(app, "node_modules")
|
||||
if _, err := esm.Vendor(dir, "multi/sub", out); err != nil {
|
||||
t.Fatalf("子路径摊不平: %v", err)
|
||||
}
|
||||
// 根包也摊一份,验证两者能共存(子路径目录嵌在根包目录里)
|
||||
if _, err := esm.Vendor(dir, "multi", out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(app, "Http"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(app, "Http", "C.ts"), []byte(`
|
||||
import { root } from "multi"
|
||||
import { only } from "multi/sub"
|
||||
export default class C {
|
||||
Run(): string { return root + "/" + only }
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loader, err := esm.NewLoader(app)
|
||||
if err != nil {
|
||||
t.Fatalf("打包失败: %v", err)
|
||||
}
|
||||
e, err := jscriptx.New(jscriptx.WithLoader(loader), jscriptx.WithLogger(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
s, err := e.Script("Http/C")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
obj, err := s.New(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
got, err := obj.Call(ctx, "Run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "根/只在子路径里" {
|
||||
t.Errorf("Run = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user