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 }