]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
npm: Use workspaces to simplify `hugo mod npm pack`
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 23 Mar 2026 15:00:50 +0000 (16:00 +0100)
committerGitHub <noreply@github.com>
Mon, 23 Mar 2026 15:00:50 +0000 (16:00 +0100)
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) <noreply@anthropic.com>
16 files changed:
commands/commandeer.go
commands/mod.go
common/loggers/logger.go
config/allconfig/load.go
hugofs/files/classifier.go
modules/client.go
modules/collect.go
modules/collect_test.go
modules/config.go
modules/module.go
modules/npm/package_builder.go
modules/npm/package_builder_integration_test.go [new file with mode: 0644]
modules/npm/package_builder_test.go
testscripts/commands/mod_npm.txt
testscripts/commands/mod_npm__moduleorder.txt [new file with mode: 0644]
testscripts/commands/mod_npm_withexisting.txt

index dc7bd7f33943f1f60ec0e0017b74c996177ed630..21a8f24fc1cbb182ce6baa5d3b17db79bc010f01 100644 (file)
@@ -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 {
index 0c49565c0613363f5c43d123e58ba66186b44218..efe3f1a5b71a21f8de85eee22f0486a8149aa3ee 100644 (file)
@@ -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
                                        }
                                },
                        },
index ff84081c06306352a5aea320b5c8607a88a8cadb..ba26137cd0aff9e0ec3e9687a8f138ffcdf7479a 100644 (file)
@@ -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) {
index b8d93a8f05ac12ee5bf6a2b844f9dbdd2a8f550f..fdb349c293b04de0443a14cd7e32aa51f24358c1 100644 (file)
@@ -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 {
index 543d741d026efe13c509d14587a21875371d1044..ed8bb63f45821f69c7a6df90e09ed1b2ddd63f36 100644 (file)
@@ -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"
 }
index d28aace7fe800f9b854296dbbf8bd4dce9877b8f..2d111229c772dacc8c74751f90e8a5c3abe08d96 100644 (file)
@@ -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
index add5ae69a013364df03ff3c224c11c630a50a9de..082ddc9250720959330eeb544d7175a95cb68dfc 100644 (file)
@@ -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/") {
index 0f0dc3a0c9961a1f4be15d2bf690dd86dfa59df6..9144526b725fd320a498f488d9d865a7464270d5 100644 (file)
 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"},
index 0bf9d263edbe5977842a5f7ab24281ff94b70e37..53cd23b38d7fe6752c6ff4691297fe286baa9929 100644 (file)
@@ -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
index 993e76286e1e7d0a4e0fdb40a8f205cdc8bdbf00..740593aa1e3a3841b869a4e34e38b1f4a94782c1 100644 (file)
@@ -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.
index 1a5062ac82ec2e7f067283b8c395610869c58f43..b98d3dcc1fbaaf744fc00bdfdea9085ca1437891 100644 (file)
@@ -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 (file)
index 0000000..028bb65
--- /dev/null
@@ -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")
+       }
+}
index 2523292ee7dce9610ec77f3d91dc011ce8423e5e..8db814eb0796c8cb19b7ff3f6d50bb921395096b 100644 (file)
@@ -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(`{
index 3d8903e6a351d479951ed20874cd097bb7d3b711..44bd8c25ac3f169f3e3cf907f7da0f980c86304f 100644 (file)
-# 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 (file)
index 0000000..0b9e783
--- /dev/null
@@ -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"
+    }
+  }
+}
index 9f72eefdc55894aed4e48fcaa0d640a0dbe3c792..33382475c2ea9c92b016802e0e27e8991e1e7cbc 100644 (file)
@@ -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