Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d88a29e

Browse files
bepclaude
andauthored
npm: Use workspaces to simplify hugo mod npm pack
Rewrite `hugo mod npm pack` to use npm workspaces. Module deps are now written to packages/hugoautogen/package.json and the root package.json gets a "workspaces" reference. A hugo_packagemeta.json sidecar stores a hash of all input package files so regular commands can warn when npm deps are out of sync. Other changes: - Workspace glob patterns (*, **, {a,b}) are resolved via gobwas/glob. - Workspaces defined in package.hugo.json are supported. - package.hugo.json is only recognised at module roots, not in workspaces. - When package.hugo.json exists, package.json is not mounted or vendored. - packages/hugoautogen is not mounted or vendored from dependencies. - Add usePackageJSON import option (auto/always/never) to control whether a module's npm deps are included. "auto" checks for Hugo config files or package.hugo.json. - The staleness check is skipped when running `hugo mod npm pack` itself. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 3ff9b7f commit d88a29e

16 files changed

Lines changed: 1086 additions & 160 deletions

File tree

commands/commandeer.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ type commonConfig struct {
9797
type configKey struct {
9898
counter int32
9999
ignoreModulesDoesNotExists bool
100+
skipNpmCheck bool
100101
}
101102

102103
// This is the root command.
@@ -195,6 +196,7 @@ func (r *rootCommand) ConfigFromConfig(key configKey, oldConf *commonConfig) (*c
195196
Logger: r.logger,
196197
Environment: r.environment,
197198
IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists,
199+
SkipNpmCheck: key.skipNpmCheck,
198200
},
199201
)
200202
if err != nil {
@@ -251,6 +253,7 @@ func (r *rootCommand) ConfigFromProvider(key configKey, cfg config.Provider) (*c
251253
Environment: r.environment,
252254
Logger: r.logger,
253255
IgnoreModuleDoesNotExist: key.ignoreModulesDoesNotExists,
256+
SkipNpmCheck: key.skipNpmCheck,
254257
},
255258
)
256259
if err != nil {

commands/mod.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121

2222
"github.com/bep/simplecobra"
2323
"github.com/gohugoio/hugo/config"
24+
"github.com/gohugoio/hugo/hugolib"
2425
"github.com/gohugoio/hugo/modules/npm"
2526
"github.com/spf13/cobra"
2627
)
@@ -49,28 +50,34 @@ func newModCommands() *modCommands {
4950
commands: []simplecobra.Commander{
5051
&simpleCommand{
5152
name: "pack",
52-
short: "Experimental: Prepares and writes a composite package.json file for your project",
53-
long: `Prepares and writes a composite package.json file for your project.
53+
short: "Merges module npm dependencies into an npm workspace",
54+
long: `Merges npm dependencies from all Hugo modules into a "packages/hugoautogen" npm workspace.
5455
55-
On first run it creates a "package.hugo.json" in the project root if not already there. This file will be used as a template file
56-
with the base dependency set.
56+
The merged dependencies are written to packages/hugoautogen/package.json, and the root package.json
57+
is updated with a "workspaces" entry pointing to "packages/hugoautogen".
5758
58-
This set will be merged with all "package.hugo.json" files found in the dependency tree, picking the version closest to the project.
59+
The source entries are read from either package.hugo.json or package.json in the module root, with package.hugo.json taking precedence if both exist.
5960
60-
This command is marked as 'Experimental'. We think it's a great idea, so it's not likely to be
61-
removed from Hugo, but we need to test this out in "real life" to get a feel of it,
62-
so this may/will change in future versions of Hugo.
61+
See [npm dependencies](/hugo-modules/npm-dependencies/) for more information.
6362
`,
6463
withc: func(cmd *cobra.Command, r *rootCommand) {
6564
cmd.ValidArgsFunction = cobra.NoFileCompletions
6665
applyLocalFlagsBuildConfig(cmd, r)
6766
},
6867
run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error {
69-
h, err := r.Hugo(flagsToCfg(cd, nil))
68+
cfg := flagsToCfg(cd, nil)
69+
k := configKey{counter: r.configVersionID.Load(), skipNpmCheck: true}
70+
h, _, err := r.hugoSites.GetOrCreate(k, func(key configKey) (*hugolib.HugoSites, error) {
71+
conf, err := r.ConfigFromProvider(key, cfg)
72+
if err != nil {
73+
return nil, err
74+
}
75+
return hugolib.NewHugoSites(r.newDepsConfig(conf))
76+
})
7077
if err != nil {
7178
return err
7279
}
73-
return npm.Pack(h.BaseFs.ProjectSourceFs, h.BaseFs.AssetsWithDuplicatesPreserved.Fs)
80+
return npm.Pack(h.BaseFs.ProjectSourceFs, h.BaseFs.AssetsWithDuplicatesPreserved.Fs, h.Configs.Modules)
7481
},
7582
},
7683
},
@@ -293,7 +300,10 @@ Run "go help get" for more information. All flags available for "go get" is also
293300
return err
294301
}
295302
client := conf.configs.ModulesClient
296-
return client.Get(args...)
303+
if err := client.Get(args...); err != nil {
304+
return err
305+
}
306+
return nil
297307
}
298308
},
299309
},

common/loggers/logger.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ func (l *logAdapter) PrintTimerIfDelayed(start time.Time, name string) {
286286
if milli < 500 {
287287
return
288288
}
289-
fmt.Fprintf(l.stdErr, "%s in %v ms", name, milli)
289+
fmt.Fprintf(l.stdErr, "%s in %v ms\n", name, milli)
290290
}
291291

292292
func (l *logAdapter) Printf(format string, v ...any) {

config/allconfig/load.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/gohugoio/hugo/helpers"
3535
hglob "github.com/gohugoio/hugo/hugofs/hglob"
3636
"github.com/gohugoio/hugo/modules"
37+
"github.com/gohugoio/hugo/modules/npm"
3738
"github.com/gohugoio/hugo/parser/metadecoders"
3839
"github.com/spf13/afero"
3940
)
@@ -95,6 +96,10 @@ func LoadConfig(d ConfigSourceDescriptor) (configs *Configs, err error) {
9596
configs.Modules = moduleConfig.AllModules
9697
configs.ModulesClient = modulesClient
9798

99+
if !d.SkipNpmCheck && npm.NpmPackNeedsUpdate(d.Fs, configs.Modules) {
100+
d.Logger.Warnln(`npm dependencies are out of sync, please run "hugo mod npm pack" (you may also want to run "npm install" after that)`)
101+
}
102+
98103
if err := configs.Init(d.Fs, d.Logger); err != nil {
99104
return nil, fmt.Errorf("failed to init config: %w", err)
100105
}
@@ -127,6 +132,9 @@ type ConfigSourceDescriptor struct {
127132

128133
// If set, this will be used to ignore the module does not exist error.
129134
IgnoreModuleDoesNotExist bool
135+
136+
// If set, skip the npm pack staleness check (used by hugo mod npm pack).
137+
SkipNpmCheck bool
130138
}
131139

132140
func (d ConfigSourceDescriptor) configFilenames() []string {

hugofs/files/classifier.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,18 @@ import (
2121
)
2222

2323
const (
24-
// The NPM package.json "template" file.
24+
// When merging, we check for this file first, and then package.json.
2525
FilenamePackageHugoJSON = "package.hugo.json"
2626
// The NPM package file.
2727
FilenamePackageJSON = "package.json"
2828

29-
FilenameHugoStatsJSON = "hugo_stats.json"
29+
FilenameHugoStatsJSON = "hugo_stats.json"
30+
FilenamePackageMetaJSON = "hugo_packagemeta.json"
3031
)
3132

33+
// FolderPackagesHugoAutoGen is the auto-generated npm workspace directory for Hugo module deps.
34+
var FolderPackagesHugoAutoGen = filepath.Join("packages", "hugoautogen")
35+
3236
func IsGoTmplExt(ext string) bool {
3337
return ext == "gotmpl"
3438
}

modules/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,7 @@ type goModule struct {
979979
Time *time.Time // time version was created
980980
Update *goModule // available update, if any (with -u)
981981
Sum string // checksum
982+
GoModSum string // checksum for go.mod
982983
Main bool // is this the main module?
983984
Indirect bool // is this module only an indirect dependency of main module?
984985
Dir string // directory holding files for this module, if any

modules/collect.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ package modules
1515

1616
import (
1717
"bufio"
18+
"encoding/json"
1819
"errors"
1920
"fmt"
2021
"io/fs"
@@ -27,6 +28,7 @@ import (
2728
"time"
2829

2930
"github.com/bep/debounce"
31+
"github.com/gobwas/glob"
3032
"github.com/gohugoio/hugo/common/herrors"
3133
"github.com/gohugoio/hugo/common/hmaps"
3234
"github.com/gohugoio/hugo/common/loggers"
@@ -677,10 +679,18 @@ func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([
677679
return mounts, fmt.Errorf("failed to read dir %q: %q", owner.Dir(), err)
678680
}
679681

682+
hasPackageHugoJSON := false
683+
for _, fi := range fis {
684+
if fi.Name() == files.FilenamePackageHugoJSON {
685+
hasPackageHugoJSON = true
686+
break
687+
}
688+
}
689+
680690
for _, fi := range fis {
681691
n := fi.Name()
682692

683-
should := n == files.FilenamePackageHugoJSON || n == files.FilenamePackageJSON
693+
should := n == files.FilenamePackageHugoJSON || (n == files.FilenamePackageJSON && !hasPackageHugoJSON)
684694
should = should || commonJSConfigs.MatchString(n)
685695

686696
if should {
@@ -692,9 +702,80 @@ func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([
692702

693703
}
694704

705+
// Mount the project's hugoautogen workspace package.json (not from dependencies).
706+
if owner.projectMod {
707+
pkgAutoGen := filepath.Join(files.FolderPackagesHugoAutoGen, files.FilenamePackageJSON)
708+
if fi, err := c.fs.Stat(filepath.Join(owner.Dir(), pkgAutoGen)); err == nil && !fi.IsDir() {
709+
mounts = append(mounts, Mount{
710+
Source: pkgAutoGen,
711+
Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, pkgAutoGen),
712+
})
713+
}
714+
}
715+
716+
// Mount workspace package.json files (skipping hugoautogen from dependencies).
717+
// Read workspaces from package.hugo.json if it exists, otherwise from package.json.
718+
pkgFile := files.FilenamePackageJSON
719+
if hasPackageHugoJSON {
720+
pkgFile = files.FilenamePackageHugoJSON
721+
}
722+
if pkgData, err := afero.ReadFile(c.fs, filepath.Join(owner.Dir(), pkgFile)); err == nil {
723+
var pkg map[string]any
724+
if err := json.Unmarshal(pkgData, &pkg); err == nil {
725+
for _, ws := range cast.ToStringSlice(pkg["workspaces"]) {
726+
wsDirs := ResolveWorkspacePattern(c.fs, owner.Dir(), ws)
727+
for _, wsDir := range wsDirs {
728+
if filepath.ToSlash(wsDir) == filepath.ToSlash(files.FolderPackagesHugoAutoGen) {
729+
continue
730+
}
731+
wsPackageJSON := filepath.Join(wsDir, files.FilenamePackageJSON)
732+
if fi, err := c.fs.Stat(filepath.Join(owner.Dir(), wsPackageJSON)); err == nil && !fi.IsDir() {
733+
mounts = append(mounts, Mount{
734+
Source: wsPackageJSON,
735+
Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, wsPackageJSON),
736+
})
737+
}
738+
}
739+
}
740+
}
741+
}
742+
695743
return mounts, nil
696744
}
697745

746+
// ResolveWorkspacePattern resolves an npm workspace pattern to actual directory paths.
747+
// Supports globs (*, **) and brace expansion ({a,b}) via gobwas/glob.
748+
// Literal paths are returned as-is.
749+
func ResolveWorkspacePattern(fs afero.Fs, root, pattern string) []string {
750+
if !strings.ContainsAny(pattern, "*?[{}") {
751+
return []string{pattern}
752+
}
753+
754+
g, err := glob.Compile(pattern, '/')
755+
if err != nil {
756+
return nil
757+
}
758+
759+
var dirs []string
760+
_ = afero.Walk(fs, root, func(path string, info os.FileInfo, err error) error {
761+
if err != nil || info.IsDir() {
762+
return nil
763+
}
764+
if info.Name() != files.FilenamePackageJSON {
765+
return nil
766+
}
767+
rel, err := filepath.Rel(root, filepath.Dir(path))
768+
if err != nil {
769+
return nil
770+
}
771+
if g.Match(filepath.ToSlash(rel)) {
772+
dirs = append(dirs, rel)
773+
}
774+
return nil
775+
})
776+
return dirs
777+
}
778+
698779
func (c *collector) nodeModulesRoot(s string) string {
699780
s = filepath.ToSlash(s)
700781
if strings.HasPrefix(s, "node_modules/") {

modules/collect_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
package modules
1515

1616
import (
17+
"path/filepath"
1718
"testing"
1819

1920
qt "github.com/frankban/quicktest"
21+
"github.com/spf13/afero"
2022
)
2123

2224
func TestPathKey(t *testing.T) {
@@ -36,6 +38,63 @@ func TestPathKey(t *testing.T) {
3638
}
3739
}
3840

41+
func TestResolveWorkspacePattern(t *testing.T) {
42+
c := qt.New(t)
43+
44+
fs := afero.NewMemMapFs()
45+
root := filepath.FromSlash("/project/mod")
46+
47+
// Create workspace package.json files.
48+
for _, dir := range []string{
49+
"packages/aws1",
50+
"packages/aws2",
51+
"packages/hugoautogen",
52+
"other/foo",
53+
} {
54+
p := filepath.Join(root, dir, "package.json")
55+
c.Assert(afero.WriteFile(fs, p, []byte(`{}`), 0o644), qt.IsNil)
56+
}
57+
58+
// Literal path, no glob.
59+
c.Assert(ResolveWorkspacePattern(fs, root, "packages/aws1"), qt.DeepEquals, []string{"packages/aws1"})
60+
61+
got := ResolveWorkspacePattern(fs, root, "packages/*")
62+
c.Assert(got, qt.DeepEquals, []string{
63+
filepath.FromSlash("packages/aws1"),
64+
filepath.FromSlash("packages/aws2"),
65+
filepath.FromSlash("packages/hugoautogen"),
66+
})
67+
68+
// We currently support only one level of globbing, so this should give the same result as above.
69+
got = ResolveWorkspacePattern(fs, root, "packages/**")
70+
c.Assert(got, qt.DeepEquals, []string{
71+
filepath.FromSlash("packages/aws1"),
72+
filepath.FromSlash("packages/aws2"),
73+
filepath.FromSlash("packages/hugoautogen"),
74+
})
75+
76+
got = ResolveWorkspacePattern(fs, root, "packages/{aws1,aws2}")
77+
c.Assert(got, qt.DeepEquals, []string{
78+
filepath.FromSlash("packages/aws1"),
79+
filepath.FromSlash("packages/aws2"),
80+
})
81+
82+
// ** matches recursively.
83+
nestedDir := filepath.Join(root, "packages", "aws1", "sub", "package.json")
84+
c.Assert(afero.WriteFile(fs, nestedDir, []byte(`{}`), 0o644), qt.IsNil)
85+
got = ResolveWorkspacePattern(fs, root, "packages/**")
86+
c.Assert(got, qt.DeepEquals, []string{
87+
filepath.FromSlash("packages/aws1"),
88+
filepath.FromSlash("packages/aws1/sub"),
89+
filepath.FromSlash("packages/aws2"),
90+
filepath.FromSlash("packages/hugoautogen"),
91+
})
92+
93+
// No matches.
94+
got = ResolveWorkspacePattern(fs, root, "nope/*")
95+
c.Assert(got, qt.HasLen, 0)
96+
}
97+
3998
func TestFilterUnwantedMounts(t *testing.T) {
4099
mounts := []Mount{
41100
{Source: "a", Target: "b", Lang: "en"},

modules/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,8 +403,20 @@ type Import struct {
403403
Disable bool
404404
// File mounts.
405405
Mounts []Mount
406+
407+
// Controls whether npm package files (package.json/package.hugo.json) are
408+
// read from this module. Values: "auto" (default), "always", "never".
409+
// "auto" reads package files when a Hugo config file or package.hugo.json
410+
// is present in the module root.
411+
UsePackageJSON string
406412
}
407413

414+
const (
415+
UsePackageJSONAuto = "auto"
416+
UsePackageJSONAlways = "always"
417+
UsePackageJSONNever = "never"
418+
)
419+
408420
type Mount struct {
409421
// Relative path in source repo, e.g. "scss".
410422
Source string

modules/module.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ type Module interface {
7171
// The version query requested in the import.
7272
VersionQuery() string
7373

74-
// The expected cryptographic hash of the module.
74+
// Checksum for path, version
7575
Sum() string
7676

7777
// Time version was created.

0 commit comments

Comments
 (0)