Repository: gohugoio/hugo (language: go)

Issue:
Hugo v0.115.3 Disobeys 'disableLiveReload = true' Config Rendering Setting
<!-- Please answer these questions before submitting your issue. Thanks! -->

Latest update broke the LiveReload settings where it disobeys the already existing configuration not to be rendered. This is not seen in hugo 0.111.3. Check screenshot:

![screenshot](https://github.com/gohugoio/hugo/assets/7259572/a72cbf85-8b13-4a46-8594-4992f20882d6)


### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.115.3-5c2e014a5150553a9fa4f9c1eb7dc4db89c0f1ab linux/amd64 BuildDate=2023-07-13T16:11:34Z VendorInfo=gohugoio
</pre>

### Does this issue reproduce with the latest release?
Yes
Bug in cache buster
This bug is a little hard to explain, but the fix will demonstrate it better. The short take of it is that this has made the typical "cache busting" setup used for Tailwind ... unstable. 


Discussion / hints:
What exactly do you mean by this issue?
Means when it is set to be disabled, it should not appear. Hence, the disobdience.
>Means when it is set to be disabled, it should not appear. Hence, the disobdience.

When _what_ is disabled?

`disableLiveReload = true`. Left side terminal is the setting being run. On the right, it is shown rendered at the Chrome debugger.
OK,

Try 

```
hugo server --disableLiveReload
```

I understand that this may have been possible to set in site config in earlier Hugo versions -- and that got lost in the big flag/commands/config consolidation/rewrite.

I will consider adding this to the main config set, but you will get a faster response by just using the flag.
> I will consider adding this to the main config set, but you will get a faster response by just using the flag.

Understood. Thank you.


Fix patch:
diff --git a/commands/helpers.go b/commands/helpers.go
index 5021221fb00..d6c5e43aced 100644
--- a/commands/helpers.go
+++ b/commands/helpers.go
@@ -86,13 +86,12 @@ func flagsToCfgWithAdditionalConfigBase(cd *simplecobra.Commandeer, cfg config.P
 
 	// Flags that we for some reason don't want to expose in the site config.
 	internalKeySet := map[string]bool{
-		"quiet":             true,
-		"verbose":           true,
-		"watch":             true,
-		"disableLiveReload": true,
-		"liveReloadPort":    true,
-		"renderToMemory":    true,
-		"clock":             true,
+		"quiet":          true,
+		"verbose":        true,
+		"watch":          true,
+		"liveReloadPort": true,
+		"renderToMemory": true,
+		"clock":          true,
 	}
 
 	cmd := cd.CobraCommand
diff --git a/commands/server.go b/commands/server.go
index 7e6ec5b13ee..bf82b748de9 100644
--- a/commands/server.go
+++ b/commands/server.go
@@ -470,14 +470,6 @@ func (c *serverCommand) Name() string {
 }
 
 func (c *serverCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, args []string) error {
-	err := func() error {
-		defer c.r.timeTrack(time.Now(), "Built")
-		err := c.build()
-		return err
-	}()
-	if err != nil {
-		return err
-	}
 
 	// Watch runs its own server as part of the routine
 	if c.serverWatch {
@@ -501,6 +493,15 @@ func (c *serverCommand) Run(ctx context.Context, cd *simplecobra.Commandeer, arg
 
 	}
 
+	err := func() error {
+		defer c.r.timeTrack(time.Now(), "Built")
+		err := c.build()
+		return err
+	}()
+	if err != nil {
+		return err
+	}
+
 	return c.serve()
 }
 
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index a0d1d0ae8e6..9960c28f372 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -57,12 +57,11 @@ type InternalConfig struct {
 	// Server mode?
 	Running bool
 
-	Quiet             bool
-	Verbose           bool
-	Clock             string
-	Watch             bool
-	DisableLiveReload bool
-	LiveReloadPort    int
+	Quiet          bool
+	Verbose        bool
+	Clock          string
+	Watch          bool
+	LiveReloadPort int
 }
 
 // All non-params config keys for language.
@@ -454,6 +453,9 @@ type RootConfig struct {
 	// Disable the injection of the Hugo generator tag on the home page.
 	DisableHugoGeneratorInject bool
 
+	// Disable live reloading in server mode.
+	DisableLiveReload bool
+
 	// Enable replacement in Pages' Content of Emoji shortcodes with their equivalent Unicode characters.
 	// <docsmeta>{"identifiers": ["Content", "Unicode"] }</docsmeta>
 	EnableEmoji bool
diff --git a/config/commonConfig.go b/config/commonConfig.go
index 5cf526708c9..ef9d475537c 100644
--- a/config/commonConfig.go
+++ b/config/commonConfig.go
@@ -333,11 +333,11 @@ func (c *CacheBuster) CompileConfig(logger loggers.Logger) error {
 	}
 
 	source := c.Source
-	target := c.Target
 	sourceRe, err := regexp.Compile(source)
 	if err != nil {
 		return fmt.Errorf("failed to compile cache buster source %q: %w", c.Source, err)
 	}
+	target := c.Target
 	var compileErr error
 	debugl := logger.Logger().WithLevel(logg.LevelDebug).WithField(loggers.FieldNameCmd, "cachebuster")
 
@@ -353,23 +353,23 @@ func (c *CacheBuster) CompileConfig(logger loggers.Logger) error {
 			return nil
 		}
 		groups := m[1:]
+		currentTarget := target
 		// Replace $1, $2 etc. in target.
-
 		for i, g := range groups {
-			target = strings.ReplaceAll(target, fmt.Sprintf("$%d", i+1), g)
+			currentTarget = strings.ReplaceAll(target, fmt.Sprintf("$%d", i+1), g)
 		}
-		targetRe, err := regexp.Compile(target)
+		targetRe, err := regexp.Compile(currentTarget)
 		if err != nil {
-			compileErr = fmt.Errorf("failed to compile cache buster target %q: %w", target, err)
+			compileErr = fmt.Errorf("failed to compile cache buster target %q: %w", currentTarget, err)
 			return nil
 		}
-		return func(s string) bool {
-			match = targetRe.MatchString(s)
+		return func(ss string) bool {
+			match = targetRe.MatchString(ss)
 			matchString := "no match"
 			if match {
 				matchString = "match!"
 			}
-			logger.Debugf("Matching %q with target %q: %s", s, target, matchString)
+			logger.Debugf("Matching %q with target %q: %s", ss, currentTarget, matchString)
 
 			return match
 		}
diff --git a/hugofs/files/classifier.go b/hugofs/files/classifier.go
index 09b239c21a9..bdac2d68698 100644
--- a/hugofs/files/classifier.go
+++ b/hugofs/files/classifier.go
@@ -31,6 +31,8 @@ const (
 	FilenamePackageHugoJSON = "package.hugo.json"
 	// The NPM package file.
 	FilenamePackageJSON = "package.json"
+
+	FilenameHugoStatsJSON = "hugo_stats.json"
 )
 
 var (
diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go
index 0a861d32e66..0c1ec050b04 100644
--- a/hugolib/hugo_sites_build.go
+++ b/hugolib/hugo_sites_build.go
@@ -23,6 +23,7 @@ import (
 	"time"
 
 	"github.com/bep/logg"
+	"github.com/gohugoio/hugo/hugofs/files"
 	"github.com/gohugoio/hugo/langs"
 	"github.com/gohugoio/hugo/publisher"
 	"github.com/gohugoio/hugo/tpl"
@@ -491,14 +492,12 @@ func (h *HugoSites) writeBuildStats() error {
 		HTMLElements: *htmlElements,
 	}
 
-	const hugoStatsName = "hugo_stats.json"
-
 	js, err := json.MarshalIndent(stats, "", "  ")
 	if err != nil {
 		return err
 	}
 
-	filename := filepath.Join(h.Configs.LoadingInfo.BaseConfig.WorkingDir, hugoStatsName)
+	filename := filepath.Join(h.Configs.LoadingInfo.BaseConfig.WorkingDir, files.FilenameHugoStatsJSON)
 
 	if existingContent, err := afero.ReadFile(hugofs.Os, filename); err == nil {
 		// Check if the content has changed.
diff --git a/hugolib/site.go b/hugolib/site.go
index eb6a4e344bb..b5c43fba3c2 100644
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -1058,7 +1058,7 @@ func (s *Site) renderAndWritePage(statCounter *uint64, name string, targetPath s
 			pd.AbsURLPath = s.absURLPath(targetPath)
 		}
 
-		if s.watching() && s.conf.Internal.Running && !s.conf.Internal.DisableLiveReload {
+		if s.watching() && s.conf.Internal.Running && !s.conf.DisableLiveReload {
 			pd.LiveReloadBaseURL = s.Conf.BaseURLLiveReload().URL()
 		}
 
diff --git a/modules/collect.go b/modules/collect.go
index 0c578b5dba9..e47563ab778 100644
--- a/modules/collect.go
+++ b/modules/collect.go
@@ -664,7 +664,21 @@ func (c *collector) normalizeMounts(owner *moduleAdapter, mounts []Mount) ([]Mou
 		// Verify that Source exists
 		_, err := c.fs.Stat(sourceDir)
 		if err != nil {
-			continue
+			if strings.HasSuffix(sourceDir, files.FilenameHugoStatsJSON) {
+				// A common pattern for Tailwind 3 is to mount that file to get it on the server watch list.
+
+				// A common pattern is also to add hugo_stats.json to .gitignore.
+
+				// Create an empty file.
+				f, err := c.fs.Create(sourceDir)
+				if err != nil {
+					return nil, fmt.Errorf("%s: %q", errMsg, err)
+				}
+				f.Close()
+			} else {
+				continue
+			}
+
 		}
 
 		// Verify that target points to one of the predefined component dirs
diff --git a/publisher/htmlElementsCollector.go b/publisher/htmlElementsCollector.go
index c9d81818ce4..6c01fd8d9cb 100644
--- a/publisher/htmlElementsCollector.go
+++ b/publisher/htmlElementsCollector.go
@@ -32,7 +32,7 @@ const eof = -1
 
 var (
 	htmlJsonFixer = strings.NewReplacer(", ", "\n")
-	jsonAttrRe    = regexp.MustCompile(`'?(.*?)'?:.*`)
+	jsonAttrRe    = regexp.MustCompile(`'?(.*?)'?:\s.*`)
 	classAttrRe   = regexp.MustCompile(`(?i)^class$|transition`)
 
 	skipInnerElementRe = regexp.MustCompile(`(?i)^(pre|textarea|script|style)`)
@@ -404,21 +404,31 @@ func (w *htmlElementsCollectorWriter) parseHTMLElement(elStr string) (el htmlEle
 					if conf.DisableClasses {
 						continue
 					}
+
 					if classAttrRe.MatchString(a.Key) {
 						el.Classes = append(el.Classes, strings.Fields(a.Val)...)
 					} else {
 						key := strings.ToLower(a.Key)
 						val := strings.TrimSpace(a.Val)
-						if strings.Contains(key, "class") && strings.HasPrefix(val, "{") {
-							// This looks like a Vue or AlpineJS class binding.
-							val = htmlJsonFixer.Replace(strings.Trim(val, "{}"))
-							lines := strings.Split(val, "\n")
-							for i, l := range lines {
-								lines[i] = strings.TrimSpace(l)
+
+						if strings.Contains(key, ":class") {
+							if strings.HasPrefix(val, "{") {
+								// This looks like a Vue or AlpineJS class binding.
+								val = htmlJsonFixer.Replace(strings.Trim(val, "{}"))
+								lines := strings.Split(val, "\n")
+								for i, l := range lines {
+									lines[i] = strings.TrimSpace(l)
+								}
+								val = strings.Join(lines, "\n")
+
+								val = jsonAttrRe.ReplaceAllString(val, "$1")
+
+								el.Classes = append(el.Classes, strings.Fields(val)...)
 							}
-							val = strings.Join(lines, "\n")
-							val = jsonAttrRe.ReplaceAllString(val, "$1")
-							el.Classes = append(el.Classes, strings.Fields(val)...)
+							// Also add single quoted strings.
+							// This may introduce some false positives, but it covers some missing cases in the above.
+							// E.g. AlpinesJS' :class="isTrue 'class1' : 'class2'"
+							el.Classes = append(el.Classes, extractSingleQuotedStrings(val)...)
 						}
 					}
 				}
@@ -519,3 +529,29 @@ LOOP:
 func isSpace(b byte) bool {
 	return b == ' ' || b == '\t' || b == '\n'
 }
+
+func extractSingleQuotedStrings(s string) []string {
+	var (
+		inQuote bool
+		lo      int
+		hi      int
+	)
+
+	var words []string
+
+	for i, r := range s {
+		switch {
+		case r == '\'':
+			if !inQuote {
+				inQuote = true
+				lo = i + 1
+			} else {
+				inQuote = false
+				hi = i
+				words = append(words, strings.Fields(s[lo:hi])...)
+			}
+		}
+	}
+
+	return words
+}


Test patch:
diff --git a/config/commonConfig_test.go b/config/commonConfig_test.go
index b8130eb0ddb..8aa1318dd93 100644
--- a/config/commonConfig_test.go
+++ b/config/commonConfig_test.go
@@ -164,3 +164,36 @@ func TestBuildConfigCacheBusters(t *testing.T) {
 	c.Assert(m("json"), qt.IsTrue)
 
 }
+
+func TestBuildConfigCacheBusterstTailwindSetup(t *testing.T) {
+	c := qt.New(t)
+	cfg := New()
+	cfg.Set("build", map[string]interface{}{
+		"cacheBusters": []map[string]string{
+			{
+				"source": "assets/watching/hugo_stats\\.json",
+				"target": "css",
+			},
+			{
+				"source": "(postcss|tailwind)\\.config\\.js",
+				"target": "css",
+			},
+			{
+				"source": "assets/.*\\.(js|ts|jsx|tsx)",
+				"target": "js",
+			},
+			{
+				"source": "assets/.*\\.(.*)$",
+				"target": "$1",
+			},
+		},
+	})
+
+	conf := DecodeBuildConfig(cfg)
+	l := loggers.NewDefault()
+	c.Assert(conf.CompileConfig(l), qt.IsNil)
+
+	m, err := conf.MatchCacheBuster(l, "assets/watching/hugo_stats.json")
+	c.Assert(err, qt.IsNil)
+	c.Assert(m("css"), qt.IsTrue)
+}
diff --git a/publisher/htmlElementsCollector_test.go b/publisher/htmlElementsCollector_test.go
index 3047d5ca99e..3cd834acbac 100644
--- a/publisher/htmlElementsCollector_test.go
+++ b/publisher/htmlElementsCollector_test.go
@@ -99,6 +99,8 @@ func TestClassCollector(t *testing.T) {
                  pl-2: b == 3,
                 'text-gray-600': (a > 1)
                 }" class="block w-36 cursor-pointer pr-3 no-underline capitalize"></a>`, f("a", "block capitalize cursor-pointer no-underline pl-2 pl-3 pr-3 text-a text-b text-gray-600 w-36", "")},
+		{"AlpineJS bind 6", `<button :class="isActive(32) ? 'border-gray-500 bg-white pt border-t-2' : 'border-transparent hover:bg-gray-100'"></button>`, f("button", "bg-white border-gray-500 border-t-2 border-transparent hover:bg-gray-100 pt", "")},
+		{"AlpineJS bind 7", `<button :class="{ 'border-gray-500 bg-white pt border-t-2': isActive(32), 'border-transparent hover:bg-gray-100': !isActive(32) }"></button>`, f("button", "bg-white border-gray-500 border-t-2 border-transparent hover:bg-gray-100 pt", "")},
 		{"AlpineJS transition 1", `<div x-transition:enter-start="opacity-0 transform mobile:-translate-x-8 sm:-translate-y-8">`, f("div", "mobile:-translate-x-8 opacity-0 sm:-translate-y-8 transform", "")},
 		{"Vue bind", `<div v-bind:class="{ active: isActive }"></div>`, f("div", "active", "")},
 		// Issue #7746
@@ -136,7 +138,9 @@ func TestClassCollector(t *testing.T) {
 			{minify: true},
 		} {
 
-			c.Run(fmt.Sprintf("%s--minify-%t", test.name, variant.minify), func(c *qt.C) {
+			name := fmt.Sprintf("%s--minify-%t", test.name, variant.minify)
+
+			c.Run(name, func(c *qt.C) {
 				w := newHTMLElementsCollectorWriter(newHTMLElementsCollector(
 					config.BuildStats{Enable: true},
 				))
diff --git a/testscripts/commands/server_disablelivereload.txt b/testscripts/commands/server_disablelivereload.txt
new file mode 100644
index 00000000000..6b1cd66cbee
--- /dev/null
+++ b/testscripts/commands/server_disablelivereload.txt
@@ -0,0 +1,20 @@
+hugo server --renderToDisk --disableLiveReload &
+
+waitServer
+
+! grep 'livereload' public/index.html
+
+stopServer
+! stderr .
+
+-- hugo.toml --
+baseURL = "http://example.org/"
+disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"]
+-- layouts/index.html --
+<html>
+<head>
+</head>
+<body>
+Home.
+</body>
+</html>
\ No newline at end of file
diff --git a/testscripts/commands/server_disablelivereload__config.txt b/testscripts/commands/server_disablelivereload__config.txt
new file mode 100644
index 00000000000..fe49ff2471d
--- /dev/null
+++ b/testscripts/commands/server_disablelivereload__config.txt
@@ -0,0 +1,21 @@
+hugo server --renderToDisk &
+
+waitServer
+
+! grep 'livereload' public/index.html
+
+stopServer
+! stderr .
+
+-- hugo.toml --
+baseURL = "http://example.org/"
+disableKinds = ["RSS", "sitemap", "robotsTXT", "404", "taxonomy", "term"]
+disableLiveReload = true
+-- layouts/index.html --
+<html>
+<head>
+</head>
+<body>
+Home.
+</body>
+</html>
\ No newline at end of file
diff --git a/testscripts/unfinished/server__watch_hugo_stats.txt b/testscripts/unfinished/server__watch_hugo_stats.txt
new file mode 100644
index 00000000000..179e59f8d8b
--- /dev/null
+++ b/testscripts/unfinished/server__watch_hugo_stats.txt
@@ -0,0 +1,18 @@
+hugo server  &
+
+waitServer
+stopServer
+! stderr .
+
+exists hugo_stats.json
+
+-- hugo.toml --
+title = "Hugo Server Test"
+baseURL = "https://example.org/"
+disableKinds = ["taxonomy", "term", "sitemap"]
+[module]
+[[module.mounts]]
+source = "hugo_stats.json"
+target = "assets/watching/hugo_stats.json"
+-- layouts/index.html --
+<body>Home</body>