Repository: gohugoio/hugo (language: go)

Issue:
Allow rendering static files to disk, dynamic to memory, in server mode
Tracking issue #7983 


Discussion / hints:

Fix patch:
diff --git a/commands/commandeer.go b/commands/commandeer.go
index bf42501e0ce..7525bf18468 100644
--- a/commands/commandeer.go
+++ b/commands/commandeer.go
@@ -92,6 +92,7 @@ type commandeer struct {
 	languagesConfigured bool
 	languages           langs.Languages
 	doLiveReload        bool
+	renderStaticToDisk  bool
 	fastRenderMode      bool
 	showErrorInBrowser  bool
 	wasError            bool
@@ -368,8 +369,9 @@ func (c *commandeer) loadConfig() error {
 	}
 
 	createMemFs := config.GetBool("renderToMemory")
+	c.renderStaticToDisk = config.GetBool("renderStaticToDisk")
 
-	if createMemFs {
+	if createMemFs && !c.renderStaticToDisk {
 		// Rendering to memoryFS, publish to Root regardless of publishDir.
 		config.Set("publishDir", "/")
 	}
@@ -380,6 +382,14 @@ func (c *commandeer) loadConfig() error {
 		if c.destinationFs != nil {
 			// Need to reuse the destination on server rebuilds.
 			fs.Destination = c.destinationFs
+		} else if createMemFs && c.renderStaticToDisk {
+			// Writes the dynamic output on memory,
+			// while serve others directly from publishDir
+			publishDir := config.GetString("publishDir")
+			writableFs := afero.NewBasePathFs(afero.NewMemMapFs(), publishDir)
+			publicFs := afero.NewOsFs()
+			fs.Destination = afero.NewCopyOnWriteFs(afero.NewReadOnlyFs(publicFs), writableFs)
+			fs.DestinationStatic = publicFs
 		} else if createMemFs {
 			// Hugo writes the output to memory instead of the disk.
 			fs.Destination = new(afero.MemMapFs)
@@ -397,10 +407,13 @@ func (c *commandeer) loadConfig() error {
 
 			changeDetector.PrepareNew()
 			fs.Destination = hugofs.NewHashingFs(fs.Destination, changeDetector)
+			fs.DestinationStatic = hugofs.NewHashingFs(fs.DestinationStatic, changeDetector)
 			c.changeDetector = changeDetector
 		}
 
 		if c.Cfg.GetBool("logPathWarnings") {
+			// Note that we only care about the "dynamic creates" here,
+			// so skip the static fs.
 			fs.Destination = hugofs.NewCreateCountingFs(fs.Destination)
 		}
 
diff --git a/commands/hugo.go b/commands/hugo.go
index 8c5294f006d..21140fa4307 100644
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -652,6 +652,9 @@ func (c *commandeer) copyStaticTo(sourceFs *filesystems.SourceFilesystem) (uint6
 	syncer.ChmodFilter = chmodFilter
 	syncer.SrcFs = fs
 	syncer.DestFs = c.Fs.Destination
+	if c.renderStaticToDisk {
+		syncer.DestFs = c.Fs.DestinationStatic
+	}
 	// Now that we are using a unionFs for the static directories
 	// We can effectively clean the publishDir on initial sync
 	syncer.Delete = c.Cfg.GetBool("cleanDestinationDir")
diff --git a/commands/server.go b/commands/server.go
index 7d9462b3655..485f2338899 100644
--- a/commands/server.go
+++ b/commands/server.go
@@ -48,15 +48,16 @@ type serverCmd struct {
 	// Can be used to stop the server. Useful in tests
 	stop <-chan bool
 
-	disableLiveReload bool
-	navigateToChanged bool
-	renderToDisk      bool
-	serverAppend      bool
-	serverInterface   string
-	serverPort        int
-	liveReloadPort    int
-	serverWatch       bool
-	noHTTPCache       bool
+	disableLiveReload  bool
+	navigateToChanged  bool
+	renderToDisk       bool
+	renderStaticToDisk bool
+	serverAppend       bool
+	serverInterface    string
+	serverPort         int
+	liveReloadPort     int
+	serverWatch        bool
+	noHTTPCache        bool
 
 	disableFastRender   bool
 	disableBrowserError bool
@@ -98,7 +99,8 @@ of a second, you will be able to save and see your changes nearly instantly.`,
 	cc.cmd.Flags().BoolVarP(&cc.serverAppend, "appendPort", "", true, "append port to baseURL")
 	cc.cmd.Flags().BoolVar(&cc.disableLiveReload, "disableLiveReload", false, "watch without enabling live browser reload on rebuild")
 	cc.cmd.Flags().BoolVar(&cc.navigateToChanged, "navigateToChanged", false, "navigate to changed content file on live browser reload")
-	cc.cmd.Flags().BoolVar(&cc.renderToDisk, "renderToDisk", false, "render to Destination path (default is render to memory & serve from there)")
+	cc.cmd.Flags().BoolVar(&cc.renderToDisk, "renderToDisk", false, "serve all files from disk (default is from memory)")
+	cc.cmd.Flags().BoolVar(&cc.renderStaticToDisk, "renderStaticToDisk", false, "serve static files from disk and dynamic files from memory")
 	cc.cmd.Flags().BoolVar(&cc.disableFastRender, "disableFastRender", false, "enables full re-renders on changes")
 	cc.cmd.Flags().BoolVar(&cc.disableBrowserError, "disableBrowserError", false, "do not show build errors in the browser")
 
@@ -141,6 +143,7 @@ func (sc *serverCmd) server(cmd *cobra.Command, args []string) error {
 
 	cfgInit := func(c *commandeer) error {
 		c.Set("renderToMemory", !sc.renderToDisk)
+		c.Set("renderStaticToDisk", sc.renderStaticToDisk)
 		if cmd.Flags().Changed("navigateToChanged") {
 			c.Set("navigateToChanged", sc.navigateToChanged)
 		}
@@ -332,6 +335,8 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, string, erro
 	if i == 0 {
 		if f.s.renderToDisk {
 			jww.FEEDBACK.Println("Serving pages from " + absPublishDir)
+		} else if f.s.renderStaticToDisk {
+			jww.FEEDBACK.Println("Serving pages from memory and static files from " + absPublishDir)
 		} else {
 			jww.FEEDBACK.Println("Serving pages from memory")
 		}
diff --git a/commands/static_syncer.go b/commands/static_syncer.go
index 5569d4de688..2eb2b666233 100644
--- a/commands/static_syncer.go
+++ b/commands/static_syncer.go
@@ -56,6 +56,9 @@ func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error {
 		syncer.ChmodFilter = chmodFilter
 		syncer.SrcFs = sourceFs.Fs
 		syncer.DestFs = c.Fs.Destination
+		if c.renderStaticToDisk {
+			syncer.DestFs = c.Fs.DestinationStatic
+		}
 
 		// prevent spamming the log on changes
 		logger := helpers.NewDistinctErrorLogger()
@@ -101,7 +104,11 @@ func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error {
 					toRemove := filepath.Join(publishDir, relPath)
 
 					logger.Println("File no longer exists in static dir, removing", toRemove)
-					_ = c.Fs.Destination.RemoveAll(toRemove)
+					if c.renderStaticToDisk {
+						_ = c.Fs.DestinationStatic.RemoveAll(toRemove)
+					} else {
+						_ = c.Fs.Destination.RemoveAll(toRemove)
+					}
 				} else if err == nil {
 					// If file still exists, sync it
 					logger.Println("Syncing", relPath, "to", publishDir)
diff --git a/hugofs/fs.go b/hugofs/fs.go
index 54d962553e7..95645204e9f 100644
--- a/hugofs/fs.go
+++ b/hugofs/fs.go
@@ -35,6 +35,9 @@ type Fs struct {
 	// Destination is Hugo's destination file system.
 	Destination afero.Fs
 
+	// Destination used for `renderStaticToDisk`
+	DestinationStatic afero.Fs
+
 	// Os is an OS file system.
 	// NOTE: Field is currently unused.
 	Os afero.Fs
@@ -69,10 +72,11 @@ func NewFrom(fs afero.Fs, cfg config.Provider) *Fs {
 
 func newFs(base afero.Fs, cfg config.Provider) *Fs {
 	return &Fs{
-		Source:      base,
-		Destination: base,
-		Os:          &afero.OsFs{},
-		WorkingDir:  getWorkingDirFs(base, cfg),
+		Source:            base,
+		Destination:       base,
+		DestinationStatic: base,
+		Os:                &afero.OsFs{},
+		WorkingDir:        getWorkingDirFs(base, cfg),
 	}
 }
 
diff --git a/hugolib/filesystems/basefs.go b/hugolib/filesystems/basefs.go
index 2e32932c66b..1614bd0b31a 100644
--- a/hugolib/filesystems/basefs.go
+++ b/hugolib/filesystems/basefs.go
@@ -71,6 +71,9 @@ type BaseFs struct {
 	// A read-only filesystem starting from the project workDir.
 	WorkDir afero.Fs
 
+	// The filesystem used for renderStaticToDisk.
+	PublishFsStatic afero.Fs
+
 	theBigFs *filesystemsCollector
 
 	// Locks.
@@ -438,15 +441,17 @@ func NewBase(p *paths.Paths, logger loggers.Logger, options ...func(*BaseFs) err
 
 	publishFs := hugofs.NewBaseFileDecorator(afero.NewBasePathFs(fs.Destination, p.AbsPublishDir))
 	sourceFs := hugofs.NewBaseFileDecorator(afero.NewBasePathFs(fs.Source, p.WorkingDir))
+	publishFsStatic := afero.NewBasePathFs(fs.Source, p.AbsPublishDir)
 
 	// Same as sourceFs, but no decoration. This is what's used by os.ReadDir etc.
 	workDir := afero.NewBasePathFs(afero.NewReadOnlyFs(fs.Source), p.WorkingDir)
 
 	b := &BaseFs{
-		SourceFs:  sourceFs,
-		WorkDir:   workDir,
-		PublishFs: publishFs,
-		buildMu:   lockedfile.MutexAt(filepath.Join(p.WorkingDir, lockFileBuild)),
+		SourceFs:        sourceFs,
+		WorkDir:         workDir,
+		PublishFs:       publishFs,
+		PublishFsStatic: publishFsStatic,
+		buildMu:         lockedfile.MutexAt(filepath.Join(p.WorkingDir, lockFileBuild)),
 	}
 
 	for _, opt := range options {
diff --git a/hugolib/pages_process.go b/hugolib/pages_process.go
index 541c0ae3e45..59b20dabc70 100644
--- a/hugolib/pages_process.go
+++ b/hugolib/pages_process.go
@@ -33,9 +33,10 @@ func newPagesProcessor(h *HugoSites, sp *source.SourceSpec) *pagesProcessor {
 	procs := make(map[string]pagesCollectorProcessorProvider)
 	for _, s := range h.Sites {
 		procs[s.Lang()] = &sitePagesProcessor{
-			m:           s.pageMap,
-			errorSender: s.h,
-			itemChan:    make(chan interface{}, config.GetNumWorkerMultiplier()*2),
+			m:                  s.pageMap,
+			errorSender:        s.h,
+			itemChan:           make(chan interface{}, config.GetNumWorkerMultiplier()*2),
+			renderStaticToDisk: h.Cfg.GetBool("renderStaticToDisk"),
 		}
 	}
 	return &pagesProcessor{
@@ -118,6 +119,8 @@ type sitePagesProcessor struct {
 	ctx       context.Context
 	itemChan  chan interface{}
 	itemGroup *errgroup.Group
+
+	renderStaticToDisk bool
 }
 
 func (p *sitePagesProcessor) Process(item interface{}) error {
@@ -162,7 +165,12 @@ func (p *sitePagesProcessor) copyFile(fim hugofs.FileMetaInfo) error {
 
 	defer f.Close()
 
-	return s.publish(&s.PathSpec.ProcessingStats.Files, target, f)
+	fs := s.PublishFs
+	if p.renderStaticToDisk {
+		fs = s.PublishFsStatic
+	}
+
+	return s.publish(&s.PathSpec.ProcessingStats.Files, target, f, fs)
 }
 
 func (p *sitePagesProcessor) doProcess(item interface{}) error {
diff --git a/hugolib/site.go b/hugolib/site.go
index c76bdc141bc..0b8e807dd40 100644
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -1824,10 +1824,10 @@ func (s *Site) lookupTemplate(layouts ...string) (tpl.Template, bool) {
 	return nil, false
 }
 
-func (s *Site) publish(statCounter *uint64, path string, r io.Reader) (err error) {
+func (s *Site) publish(statCounter *uint64, path string, r io.Reader, fs afero.Fs) (err error) {
 	s.PathSpec.ProcessingStats.Incr(statCounter)
 
-	return helpers.WriteToDisk(filepath.Clean(path), r, s.BaseFs.PublishFs)
+	return helpers.WriteToDisk(filepath.Clean(path), r, fs)
 }
 
 func (s *Site) kindFromFileInfoOrSections(fi *fileInfo, sections []string) string {


Test patch: