Skip to content

Commit 44fbcd5

Browse files
committed
Fix some server rebuild issues for non-HTML custom output formats
The failing test case here is * A custom search output format defined on the home page, marked as `noAlternative` and not `permalinkable` * In fast render mode, when making a change to a data source for that search output format, the JSON file was not refreshed. There are variants of the above, but the gist of it is: * The change set was correctly determined, but since the search JSON file was not in the recently visited browser stack, we skipped rendering it. Running with `hugo server --disableFastRender` would be a workaround for the above. This commit fixes this by: * Adding a check for the HTTP request header `Sec-Fetch-Mode = navigation` to the condition for if we should track server request as a user navigation (and not e.g. a HTTP request for a linked CSS stylesheet). * Making sure that we compare against the real relative URL for non-permalinkable output formats. Fixes gohugoio#13014
1 parent a563783 commit 44fbcd5

10 files changed

+117
-42
lines changed

commands/hugobuilder.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ type hugoBuilder struct {
6262

6363
// Currently only set when in "fast render mode".
6464
changeDetector *fileChangeDetector
65-
visitedURLs *types.EvictingStringQueue
65+
visitedURLs *types.EvictingQueue[string]
6666

6767
fullRebuildSem *semaphore.Weighted
6868
debounce func(f func())
@@ -1103,7 +1103,7 @@ func (c *hugoBuilder) rebuildSites(events []fsnotify.Event) (err error) {
11031103
if err != nil {
11041104
return
11051105
}
1106-
err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...)
1106+
err = h.Build(hugolib.BuildCfg{NoBuildLock: true, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()}, events...)
11071107
return
11081108
}
11091109

@@ -1119,7 +1119,7 @@ func (c *hugoBuilder) rebuildSitesForChanges(ids []identity.Identity) (err error
11191119
}
11201120
whatChanged := &hugolib.WhatChanged{}
11211121
whatChanged.Add(ids...)
1122-
err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyVisited: c.visitedURLs, ErrRecovery: c.errState.wasErr()})
1122+
err = h.Build(hugolib.BuildCfg{NoBuildLock: true, WhatChanged: whatChanged, RecentlyTouched: c.visitedURLs, ErrRecovery: c.errState.wasErr()})
11231123

11241124
return
11251125
}

commands/server.go

+8-4
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const (
8585
)
8686

8787
func newHugoBuilder(r *rootCommand, s *serverCommand, onConfigLoaded ...func(reloaded bool) error) *hugoBuilder {
88-
var visitedURLs *types.EvictingStringQueue
88+
var visitedURLs *types.EvictingQueue[string]
8989
if s != nil && !s.disableFastRender {
9090
visitedURLs = types.NewEvictingStringQueue(20)
9191
}
@@ -364,7 +364,11 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string
364364
}
365365

