From 747cf4ad657b6eaeffcd0c7b0a10a38980c7b54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 5 Sep 2025 08:00:39 +0200 Subject: [PATCH] modules: Add support for direct version module imports in hugo.toml Fixes #13964 --- main_test.go | 61 +++++- modules/client.go | 133 ++++++++++++- modules/client_test.go | 3 +- modules/collect.go | 184 +++++++++++------- modules/collect_test.go | 2 +- modules/config.go | 5 + modules/module.go | 53 ++++- modules/modules_integration_test.go | 50 +++++ testscripts/commands/mod__hugodirectsum.txt | 15 ++ .../commands/mod__hugodirectsum_tamper.txt | 16 ++ testscripts/commands/mod__tamper_gosum.txt | 22 +++ testscripts/commands/mod_vendor__versions.txt | 102 ++++++++++ 12 files changed, 562 insertions(+), 84 deletions(-) create mode 100644 modules/modules_integration_test.go create mode 100644 testscripts/commands/mod__hugodirectsum.txt create mode 100644 testscripts/commands/mod__hugodirectsum_tamper.txt create mode 100644 testscripts/commands/mod__tamper_gosum.txt create mode 100644 testscripts/commands/mod_vendor__versions.txt diff --git a/main_test.go b/main_test.go index 683defb1a..fe17cd32e 100644 --- a/main_test.go +++ b/main_test.go @@ -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. diff --git a/modules/client.go b/modules/client.go index a8998bb8d..09adeab90 100644 --- a/modules/client.go +++ b/modules/client.go @@ -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 diff --git a/modules/client_test.go b/modules/client_test.go index 1b4b1161a..78fdb9237 100644 --- a/modules/client_test.go +++ b/modules/client_test.go @@ -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"), diff --git a/modules/collect.go b/modules/collect.go index 7034a6b16..6ff37aa63 100644 --- a/modules/collect.go +++ b/modules/collect.go @@ -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" - } + 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 - } + // 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 + 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) } diff --git a/modules/collect_test.go b/modules/collect_test.go index 9487c0a0e..5debda28c 100644 --- a/modules/collect_test.go +++ b/modules/collect_test.go @@ -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) } } diff --git a/modules/config.go b/modules/config.go index 1a833b301..b6a825416 100644 --- a/modules/config.go +++ b/modules/config.go @@ -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). diff --git a/modules/module.go b/modules/module.go index 8c7316eea..5eb78e98e 100644 --- a/modules/module.go +++ b/modules/module.go @@ -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 index 000000000..6f09a35e0 --- /dev/null +++ b/modules/modules_integration_test.go @@ -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 index 000000000..ce3d3e49f --- /dev/null +++ b/testscripts/commands/mod__hugodirectsum.txt @@ -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 index 000000000..e07676dc3 --- /dev/null +++ b/testscripts/commands/mod__hugodirectsum_tamper.txt @@ -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 index 000000000..8aabba986 --- /dev/null +++ b/testscripts/commands/mod__tamper_gosum.txt @@ -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 index 000000000..10a8a0367 --- /dev/null +++ b/testscripts/commands/mod_vendor__versions.txt @@ -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" + } + ] +}