]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
modules: Add support for direct version module imports in hugo.toml
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 5 Sep 2025 06:00:39 +0000 (08:00 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 7 Sep 2025 20:47:25 +0000 (22:47 +0200)
Fixes #13964

12 files changed:
main_test.go
modules/client.go
modules/client_test.go
modules/collect.go
modules/collect_test.go
modules/config.go
modules/module.go
modules/modules_integration_test.go [new file with mode: 0644]
testscripts/commands/mod__hugodirectsum.txt [new file with mode: 0644]
testscripts/commands/mod__hugodirectsum_tamper.txt [new file with mode: 0644]
testscripts/commands/mod__tamper_gosum.txt [new file with mode: 0644]
testscripts/commands/mod_vendor__versions.txt [new file with mode: 0644]

index 683defb1ac477decd0822d13e08d7c89bdae4888..fe17cd32e6c61d81acfb529e78fad27f5b6777ff 100644 (file)
@@ -51,6 +51,7 @@ func TestUnfinished(t *testing.T) {
        p := commonTestScriptsParam
        p.Dir = "testscripts/unfinished"
        // p.UpdateScripts = true
+       // p.TestWork = true
 
        testscript.Run(t, p)
 }
@@ -110,6 +111,35 @@ var commonTestScriptsParam = testscript.Params{
                        }
                        time.Sleep(time.Duration(i) * time.Second)
                },
+               // tree lists a directory recursively to stdout as a simple tree.
+               "tree": func(ts *testscript.TestScript, neg bool, args []string) {
+                       dirname := ts.MkAbs(args[0])
+
+                       err := filepath.WalkDir(dirname, func(path string, d fs.DirEntry, err error) error {
+                               if err != nil {
+                                       return err
+                               }
+                               rel, err := filepath.Rel(dirname, path)
+                               if err != nil {
+                                       return err
+                               }
+                               if rel == "." {
+                                       fmt.Fprintln(ts.Stdout(), ".")
+                                       return nil
+                               }
+                               depth := strings.Count(rel, string(os.PathSeparator))
+                               prefix := strings.Repeat("  ", depth) + "└─"
+                               if d.IsDir() {
+                                       fmt.Fprintf(ts.Stdout(), "%s%s/\n", prefix, d.Name())
+                               } else {
+                                       fmt.Fprintf(ts.Stdout(), "%s%s\n", prefix, d.Name())
+                               }
+                               return nil
+                       })
+                       if err != nil {
+                               ts.Fatalf("%v", err)
+                       }
+               },
                // ls lists a directory to stdout.
                "ls": func(ts *testscript.TestScript, neg bool, args []string) {
                        dirname := ts.MkAbs(args[0])
@@ -128,7 +158,36 @@ var commonTestScriptsParam = testscript.Params{
                                return
                        }
                        for _, fi := range fis {
-                               fmt.Fprintf(ts.Stdout(), "%s %04o %s %s\n", fi.Mode(), fi.Mode().Perm(), fi.ModTime().Format(time.RFC3339Nano), fi.Name())
+                               fmt.Fprintf(ts.Stdout(), "%s %04o %s %s\n", fi.Mode(), fi.Mode().Perm(), fi.ModTime().Format(time.RFC3339), fi.Name())
+                       }
+               },
+               // lsr lists a directory recursively to stdout.
+               "lsr": func(ts *testscript.TestScript, neg bool, args []string) {
+                       dirname := ts.MkAbs(args[0])
+
+                       err := filepath.WalkDir(dirname, func(path string, d fs.DirEntry, err error) error {
+                               if err != nil {
+                                       return err
+                               }
+                               if d.IsDir() {
+                                       return nil
+                               }
+
+                               fi, err := d.Info()
+                               if err != nil {
+                                       return err
+                               }
+
+                               rel, err := filepath.Rel(dirname, path)
+                               if err != nil {
+                                       return err
+                               }
+
+                               fmt.Fprintf(ts.Stdout(), "%s %04o %s\n", fi.Mode(), fi.Mode().Perm(), filepath.ToSlash(rel))
+                               return nil
+                       })
+                       if err != nil {
+                               ts.Fatalf("%v", err)
                        }
                },
                // append appends to a file with a leading newline.
