From d88a29e002ebc31bf9b4e1e024f141cc0b39a657 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Mon, 23 Mar 2026 16:00:50 +0100 Subject: [PATCH] npm: Use workspaces to simplify `hugo mod npm pack` Rewrite `hugo mod npm pack` to use npm workspaces. Module deps are now written to packages/hugoautogen/package.json and the root package.json gets a "workspaces" reference. A hugo_packagemeta.json sidecar stores a hash of all input package files so regular commands can warn when npm deps are out of sync. Other changes: - Workspace glob patterns (*, **, {a,b}) are resolved via gobwas/glob. - Workspaces defined in package.hugo.json are supported. - package.hugo.json is only recognised at module roots, not in workspaces. - When package.hugo.json exists, package.json is not mounted or vendored. - packages/hugoautogen is not mounted or vendored from dependencies. - Add usePackageJSON import option (auto/always/never) to control whether a module's npm deps are included. "auto" checks for Hugo config files or package.hugo.json. - The staleness check is skipped when running `hugo mod npm pack` itself. Co-authored-by: Claude Opus 4.6 (1M context) --- commands/commandeer.go | 3 + commands/mod.go | 32 +- common/loggers/logger.go | 2 +- config/allconfig/load.go | 8 + hugofs/files/classifier.go | 8 +- modules/client.go | 1 + modules/collect.go | 83 ++- modules/collect_test.go | 59 ++ modules/config.go | 12 + modules/module.go | 2 +- modules/npm/package_builder.go | 610 +++++++++++++++--- .../npm/package_builder_integration_test.go | 88 +++ modules/npm/package_builder_test.go | 12 +- testscripts/commands/mod_npm.txt | 217 ++++++- testscripts/commands/mod_npm__moduleorder.txt | 68 ++ testscripts/commands/mod_npm_withexisting.txt | 41 +- 16 files changed, 1086 insertions(+), 160 deletions(-) create mode 100644 modules/npm/package_builder_integration_test.go create mode 100644 testscripts/commands/mod_npm__moduleorder.txt diff --git a/commands/commandeer.go b/commands/commandeer.go index dc7bd7f33..21a8f24fc 100644 --- a/commands/commandeer.go +++ b/commands/commandeer.go @@ -97,6 +97,7 @@ type commonConfig struct { type configKey struct { counter int32 ignoreModulesDoesNotExists bool + skipNpmCheck bool } // This is the root command. @@ -195,6 +196,7 @@ func (r *rootCommand) ConfigFromConfig(key configKey, oldConf *commonConfig) (*c Logger: r.logger, Environment: r.environment, IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists, + SkipNpmCheck: key.skipNpmCheck, }, ) if err != nil { @@ -251,6 +253,7 @@ func (r *rootCommand) ConfigFromProvider(key configKey, cfg config.Provider) (*c Environment: r.environment, Logger: r.logger, IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists, + SkipNpmCheck: key.skipNpmCheck, }, ) if err != nil { diff --git a/commands/mod.go b/commands/mod.go index 0c49565c0..efe3f1a5b 100644 --- a/commands/mod.go +++ b/commands/mod.go @@ -21,6 +21,7 @@ import ( "github.com/bep/simplecobra" "github.com/gohugoio/hugo/config" + "github.com/gohugoio/hugo/hugolib" "github.com/gohugoio/hugo/modules/npm" "github.com/spf13/cobra" ) @@ -49,28 +50,34 @@ func newModCommands() *modCommands { commands: []simplecobra.Commander{ &simpleCommand{ name: "pack", - short: "Experimental: Prepares and writes a composite package.json file for your project", - long: `Prepares and writes a composite package.json file for your project. + short: "Merges module npm dependencies into an npm workspace", + long: `Merges npm dependencies from all Hugo modules into a "packages/hugoautogen" npm workspace. -On first run it creates a "package.hugo.json" in the project root if not already there. This file will be used as a template file -with the base dependency set. +The merged dependencies are written to packages/hugoautogen/package.json, and the root package.json +is updated with a "workspaces" entry pointing to "packages/hugoautogen". -This set will be merged with all "package.hugo.json" files found in the dependency tree, picking the version closest to the project. +The source entries are read from either package.hugo.json or package.json in the module root, with package.hugo.json taking precedence if both exist. -This command is marked as 'Experimental'. We think it's a great idea, so it's not likely to be -removed from Hugo, but we need to test this out in "real life" to get a feel of it, -so this may/will change in future versions of Hugo. +See [npm dependencies](/hugo-modules/npm-dependencies/) for more information. `, withc: func(cmd *cobra.Command, r *rootCommand) { cmd.ValidArgsFunction = cobra.NoFileCompletions applyLocalFlagsBuildConfig(cmd, r) }, run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error { - h, err := r.Hugo(flagsToCfg(cd, nil)) + cfg := flagsToCfg(cd, nil) + k := configKey{counter: r.configVersionID.Load(), skipNpmCheck: true} + h, _, err := r.hugoSites.GetOrCreate(k, func(key configKey) (*hugolib.HugoSites, error) { + conf, err := r.ConfigFromProvider(key, cfg) + if err != nil { + return nil, err + } + return hugolib.NewHugoSites(r.newDepsConfig(conf)) + }) if err != nil { return err } - return npm.Pack(h.BaseFs.ProjectSourceFs, h.BaseFs.AssetsWithDuplicatesPreserved.Fs) + return npm.Pack(h.BaseFs.ProjectSourceFs, h.BaseFs.AssetsWithDuplicatesPreserved.Fs, h.Configs.Modules) }, }, }, @@ -293,7 +300,10 @@ Run "go help get" for more information. All flags available for "go get" is also return err } client := conf.configs.ModulesClient - return client.Get(args...) + if err := client.Get(args...); err != nil { + return err + } + return nil } }, }, diff --git a/common/loggers/logger.go b/common/loggers/logger.go index ff84081c0..ba26137cd 100644 --- a/common/loggers/logger.go +++ b/common/loggers/logger.go @@ -286,7 +286,7 @@ func (l *logAdapter) PrintTimerIfDelayed(start time.Time, name string) { if milli < 500 { return } - fmt.Fprintf(l.stdErr, "%s in %v ms", name, milli) + fmt.Fprintf(l.stdErr, "%s in %v ms\n", name, milli) } func (l *logAdapter) Printf(format string, v ...any) { diff --git a/config/allconfig/load.go b/config/allconfig/load.go index b8d93a8f0..fdb349c29 100644 --- a/config/allconfig/load.go +++ b/config/allconfig/load.go @@ -34,6 +34,7 @@ import ( "github.com/gohugoio/hugo/helpers" hglob "github.com/gohugoio/hugo/hugofs/hglob" "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/modules/npm" "github.com/gohugoio/hugo/parser/metadecoders" "github.com/spf13/afero" ) @@ -95,6 +96,10 @@ func LoadConfig(d ConfigSourceDescriptor) (configs *Configs, err error) { configs.Modules = moduleConfig.AllModules configs.ModulesClient = modulesClient + if !d.SkipNpmCheck && npm.NpmPackNeedsUpdate(d.Fs, configs.Modules) { + d.Logger.Warnln(`npm dependencies are out of sync, please run "hugo mod npm pack" (you may also want to run "npm install" after that)`) + } + if err := configs.Init(d.Fs, d.Logger); err != nil { return nil, fmt.Errorf("failed to init config: %w", err) } @@ -127,6 +132,9 @@ type ConfigSourceDescriptor struct { // If set, this will be used to ignore the module does not exist error. IgnoreModuleDoesNotExist bool + + // If set, skip the npm pack staleness check (used by hugo mod npm pack). + SkipNpmCheck bool } func (d ConfigSourceDescriptor) configFilenames() []string { diff --git a/hugofs/files/classifier.go b/hugofs/files/classifier.go index 543d741d0..ed8bb63f4 100644 --- a/hugofs/files/classifier.go +++ b/hugofs/files/classifier.go @@ -21,14 +21,18 @@ import ( ) const ( - // The NPM package.json "template" file. + // When merging, we check for this file first, and then package.json. FilenamePackageHugoJSON = "package.hugo.json" // The NPM package file. FilenamePackageJSON = "package.json" - FilenameHugoStatsJSON = "hugo_stats.json" + FilenameHugoStatsJSON = "hugo_stats.json" + FilenamePackageMetaJSON = "hugo_packagemeta.json" ) +// FolderPackagesHugoAutoGen is the auto-generated npm workspace directory for Hugo module deps. +var FolderPackagesHugoAutoGen = filepath.Join("packages", "hugoautogen") + func IsGoTmplExt(ext string) bool { return ext == "gotmpl" } diff --git a/modules/client.go b/modules/client.go index d28aace7f..2d111229c 100644 --- a/modules/client.go +++ b/modules/client.go @@ -979,6 +979,7 @@ type goModule struct { Time *time.Time // time version was created Update *goModule // available update, if any (with -u) Sum string // checksum + GoModSum string // checksum for go.mod Main bool // is this the main module? Indirect bool // is this module only an indirect dependency of main module? Dir string // directory holding files for this module, if any diff --git a/modules/collect.go b/modules/collect.go index add5ae69a..082ddc925 100644 --- a/modules/collect.go +++ b/modules/collect.go @@ -15,6 +15,7 @@ package modules import ( "bufio" + "encoding/json" "errors" "fmt" "io/fs" @@ -27,6 +28,7 @@ import ( "time" "github.com/bep/debounce" + "github.com/gobwas/glob" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/hmaps" "github.com/gohugoio/hugo/common/loggers" @@ -677,10 +679,18 @@ func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([ return mounts, fmt.Errorf("failed to read dir %q: %q", owner.Dir(), err) } + hasPackageHugoJSON := false + for _, fi := range fis { + if fi.Name() == files.FilenamePackageHugoJSON { + hasPackageHugoJSON = true + break + } + } + for _, fi := range fis { n := fi.Name() - should := n == files.FilenamePackageHugoJSON || n == files.FilenamePackageJSON + should := n == files.FilenamePackageHugoJSON || (n == files.FilenamePackageJSON && !hasPackageHugoJSON) should = should || commonJSConfigs.MatchString(n) if should { @@ -692,9 +702,80 @@ func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([ } + // Mount the project's hugoautogen workspace package.json (not from dependencies). + if owner.projectMod { + pkgAutoGen := filepath.Join(files.FolderPackagesHugoAutoGen, files.FilenamePackageJSON) + if fi, err := c.fs.Stat(filepath.Join(owner.Dir(), pkgAutoGen)); err == nil && !fi.IsDir() { + mounts = append(mounts, Mount{ + Source: pkgAutoGen, + Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, pkgAutoGen), + }) + } + } + + // Mount workspace package.json files (skipping hugoautogen from dependencies). + // Read workspaces from package.hugo.json if it exists, otherwise from package.json. + pkgFile := files.FilenamePackageJSON + if hasPackageHugoJSON { + pkgFile = files.FilenamePackageHugoJSON + } + if pkgData, err := afero.ReadFile(c.fs, filepath.Join(owner.Dir(), pkgFile)); err == nil { + var pkg map[string]any + if err := json.Unmarshal(pkgData, &pkg); err == nil { + for _, ws := range cast.ToStringSlice(pkg["workspaces"]) { + wsDirs := ResolveWorkspacePattern(c.fs, owner.Dir(), ws) + for _, wsDir := range wsDirs { + if filepath.ToSlash(wsDir) == filepath.ToSlash(files.FolderPackagesHugoAutoGen) { + continue + } + wsPackageJSON := filepath.Join(wsDir, files.FilenamePackageJSON) + if fi, err := c.fs.Stat(filepath.Join(owner.Dir(), wsPackageJSON)); err == nil && !fi.IsDir() { + mounts = append(mounts, Mount{ + Source: wsPackageJSON, + Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, wsPackageJSON), + }) + } + } + } + } + } + return mounts, nil } +// ResolveWorkspacePattern resolves an npm workspace pattern to actual directory paths. +// Supports globs (*, **) and brace expansion ({a,b}) via gobwas/glob. +// Literal paths are returned as-is. +func ResolveWorkspacePattern(fs afero.Fs, root, pattern string) []string { + if !strings.ContainsAny(pattern, "*?[{}") { + return []string{pattern} + } + + g, err := glob.Compile(pattern, '/') + if err != nil { + return nil + } + + var dirs []string + _ = afero.Walk(fs, root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if info.Name() != files.FilenamePackageJSON { + return nil + } + rel, err := filepath.Rel(root, filepath.Dir(path)) + if err != nil { + return nil + } + if g.Match(filepath.ToSlash(rel)) { + dirs = append(dirs, rel) + } + return nil + }) + return dirs +} + func (c *collector) nodeModulesRoot(s string) string { s = filepath.ToSlash(s) if strings.HasPrefix(s, "node_modules/") { diff --git a/modules/collect_test.go b/modules/collect_test.go index 0f0dc3a0c..9144526b7 100644 --- a/modules/collect_test.go +++ b/modules/collect_test.go @@ -14,9 +14,11 @@ package modules import ( + "path/filepath" "testing" qt "github.com/frankban/quicktest" + "github.com/spf13/afero" ) func TestPathKey(t *testing.T) { @@ -36,6 +38,63 @@ func TestPathKey(t *testing.T) { } } +func TestResolveWorkspacePattern(t *testing.T) { + c := qt.New(t) + + fs := afero.NewMemMapFs() + root := filepath.FromSlash("/project/mod") + + // Create workspace package.json files. + for _, dir := range []string{ + "packages/aws1", + "packages/aws2", + "packages/hugoautogen", + "other/foo", + } { + p := filepath.Join(root, dir, "package.json") + c.Assert(afero.WriteFile(fs, p, []byte(`{}`), 0o644), qt.IsNil) + } + + // Literal path, no glob. + c.Assert(ResolveWorkspacePattern(fs, root, "packages/aws1"), qt.DeepEquals, []string{"packages/aws1"}) + + got := ResolveWorkspacePattern(fs, root, "packages/*") + c.Assert(got, qt.DeepEquals, []string{ + filepath.FromSlash("packages/aws1"), + filepath.FromSlash("packages/aws2"), + filepath.FromSlash("packages/hugoautogen"), + }) + + // We currently support only one level of globbing, so this should give the same result as above. + got = ResolveWorkspacePattern(fs, root, "packages/**") + c.Assert(got, qt.DeepEquals, []string{ + filepath.FromSlash("packages/aws1"), + filepath.FromSlash("packages/aws2"), + filepath.FromSlash("packages/hugoautogen"), + }) + + got = ResolveWorkspacePattern(fs, root, "packages/{aws1,aws2}") + c.Assert(got, qt.DeepEquals, []string{ + filepath.FromSlash("packages/aws1"), + filepath.FromSlash("packages/aws2"), + }) + + // ** matches recursively. + nestedDir := filepath.Join(root, "packages", "aws1", "sub", "package.json") + c.Assert(afero.WriteFile(fs, nestedDir, []byte(`{}`), 0o644), qt.IsNil) + got = ResolveWorkspacePattern(fs, root, "packages/**") + c.Assert(got, qt.DeepEquals, []string{ + filepath.FromSlash("packages/aws1"), + filepath.FromSlash("packages/aws1/sub"), + filepath.FromSlash("packages/aws2"), + filepath.FromSlash("packages/hugoautogen"), + }) + + // No matches. + got = ResolveWorkspacePattern(fs, root, "nope/*") + c.Assert(got, qt.HasLen, 0) +} + func TestFilterUnwantedMounts(t *testing.T) { mounts := []Mount{ {Source: "a", Target: "b", Lang: "en"}, diff --git a/modules/config.go b/modules/config.go index 0bf9d263e..53cd23b38 100644 --- a/modules/config.go +++ b/modules/config.go @@ -403,8 +403,20 @@ type Import struct { Disable bool // File mounts. Mounts []Mount + + // Controls whether npm package files (package.json/package.hugo.json) are + // read from this module. Values: "auto" (default), "always", "never". + // "auto" reads package files when a Hugo config file or package.hugo.json + // is present in the module root. + UsePackageJSON string } +const ( + UsePackageJSONAuto = "auto" + UsePackageJSONAlways = "always" + UsePackageJSONNever = "never" +) + type Mount struct { // Relative path in source repo, e.g. "scss". Source string diff --git a/modules/module.go b/modules/module.go index 993e76286..740593aa1 100644 --- a/modules/module.go +++ b/modules/module.go @@ -71,7 +71,7 @@ type Module interface { // The version query requested in the import. VersionQuery() string - // The expected cryptographic hash of the module. + // Checksum for path, version Sum() string // Time version was created. diff --git a/modules/npm/package_builder.go b/modules/npm/package_builder.go index 1a5062ac8..b98d3dcc1 100644 --- a/modules/npm/package_builder.go +++ b/modules/npm/package_builder.go @@ -15,16 +15,20 @@ package npm import ( "bytes" + "context" "encoding/json" "fmt" "io" - "io/fs" + "os" + "path/filepath" + "runtime" "strings" + "github.com/gohugoio/hugo/common/hashing" "github.com/gohugoio/hugo/common/hmaps" - "github.com/gohugoio/hugo/common/hugio" "github.com/gohugoio/hugo/hugofs/files" + "github.com/gohugoio/hugo/modules" "github.com/gohugoio/hugo/hugofs" "github.com/spf13/afero" @@ -36,149 +40,379 @@ const ( dependenciesKey = "dependencies" devDependenciesKey = "devDependencies" - packageJSONName = "package.json" + packageJSONName = "package.json" + packageMetaJSONName = "hugo_packagemeta.json" +) - packageJSONTemplate = `{ - "name": "%s", - "version": "%s" -}` +var ( + workspacePackageJSON = filepath.Join(files.FolderPackagesHugoAutoGen, packageJSONName) + workspacePackageMetaJSON = filepath.Join(files.FolderPackagesHugoAutoGen, packageMetaJSONName) ) -func Pack(sourceFs, assetsWithDuplicatesPreservedFs afero.Fs) error { - var b *packageBuilder +func Pack(sourceFs, assetsWithDuplicatesPreservedFs afero.Fs, mods modules.Modules) error { + b := &packageBuilder{ + devDependencies: make(map[string]any), + devDependenciesComments: make(map[string]any), + dependencies: make(map[string]any), + dependenciesComments: make(map[string]any), + } - // Have a package.hugo.json? - fi, err := sourceFs.Stat(files.FilenamePackageHugoJSON) - if err != nil { - // Have a package.json? - fi, err = sourceFs.Stat(packageJSONName) - if err == nil { - // Preserve the original in package.hugo.json. - if err = hugio.CopyFile(sourceFs, packageJSONName, files.FilenamePackageHugoJSON); err != nil { - return fmt.Errorf("npm pack: failed to copy package file: %w", err) + workspacePath := filepath.ToSlash(files.FolderPackagesHugoAutoGen) + + // skip modules that shouldn't have their package files processed, either because they are the project module (handled separately) + // or because their UsePackageJSON setting disables it. + skipPackageJSON := buildSkipPackageJSON(mods) + + // 1. Read project deps: prefer package.hugo.json, fall back to package.json. + var rootPkg map[string]any + rootData, err := afero.ReadFile(sourceFs, packageJSONName) + if err == nil { + rootPkg = b.unmarshal(bytes.NewReader(rootData)) + if b.err != nil { + return fmt.Errorf("npm pack: failed to parse package.json: %w", b.err) + } + } + + // Workspaces source: prefer package.hugo.json, fall back to package.json. + var workspacesSource map[string]any + hugoData, hugoErr := afero.ReadFile(sourceFs, files.FilenamePackageHugoJSON) + if hugoErr == nil { + hugoPkg := b.unmarshal(bytes.NewReader(hugoData)) + if b.err != nil { + return fmt.Errorf("npm pack: failed to parse %s: %w", files.FilenamePackageHugoJSON, b.err) + } + b.addm("project", hugoPkg) + workspacesSource = hugoPkg + } else if rootPkg != nil { + b.addm("project", rootPkg) + workspacesSource = rootPkg + } + + // 2. Read deps from referenced workspaces (always from package.json). + for _, wsDir := range resolveProjectWorkspaces(sourceFs, workspacesSource, workspacePath) { + wsFile := filepath.Join(wsDir, packageJSONName) + wsData, err := afero.ReadFile(sourceFs, wsFile) + if err != nil { + continue + } + wsm := b.unmarshal(bytes.NewReader(wsData)) + if b.err != nil { + return fmt.Errorf("npm pack: failed to parse %s: %w", wsFile, b.err) + } + b.addm("project", wsm) + } + + // 3. Walk _jsconfig for module deps. + // We use hugofs.Walkway (which uses ReadDir) instead of afero.Walk because + // afero.Walk uses Readdirnames+Stat, which loses the per-module identity + // when multiple modules mount a file (e.g. package.json) to the same virtual path. + // Note that the order of files here is in the order of importance. + type pkgEntry struct { + info hugofs.FileMetaInfo + isHugoJSON bool + isRootLevel bool + } + var entries []pkgEntry + modulesWithHugoJSON := make(map[string]bool) + + w := hugofs.NewWalkway(hugofs.WalkwayConfig{ + Fs: assetsWithDuplicatesPreservedFs, + Root: files.FolderJSConfig, + WalkFn: func(ctx context.Context, path string, info hugofs.FileMetaInfo) error { + if info.IsDir() { + return nil } - } else { - // Create one. - name := "project" - // Use the Hugo project's folder name as the default name. - // The owner can change it later. - rfi, err := sourceFs.Stat("") - if err == nil { - name = rfi.Name() + + isPackageJSON := info.Name() == files.FilenamePackageJSON + isHugoJSON := info.Name() == files.FilenamePackageHugoJSON + + if !isPackageJSON && !isHugoJSON { + return nil } - packageJSONContent := fmt.Sprintf(packageJSONTemplate, name, "0.1.0") - if err = afero.WriteFile(sourceFs, files.FilenamePackageHugoJSON, []byte(packageJSONContent), 0o666); err != nil { - return err + + m := info.Meta() + if skipPackageJSON[m.ModulePath()] { + return nil } - fi, err = sourceFs.Stat(files.FilenamePackageHugoJSON) - if err != nil { - return err + + // package.hugo.json is only valid at module roots, not inside workspaces. + isRootLevel := filepath.Dir(path) == files.FolderJSConfig + if isHugoJSON && !isRootLevel { + return nil } + + if isHugoJSON { + modulesWithHugoJSON[m.ModulePath()] = true + } + + entries = append(entries, pkgEntry{info: info, isHugoJSON: isHugoJSON, isRootLevel: isRootLevel}) + return nil + }, + }) + if err := w.Walk(); err != nil { + return err + } + + // Process collected entries: for each module, prefer package.hugo.json + // over package.json at the root level. Workspace package.json files are always processed. + for _, e := range entries { + m := e.info.Meta() + // Skip root-level package.json if this module has package.hugo.json. + if !e.isHugoJSON && e.isRootLevel && modulesWithHugoJSON[m.ModulePath()] { + continue + } + + f, err := m.Open() + if err != nil { + return fmt.Errorf("npm pack: failed to open package file: %w", err) } + b.Add(m.ModulePath(), f) + f.Close() } - meta := fi.(hugofs.FileMetaInfo).Meta() - masterFilename := meta.Filename - f, err := meta.Open() - if err != nil { - return fmt.Errorf("npm pack: failed to open package file: %w", err) + if b.Err() != nil { + return fmt.Errorf("npm pack: failed to build: %w", b.Err()) } - b = newPackageBuilder(meta.ModulePath(), f) - f.Close() + // 4. Build the autogenerated workspace package.json. + // Exclude deps already defined by the project itself — they don't + // need to be duplicated in the workspace and it simplifies maintenance. + moduleDeps := make(map[string]any) + moduleDepsComments := make(map[string]any) + for k, v := range b.dependencies { + if b.dependenciesComments[k] != "project" { + moduleDeps[k] = v + moduleDepsComments[k] = b.dependenciesComments[k] + } + } + moduleDevDeps := make(map[string]any) + moduleDevDepsComments := make(map[string]any) + for k, v := range b.devDependencies { + if b.devDependenciesComments[k] != "project" { + moduleDevDeps[k] = v + moduleDevDepsComments[k] = b.devDependenciesComments[k] + } + } - d, err := assetsWithDuplicatesPreservedFs.Open(files.FolderJSConfig) - if err != nil { - return nil + name := "project" + rfi, err := sourceFs.Stat("") + if err == nil { + name = rfi.Name() + } + + autoGenPkg := map[string]any{ + "name": name, + "version": "0.1.0", + dependenciesKey: moduleDeps, + devDependenciesKey: moduleDevDeps, + } + + metaFile := packageMeta{ + Sum: PackageFilesSum(sourceFs, mods), + DependencySources: dependencySources{ + Dependencies: moduleDepsComments, + DevDependencies: moduleDevDepsComments, + }, + } + + if err := sourceFs.MkdirAll(files.FolderPackagesHugoAutoGen, 0o777); err != nil { + return err + } + if err := writeJSON(sourceFs, workspacePackageJSON, autoGenPkg); err != nil { + return err } + if err := writeJSON(sourceFs, workspacePackageMetaJSON, metaFile); err != nil { + return err + } + + // 5. Ensure root package.json references the workspace. + return ensureWorkspaceRef(sourceFs, workspacePath) +} - fis, err := d.(fs.ReadDirFile).ReadDir(-1) +// ensureWorkspaceRef adds workspacePath to the "workspaces" array in root +// package.json with minimal formatting changes. +func ensureWorkspaceRef(fsys afero.Fs, workspacePath string) error { + data, err := afero.ReadFile(fsys, packageJSONName) if err != nil { - return fmt.Errorf("npm pack: failed to read assets: %w", err) + content := fmt.Sprintf("{\n \"workspaces\": [\n %q\n ]\n}\n", workspacePath) + return afero.WriteFile(fsys, packageJSONName, []byte(content), 0o666) } - for _, fi := range fis { - if fi.IsDir() { - continue - } + if runtime.GOOS == "windows" { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + } - if fi.Name() != files.FilenamePackageHugoJSON { - continue - } + // Parse to check if already present. + var pkg map[string]any + if err := json.Unmarshal(data, &pkg); err != nil { + return fmt.Errorf("npm pack: failed to parse package.json: %w", err) + } - meta := fi.(hugofs.FileMetaInfo).Meta() + indent := detectIndent(data) + quoted := fmt.Sprintf("%q", workspacePath) - if meta.Filename == masterFilename { - continue + wsVal, hasWS := pkg["workspaces"] + + switch v := wsVal.(type) { + case []interface{}: + // Array form: ["pkg-a", "pkg-b", ...] + if containsString(toStringSlice(v), workspacePath) { + if runtime.GOOS == "windows" { + return afero.WriteFile(fsys, packageJSONName, data, 0o666) + } + return nil + } + // Fall through to byte-based insertion into the existing array below. + case map[string]interface{}: + // Object form: { "workspaces": { "packages": [...] } } + packagesVal, ok := v["packages"] + if !ok { + return fmt.Errorf("npm pack: unsupported workspaces object; missing \"packages\" field") + } + packagesSlice := toStringSlice(packagesVal) + if containsString(packagesSlice, workspacePath) { + if runtime.GOOS == "windows" { + return afero.WriteFile(fsys, packageJSONName, data, 0o666) + } + return nil + } + + // Append the new workspace path to the packages slice. + newPkgs := make([]interface{}, 0, len(packagesSlice)+1) + for _, s := range packagesSlice { + newPkgs = append(newPkgs, s) } + newPkgs = append(newPkgs, workspacePath) + v["packages"] = newPkgs - f, err := meta.Open() + updated, err := json.MarshalIndent(pkg, "", indent) if err != nil { - return fmt.Errorf("npm pack: failed to open package file: %w", err) + return fmt.Errorf("npm pack: failed to marshal package.json with updated workspaces: %w", err) } - b.Add(meta.ModulePath(), f) - f.Close() + updated = append(updated, '\n') + return afero.WriteFile(fsys, packageJSONName, updated, 0o666) + case nil: + // Treat explicit null as "no workspaces"; handled below as missing key. + default: + return fmt.Errorf("npm pack: unsupported workspaces type %T", v) } - if b.Err() != nil { - return fmt.Errorf("npm pack: failed to build: %w", b.Err()) + // Try adding to existing workspaces array when present. + if hasWS && wsVal != nil { + if wsIdx := bytes.Index(data, []byte(`"workspaces"`)); wsIdx >= 0 { + rest := data[wsIdx:] + if bracketOpen := bytes.IndexByte(rest, '['); bracketOpen >= 0 { + if bracketClose := bytes.IndexByte(rest[bracketOpen:], ']'); bracketClose >= 0 { + pos := wsIdx + bracketOpen + bracketClose + arrayContent := bytes.TrimSpace(data[wsIdx+bracketOpen+1 : pos]) + var insertion string + if len(arrayContent) == 0 { + insertion = "\n" + indent + indent + quoted + "\n" + indent + } else { + insertion = ",\n" + indent + indent + quoted + "\n" + indent + } + result := make([]byte, 0, len(data)+len(insertion)) + result = append(result, data[:pos]...) + result = append(result, insertion...) + result = append(result, data[pos:]...) + return afero.WriteFile(fsys, packageJSONName, result, 0o666) + } + } + } + + // We know a workspaces array exists but could not locate it reliably in the raw data. + return fmt.Errorf("npm pack: could not locate existing workspaces array for insertion") } - // Replace the dependencies in the original template with the merged set. - b.originalPackageJSON[dependenciesKey] = b.dependencies - b.originalPackageJSON[devDependenciesKey] = b.devDependencies - var commentsm map[string]any - comments, found := b.originalPackageJSON["comments"] - if found { - commentsm = hmaps.ToStringMap(comments) + // No workspaces key — add before closing brace. + lastBrace := bytes.LastIndexByte(data, '}') + if lastBrace < 0 { + return fmt.Errorf("npm pack: malformed package.json") + } + + before := bytes.TrimRight(data[:lastBrace], " \t\n\r") + needComma := len(before) > 0 && before[len(before)-1] != '{' && before[len(before)-1] != ',' + + var insertion string + if needComma { + insertion = ",\n" + indent + `"workspaces": [` + "\n" + indent + indent + quoted + "\n" + indent + "]\n" } else { - commentsm = make(map[string]any) + insertion = indent + `"workspaces": [` + "\n" + indent + indent + quoted + "\n" + indent + "]\n" } - commentsm[dependenciesKey] = b.dependenciesComments - commentsm[devDependenciesKey] = b.devDependenciesComments - b.originalPackageJSON["comments"] = commentsm - // Write it out to the project package.json - packageJSONData := new(bytes.Buffer) - encoder := json.NewEncoder(packageJSONData) + result := make([]byte, 0, len(data)+len(insertion)) + result = append(result, before...) + result = append(result, insertion...) + result = append(result, data[lastBrace:]...) + return afero.WriteFile(fsys, packageJSONName, result, 0o666) +} + +func detectIndent(data []byte) string { + for _, line := range bytes.Split(data, []byte("\n")) { + trimmed := bytes.TrimLeft(line, " \t") + if len(trimmed) < len(line) && len(trimmed) > 0 && trimmed[0] == '"' { + return string(line[:len(line)-len(trimmed)]) + } + } + return " " +} + +func writeJSON(fs afero.Fs, filename string, v any) error { + buf := new(bytes.Buffer) + encoder := json.NewEncoder(buf) encoder.SetEscapeHTML(false) encoder.SetIndent("", strings.Repeat(" ", 2)) - if err := encoder.Encode(b.originalPackageJSON); err != nil { + if err := encoder.Encode(v); err != nil { return fmt.Errorf("npm pack: failed to marshal JSON: %w", err) } + return afero.WriteFile(fs, filename, buf.Bytes(), 0o666) +} - if err := afero.WriteFile(sourceFs, packageJSONName, packageJSONData.Bytes(), 0o666); err != nil { - return fmt.Errorf("npm pack: failed to write package.json: %w", err) +func toStringSlice(v any) []string { + if v == nil { + return nil + } + if s, ok := v.([]any); ok { + var out []string + for _, item := range s { + if str, ok := item.(string); ok { + out = append(out, str) + } + } + return out } - return nil } -func newPackageBuilder(source string, first io.Reader) *packageBuilder { - b := &packageBuilder{ - devDependencies: make(map[string]any), - devDependenciesComments: make(map[string]any), - dependencies: make(map[string]any), - dependenciesComments: make(map[string]any), +func containsString(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } } + return false +} - m := b.unmarshal(first) - if b.err != nil { - return b +// resolveProjectWorkspaces resolves workspace patterns from the project's +// package source, skipping the hugoautogen workspace. +func resolveProjectWorkspaces(sourceFs afero.Fs, workspacesSource map[string]any, skipPath string) []string { + if workspacesSource == nil { + return nil } - - b.addm(source, m) - b.originalPackageJSON = m - - return b + var dirs []string + for _, ws := range toStringSlice(workspacesSource["workspaces"]) { + for _, wsDir := range modules.ResolveWorkspacePattern(sourceFs, "", ws) { + if filepath.ToSlash(wsDir) != skipPath { + dirs = append(dirs, wsDir) + } + } + } + return dirs } type packageBuilder struct { err error - // The original package.hugo.json. - originalPackageJSON map[string]any - devDependencies map[string]any devDependenciesComments map[string]any dependencies map[string]any @@ -205,13 +439,9 @@ func (b *packageBuilder) addm(source string, m map[string]any) { source = "project" } - // The version selection is currently very simple. - // We may consider minimal version selection or something - // after testing this out. - // - // But for now, the first version string for a given dependency wins. - // These packages will be added by order of import (project, module1, module2...), - // so that should at least give the project control over the situation. + // First version for a given dependency wins. + // Packages added by order of import (project, module1, module2...), + // so the project has control over versions. if devDeps, found := m[devDependenciesKey]; found { mm := hmaps.ToStringMapString(devDeps) for k, v := range mm { @@ -245,3 +475,179 @@ func (b *packageBuilder) unmarshal(r io.Reader) map[string]any { func (b *packageBuilder) Err() error { return b.err } + +// buildSkipPackageJSON determines which modules should NOT have their package +// files processed. The project module is always skipped (handled separately). +// For other modules, the behavior is controlled by the UsePackageJSON import +// setting: "auto" (default) reads package files when a Hugo config file or +// package.hugo.json is present; "always" always reads; "never" never reads. +func buildSkipPackageJSON(mods modules.Modules) map[string]bool { + skip := make(map[string]bool) + for _, m := range mods { + if m.Owner() == nil { + skip[m.Path()] = true + continue + } + if !usePackageJSON(m) { + skip[m.Path()] = true + } + } + return skip +} + +// usePackageJSON checks the import config for this module and applies the +// UsePackageJSON setting. For "auto", it checks for Hugo config files or +// package.hugo.json in the module root. +func usePackageJSON(m modules.Module) bool { + setting := findImportSetting(m) + switch setting { + case modules.UsePackageJSONAlways: + return true + case modules.UsePackageJSONNever: + return false + default: + // "auto": use if Hugo config file or package.hugo.json is present. + if len(m.ConfigFilenames()) > 0 { + return true + } + if m.Dir() != "" { + if _, err := os.Stat(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON)); err == nil { + return true + } + } + return false + } +} + +func findImportSetting(m modules.Module) string { + if m.Owner() == nil { + return modules.UsePackageJSONAuto + } + for _, imp := range m.Owner().Config().Imports { + if imp.Path == m.Path() { + if imp.UsePackageJSON != "" { + return imp.UsePackageJSON + } + return modules.UsePackageJSONAuto + } + } + return modules.UsePackageJSONAuto +} + +type packageMeta struct { + // Sum is a hash of all package files that feed into the npm pack output. + Sum string `json:"sum"` + + DependencySources dependencySources `json:"dependencySources"` +} + +type dependencySources struct { + Dependencies map[string]any `json:"dependencies"` + DevDependencies map[string]any `json:"devDependencies"` +} + +// PackageFilesSum hashes the package files that Pack would use, +// using the same file selection logic as Pack. +func PackageFilesSum(sourceFs afero.Fs, mods modules.Modules) string { + h := hashing.XxHasher() + defer h.Close() + + var w io.Writer = h + if runtime.GOOS == "windows" { + w = &crlfReplacer{w: h} + } + + copyFile := func(fsys afero.Fs, name string) { + f, err := fsys.Open(name) + if err != nil { + return + } + defer f.Close() + io.Copy(w, f) + } + copyOsFile := func(name string) { + f, err := os.Open(name) + if err != nil { + return + } + defer f.Close() + io.Copy(w, f) + } + + // Project level: prefer package.hugo.json, fall back to package.json. + // We need to parse whichever file we pick to discover workspaces. + var workspacesSource map[string]any + if data, err := afero.ReadFile(sourceFs, files.FilenamePackageHugoJSON); err == nil { + w.Write(data) + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + workspacesSource = m + } + } else if data, err := afero.ReadFile(sourceFs, packageJSONName); err == nil { + w.Write(data) + var m map[string]any + if err := json.Unmarshal(data, &m); err == nil { + workspacesSource = m + } + } + + // Workspace package.json files (skipping hugoautogen). + workspacePath := filepath.ToSlash(files.FolderPackagesHugoAutoGen) + for _, wsDir := range resolveProjectWorkspaces(sourceFs, workspacesSource, workspacePath) { + copyFile(sourceFs, filepath.Join(wsDir, packageJSONName)) + } + + // Module package files: prefer package.hugo.json, fall back to package.json. + skip := buildSkipPackageJSON(mods) + modulesWithHugoJSON := make(map[string]bool) + for _, m := range mods { + if skip[m.Path()] || m.Dir() == "" { + continue + } + if _, err := os.Stat(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON)); err == nil { + modulesWithHugoJSON[m.Path()] = true + } + } + for _, m := range mods { + if skip[m.Path()] || m.Dir() == "" { + continue + } + if modulesWithHugoJSON[m.Path()] { + copyOsFile(filepath.Join(m.Dir(), files.FilenamePackageHugoJSON)) + } else { + copyOsFile(filepath.Join(m.Dir(), packageJSONName)) + } + } + + return fmt.Sprintf("%x", h.Sum64()) +} + +// crlfReplacer wraps a writer and strips \r bytes. +type crlfReplacer struct { + w io.Writer +} + +func (c *crlfReplacer) Write(p []byte) (int, error) { + n := len(p) + _, err := c.w.Write(bytes.ReplaceAll(p, []byte("\r\n"), []byte("\n"))) + return n, err +} + +// NpmPackNeedsUpdate checks if the npm pack output is stale by comparing +// the stored hash against the current package files. +func NpmPackNeedsUpdate(sourceFs afero.Fs, mods modules.Modules) bool { + data, err := afero.ReadFile(sourceFs, workspacePackageMetaJSON) + if err != nil { + // No meta file means npm pack hasn't been run yet. + return false + } + + // We only need the sum for this check. + meta := struct { + Sum string `json:"sum"` + }{} + if err := json.Unmarshal(data, &meta); err != nil || meta.Sum == "" { + return true + } + return meta.Sum != PackageFilesSum(sourceFs, mods) +} diff --git a/modules/npm/package_builder_integration_test.go b/modules/npm/package_builder_integration_test.go new file mode 100644 index 000000000..028bb6549 --- /dev/null +++ b/modules/npm/package_builder_integration_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Hugo Authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language gxoverning permissions and +// limitations under the License. + +package npm_test + +import ( + "strings" + "testing" + + qt "github.com/frankban/quicktest" + "github.com/gohugoio/hugo/hugolib" + "github.com/gohugoio/hugo/modules" + "github.com/gohugoio/hugo/modules/npm" +) + +func getPackageBuilderTestFiles() string { + files := ` +-- hugo.toml -- +-- package.json -- +{ + "workspaces": [ + "packages/*" + ] +} +-- packages/hugoautogen/package.json -- +-- packages/a/package.json -- +PACKAGE_CONTENT +-- packages/b/package.json -- +PACKAGE_CONTENT +-- packages/c/package.json -- +PACKAGE_CONTENT +-- packages/d/package.json -- +PACKAGE_CONTENT +-- packages/e/package.json -- +PACKAGE_CONTENT +` + packageContent := `{ +"name": "foo", +"version": "0.1.1", +"dependencies": { + "react-dom": "1.1.1", + "tailwindcss": "1.2.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5" +}, +"devDependencies": { + "postcss-cli": "7.1.0", + "tailwindcss": "1.2.0", + "@babel/cli": "7.8.4", + "@babel/core": "7.9.0", + "@babel/preset-env": "7.9.5" +} +}` + files = strings.ReplaceAll(files, "PACKAGE_CONTENT", packageContent) + return files +} + +func TestPackageBuilder(t *testing.T) { + files := getPackageBuilderTestFiles() + b := hugolib.Test(t, files) + fs := b.H.Fs.WorkingDirReadOnly + + sum := npm.PackageFilesSum(fs, b.H.AllModules()) + b.Assert(sum, qt.Equals, "ce880d142ad9a16a") +} + +func BenchmarkPackageFilesSum(b *testing.B) { + files := getPackageBuilderTestFiles() + bb := hugolib.Test(b, files) + fs := bb.H.Fs.WorkingDirReadOnly + b.ResetTimer() + + for b.Loop() { + sum := npm.PackageFilesSum(fs, modules.Modules{}) + bb.Assert(sum, qt.Equals, "ce880d142ad9a16a") + } +} diff --git a/modules/npm/package_builder_test.go b/modules/npm/package_builder_test.go index 2523292ee..8db814eb0 100644 --- a/modules/npm/package_builder_test.go +++ b/modules/npm/package_builder_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 The Hugo Authors. All rights reserved. +// Copyright 2026 The Hugo Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -8,7 +8,7 @@ // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and +// See the License for the specific language gxoverning permissions and // limitations under the License. package npm @@ -43,7 +43,13 @@ const templ = `{ func TestPackageBuilder(t *testing.T) { c := qt.New(t) - b := newPackageBuilder("", strings.NewReader(templ)) + b := &packageBuilder{ + devDependencies: make(map[string]any), + devDependenciesComments: make(map[string]any), + dependencies: make(map[string]any), + dependenciesComments: make(map[string]any), + } + b.Add("", strings.NewReader(templ)) c.Assert(b.Err(), qt.IsNil) b.Add("mymod", strings.NewReader(`{ diff --git a/testscripts/commands/mod_npm.txt b/testscripts/commands/mod_npm.txt index 3d8903e6a..44bd8c25a 100644 --- a/testscripts/commands/mod_npm.txt +++ b/testscripts/commands/mod_npm.txt @@ -1,41 +1,218 @@ -# Test mod npm. +# Test mod npm pack. -dostounix golden/package.json +dostounix golden1/package.json +dostounix golden1/packages/hugoautogen/package.json +dostounix golden1/packages/hugoautogen/hugo_packagemeta.json +dostounix golden2/packages/hugoautogen/package.json +dostounix golden3/packages/hugoautogen/package.json +dostounix golden4/packages/hugoautogen/package.json +dostounix golden5/packages/hugoautogen/package.json +hugo mod npm pack +cmp packages/hugoautogen/package.json golden1/packages/hugoautogen/package.json +cmp package.json golden1/package.json +cmp packages/hugoautogen/hugo_packagemeta.json golden1/packages/hugoautogen/hugo_packagemeta.json + +# c module commits: +# 324e3adae608ff8b6a52b4630975ffe652859f27: add is-odd +# 40c59ae8595d8ff3f56b87fcf4ba3325649a9a52: replace is-odd with is-even +# 7dd9b2dc7f505508adbb3fdea36c308aad0de3ec: add package.hugo.json with is-number +# bf55e8bb813a7f6cbe6f212bd7e1dd4fb44c79df: update README.md only +# cc5188f671e1852f470df9fed52c7f93d1cab476: Add myworkspace with my-prime (in package.hugo.json) +replace hugo.toml "324e3adae608ff8b6a52b4630975ffe652859f27" "40c59ae8595d8ff3f56b87fcf4ba3325649a9a52" + +hugo mod graph +stderr 'WARN npm dependencies are out of sync' + +hugo mod npm pack +cmp packages/hugoautogen/package.json golden2/packages/hugoautogen/package.json + +replace hugo.toml "40c59ae8595d8ff3f56b87fcf4ba3325649a9a52" "7dd9b2dc7f505508adbb3fdea36c308aad0de3ec" +hugo mod npm pack +cmp packages/hugoautogen/package.json golden3/packages/hugoautogen/package.json +# This commit only updates README.md, no npm package updates. +replace hugo.toml "7dd9b2dc7f505508adbb3fdea36c308aad0de3ec" "bf55e8bb813a7f6cbe6f212bd7e1dd4fb44c79df" +hugo mod graph +! stderr 'WARN' + +# Add myworkspace with my-prime (in package.hugo.json) +replace hugo.toml "bf55e8bb813a7f6cbe6f212bd7e1dd4fb44c79df" "cc5188f671e1852f470df9fed52c7f93d1cab476" +hugo mod npm pack +cmp packages/hugoautogen/package.json golden4/packages/hugoautogen/package.json + +# usePackageJSON="auto" => usePackageJSON="never" in hugo.toml will effectively disable c's npm deps. +replace hugo.toml "auto" "never" +hugo mod graph +# dependencies are out of sync +stderr 'WARN' +hugo mod npm pack +cmp packages/hugoautogen/package.json golden5/packages/hugoautogen/package.json +# Turn it back on using "always" +replace hugo.toml "never" "always" +hugo mod graph +stderr 'WARN' hugo mod npm pack -cmp package.json golden/package.json +hugo mod graph +! stderr 'WARN' +cmp packages/hugoautogen/package.json golden4/packages/hugoautogen/package.json + +# Now test vendoring.. +hugo mod vendor +# a ab c@cc5188f671e1852f470df9fed52c7f93d1cab476 d +ls _vendor/github.com/gohugoio/hugoTestModsNPMNested/a +stdout 'package.json' +ls _vendor/github.com/gohugoio/hugoTestModsNPMNested/a/packages/aws1 +stdout 'package.json' + +ls _vendor/github.com/gohugoio/hugoTestModsNPMNested/c@cc5188f671e1852f470df9fed52c7f93d1cab476 +stdout 'package.hugo.json' +# package.json is not needed when package.hugo.json exists. +! stdout 'package.json' +! exists _vendor/github.com/gohugoio/hugoTestModsNPMNested/ab/packages/hugoautogen -- hugo.toml -- baseURL = "https://example.org/" [module] [[module.imports]] -path="github.com/gohugoio/hugoTestModule2" - - --- golden/package.json -- +path="github.com/gohugoio/hugoTestModsNPMNested/a" +[[module.imports]] +path="github.com/gohugoio/hugoTestModsNPMNested/c" +version="324e3adae608ff8b6a52b4630975ffe652859f27" +usePackageJSON="auto" +-- package.json -- { - "comments": { + "workspaces": [ + "packages/hugoautogen" + ], + "name": "npmpack", + "version": "1.0.0", + "dependencies": { + "dedupe": "4.0.3" + } +} +-- golden1/packages/hugoautogen/hugo_packagemeta.json -- +{ + "sum": "259f33c1d93dcc01", + "dependencySources": { "dependencies": { - "react-dom": "github.com/gohugoio/hugoTestModule2" + "count-days-in-month": "github.com/gohugoio/hugoTestModsNPMNested/a", + "fast-cartesian": "github.com/gohugoio/hugoTestModsNPMNested/a", + "is-odd": "github.com/gohugoio/hugoTestModsNPMNested/c", + "is-sorted": "github.com/gohugoio/hugoTestModsNPMNested/a", + "once": "github.com/gohugoio/hugoTestModsNPMNested/d", + "pluralize": "github.com/gohugoio/hugoTestModsNPMNested/ab", + "to-space-case": "github.com/gohugoio/hugoTestModsNPMNested/a" }, "devDependencies": { - "@babel/cli": "github.com/gohugoio/hugoTestModule2", - "@babel/core": "github.com/gohugoio/hugoTestModule2", - "@babel/preset-env": "github.com/gohugoio/hugoTestModule2", - "postcss-cli": "github.com/gohugoio/hugoTestModule2", - "tailwindcss": "github.com/gohugoio/hugoTestModule2" + "decamelize": "github.com/gohugoio/hugoTestModsNPMNested/a", + "strip-ansi": "github.com/gohugoio/hugoTestModsNPMNested/a", + "to-pascal-case": "github.com/gohugoio/hugoTestModsNPMNested/a" } + } +} +-- golden1/packages/hugoautogen/package.json -- +{ + "dependencies": { + "count-days-in-month": "^1.0.0", + "fast-cartesian": "9.0.0", + "is-odd": "3.0.0", + "is-sorted": "1.0.4", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" + }, + "devDependencies": { + "decamelize": "6.0.0", + "strip-ansi": "7.2.0", + "to-pascal-case": "1.0.0" }, + "name": "script-mod_npm", + "version": "0.1.0" +} +-- golden1/package.json -- +{ + "workspaces": [ + "packages/hugoautogen" + ], + "name": "npmpack", + "version": "1.0.0", + "dependencies": { + "dedupe": "4.0.3" + } +} +-- golden2/packages/hugoautogen/package.json -- +{ + "dependencies": { + "count-days-in-month": "^1.0.0", + "fast-cartesian": "9.0.0", + "is-even": "1.0.0", + "is-sorted": "1.0.4", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" + }, + "devDependencies": { + "decamelize": "6.0.0", + "strip-ansi": "7.2.0", + "to-pascal-case": "1.0.0" + }, + "name": "script-mod_npm", + "version": "0.1.0" +} +-- golden3/packages/hugoautogen/package.json -- +{ + "dependencies": { + "count-days-in-month": "^1.0.0", + "fast-cartesian": "9.0.0", + "is-number": "7.0.0", + "is-sorted": "1.0.4", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" + }, + "devDependencies": { + "decamelize": "6.0.0", + "strip-ansi": "7.2.0", + "to-pascal-case": "1.0.0" + }, + "name": "script-mod_npm", + "version": "0.1.0" +} +-- golden4/packages/hugoautogen/package.json -- +{ + "dependencies": { + "count-days-in-month": "^1.0.0", + "fast-cartesian": "9.0.0", + "is-number": "7.0.0", + "is-sorted": "1.0.4", + "my-prime": "^1.0.1", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" + }, + "devDependencies": { + "decamelize": "6.0.0", + "strip-ansi": "7.2.0", + "to-pascal-case": "1.0.0" + }, + "name": "script-mod_npm", + "version": "0.1.0" +} +-- golden5/packages/hugoautogen/package.json -- +{ "dependencies": { - "react-dom": "^16.13.1" + "count-days-in-month": "^1.0.0", + "fast-cartesian": "9.0.0", + "is-sorted": "1.0.4", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" }, "devDependencies": { - "@babel/cli": "7.8.4", - "@babel/core": "7.9.0", - "@babel/preset-env": "7.9.5", - "postcss-cli": "7.1.0", - "tailwindcss": "1.2.0" + "decamelize": "6.0.0", + "strip-ansi": "7.2.0", + "to-pascal-case": "1.0.0" }, "name": "script-mod_npm", "version": "0.1.0" diff --git a/testscripts/commands/mod_npm__moduleorder.txt b/testscripts/commands/mod_npm__moduleorder.txt new file mode 100644 index 000000000..0b9e783eb --- /dev/null +++ b/testscripts/commands/mod_npm__moduleorder.txt @@ -0,0 +1,68 @@ +# Test mod npm pack with shuffled modules. + +dostounix golden1/package.json +dostounix golden1/packages/hugoautogen/package.json +dostounix golden1/packages/hugoautogen/hugo_packagemeta.json + +hugo mod npm pack +cmp packages/hugoautogen/package.json golden1/packages/hugoautogen/package.json +cmp package.json golden1/package.json +cmp packages/hugoautogen/hugo_packagemeta.json golden1/packages/hugoautogen/hugo_packagemeta.json + +-- hugo.toml -- +baseURL = "https://example.org/" +[module] +[[module.imports]] +path="github.com/gohugoio/hugoTestModsNPMNested/d" +[[module.imports]] +path="github.com/gohugoio/hugoTestModsNPMNested/a" + +-- go.mod -- +module github.com/gohugoio/hugoTestModule +go 1.20 + +-- golden1/package.json -- +{ + "workspaces": [ + "packages/hugoautogen" + ] +} +-- golden1/packages/hugoautogen/package.json -- +{ + "dependencies": { + "count-days-in-month": "^1.0.0", + "dedupe": "4.0.1", + "fast-cartesian": "9.0.0", + "is-sorted": "1.0.2", + "once": "1.4.0", + "pluralize": "8.0.0", + "to-space-case": "0.1.3" + }, + "devDependencies": { + "decamelize": "6.0.0", + "strip-ansi": "7.0.0", + "to-pascal-case": "1.0.0" + }, + "name": "script-mod_npm__moduleorder", + "version": "0.1.0" +} +-- golden1/packages/hugoautogen/hugo_packagemeta.json -- +{ + "sum": "dd3590c300b0bebb", + "dependencySources": { + "dependencies": { + "count-days-in-month": "github.com/gohugoio/hugoTestModsNPMNested/a", + "dedupe": "github.com/gohugoio/hugoTestModsNPMNested/ab", + "fast-cartesian": "github.com/gohugoio/hugoTestModsNPMNested/a", + "is-sorted": "github.com/gohugoio/hugoTestModsNPMNested/d", + "once": "github.com/gohugoio/hugoTestModsNPMNested/d", + "pluralize": "github.com/gohugoio/hugoTestModsNPMNested/ab", + "to-space-case": "github.com/gohugoio/hugoTestModsNPMNested/a" + }, + "devDependencies": { + "decamelize": "github.com/gohugoio/hugoTestModsNPMNested/a", + "strip-ansi": "github.com/gohugoio/hugoTestModsNPMNested/ab", + "to-pascal-case": "github.com/gohugoio/hugoTestModsNPMNested/a" + } + } +} diff --git a/testscripts/commands/mod_npm_withexisting.txt b/testscripts/commands/mod_npm_withexisting.txt index 9f72eefdc..33382475c 100644 --- a/testscripts/commands/mod_npm_withexisting.txt +++ b/testscripts/commands/mod_npm_withexisting.txt @@ -1,12 +1,15 @@ -# Test mod npm. +# Test mod npm with existing package.json. # See https://github.com/gohugoio/hugo/issues/13465 [windows] skip +dostounix golden/packages/hugoautogen/package.json dostounix golden/package.json hugo mod npm pack +cmp packages/hugoautogen/package.json golden/packages/hugoautogen/package.json cmp package.json golden/package.json +exists packages/hugoautogen/hugo_packagemeta.json -- hugo.toml -- baseURL = "https://example.org/" @@ -26,23 +29,8 @@ path="github.com/gohugoio/hugoTestModule2" "name": "mypackage", "version": "1.1.0" } --- golden/package.json -- +-- golden/packages/hugoautogen/package.json -- { - "comments": { - "dependencies": { - "react-dom": "github.com/gohugoio/hugoTestModule2" - }, - "devDependencies": { - "@babel/cli": "github.com/gohugoio/hugoTestModule2", - "@babel/core": "github.com/gohugoio/hugoTestModule2", - "@babel/preset-env": "github.com/gohugoio/hugoTestModule2", - "postcss-cli": "github.com/gohugoio/hugoTestModule2", - "tailwindcss": "project" - }, - "foo": { - "a": "b" - } - }, "dependencies": { "react-dom": "^16.13.1" }, @@ -50,11 +38,26 @@ path="github.com/gohugoio/hugoTestModule2" "@babel/cli": "7.8.4", "@babel/core": "7.9.0", "@babel/preset-env": "7.9.5", - "postcss-cli": "7.1.0", + "postcss-cli": "7.1.0" + }, + "name": "script-mod_npm_withexisting", + "version": "0.1.0" +} +-- golden/package.json -- +{ + "comments": { + "foo": { + "a": "b" + } + }, + "devDependencies": { "tailwindcss": "2.2.0" }, "name": "mypackage", - "version": "1.1.0" + "version": "1.1.0", + "workspaces": [ + "packages/hugoautogen" + ] } -- go.mod -- module github.com/gohugoio/hugoTestModule -- 2.39.5