diff --git a/commands/hugobuilder.go b/commands/hugobuilder.go index dcc5c099bd3..4c2d865c071 100644 --- a/commands/hugobuilder.go +++ b/commands/hugobuilder.go @@ -62,7 +62,7 @@ type hugoBuilder struct { // Currently only set when in "fast render mode". changeDetector *fileChangeDetector - visitedURLs *types.EvictingStringQueue + visitedURLs *types.EvictingQueue[string] fullRebuildSem *semaphore.Weighted debounce func(f func()) @@ -1103,7 +1103,7 @@ func (c *hugoBuilder) rebuildSites(events []fsnotify.Event) (err error) { if err != nil { return } - err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...) + err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...) return } @@ -1119,7 +1119,7 @@ func (c *hugoBuilder) rebuildSitesForChanges(ids []identity.Identity) (err error } whatChanged := &hugolib.WhatChanged{} whatChanged.Add(ids...) - err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()}) + err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}) return } diff --git a/commands/server.go b/commands/server.go index c4a8ddd2997..80af40c0287 100644 --- a/commands/server.go +++ b/commands/server.go @@ -85,9 +85,9 @@ const ( ) func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder { - var visitedURLs *types.EvictingStringQueue + var visitedURLs *types.EvictingQueue[string] if s != nil && !s.disableFastRender { - visitedURLs = types.NewEvictingStringQueue(20) + visitedURLs = types.NewEvictingQueue[string](20) } return &hugoBuilder{ r: r, @@ -364,7 +364,11 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string } if f.c.fastRenderMode && f.c.errState.buildErr() == nil { - if strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") { + // Sec-Fetch-Dest = document + // Sec-Fetch-Mode should be sent by all recent browser versions, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode#navigate + // Fall back to the file extension if not set. + // The main take here is that we don't want to have CSS/JS files etc. partake in this logic. + if r.Header.Get("Sec-Fetch-Mode") == "navigate" || strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") { if !f.c.visitedURLs.Contains(requestURI) { // If not already on stack, re-render that single page. if err := f.c.partialReRender(requestURI); err != nil { @@ -838,7 +842,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) { defer func() { c.errState.setWasErr(false) }() - visited := types.NewEvictingStringQueue(len(urls)) + visited := types.NewEvictingQueue[string](len(urls)) for _, url := range urls { visited.Add(url) } @@ -850,7 +854,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) { } // Note: We do not set NoBuildLock as the file lock is not acquired at this stage. - err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyVisited: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()}) + err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyTouched: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()}) return } diff --git a/common/types/evictingqueue.go b/common/types/evictingqueue.go index 5ab715aa871..c3598f19f76 100644 --- a/common/types/evictingqueue.go +++ b/common/types/evictingqueue.go @@ -18,24 +18,24 @@ import ( "sync" ) -// EvictingStringQueue is a queue which automatically evicts elements from the head of +// EvictingQueue is a queue which automatically evicts elements from the head of // the queue when attempting to add new elements onto the queue and it is full. // This queue orders elements LIFO (last-in-first-out). It throws away duplicates. -// Note: This queue currently does not contain any remove (poll etc.) methods. -type EvictingStringQueue struct { +type EvictingQueue[T comparable] struct { size int - vals []string - set map[string]bool + vals []T + set map[T]bool mu sync.Mutex + zero T } -// NewEvictingStringQueue creates a new queue with the given size. -func NewEvictingStringQueue(size int) *EvictingStringQueue { - return &EvictingStringQueue{size: size, set: make(map[string]bool)} +// NewEvictingQueue creates a new queue with the given size. +func NewEvictingQueue[T comparable](size int) *EvictingQueue[T] { + return &EvictingQueue[T]{size: size, set: make(map[T]bool)} } // Add adds a new string to the tail of the queue if it's not already there. -func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue { +func (q *EvictingQueue[T]) Add(v T) *EvictingQueue[T] { q.mu.Lock() if q.set[v] { q.mu.Unlock() @@ -54,7 +54,7 @@ func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue { return q } -func (q *EvictingStringQueue) Len() int { +func (q *EvictingQueue[T]) Len() int { if q == nil { return 0 } @@ -64,7 +64,7 @@ func (q *EvictingStringQueue) Len() int { } // Contains returns whether the queue contains v. -func (q *EvictingStringQueue) Contains(v string) bool { +func (q *EvictingQueue[T]) Contains(v T) bool { if q == nil { return false } @@ -74,12 +74,12 @@ func (q *EvictingStringQueue) Contains(v string) bool { } // Peek looks at the last element added to the queue. -func (q *EvictingStringQueue) Peek() string { +func (q *EvictingQueue[T]) Peek() T { q.mu.Lock() l := len(q.vals) if l == 0 { q.mu.Unlock() - return "" + return q.zero } elem := q.vals[l-1] q.mu.Unlock() @@ -87,9 +87,12 @@ func (q *EvictingStringQueue) Peek() string { } // PeekAll looks at all the elements in the queue, with the newest first. -func (q *EvictingStringQueue) PeekAll() []string { +func (q *EvictingQueue[T]) PeekAll() []T { + if q == nil { + return nil + } q.mu.Lock() - vals := make([]string, len(q.vals)) + vals := make([]T, len(q.vals)) copy(vals, q.vals) q.mu.Unlock() for i, j := 0, len(vals)-1; i < j; i, j = i+1, j-1 { @@ -99,9 +102,9 @@ func (q *EvictingStringQueue) PeekAll() []string { } // PeekAllSet returns PeekAll as a set. -func (q *EvictingStringQueue) PeekAllSet() map[string]bool { +func (q *EvictingQueue[T]) PeekAllSet() map[T]bool { all := q.PeekAll() - set := make(map[string]bool) + set := make(map[T]bool) for _, v := range all { set[v] = true } diff --git a/common/types/evictingqueue_test.go b/common/types/evictingqueue_test.go index 7489ba88d9b..b44e18f962c 100644 --- a/common/types/evictingqueue_test.go +++ b/common/types/evictingqueue_test.go @@ -23,7 +23,7 @@ import ( func TestEvictingStringQueue(t *testing.T) { c := qt.New(t) - queue := NewEvictingStringQueue(3) + queue := NewEvictingQueue(3) c.Assert(queue.Peek(), qt.Equals, "") queue.Add("a") @@ -53,7 +53,7 @@ func TestEvictingStringQueueConcurrent(t *testing.T) { var wg sync.WaitGroup val := "someval" - queue := NewEvictingStringQueue(3) + queue := NewEvictingQueue(3) for j := 0; j < 100; j++ { wg.Add(1) diff --git a/hugolib/hugo_sites.go b/hugolib/hugo_sites.go index 792d6a990ea..55dc3b236ac 100644 --- a/hugolib/hugo_sites.go +++ b/hugolib/hugo_sites.go @@ -416,8 +416,8 @@ type BuildCfg struct { // Set in server mode when the last build failed for some reason. ErrRecovery bool - // Recently visited URLs. This is used for partial re-rendering. - RecentlyVisited *types.EvictingStringQueue + // Recently visited or touched URLs. This is used for partial re-rendering. + RecentlyTouched *types.EvictingQueue[string] // Can be set to build only with a sub set of the content source. ContentInclusionFilter *glob.FilenameFilter @@ -428,8 +428,14 @@ type BuildCfg struct { testCounters *buildCounters } +// TouchedURL represents a URL that has been recently visited or touched. +type TouchedURL struct { + URL string + Reason string // Used for logging. +} + // shouldRender returns whether this output format should be rendered or not. -func (cfg *BuildCfg) shouldRender(p *pageState) bool { +func (cfg *BuildCfg) shouldRender(infol logg.LevelLogger, p *pageState) bool { if p.skipRender() { return false } @@ -457,18 +463,20 @@ func (cfg *BuildCfg) shouldRender(p *pageState) bool { return false } - if p.outputFormat().IsHTML { - // This is fast render mode and the output format is HTML, - // rerender if this page is one of the recently visited. - return cfg.RecentlyVisited.Contains(p.RelPermalink()) + if relURL := p.getRelURL(); relURL != "" { + if cfg.RecentlyTouched.Contains(relURL) { + infol.Logf("render recently touched %s (%s)", relURL, p.outputFormat().Name) + return true + } } // In fast render mode, we want to avoid re-rendering the sitemaps etc. and // other big listings whenever we e.g. change a content file, - // but we want partial renders of the recently visited pages to also include + // but we want partial renders of the recently touched pages to also include // alternative formats of the same HTML page (e.g. RSS, JSON). for _, po := range p.pageOutputs { - if po.render && po.f.IsHTML && cfg.RecentlyVisited.Contains(po.RelPermalink()) { + if po.render && po.f.IsHTML && cfg.RecentlyTouched.Contains(po.getRelURL()) { + infol.Logf("render recently touched %s, %s version of %s", po.getRelURL(), po.f.Name, p.outputFormat().Name) return true } } diff --git a/hugolib/hugo_sites_build.go b/hugolib/hugo_sites_build.go index 7e7f61031ca..8f3b71bafbb 100644 --- a/hugolib/hugo_sites_build.go +++ b/hugolib/hugo_sites_build.go @@ -341,7 +341,7 @@ func (h *HugoSites) render(l logg.LevelLogger, config *BuildCfg) error { loggers.TimeTrackf(l, start, h.buildCounters.loggFields(), "") }() - siteRenderContext := &siteRenderContext{cfg: config, multihost: h.Configs.IsMultihost} + siteRenderContext := &siteRenderContext{cfg: config, infol: l, multihost: h.Configs.IsMultihost} renderErr := func(err error) error { if err == nil { @@ -902,12 +902,12 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo needsPagesAssemble = true - if config.RecentlyVisited != nil { + if config.RecentlyTouched != nil { // Fast render mode. Adding them to the visited queue // avoids rerendering them on navigation. for _, id := range changes { if p, ok := id.(page.Page); ok { - config.RecentlyVisited.Add(p.RelPermalink()) + config.RecentlyTouched.Add(p.RelPermalink()) } } } diff --git a/hugolib/integrationtest_builder.go b/hugolib/integrationtest_builder.go index 18785ce75c1..ff45ec2757f 100644 --- a/hugolib/integrationtest_builder.go +++ b/hugolib/integrationtest_builder.go @@ -487,11 +487,11 @@ func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTest if !s.Cfg.Running { panic("BuildPartial can only be used in server mode") } - visited := types.NewEvictingStringQueue(len(urls)) + visited := types.NewEvictingQueue[string](len(urls)) for _, url := range urls { visited.Add(url) } - buildCfg := BuildCfg{RecentlyVisited: visited, PartialReRender: true} + buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true} return s, s.build(buildCfg) } diff --git a/hugolib/page__paths.go b/hugolib/page__paths.go index 6324b587125..1b8228c9571 100644 --- a/hugolib/page__paths.go +++ b/hugolib/page__paths.go @@ -71,11 +71,17 @@ func newPagePaths(ps *pageState) (pagePaths, error) { // Use the main format for permalinks, usually HTML. permalinksIndex := 0 if f.Permalinkable { - // Unless it's permalinkable + // Unless it's permalinkable. permalinksIndex = i } + relURL := relPermalink + if relURL == "" { + relURL = paths.RelPermalink(s.PathSpec) + } + targets[f.Name] = targetPathsHolder{ + relURL: relURL, paths: paths, OutputFormat: pageOutputFormats[permalinksIndex], } diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index 7c6395e57b5..2915c6b8a5f 100644 --- a/hugolib/page__per_output.go +++ b/hugolib/page__per_output.go @@ -469,13 +469,21 @@ type pagePerOutputProviders interface { type targetPather interface { targetPaths() page.TargetPaths + getRelURL() string } type targetPathsHolder struct { - paths page.TargetPaths + // relURL is usually the same as OutputFormat.RelPermalink, but can be different + // for non-permalinkable output formats. These shares RelPermalink with the main (first) output format. + relURL string + paths page.TargetPaths page.OutputFormat } +func (t targetPathsHolder) getRelURL() string { + return t.relURL +} + func (t targetPathsHolder) targetPaths() page.TargetPaths { return t.paths } diff --git a/hugolib/rebuild_test.go b/hugolib/rebuild_test.go index 793263e0409..dc2c6524f88 100644 --- a/hugolib/rebuild_test.go +++ b/hugolib/rebuild_test.go @@ -357,8 +357,8 @@ RegularPages: {{ range .Site.RegularPages }}{{ .RelPermalink }}|{{ end }}$ } func TestRebuildRenameDirectoryWithBranchBundleFastRender(t *testing.T) { - recentlyVisited := types.NewEvictingStringQueue(10).Add("/a/b/c/") - b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyVisited: recentlyVisited} }) + recentlyVisited := types.NewEvictingQueue[string](10).Add("/a/b/c/") + b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyTouched: recentlyVisited} }) b.RenameDir("content/mysection", "content/mysectionrenamed").Build() b.AssertFileContent("public/mysectionrenamed/index.html", "My Section") b.AssertFileContent("public/mysectionrenamed/mysectionbundle/index.html", "My Section Bundle") @@ -1181,6 +1181,49 @@ Content: {{ .Content }} b.AssertFileContent("public/index.html", "Content:
Home
") } +// Issue #13014. +func TestRebuildEditNotPermalinkableCustomOutputFormatTemplateInFastRenderMode(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +baseURL = "https://example.com/docs/" +disableLiveReload = true +[internal] +fastRenderMode = true +disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404"] +[outputFormats] + [outputFormats.SearchIndex] + baseName = 'Search' + isPlainText = true + mediaType = 'text/plain' + noAlternative = true + permalinkable = false + +[outputs] + home = ['HTML', 'SearchIndex'] +-- content/_index.md -- +--- +title: "Home" +--- +Home. +-- layouts/index.html -- +Home. +-- layouts/_default/index.searchindex.txt -- +Text. {{ .Title }}|{{ .RelPermalink }}| + +` + b := TestRunning(t, files, TestOptInfo()) + + b.AssertFileContent("public/search.txt", "Text.") + + b.EditFileReplaceAll("layouts/_default/index.searchindex.txt", "Text.", "Text Edited.").Build() + + b.BuildPartial("/docs/search.txt") + + b.AssertFileContent("public/search.txt", "Text Edited.") +} + func TestRebuildVariationsAssetsJSImport(t *testing.T) { t.Parallel() files := ` diff --git a/hugolib/site_render.go b/hugolib/site_render.go index e5b2b62ab71..5ac3d5a759f 100644 --- a/hugolib/site_render.go +++ b/hugolib/site_render.go @@ -20,6 +20,7 @@ import ( "strings" "sync" + "github.com/bep/logg" "github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/hugolib/doctree" @@ -33,6 +34,8 @@ import ( type siteRenderContext struct { cfg *BuildCfg + infol logg.LevelLogger + // languageIdx is the zero based index of the site. languageIdx int @@ -86,7 +89,7 @@ func (s *Site) renderPages(ctx *siteRenderContext) error { Tree: s.pageMap.treePages, Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) { if p, ok := n.(*pageState); ok { - if cfg.shouldRender(p) { + if cfg.shouldRender(ctx.infol, p) { select { case <-s.h.Done(): return true, nil