modules: Add support for direct version module imports in hugo.toml

Fixes #13964
This commit is contained in:
Bjørn Erik Pedersen 2025-09-05 08:00:39 +02:00
parent d8774d7fc3
commit 747cf4ad65
No known key found for this signature in database
12 changed files with 562 additions and 84 deletions

View 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.

View 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

View 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"),

View 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"
}
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)
}

View 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)
}
}

View 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).

View 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{}

View File

@ -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
}

View File

@ -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

View File

@ -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=

View File

@ -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=

View File

@ -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"
}
]
}