index a8998bb8deda7d77a74c792da46983616a36e5e4..09adeab906b94b9f24d1f86d6e86f23c1d6020bb 100644 (file)
@@ -25,6 +25,7 @@ import (
        "os/exec"
        "path/filepath"
        "regexp"
+       "sort"
        "strings"
        "time"
 
@@ -63,6 +64,10 @@ const vendord = "_vendor"
 const (
        goModFilename = "go.mod"
        goSumFilename = "go.sum"
+
+       // Checksum file for direct dependencies only,
+       // that is, module imports with version set.
+       hugoDirectSumFilename = "hugo.direct.sum"
 )
 
 // NewClient creates a new Client that can be used to manage the Hugo Components
@@ -208,7 +213,7 @@ func (c *Client) Vendor() error {
                        continue
                }
 
-               if !c.shouldVendor(t.Path()) {
+               if c.shouldNotVendor(t.PathVersionQuery(false)) || c.shouldNotVendor(t.PathVersionQuery(true)) {
                        continue
                }
 
@@ -221,16 +226,16 @@ func (c *Client) Vendor() error {
                // See https://github.com/gohugoio/hugo/issues/8239
                // This is an error situation. We need something to vendor.
                if t.Mounts() == nil {
-                       return fmt.Errorf("cannot vendor module %q, need at least one mount", t.Path())
+                       return fmt.Errorf("cannot vendor module %q, need at least one mount", t.PathVersionQuery(false))
                }
 
-               fmt.Fprintln(&modulesContent, "# "+t.Path()+" "+t.Version())
+               fmt.Fprintln(&modulesContent, "# "+t.PathVersionQuery(true)+" "+t.Version())
 
                dir := t.Dir()
 
                for _, mount := range t.Mounts() {
                        sourceFilename := filepath.Join(dir, mount.Source)
-                       targetFilename := filepath.Join(vendorDir, t.Path(), mount.Source)
+                       targetFilename := filepath.Join(vendorDir, t.PathVersionQuery(true), mount.Source)
                        fi, err := c.fs.Stat(sourceFilename)
                        if err != nil {
                                return fmt.Errorf("failed to vendor module: %w", err)
@@ -257,7 +262,7 @@ func (c *Client) Vendor() error {
                resourcesDir := filepath.Join(dir, files.FolderResources)
                _, err := c.fs.Stat(resourcesDir)
                if err == nil {
-                       if err := hugio.CopyDir(c.fs, resourcesDir, filepath.Join(vendorDir, t.Path(), files.FolderResources), nil); err != nil {
+                       if err := hugio.CopyDir(c.fs, resourcesDir, filepath.Join(vendorDir, t.PathVersionQuery(true), files.FolderResources), nil); err != nil {
                                return fmt.Errorf("failed to copy resources to vendor dir: %w", err)
                        }
                }
@@ -266,7 +271,7 @@ func (c *Client) Vendor() error {
                configDir := filepath.Join(dir, "config")
                _, err = c.fs.Stat(configDir)
                if err == nil {
-                       if err := hugio.CopyDir(c.fs, configDir, filepath.Join(vendorDir, t.Path(), "config"), nil); err != nil {
+                       if err := hugio.CopyDir(c.fs, configDir, filepath.Join(vendorDir, t.PathVersionQuery(true), "config"), nil); err != nil {
                                return fmt.Errorf("failed to copy config dir to vendor dir: %w", err)
                        }
                }
@@ -277,7 +282,7 @@ func (c *Client) Vendor() error {
                configFiles = append(configFiles, configFiles2...)
                configFiles = append(configFiles, filepath.Join(dir, "theme.toml"))
                for _, configFile := range configFiles {
-                       if err := hugio.CopyFile(c.fs, configFile, filepath.Join(vendorDir, t.Path(), filepath.Base(configFile))); err != nil {
+                       if err := hugio.CopyFile(c.fs, configFile, filepath.Join(vendorDir, t.PathVersionQuery(true), filepath.Base(configFile))); err != nil {
                                if !herrors.IsNotExist(err) {
                                        return err
                                }
@@ -434,13 +439,34 @@ func isProbablyModule(path string) bool {
        return module.CheckPath(path) == nil
 }
 
+func (c *Client) downloadModuleVersion(path, version string) (*goModule, error) {
+       args := []string{"mod", "download", "-json", fmt.Sprintf("%s@%s", path, version)}
+       b := &bytes.Buffer{}
+
+       err := c.runGo(context.Background(), b, args...)
+       if err != nil {
+               return nil, fmt.Errorf("failed to download module %s@%s: %w", path, version, err)
+       }
+
+       m := &goModule{}
+       if err := json.NewDecoder(b).Decode(m); err != nil {
+               return nil, fmt.Errorf("failed to decode module download result: %w", err)
+       }
+
+       if m.Error != nil {
+               return nil, errors.New(m.Error.Err)
+       }
+
+       return m, nil
+}
+
 func (c *Client) listGoMods() (goModules, error) {
        if c.GoModulesFilename == "" || !c.moduleConfig.hasModuleImport() {
                return nil, nil
        }
 
        downloadModules := func(modules ...string) error {
-               args := []string{"mod", "download", "-modcacherw"}
+               args := []string{"mod", "download"}
                args = append(args, modules...)
                out := io.Discard
                err := c.runGo(context.Background(), out, args...)
@@ -521,6 +547,86 @@ func (c *Client) listGoMods() (goModules, error) {
        return modules, err
 }
 
+func (c *Client) writeHugoDirectSum(mods Modules) error {
+       if c.GoModulesFilename == "" {
+               return nil
+       }
+       var sums []modSum
+       for _, m := range mods {
+               if m.Owner() == nil {
+                       // This is the project.
+                       continue
+               }
+               if m.IsGoMod() && m.VersionQuery() != "" {
+                       sums = append(sums, modSum{pathVersionKey: pathVersionKey{path: m.Path(), version: m.Version()}, sum: m.Sum()})
+               }
+       }
+
+       // Read the existing sums.
+       existingSums, err := c.readModSumFile(hugoDirectSumFilename)
+       if err != nil {
+               return err
+       }
+       if len(sums) == 0 && len(existingSums) == 0 {
+               // Nothing to do.
+               return nil
+       }
+
+       dirty := len(sums) != len(existingSums)
+       for _, s1 := range sums {
+               if s2, ok := existingSums[s1.pathVersionKey]; ok {
+                       if s1.sum != s2 {
+                               return fmt.Errorf("verifying %s@%s: checksum mismatch: %s != %s", s1.path, s1.version, s1.sum, s2)
+                       }
+               } else if !dirty {
+                       dirty = true
+               }
+       }
+       if !dirty {
+               // Nothing changed.
+               return nil
+       }
+
+       // Write the sums file.
+       // First sort the sums for reproducible output.
+       sort.Slice(sums, func(i, j int) bool {
+               pvi, pvj := sums[i].pathVersionKey, sums[j].pathVersionKey
+               return pvi.path < pvj.path || (pvi.path == pvj.path && pvi.version < pvj.version)
+       })
+
+       f, err := c.fs.OpenFile(filepath.Join(c.ccfg.WorkingDir, hugoDirectSumFilename), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o666)
+       if err != nil {
+               return err
+       }
+       defer f.Close()
+
+       for _, s := range sums {
+               fmt.Fprintf(f, "%s %s %s\n", s.pathVersionKey.path, s.pathVersionKey.version, s.sum)
+       }
+
+       return nil
+}
+
+func (c *Client) readModSumFile(filename string) (map[pathVersionKey]string, error) {
+       b, err := afero.ReadFile(c.fs, filepath.Join(c.ccfg.WorkingDir, filename))
+       if err != nil {
+               if herrors.IsNotExist(err) {
+                       return make(map[pathVersionKey]string), nil
+               }
+               return nil, err
+       }
+       lines := bytes.Split(b, []byte{'\n'})
+       sums := make(map[pathVersionKey]string)
+       for _, line := range lines {
+               parts := bytes.Fields(line)
+               if len(parts) == 3 {
+                       sums[pathVersionKey{path: string(parts[0]), version: string(parts[1])}] = string(parts[2])
+               }
+       }
+
+       return sums, nil
+}
+
 func (c *Client) rewriteGoMod(name string, isGoMod map[string]bool) error {
        data, err := c.rewriteGoModRewrite(name, isGoMod)
        if err != nil {
@@ -707,8 +813,8 @@ func (c *Client) tidy(mods Modules, goModOnly bool) error {
        return nil
 }
 
-func (c *Client) shouldVendor(path string) bool {
-       return c.noVendor == nil || !c.noVendor.Match(path)
+func (c *Client) shouldNotVendor(path string) bool {
+       return c.noVendor != nil && c.noVendor.Match(path)
 }
 
 func (c *Client) createThemeDirname(modulePath string, isProjectMod bool) (string, error) {
@@ -773,6 +879,7 @@ func (cfg ClientConfig) toEnv() []string {
        keyVals := []string{
                "PWD", cfg.WorkingDir,
                "GO111MODULE", "on",
+               "GOFLAGS", "-modcacherw",
                "GOPATH", cfg.CacheDir,
                "GOWORK", mcfg.Workspace, // Requires Go 1.18, see https://tip.golang.org/doc/go1.18
                // GOCACHE was introduced in Go 1.15. This matches the location derived from GOPATH above.
@@ -807,6 +914,7 @@ type goModule struct {
        Replace  *goModule      // replaced by this module
        Time     *time.Time     // time version was created
        Update   *goModule      // available update, if any (with -u)
+       Sum      string         // checksum
        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
@@ -820,6 +928,11 @@ type goModuleError struct {
 
 type goModules []*goModule
 
+type modSum struct {
+       pathVersionKey
+       sum string
+}
+
 func (modules goModules) GetByPath(p string) *goModule {
        if modules == nil {
                return nil
index 1b4b1161a508a9007d8ad434836713fe759a9c7c..78fdb9237253fe9895cade36c92fc5e88cff5c77 100644 (file)
@@ -227,7 +227,7 @@ func TestClientConfigToEnv(t *testing.T) {
 
        env := ccfg.toEnv()
 
-       c.Assert(env, qt.DeepEquals, []string{"PWD=/mywork", "GO111MODULE=on", "GOPATH=/mycache", "GOWORK=", filepath.FromSlash("GOCACHE=/mycache/pkg/mod")})
+       c.Assert(env, qt.DeepEquals, []string{"PWD=/mywork", "GO111MODULE=on", "GOFLAGS=-modcacherw", "GOPATH=/mycache", "GOWORK=", filepath.FromSlash("GOCACHE=/mycache/pkg/mod")})
 
        ccfg = ClientConfig{
                WorkingDir: "/mywork",
@@ -246,6 +246,7 @@ func TestClientConfigToEnv(t *testing.T) {
        c.Assert(env, qt.DeepEquals, []string{
                "PWD=/mywork",
                "GO111MODULE=on",
+               "GOFLAGS=-modcacherw",
                "GOPATH=/mycache",
                "GOWORK=myworkspace",
                filepath.FromSlash("GOCACHE=/mycache/pkg/mod"),
index 7034a6b161f71edce20618cca32040d3a3ec9e28..6ff37aa63a0fb7650619a2bd90e7a381cb6e28b0 100644 (file)
@@ -1,4 +1,4 @@
-// Copyright 2019 The Hugo Authors. All rights reserved.
+// Copyright 2025 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.
@@ -18,6 +18,7 @@ import (
        "errors"
        "fmt"
        "io/fs"
+       "net/url"
        "os"
        "path/filepath"
        "regexp"
@@ -28,6 +29,7 @@ import (
        "github.com/gohugoio/hugo/common/herrors"
        "github.com/gohugoio/hugo/common/loggers"
        "github.com/gohugoio/hugo/common/paths"
+       "golang.org/x/mod/module"
 
        "github.com/spf13/cast"
 
@@ -38,8 +40,6 @@ import (
 
        "github.com/gohugoio/hugo/hugofs/files"
 
-       "golang.org/x/mod/module"
-
        "github.com/gohugoio/hugo/config"
        "github.com/spf13/afero"
 )
@@ -155,9 +155,14 @@ func filterUnwantedMounts(mounts []Mount) []Mount {
        return tmp
 }
 
+type pathVersionKey struct {
+       path    string
+       version string
+}
+
 type collected struct {
        // Pick the first and prevent circular loops.
-       seen map[string]bool
+       seenPaths map[string]*moduleAdapter
 
        // Maps module path to a _vendor dir. These values are fetched from
        // _vendor/modules.txt, and the first (top-most) will win.
@@ -186,9 +191,9 @@ type collector struct {
 
 func (c *collector) initModules() error {
        c.collected = &collected{
-               seen:     make(map[string]bool),
-               vendored: make(map[string]vendoredModule),
-               gomods:   goModules{},
+               seenPaths: make(map[string]*moduleAdapter),
+               vendored:  make(map[string]vendoredModule),
+               gomods:    goModules{},
        }
 
        // If both these are true, we don't even need Go installed to build.
@@ -200,13 +205,16 @@ func (c *collector) initModules() error {
        return c.loadModules()
 }
 
-func (c *collector) isSeen(path string) bool {
-       key := pathKey(path)
-       if c.seen[key] {
-               return true
+func (c *collector) isPathSeen(p string, owner *moduleAdapter) *moduleAdapter {
+       // Remove any major version suffix.
+       // We do allow multiple major versions in the same project,
+       // but not as transitive dependencies.
+       p = pathBase(p)
+       if v, ok := c.seenPaths[p]; ok {
+               return v
        }
-       c.seen[key] = true
-       return false
+       c.seenPaths[p] = owner
+       return nil
 }
 
 func (c *collector) getVendoredDir(path string) (vendoredModule, bool) {
@@ -214,29 +222,37 @@ func (c *collector) getVendoredDir(path string) (vendoredModule, bool) {
        return v, found
 }
 
-func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapter, error) {
+func (c *collector) getAndCreateModule(owner *moduleAdapter, moduleImport Import) (*moduleAdapter, error) {
        var (
-               mod       *goModule
-               moduleDir string
-               version   string
-               vendored  bool
+               mod                   *goModule
+               moduleDir             string
+               versionMod            string
+               requestedVersionQuery string = moduleImport.Version
+               vendored              bool
        )
 
        modulePath := moduleImport.Path
+       vendorPath := modulePath
+       vendorPathEscaped := modulePath
+       if requestedVersionQuery != "" {
+               vendorPath += "@" + requestedVersionQuery
+               vendorPathEscaped += "@" + url.QueryEscape(requestedVersionQuery)
+       }
+
        var realOwner Module = owner
 
-       if !c.ccfg.shouldIgnoreVendor(modulePath) {
+       if !(c.ccfg.shouldIgnoreVendor(vendorPath)) {
                if err := c.collectModulesTXT(owner); err != nil {
                        return nil, err
                }
 
                // Try _vendor first.
                var vm vendoredModule
-               vm, vendored = c.getVendoredDir(modulePath)
+               vm, vendored = c.getVendoredDir(vendorPathEscaped)
                if vendored {
                        moduleDir = vm.Dir
                        realOwner = vm.Owner
-                       version = vm.Version
+                       versionMod = vm.Version
 
                        if owner.projectMod {
                                // We want to keep the go.mod intact with the versions and all.
@@ -247,34 +263,45 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapt
        }
 
        if moduleDir == "" {
-               var versionQuery string
-               mod = c.gomods.GetByPath(modulePath)
-               if mod != nil {
-                       moduleDir = mod.Dir
-                       versionQuery = mod.Version
+               if requestedVersionQuery == "" {
+                       mod = c.gomods.GetByPath(modulePath)
+                       if mod != nil {
+                               moduleDir = mod.Dir
+                       }
                }
 
                if moduleDir == "" {
-                       if c.GoModulesFilename != "" && isProbablyModule(modulePath) {
-                               // Try to "go get" it and reload the module configuration.
-                               if versionQuery == "" {
+                       if isProbablyModule(modulePath) {
+                               if requestedVersionQuery != "" {
+                                       var err error
+                                       mod, err = c.downloadModuleVersion(modulePath, requestedVersionQuery)
+                                       if err != nil {
+                                               return nil, err
+                                       }
+                                       if mod == nil {
+                                               return nil, fmt.Errorf("module %q not found", modulePath)
+                                       }
+                                       moduleDir = mod.Dir
+                                       versionMod = mod.Version
+                               } else if c.GoModulesFilename != "" {
                                        // See https://golang.org/ref/mod#version-queries
                                        // This will select the latest release-version (not beta etc.).
-                                       versionQuery = "upgrade"
-                               }
-
-                               // Note that we cannot use c.Get for this, as that may
-                               // trigger a new module collection and potentially create a infinite loop.
-                               if err := c.get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil {
-                                       return nil, err
-                               }
-                               if err := c.loadModules(); err != nil {
-                                       return nil, err
-                               }
-
-                               mod = c.gomods.GetByPath(modulePath)
-                               if mod != nil {
-                                       moduleDir = mod.Dir
+                                       const versionQuery = "upgrade"
+                                       // Try to "go get" it and reload the module configuration.
+
+                                       // Note that we cannot use c.Get for this, as that may
+                                       // trigger a new module collection and potentially create a infinite loop.
+                                       if err := c.get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil {
+                                               return nil, err
+                                       }
+                                       if err := c.loadModules(); err != nil {
+                                               return nil, err
+                                       }
+
+                                       mod = c.gomods.GetByPath(modulePath)
+                                       if mod != nil {
+                                               moduleDir = mod.Dir
+                                       }
                                }
                        }
 
@@ -305,10 +332,11 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapt
        }
 
        ma := &moduleAdapter{
-               dir:     moduleDir,
-               vendor:  vendored,
-               gomod:   mod,
-               version: version,
+               dir:          moduleDir,
+               vendor:       vendored,
+               gomod:        mod,
+               version:      versionMod,
+               versionQuery: requestedVersionQuery,
                // This may be the owner of the _vendor dir
                owner: realOwner,
        }
@@ -327,7 +355,6 @@ func (c *collector) add(owner *moduleAdapter, moduleImport Import) (*moduleAdapt
                return nil, err
        }
 
-       c.modules = append(c.modules, ma)
        return ma, nil
 }
 
@@ -338,23 +365,50 @@ func (c *collector) addAndRecurse(owner *moduleAdapter) error {
                        return fmt.Errorf("failed to apply mounts for project: %w", err)
                }
        }
-
+       seen := make(map[pathVersionKey]bool)
        for _, moduleImport := range moduleConfig.Imports {
                if moduleImport.Disable {
                        continue
                }
-               if !c.isSeen(moduleImport.Path) {
-                       tc, err := c.add(owner, moduleImport)
-                       if err != nil {
-                               return err
-                       }
-                       if tc == nil || moduleImport.IgnoreImports {
-                               continue
-                       }
-                       if err := c.addAndRecurse(tc); err != nil {
-                               return err
+
+               // Prevent cyclic references.
+               if v := c.isPathSeen(moduleImport.Path, owner); v != nil && v != owner {
+                       continue
+               }
+
+               tc, err := c.getAndCreateModule(owner, moduleImport)
+               if err != nil {
+                       return err
+               }
+
+               if tc == nil {
+                       continue
+               }
+
+               pk := pathVersionKey{path: tc.Path(), version: tc.Version()}
+               seenInCurrent := seen[pk]
+               if seenInCurrent {
+                       // Only one import of the same module per project.
+                       if owner.projectMod {
+                               // In Hugo v0.150.0 we introduced direct dependencies, and it may be tempting to import the same version
+                               // with different mount setups. We may allow that in the future, but we need to get some experience first.
+                               // For now, we just warn. The user needs to add multiple mount points in the same import.
+                               c.logger.Warnf("module with path %q is imported for the same version %q more than once", tc.Path(), tc.Version())
                        }
+                       continue
                }
+               seen[pk] = true
+
+               c.modules = append(c.modules, tc)
+
+               if moduleImport.IgnoreImports {
+                       continue
+               }
+
+               if err := c.addAndRecurse(tc); err != nil {
+                       return err
+               }
+
        }
        return nil
 }
@@ -527,6 +581,11 @@ func (c *collector) collect() {
 
        // Add the project mod on top.
        c.modules = append(Modules{projectMod}, c.modules...)
+
+       if err := c.writeHugoDirectSum(c.modules); err != nil {
+               c.err = err
+               return
+       }
 }
 
 func (c *collector) isVendored(dir string) bool {
@@ -744,12 +803,7 @@ func createProjectModule(gomod *goModule, workingDir string, conf Config) *modul
        }
 }
 
-// In the first iteration of Hugo Modules, we do not support multiple
-// major versions running at the same time, so we pick the first (upper most).
-// We will investigate namespaces in future versions.
-// TODO(bep) add a warning when the above happens.
-func pathKey(p string) string {
+func pathBase(p string) string {
        prefix, _, _ := module.SplitPathVersion(p)
-
        return strings.ToLower(prefix)
 }
index 9487c0a0e172f62050e08473d14110e7e6339fd5..5debda28ca070735e7dde39f828f65b0d658f2eb 100644 (file)
@@ -32,7 +32,7 @@ func TestPathKey(t *testing.T) {
                {"github.com/foo/v3d", "github.com/foo/v3d"},
                {"MyTheme", "mytheme"},
        } {
-               c.Assert(pathKey(test.in), qt.Equals, test.expect)
+               c.Assert(pathBase(test.in), qt.Equals, test.expect)
        }
 }
 
index 1a833b30137777be6e83d3765ce9c87b6ba9b8b7..b6a8254160b95c7543955dcca5265cf654f28503 100644 (file)
@@ -377,6 +377,11 @@ func (v HugoVersion) IsValid() bool {
 type Import struct {
        // Module path
        Path string
+
+       // The common case is to leave this empty and let Go Modules resolve the version.
+       // Can be set to a version query, e.g. "v1.2.3", ">=v1.2.0", "latest", which will
+       // make this a direct dependency.
+       Version string
        // Set when Path is replaced in project config.
        pathProjectReplaced bool
        // Ignore any config in config.toml (will still follow imports).
index 8c7316eeab2b9e5dbe4f2ef39636146f25dc9710..5eb78e98e572229f478ef17115dfb020798a21b1 100644 (file)
@@ -17,6 +17,7 @@
 package modules
 
 import (
+       "net/url"
        "time"
 
        "github.com/gohugoio/hugo/config"
@@ -54,6 +55,10 @@ type Module interface {
        // or the path below your /theme folder, e.g. "mytheme".
        Path() string
 
+       // For direct dependencies, this will be Path + "@" + VersionQuery.
+       // For managed dependencies, this will be the same as Path.
+       PathVersionQuery(escapeQuery bool) string
+
        // Replaced by this module.
        Replace() Module
 
@@ -63,6 +68,12 @@ type Module interface {
        // The module version.
        Version() string
 
+       // The version query requested in the import.
+       VersionQuery() string
+
+       // The expected cryptographic hash of the module.
+       Sum() string
+
        // Time version was created.
        Time() time.Time
 
@@ -73,12 +84,13 @@ type Module interface {
 type Modules []Module
 
 type moduleAdapter struct {
-       path       string
-       dir        string
-       version    string
-       vendor     bool
-       projectMod bool
-       owner      Module
+       path         string
+       dir          string
+       version      string
+       versionQuery string
+       vendor       bool
+       projectMod   bool
+       owner        Module
 
        mounts []Mount
 
@@ -129,6 +141,23 @@ func (m *moduleAdapter) Path() string {
        return m.gomod.Path
 }
 
+func (m *moduleAdapter) PathVersionQuery(escapeQuery bool) string {
+       // We added version as a config option in Hugo v0.150.0, so
+       // to make this backward compatible, we only add the version
+       // if it was explicitly requested.
+       pathBase := m.Path()
+       if m.versionQuery == "" || !m.IsGoMod() {
+               return pathBase
+       }
+
+       q := m.versionQuery
+       if escapeQuery {
+               q = url.QueryEscape(q)
+       }
+
+       return pathBase + "@" + q
+}
+
 func (m *moduleAdapter) Replace() Module {
        if m.IsGoMod() && !m.Vendor() && m.gomod.Replace != nil {
                return &moduleAdapter{
@@ -150,6 +179,18 @@ func (m *moduleAdapter) Version() string {
        return m.gomod.Version
 }
 
+func (m *moduleAdapter) VersionQuery() string {
+       return m.versionQuery
+}
+
+func (m *moduleAdapter) Sum() string {
+       if !m.IsGoMod() {
+               return ""
+       }
+
+       return m.gomod.Sum
+}
+
 func (m *moduleAdapter) Time() time.Time {
        if !m.IsGoMod() || m.gomod.Time == nil {
                return time.Time{}
diff --git a/modules/modules_integration_test.go b/modules/modules_integration_test.go
new file mode 100644 (file)
index 0000000..6f09a35
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright 2025 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 governing permissions and
+// limitations under the License.
+
+package modules_test
+
+import (
+       "testing"
+
+       "github.com/gohugoio/hugo/hugolib"
+)
+
+func TestModuleImportWithVersion(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.2.0"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.1.0"
+[[module.imports.mounts]]
+source = "content"
+target = "content/v1"
+-- layouts/all.html --
+Title: {{ .Title }}|Summary: {{ .Summary }}|
+Deps: {{ range hugo.Deps}}{{ printf "%s@%s" .Path .Version }}|{{ end }}$
+
+
+`
+
+       b := hugolib.Test(t, files, hugolib.TestOptWithOSFs()).Build()
+
+       b.AssertFileContent("public/index.html", "Deps: project@|github.com/bep/hugo-mod-misc/dummy-content@v0.2.0|github.com/bep/hugo-mod-misc/dummy-content@v0.1.0|$")
+
+       b.AssertFileContent("public/blog/music/autumn-leaves/index.html", "Autumn Leaves is a popular jazz standard") // v0.2.0
+       b.AssertFileContent("public/v1/blog/music/autumn-leaves/index.html", "Lorem markdownum, placidi peremptis")   // v0.1.0
+}
diff --git a/testscripts/commands/mod__hugodirectsum.txt b/testscripts/commands/mod__hugodirectsum.txt
new file mode 100644 (file)
index 0000000..ce3d3e4
--- /dev/null
@@ -0,0 +1,15 @@
+hugo mod graph
+stdout 'foo'
+grep 'github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD\+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4II=' hugo.direct.sum
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-nop"
+version = "v1.0.0"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
\ No newline at end of file
diff --git a/testscripts/commands/mod__hugodirectsum_tamper.txt b/testscripts/commands/mod__hugodirectsum_tamper.txt
new file mode 100644 (file)
index 0000000..e07676d
--- /dev/null
@@ -0,0 +1,16 @@
+! hugo
+stderr 'checksum mismatch'
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-nop"
+version = "v1.0.0"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
+-- hugo.direct.sum --
+github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4Ix=
\ No newline at end of file
diff --git a/testscripts/commands/mod__tamper_gosum.txt b/testscripts/commands/mod__tamper_gosum.txt
new file mode 100644 (file)
index 0000000..8aabba9
--- /dev/null
@@ -0,0 +1,22 @@
+
+# We're not testing Go's security here, so we just edit the go.sum and verifies that the build fails.
+
+! hugo
+stderr 'checksum mismatch'
+
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-nop"
+-- layouts/all.html --
+All.
+-- go.mod --
+module foo
+
+go 1.24.0
+
+require github.com/bep/hugo-mod-nop v1.0.0 // indirect
+-- go.sum --
+github.com/bep/hugo-mod-nop v1.0.0 h1:NRDMRPCD+4dw5K8XvaZURvZJCfBMCifkRe6C6x6W4II=
+github.com/bep/hugo-mod-nop v1.0.0/go.mod h1:6cqjOCVJHP6+aQ+5IYvaS9j8BfIVksAct4xzl9OnmIX=
\ No newline at end of file
diff --git a/testscripts/commands/mod_vendor__versions.txt b/testscripts/commands/mod_vendor__versions.txt
new file mode 100644 (file)
index 0000000..10a8a03
--- /dev/null
@@ -0,0 +1,102 @@
+dostounix golden/vendor.txt
+
+hugo mod vendor
+cmp _vendor/modules.txt golden/vendor.txt
+lsr _vendor
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/config.toml'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/config.toml'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/content/blog/music/all-of-me/index.md'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/content/blog/music/blue-bossa/index.md'
+stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/content/blog/music/all-of-me/index.md'
+! stdout 'github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/content/blog/music/blue-bossa/index.md' # not mounted
+
+hugo mod graph
+stdout 'project github.com/bep/hugo-mod-misc/dummy-content@v0.2.0' 
+stdout 'project github.com/bep/hugo-mod-misc/dummy-content@v0.1.0'
+
+hugo config mounts
+[unix] cmpenv stdout golden/mounts.json
+
+-- hugo.toml --
+baseURL = "https://example.org"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "v0.2.0"
+[[module.imports]]
+path    = "github.com/bep/hugo-mod-misc/dummy-content"
+version = "<=v0.1.0"
+[[module.imports.mounts]]
+source = "content/blog/music/all-of-me"
+target = "content/all"
+-- layouts/all.html --
+Title: {{ .Title }}|Summary: {{ .Summary }}|
+Deps: {{ range hugo.Deps}}{{ printf "%s@%s" .Path .Version }}|{{ end }}$
+
+
+-- golden/vendor.txt --
+# github.com/bep/hugo-mod-misc/dummy-content@v0.2.0 v0.2.0
+# github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0 v0.1.0
+-- golden/mounts.json --
+{
+   "path": "project",
+   "version": "",
+   "time": "0001-01-01T00:00:00Z",
+   "owner": "",
+   "dir": "$WORK",
+   "mounts": [
+      {
+         "source": "content",
+         "target": "content"
+      },
+      {
+         "source": "data",
+         "target": "data"
+      },
+      {
+         "source": "layouts",
+         "target": "layouts"
+      },
+      {
+         "source": "i18n",
+         "target": "i18n"
+      },
+      {
+         "source": "archetypes",
+         "target": "archetypes"
+      },
+      {
+         "source": "assets",
+         "target": "assets"
+      },
+      {
+         "source": "static",
+         "target": "static"
+      }
+   ]
+}
+{
+   "path": "github.com/bep/hugo-mod-misc/dummy-content",
+   "version": "v0.2.0",
+   "time": "0001-01-01T00:00:00Z",
+   "owner": "project",
+   "dir": "$WORK/_vendor/github.com/bep/hugo-mod-misc/dummy-content@v0.2.0/",
+   "mounts": [
+      {
+         "source": "content",
+         "target": "content"
+      }
+   ]
+}
+{
+   "path": "github.com/bep/hugo-mod-misc/dummy-content",
+   "version": "v0.1.0",
+   "time": "0001-01-01T00:00:00Z",
+   "owner": "project",
+   "dir": "$WORK/_vendor/github.com/bep/hugo-mod-misc/dummy-content@%3C%3Dv0.1.0/",
+   "mounts": [
+      {
+         "source": "content/blog/music/all-of-me",
+         "target": "content/all"
+      }
+   ]
+}