366366
if f.c.fastRenderMode && f.c.errState.buildErr() == nil {
367-
if strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
367+
// Sec-Fetch-Dest = document
368+
// 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
369+
// Fall back to the file extension if not set.
370+
// The main take here is that we don't want to have CSS/JS files etc. partake in this logic.
371+
if r.Header.Get("Sec-Fetch-Mode") == "navigate" || strings.HasSuffix(requestURI, "/") || strings.HasSuffix(requestURI, "html") || strings.HasSuffix(requestURI, "htm") {
368372
if !f.c.visitedURLs.Contains(requestURI) {
369373
// If not already on stack, re-render that single page.
370374
if err := f.c.partialReRender(requestURI); err != nil {
@@ -838,7 +842,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) {
838842
defer func() {
839843
c.errState.setWasErr(false)
840844
}()
841-
visited := types.NewEvictingStringQueue(len(urls))
845+
visited := types.NewEvictingStringQueue[string](len(urls))
842846
for _, url := range urls {
843847
visited.Add(url)
844848
}
@@ -850,7 +854,7 @@ func (c *serverCommand) partialReRender(urls ...string) (err error) {
850854
}
851855

852856
// Note: We do not set NoBuildLock as the file lock is not acquired at this stage.
853-
err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyVisited: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()})
857+
err = h.Build(hugolib.BuildCfg{NoBuildLock: false, RecentlyTouched: visited, PartialReRender: true, ErrRecovery: c.errState.wasErr()})
854858

855859
return
856860
}

common/types/evictingqueue.go

+19-16
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,24 @@ import (
1818
"sync"
1919
)
2020

21-
// EvictingStringQueue is a queue which automatically evicts elements from the head of
21+
// EvictingQueue is a queue which automatically evicts elements from the head of
2222
// the queue when attempting to add new elements onto the queue and it is full.
2323
// This queue orders elements LIFO (last-in-first-out). It throws away duplicates.
24-
// Note: This queue currently does not contain any remove (poll etc.) methods.
25-
type EvictingStringQueue struct {
24+
type EvictingQueue[T comparable] struct {
2625
size int
27-
vals []string
28-
set map[string]bool
26+
vals []T
27+
set map[T]bool
2928
mu sync.Mutex
29+
zero T
3030
}
3131

3232
// NewEvictingStringQueue creates a new queue with the given size.
33-
func NewEvictingStringQueue(size int) *EvictingStringQueue {
34-
return &EvictingStringQueue{size: size, set: make(map[string]bool)}
33+
func NewEvictingStringQueue[T comparable](size int) *EvictingQueue[T] {
34+
return &EvictingQueue[T]{size: size, set: make(map[T]bool)}
3535
}
3636

3737
// Add adds a new string to the tail of the queue if it's not already there.
38-
func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue {
38+
func (q *EvictingQueue[T]) Add(v T) *EvictingQueue[T] {
3939
q.mu.Lock()
4040
if q.set[v] {
4141
q.mu.Unlock()
@@ -54,7 +54,7 @@ func (q *EvictingStringQueue) Add(v string) *EvictingStringQueue {
5454
return q
5555
}
5656

57-
func (q *EvictingStringQueue) Len() int {
57+
func (q *EvictingQueue[T]) Len() int {
5858
if q == nil {
5959
return 0
6060
}
@@ -64,7 +64,7 @@ func (q *EvictingStringQueue) Len() int {
6464
}
6565

6666
// Contains returns whether the queue contains v.
67-
func (q *EvictingStringQueue) Contains(v string) bool {
67+
func (q *EvictingQueue[T]) Contains(v T) bool {
6868
if q == nil {
6969
return false
7070
}
@@ -74,22 +74,25 @@ func (q *EvictingStringQueue) Contains(v string) bool {
7474
}
7575

7676
// Peek looks at the last element added to the queue.
77-
func (q *EvictingStringQueue) Peek() string {
77+
func (q *EvictingQueue[T]) Peek() T {
7878
q.mu.Lock()
7979
l := len(q.vals)
8080
if l == 0 {
8181
q.mu.Unlock()
82-
return ""
82+
return q.zero
8383
}
8484
elem := q.vals[l-1]
8585
q.mu.Unlock()
8686
return elem
8787
}
8888

8989
// PeekAll looks at all the elements in the queue, with the newest first.
90-
func (q *EvictingStringQueue) PeekAll() []string {
90+
func (q *EvictingQueue[T]) PeekAll() []T {
91+
if q == nil {
92+
return nil
93+
}
9194
q.mu.Lock()
92-
vals := make([]string, len(q.vals))
95+
vals := make([]T, len(q.vals))
9396
copy(vals, q.vals)
9497
q.mu.Unlock()
9598
for i, j := 0, len(vals)-1; i < j; i, j = i+1, j-1 {
@@ -99,9 +102,9 @@ func (q *EvictingStringQueue) PeekAll() []string {
99102
}
100103

101104
// PeekAllSet returns PeekAll as a set.
102-
func (q *EvictingStringQueue) PeekAllSet() map[string]bool {
105+
func (q *EvictingQueue[T]) PeekAllSet() map[T]bool {
103106
all := q.PeekAll()
104-
set := make(map[string]bool)
107+
set := make(map[T]bool)
105108
for _, v := range all {
106109
set[v] = true
107110
}

hugolib/hugo_sites.go

+17-9
Original file line numberDiff line numberDiff line change
@@ -416,8 +416,8 @@ type BuildCfg struct {
416416
// Set in server mode when the last build failed for some reason.
417417
ErrRecovery bool
418418

419-
// Recently visited URLs. This is used for partial re-rendering.
420-
RecentlyVisited *types.EvictingStringQueue
419+
// Recently visited or touched URLs. This is used for partial re-rendering.
420+
RecentlyTouched *types.EvictingQueue[string]
421421

422422
// Can be set to build only with a sub set of the content source.
423423
ContentInclusionFilter *glob.FilenameFilter
@@ -428,8 +428,14 @@ type BuildCfg struct {
428428
testCounters *buildCounters
429429
}
430430

431+
// TouchedURL represents a URL that has been recently visited or touched.
432+
type TouchedURL struct {
433+
URL string
434+
Reason string // Used for logging.
435+
}
436+
431437
// shouldRender returns whether this output format should be rendered or not.
432-
func (cfg *BuildCfg) shouldRender(p *pageState) bool {
438+
func (cfg *BuildCfg) shouldRender(infol logg.LevelLogger, p *pageState) bool {
433439
if p.skipRender() {
434440
return false
435441
}
@@ -457,18 +463,20 @@ func (cfg *BuildCfg) shouldRender(p *pageState) bool {
457463
return false
458464
}
459465

460-
if p.outputFormat().IsHTML {
461-
// This is fast render mode and the output format is HTML,
462-
// rerender if this page is one of the recently visited.
463-
return cfg.RecentlyVisited.Contains(p.RelPermalink())
466+
if relURL := p.getRelURL(); relURL != "" {
467+
if cfg.RecentlyTouched.Contains(relURL) {
468+
infol.Logf("render recently touched %s (%s)", relURL, p.outputFormat().Name)
469+
return true
470+
}
464471
}
465472

466473
// In fast render mode, we want to avoid re-rendering the sitemaps etc. and
467474
// other big listings whenever we e.g. change a content file,
468-
// but we want partial renders of the recently visited pages to also include
475+
// but we want partial renders of the recently touched pages to also include
469476
// alternative formats of the same HTML page (e.g. RSS, JSON).
470477
for _, po := range p.pageOutputs {
471-
if po.render && po.f.IsHTML && cfg.RecentlyVisited.Contains(po.RelPermalink()) {
478+
if po.render && po.f.IsHTML && cfg.RecentlyTouched.Contains(po.getRelURL()) {
479+
infol.Logf("render recently touched %s, %s version of %s", po.getRelURL(), po.f.Name, p.outputFormat().Name)
472480
return true
473481
}
474482
}

hugolib/hugo_sites_build.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ func (h *HugoSites) render(l logg.LevelLogger, config *BuildCfg) error {
341341
loggers.TimeTrackf(l, start, h.buildCounters.loggFields(), "")
342342
}()
343343

344-
siteRenderContext := &siteRenderContext{cfg: config, multihost: h.Configs.IsMultihost}
344+
siteRenderContext := &siteRenderContext{cfg: config, infol: l, multihost: h.Configs.IsMultihost}
345345

346346
renderErr := func(err error) error {
347347
if err == nil {
@@ -902,12 +902,12 @@ func (h *HugoSites) processPartialFileEvents(ctx context.Context, l logg.LevelLo
902902

903903
needsPagesAssemble = true
904904

905-
if config.RecentlyVisited != nil {
905+
if config.RecentlyTouched != nil {
906906
// Fast render mode. Adding them to the visited queue
907907
// avoids rerendering them on navigation.
908908
for _, id := range changes {
909909
if p, ok := id.(page.Page); ok {
910-
config.RecentlyVisited.Add(p.RelPermalink())
910+
config.RecentlyTouched.Add(p.RelPermalink())
911911
}
912912
}
913913
}

hugolib/integrationtest_builder.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -487,11 +487,11 @@ func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTest
487487
if !s.Cfg.Running {
488488
panic("BuildPartial can only be used in server mode")
489489
}
490-
visited := types.NewEvictingStringQueue(len(urls))
490+
visited := types.NewEvictingStringQueue[string](len(urls))
491491
for _, url := range urls {
492492
visited.Add(url)
493493
}
494-
buildCfg := BuildCfg{RecentlyVisited: visited, PartialReRender: true}
494+
buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true}
495495
return s, s.build(buildCfg)
496496
}
497497

hugolib/page__paths.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,17 @@ func newPagePaths(ps *pageState) (pagePaths, error) {
7171
// Use the main format for permalinks, usually HTML.
7272
permalinksIndex := 0
7373
if f.Permalinkable {
74-
// Unless it's permalinkable
74+
// Unless it's permalinkable.
7575
permalinksIndex = i
7676
}
7777

78+
relURL := relPermalink
79+
if relURL == "" {
80+
relURL = paths.RelPermalink(s.PathSpec)
81+
}
82+
7883
targets[f.Name] = targetPathsHolder{
84+
relURL: relURL,
7985
paths: paths,
8086
OutputFormat: pageOutputFormats[permalinksIndex],
8187
}

hugolib/page__per_output.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -469,13 +469,21 @@ type pagePerOutputProviders interface {
469469

470470
type targetPather interface {
471471
targetPaths() page.TargetPaths
472+
getRelURL() string
472473
}
473474

474475
type targetPathsHolder struct {
475-
paths page.TargetPaths
476+
// relURL is usually the same as OutputFormat.RelPermalink, but can be different
477+
// for non-permalinkable output formats. These shares RelPermalink with the main (first) output format.
478+
relURL string
479+
paths page.TargetPaths
476480
page.OutputFormat
477481
}
478482

483+
func (t targetPathsHolder) getRelURL() string {
484+
return t.relURL
485+
}
486+
479487
func (t targetPathsHolder) targetPaths() page.TargetPaths {
480488
return t.paths
481489
}

hugolib/rebuild_test.go

+45-2
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ RegularPages: {{ range .Site.RegularPages }}{{ .RelPermalink }}|{{ end }}$
357357
}
358358

359359
func TestRebuildRenameDirectoryWithBranchBundleFastRender(t *testing.T) {
360-
recentlyVisited := types.NewEvictingStringQueue(10).Add("/a/b/c/")
361-
b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyVisited: recentlyVisited} })
360+
recentlyVisited := types.NewEvictingStringQueue[string](10).Add("/a/b/c/")
361+
b := TestRunning(t, rebuildFilesSimple, func(cfg *IntegrationTestConfig) { cfg.BuildCfg = BuildCfg{RecentlyTouched: recentlyVisited} })
362362
b.RenameDir("content/mysection", "content/mysectionrenamed").Build()
363363
b.AssertFileContent("public/mysectionrenamed/index.html", "My Section")
364364
b.AssertFileContent("public/mysectionrenamed/mysectionbundle/index.html", "My Section Bundle")
@@ -1181,6 +1181,49 @@ Content: {{ .Content }}
11811181
b.AssertFileContent("public/index.html", "Content: <p>Home</p>")
11821182
}
11831183

1184+
// Issue #13014.
1185+
func TestRebuildEditNotPermalinkableCustomOutputFormatTemplateInFastRenderMode(t *testing.T) {
1186+
t.Parallel()
1187+
1188+
files := `
1189+
-- hugo.toml --
1190+
baseURL = "https://example.com/docs/"
1191+
disableLiveReload = true
1192+
[internal]
1193+
fastRenderMode = true
1194+
disableKinds = ["taxonomy", "term", "sitemap", "robotsTXT", "404"]
1195+
[outputFormats]
1196+
[outputFormats.SearchIndex]
1197+
baseName = 'Search'
1198+
isPlainText = true
1199+
mediaType = 'text/plain'
1200+
noAlternative = true
1201+
permalinkable = false
1202+
1203+
[outputs]
1204+
home = ['HTML', 'SearchIndex']
1205+
-- content/_index.md --
1206+
---
1207+
title: "Home"
1208+
---
1209+
Home.
1210+
-- layouts/index.html --
1211+
Home.
1212+
-- layouts/_default/index.searchindex.txt --
1213+
Text. {{ .Title }}|{{ .RelPermalink }}|
1214+
1215+
`
1216+
b := TestRunning(t, files, TestOptInfo())
1217+
1218+
b.AssertFileContent("public/search.txt", "Text.")
1219+
1220+
b.EditFileReplaceAll("layouts/_default/index.searchindex.txt", "Text.", "Text Edited.").Build()
1221+
1222+
b.BuildPartial("/docs/search.txt")
1223+
1224+
b.AssertFileContent("public/search.txt", "Text Edited.")
1225+
}
1226+
11841227
func TestRebuildVariationsAssetsJSImport(t *testing.T) {
11851228
t.Parallel()
11861229
files := `

hugolib/site_render.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"strings"
2121
"sync"
2222

23+
"github.com/bep/logg"
2324
"github.com/gohugoio/hugo/common/herrors"
2425
"github.com/gohugoio/hugo/hugolib/doctree"
2526

@@ -33,6 +34,8 @@ import (
3334
type siteRenderContext struct {
3435
cfg *BuildCfg
3536

37+
infol logg.LevelLogger
38+
3639
// languageIdx is the zero based index of the site.
3740
languageIdx int
3841

@@ -86,7 +89,7 @@ func (s *Site) renderPages(ctx *siteRenderContext) error {
8689
Tree: s.pageMap.treePages,
8790
Handle: func(key string, n contentNodeI, match doctree.DimensionFlag) (bool, error) {
8891
if p, ok := n.(*pageState); ok {
89-
if cfg.shouldRender(p) {
92+
if cfg.shouldRender(ctx.infol, p) {
9093
select {
9194
case <-s.h.Done():
9295
return true, nil

0 commit comments

Comments
 (0)