mirror of
https://github.com/gohugoio/hugo.git
synced 2025-09-10 04:59:15 -04:00
modules: Add support for direct version module imports in hugo.toml
Fixes #13964
This commit is contained in:
parent
d8774d7fc3
commit
747cf4ad65
61
main_test.go
61
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.
|
||||
|
@ -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
|
||||
|
@ -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"),
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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).
|
||||
|
@ -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{}
|
||||
|
50
modules/modules_integration_test.go
Normal file
50
modules/modules_integration_test.go
Normal 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
|
||||
}
|
15
testscripts/commands/mod__hugodirectsum.txt
Normal file
15
testscripts/commands/mod__hugodirectsum.txt
Normal 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
|
16
testscripts/commands/mod__hugodirectsum_tamper.txt
Normal file
16
testscripts/commands/mod__hugodirectsum_tamper.txt
Normal 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=
|
22
testscripts/commands/mod__tamper_gosum.txt
Normal file
22
testscripts/commands/mod__tamper_gosum.txt
Normal 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=
|
102
testscripts/commands/mod_vendor__versions.txt
Normal file
102
testscripts/commands/mod_vendor__versions.txt
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user