collect.go (16820B)
1 // Copyright 2019 The Hugo Authors. All rights reserved.
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 // http://www.apache.org/licenses/LICENSE-2.0
7 //
8 // Unless required by applicable law or agreed to in writing, software
9 // distributed under the License is distributed on an "AS IS" BASIS,
10 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 // See the License for the specific language governing permissions and
12 // limitations under the License.
13
14 package modules
15
16 import (
17 "bufio"
18 "fmt"
19 "os"
20 "path/filepath"
21 "regexp"
22 "strings"
23 "time"
24
25 "github.com/bep/debounce"
26 "github.com/gohugoio/hugo/common/loggers"
27
28 "github.com/spf13/cast"
29
30 "github.com/gohugoio/hugo/common/maps"
31
32 "github.com/gohugoio/hugo/common/hugo"
33 "github.com/gohugoio/hugo/parser/metadecoders"
34
35 "github.com/gohugoio/hugo/hugofs/files"
36
37 "github.com/rogpeppe/go-internal/module"
38
39 "errors"
40
41 "github.com/gohugoio/hugo/config"
42 "github.com/spf13/afero"
43 )
44
45 var ErrNotExist = errors.New("module does not exist")
46
47 const vendorModulesFilename = "modules.txt"
48
49 // IsNotExist returns whether an error means that a module could not be found.
50 func IsNotExist(err error) bool {
51 return errors.Is(err, os.ErrNotExist)
52 }
53
54 // CreateProjectModule creates modules from the given config.
55 // This is used in tests only.
56 func CreateProjectModule(cfg config.Provider) (Module, error) {
57 workingDir := cfg.GetString("workingDir")
58 var modConfig Config
59
60 mod := createProjectModule(nil, workingDir, modConfig)
61 if err := ApplyProjectConfigDefaults(cfg, mod); err != nil {
62 return nil, err
63 }
64
65 return mod, nil
66 }
67
68 func (h *Client) Collect() (ModulesConfig, error) {
69 mc, coll := h.collect(true)
70 if coll.err != nil {
71 return mc, coll.err
72 }
73
74 if err := (&mc).setActiveMods(h.logger); err != nil {
75 return mc, err
76 }
77
78 if h.ccfg.HookBeforeFinalize != nil {
79 if err := h.ccfg.HookBeforeFinalize(&mc); err != nil {
80 return mc, err
81 }
82 }
83
84 if err := (&mc).finalize(h.logger); err != nil {
85 return mc, err
86 }
87
88 return mc, nil
89 }
90
91 func (h *Client) collect(tidy bool) (ModulesConfig, *collector) {
92 c := &collector{
93 Client: h,
94 }
95
96 c.collect()
97 if c.err != nil {
98 return ModulesConfig{}, c
99 }
100
101 // https://github.com/gohugoio/hugo/issues/6115
102 /*if !c.skipTidy && tidy {
103 if err := h.tidy(c.modules, true); err != nil {
104 c.err = err
105 return ModulesConfig{}, c
106 }
107 }*/
108
109 return ModulesConfig{
110 AllModules: c.modules,
111 GoModulesFilename: c.GoModulesFilename,
112 }, c
113 }
114
115 type ModulesConfig struct {
116 // All modules, including any disabled.
117 AllModules Modules
118
119 // All active modules.
120 ActiveModules Modules
121
122 // Set if this is a Go modules enabled project.
123 GoModulesFilename string
124 }
125
126 func (m *ModulesConfig) setActiveMods(logger loggers.Logger) error {
127 var activeMods Modules
128 for _, mod := range m.AllModules {
129 if !mod.Config().HugoVersion.IsValid() {
130 logger.Warnf(`Module %q is not compatible with this Hugo version; run "hugo mod graph" for more information.`, mod.Path())
131 }
132 if !mod.Disabled() {
133 activeMods = append(activeMods, mod)
134 }
135 }
136
137 m.ActiveModules = activeMods
138
139 return nil
140 }
141
142 func (m *ModulesConfig) finalize(logger loggers.Logger) error {
143 for _, mod := range m.AllModules {
144 m := mod.(*moduleAdapter)
145 m.mounts = filterUnwantedMounts(m.mounts)
146 }
147 return nil
148 }
149
150 func filterUnwantedMounts(mounts []Mount) []Mount {
151 // Remove duplicates
152 seen := make(map[string]bool)
153 tmp := mounts[:0]
154 for _, m := range mounts {
155 if !seen[m.key()] {
156 tmp = append(tmp, m)
157 }
158 seen[m.key()] = true
159 }
160 return tmp
161 }
162
163 type collected struct {
164 // Pick the first and prevent circular loops.
165 seen map[string]bool
166
167 // Maps module path to a _vendor dir. These values are fetched from
168 // _vendor/modules.txt, and the first (top-most) will win.
169 vendored map[string]vendoredModule
170
171 // Set if a Go modules enabled project.
172 gomods goModules
173
174 // Ordered list of collected modules, including Go Modules and theme
175 // components stored below /themes.
176 modules Modules
177 }
178
179 // Collects and creates a module tree.
180 type collector struct {
181 *Client
182
183 // Store away any non-fatal error and return at the end.
184 err error
185
186 // Set to disable any Tidy operation in the end.
187 skipTidy bool
188
189 *collected
190 }
191
192 func (c *collector) initModules() error {
193 c.collected = &collected{
194 seen: make(map[string]bool),
195 vendored: make(map[string]vendoredModule),
196 gomods: goModules{},
197 }
198
199 // If both these are true, we don't even need Go installed to build.
200 if c.ccfg.IgnoreVendor == nil && c.isVendored(c.ccfg.WorkingDir) {
201 return nil
202 }
203
204 // We may fail later if we don't find the mods.
205 return c.loadModules()
206 }
207
208 func (c *collector) isSeen(path string) bool {
209 key := pathKey(path)
210 if c.seen[key] {
211 return true
212 }
213 c.seen[key] = true
214 return false
215 }
216
217 func (c *collector) getVendoredDir(path string) (vendoredModule, bool) {
218 v, found := c.vendored[path]
219 return v, found
220 }
221
222 func (c *collector) add(owner *moduleAdapter, moduleImport Import, disabled bool) (*moduleAdapter, error) {
223 var (
224 mod *goModule
225 moduleDir string
226 version string
227 vendored bool
228 )
229
230 modulePath := moduleImport.Path
231 var realOwner Module = owner
232
233 if !c.ccfg.shouldIgnoreVendor(modulePath) {
234 if err := c.collectModulesTXT(owner); err != nil {
235 return nil, err
236 }
237
238 // Try _vendor first.
239 var vm vendoredModule
240 vm, vendored = c.getVendoredDir(modulePath)
241 if vendored {
242 moduleDir = vm.Dir
243 realOwner = vm.Owner
244 version = vm.Version
245
246 if owner.projectMod {
247 // We want to keep the go.mod intact with the versions and all.
248 c.skipTidy = true
249 }
250
251 }
252 }
253
254 if moduleDir == "" {
255 var versionQuery string
256 mod = c.gomods.GetByPath(modulePath)
257 if mod != nil {
258 moduleDir = mod.Dir
259 versionQuery = mod.Version
260 }
261
262 if moduleDir == "" {
263 if c.GoModulesFilename != "" && isProbablyModule(modulePath) {
264 // Try to "go get" it and reload the module configuration.
265 if versionQuery == "" {
266 // See https://golang.org/ref/mod#version-queries
267 // This will select the latest release-version (not beta etc.).
268 versionQuery = "upgrade"
269 }
270 if err := c.Get(fmt.Sprintf("%s@%s", modulePath, versionQuery)); err != nil {
271 return nil, err
272 }
273 if err := c.loadModules(); err != nil {
274 return nil, err
275 }
276
277 mod = c.gomods.GetByPath(modulePath)
278 if mod != nil {
279 moduleDir = mod.Dir
280 }
281 }
282
283 // Fall back to project/themes/<mymodule>
284 if moduleDir == "" {
285 var err error
286 moduleDir, err = c.createThemeDirname(modulePath, owner.projectMod || moduleImport.pathProjectReplaced)
287 if err != nil {
288 c.err = err
289 return nil, nil
290 }
291 if found, _ := afero.Exists(c.fs, moduleDir); !found {
292 c.err = c.wrapModuleNotFound(fmt.Errorf(`module %q not found; either add it as a Hugo Module or store it in %q.`, modulePath, c.ccfg.ThemesDir))
293 return nil, nil
294 }
295 }
296 }
297 }
298
299 if found, _ := afero.Exists(c.fs, moduleDir); !found {
300 c.err = c.wrapModuleNotFound(fmt.Errorf("%q not found", moduleDir))
301 return nil, nil
302 }
303
304 if !strings.HasSuffix(moduleDir, fileSeparator) {
305 moduleDir += fileSeparator
306 }
307
308 ma := &moduleAdapter{
309 dir: moduleDir,
310 vendor: vendored,
311 disabled: disabled,
312 gomod: mod,
313 version: version,
314 // This may be the owner of the _vendor dir
315 owner: realOwner,
316 }
317
318 if mod == nil {
319 ma.path = modulePath
320 }
321
322 if !moduleImport.IgnoreConfig {
323 if err := c.applyThemeConfig(ma); err != nil {
324 return nil, err
325 }
326 }
327
328 if err := c.applyMounts(moduleImport, ma); err != nil {
329 return nil, err
330 }
331
332 c.modules = append(c.modules, ma)
333 return ma, nil
334 }
335
336 func (c *collector) addAndRecurse(owner *moduleAdapter, disabled bool) error {
337 moduleConfig := owner.Config()
338 if owner.projectMod {
339 if err := c.applyMounts(Import{}, owner); err != nil {
340 return err
341 }
342 }
343
344 for _, moduleImport := range moduleConfig.Imports {
345 disabled := disabled || moduleImport.Disable
346
347 if !c.isSeen(moduleImport.Path) {
348 tc, err := c.add(owner, moduleImport, disabled)
349 if err != nil {
350 return err
351 }
352 if tc == nil || moduleImport.IgnoreImports {
353 continue
354 }
355 if err := c.addAndRecurse(tc, disabled); err != nil {
356 return err
357 }
358 }
359 }
360 return nil
361 }
362
363 func (c *collector) applyMounts(moduleImport Import, mod *moduleAdapter) error {
364 if moduleImport.NoMounts {
365 mod.mounts = nil
366 return nil
367 }
368
369 mounts := moduleImport.Mounts
370
371 modConfig := mod.Config()
372
373 if len(mounts) == 0 {
374 // Mounts not defined by the import.
375 mounts = modConfig.Mounts
376 }
377
378 if !mod.projectMod && len(mounts) == 0 {
379 // Create default mount points for every component folder that
380 // exists in the module.
381 for _, componentFolder := range files.ComponentFolders {
382 sourceDir := filepath.Join(mod.Dir(), componentFolder)
383 _, err := c.fs.Stat(sourceDir)
384 if err == nil {
385 mounts = append(mounts, Mount{
386 Source: componentFolder,
387 Target: componentFolder,
388 })
389 }
390 }
391 }
392
393 var err error
394 mounts, err = c.normalizeMounts(mod, mounts)
395 if err != nil {
396 return err
397 }
398
399 mounts, err = c.mountCommonJSConfig(mod, mounts)
400 if err != nil {
401 return err
402 }
403
404 mod.mounts = mounts
405 return nil
406 }
407
408 func (c *collector) applyThemeConfig(tc *moduleAdapter) error {
409 var (
410 configFilename string
411 themeCfg map[string]any
412 hasConfigFile bool
413 err error
414 )
415
416 // Viper supports more, but this is the sub-set supported by Hugo.
417 for _, configFormats := range config.ValidConfigFileExtensions {
418 configFilename = filepath.Join(tc.Dir(), "config."+configFormats)
419 hasConfigFile, _ = afero.Exists(c.fs, configFilename)
420 if hasConfigFile {
421 break
422 }
423 }
424
425 // The old theme information file.
426 themeTOML := filepath.Join(tc.Dir(), "theme.toml")
427
428 hasThemeTOML, _ := afero.Exists(c.fs, themeTOML)
429 if hasThemeTOML {
430 data, err := afero.ReadFile(c.fs, themeTOML)
431 if err != nil {
432 return err
433 }
434 themeCfg, err = metadecoders.Default.UnmarshalToMap(data, metadecoders.TOML)
435 if err != nil {
436 c.logger.Warnf("Failed to read module config for %q in %q: %s", tc.Path(), themeTOML, err)
437 } else {
438 maps.PrepareParams(themeCfg)
439 }
440 }
441
442 if hasConfigFile {
443 if configFilename != "" {
444 var err error
445 tc.cfg, err = config.FromFile(c.fs, configFilename)
446 if err != nil {
447 return err
448 }
449 }
450
451 tc.configFilenames = append(tc.configFilenames, configFilename)
452
453 }
454
455 // Also check for a config dir, which we overlay on top of the file configuration.
456 configDir := filepath.Join(tc.Dir(), "config")
457 dcfg, dirnames, err := config.LoadConfigFromDir(c.fs, configDir, c.ccfg.Environment)
458 if err != nil {
459 return err
460 }
461
462 if len(dirnames) > 0 {
463 tc.configFilenames = append(tc.configFilenames, dirnames...)
464
465 if hasConfigFile {
466 // Set will overwrite existing keys.
467 tc.cfg.Set("", dcfg.Get(""))
468 } else {
469 tc.cfg = dcfg
470 }
471 }
472
473 config, err := decodeConfig(tc.cfg, c.moduleConfig.replacementsMap)
474 if err != nil {
475 return err
476 }
477
478 const oldVersionKey = "min_version"
479
480 if hasThemeTOML {
481
482 // Merge old with new
483 if minVersion, found := themeCfg[oldVersionKey]; found {
484 if config.HugoVersion.Min == "" {
485 config.HugoVersion.Min = hugo.VersionString(cast.ToString(minVersion))
486 }
487 }
488
489 if config.Params == nil {
490 config.Params = make(map[string]any)
491 }
492
493 for k, v := range themeCfg {
494 if k == oldVersionKey {
495 continue
496 }
497 config.Params[k] = v
498 }
499
500 }
501
502 tc.config = config
503
504 return nil
505 }
506
507 func (c *collector) collect() {
508 defer c.logger.PrintTimerIfDelayed(time.Now(), "hugo: collected modules")
509 d := debounce.New(2 * time.Second)
510 d(func() {
511 c.logger.Println("hugo: downloading modules …")
512 })
513 defer d(func() {})
514
515 if err := c.initModules(); err != nil {
516 c.err = err
517 return
518 }
519
520 projectMod := createProjectModule(c.gomods.GetMain(), c.ccfg.WorkingDir, c.moduleConfig)
521
522 if err := c.addAndRecurse(projectMod, false); err != nil {
523 c.err = err
524 return
525 }
526
527 // Add the project mod on top.
528 c.modules = append(Modules{projectMod}, c.modules...)
529 }
530
531 func (c *collector) isVendored(dir string) bool {
532 _, err := c.fs.Stat(filepath.Join(dir, vendord, vendorModulesFilename))
533 return err == nil
534 }
535
536 func (c *collector) collectModulesTXT(owner Module) error {
537 vendorDir := filepath.Join(owner.Dir(), vendord)
538 filename := filepath.Join(vendorDir, vendorModulesFilename)
539
540 f, err := c.fs.Open(filename)
541 if err != nil {
542 if os.IsNotExist(err) {
543 return nil
544 }
545
546 return err
547 }
548
549 defer f.Close()
550
551 scanner := bufio.NewScanner(f)
552
553 for scanner.Scan() {
554 // # github.com/alecthomas/chroma v0.6.3
555 line := scanner.Text()
556 line = strings.Trim(line, "# ")
557 line = strings.TrimSpace(line)
558 parts := strings.Fields(line)
559 if len(parts) != 2 {
560 return fmt.Errorf("invalid modules list: %q", filename)
561 }
562 path := parts[0]
563
564 shouldAdd := c.Client.moduleConfig.VendorClosest
565
566 if !shouldAdd {
567 if _, found := c.vendored[path]; !found {
568 shouldAdd = true
569 }
570 }
571
572 if shouldAdd {
573 c.vendored[path] = vendoredModule{
574 Owner: owner,
575 Dir: filepath.Join(vendorDir, path),
576 Version: parts[1],
577 }
578 }
579
580 }
581 return nil
582 }
583
584 func (c *collector) loadModules() error {
585 modules, err := c.listGoMods()
586 if err != nil {
587 return err
588 }
589 c.gomods = modules
590 return nil
591 }
592
593 // Matches postcss.config.js etc.
594 var commonJSConfigs = regexp.MustCompile(`(babel|postcss|tailwind)\.config\.js`)
595
596 func (c *collector) mountCommonJSConfig(owner *moduleAdapter, mounts []Mount) ([]Mount, error) {
597 for _, m := range mounts {
598 if strings.HasPrefix(m.Target, files.JsConfigFolderMountPrefix) {
599 // This follows the convention of the other component types (assets, content, etc.),
600 // if one or more is specified by the user, we skip the defaults.
601 // These mounts were added to Hugo in 0.75.
602 return mounts, nil
603 }
604 }
605
606 // Mount the common JS config files.
607 fis, err := afero.ReadDir(c.fs, owner.Dir())
608 if err != nil {
609 return mounts, err
610 }
611
612 for _, fi := range fis {
613 n := fi.Name()
614
615 should := n == files.FilenamePackageHugoJSON || n == files.FilenamePackageJSON
616 should = should || commonJSConfigs.MatchString(n)
617
618 if should {
619 mounts = append(mounts, Mount{
620 Source: n,
621 Target: filepath.Join(files.ComponentFolderAssets, files.FolderJSConfig, n),
622 })
623 }
624
625 }
626
627 return mounts, nil
628 }
629
630 func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mount, error) {
631 var out []Mount
632 dir := owner.Dir()
633
634 for _, mnt := range mounts {
635 errMsg := fmt.Sprintf("invalid module config for %q", owner.Path())
636
637 if mnt.Source == "" || mnt.Target == "" {
638 return nil, errors.New(errMsg + ": both source and target must be set")
639 }
640
641 mnt.Source = filepath.Clean(mnt.Source)
642 mnt.Target = filepath.Clean(mnt.Target)
643 var sourceDir string
644
645 if owner.projectMod && filepath.IsAbs(mnt.Source) {
646 // Abs paths in the main project is allowed.
647 sourceDir = mnt.Source
648 } else {
649 sourceDir = filepath.Join(dir, mnt.Source)
650 }
651
652 // Verify that Source exists
653 _, err := c.fs.Stat(sourceDir)
654 if err != nil {
655 continue
656 }
657
658 // Verify that target points to one of the predefined component dirs
659 targetBase := mnt.Target
660 idxPathSep := strings.Index(mnt.Target, string(os.PathSeparator))
661 if idxPathSep != -1 {
662 targetBase = mnt.Target[0:idxPathSep]
663 }
664 if !files.IsComponentFolder(targetBase) {
665 return nil, fmt.Errorf("%s: mount target must be one of: %v", errMsg, files.ComponentFolders)
666 }
667
668 out = append(out, mnt)
669 }
670
671 return out, nil
672 }
673
674 func (c *collector) wrapModuleNotFound(err error) error {
675 err = fmt.Errorf(err.Error()+": %w", ErrNotExist)
676 if c.GoModulesFilename == "" {
677 return err
678 }
679
680 baseMsg := "we found a go.mod file in your project, but"
681
682 switch c.goBinaryStatus {
683 case goBinaryStatusNotFound:
684 return fmt.Errorf(baseMsg+" you need to install Go to use it. See https://golang.org/dl/ : %q", err)
685 case goBinaryStatusTooOld:
686 return fmt.Errorf(baseMsg+" you need to a newer version of Go to use it. See https://golang.org/dl/ : %w", err)
687 }
688
689 return err
690 }
691
692 type vendoredModule struct {
693 Owner Module
694 Dir string
695 Version string
696 }
697
698 func createProjectModule(gomod *goModule, workingDir string, conf Config) *moduleAdapter {
699 // Create a pseudo module for the main project.
700 var path string
701 if gomod == nil {
702 path = "project"
703 }
704
705 return &moduleAdapter{
706 path: path,
707 dir: workingDir,
708 gomod: gomod,
709 projectMod: true,
710 config: conf,
711 }
712 }
713
714 // In the first iteration of Hugo Modules, we do not support multiple
715 // major versions running at the same time, so we pick the first (upper most).
716 // We will investigate namespaces in future versions.
717 // TODO(bep) add a warning when the above happens.
718 func pathKey(p string) string {
719 prefix, _, _ := module.SplitPathVersion(p)
720
721 return strings.ToLower(prefix)
722 }