shithub: hugo

Download patch

ref: 60dfb9a6e076200ab3ca3fd30e34bb3c14e0a893
parent: 2e0465764b5dacc511b977b1c9aa07324ad0ee9c
author: Bjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
date: Sun Nov 12 05:03:56 EST 2017

Add support for multiple staticDirs

This commit adds support for multiple statDirs both on the global and language level.

A simple `config.toml` example:

```bash
staticDir = ["static1", "static2"]
[languages]
[languages.no]
staticDir = ["staticDir_override", "static_no"]
baseURL = "https://example.no"
languageName = "Norsk"
weight = 1
title = "På norsk"

[languages.en]
staticDir2 = "static_en"
baseURL = "https://example.com"
languageName = "English"
weight = 2
title = "In English"
```

In the above, with no theme used:

the English site will get its static files as a union of "static1", "static2" and "static_en". On file duplicates, the right-most version will win.
the Norwegian site will get its static files as a union of "staticDir_override" and "static_no".

This commit also concludes the Multihost support in #4027.

Fixes #36
Closes #4027

--- a/Gopkg.lock
+++ b/Gopkg.lock
@@ -193,10 +193,10 @@
   revision = "86672fcb3f950f35f2e675df2240550f2a50762f"
 
 [[projects]]
-  branch = "master"
   name = "github.com/spf13/afero"
   packages = [".","mem"]
-  revision = "5660eeed305fe5f69c8fc6cf899132a459a97064"
+  revision = "8d919cbe7e2627e417f3e45c3c0e489a5b7e2536"
+  version = "v1.0.0"
 
 [[projects]]
   name = "github.com/spf13/cast"
@@ -285,6 +285,6 @@
 [solve-meta]
   analyzer-name = "dep"
   analyzer-version = 1
-  inputs-digest = "271e5ca84d4f9c63392ca282b940207c0c96995efb3a0a9fbc43114b0669bfa0"
+  inputs-digest = "a7cec7b1df49f84fdd4073cc70139d56c62c5fffcc7e3fcea5ca29615d4b9568"
   solver-name = "gps-cdcl"
   solver-version = 1
--- a/Gopkg.toml
+++ b/Gopkg.toml
@@ -81,8 +81,8 @@
   version = "1.5.0"
 
 [[constraint]]
-  branch = "master"
   name = "github.com/spf13/afero"
+  version = "1.0.0"
 
 [[constraint]]
   name = "github.com/spf13/cast"
--- a/commands/commandeer.go
+++ b/commands/commandeer.go
@@ -24,7 +24,8 @@
 	*deps.DepsCfg
 	pathSpec    *helpers.PathSpec
 	visitedURLs *types.EvictingStringQueue
-	configured  bool
+
+	configured bool
 }
 
 func (c *commandeer) Set(key string, value interface{}) {
--- a/commands/hugo.go
+++ b/commands/hugo.go
@@ -22,7 +22,6 @@
 	"github.com/gohugoio/hugo/hugofs"
 
 	"log"
-	"net/http"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -30,6 +29,8 @@
 	"sync"
 	"time"
 
+	src "github.com/gohugoio/hugo/source"
+
 	"github.com/gohugoio/hugo/config"
 
 	"github.com/gohugoio/hugo/parser"
@@ -526,8 +527,7 @@
 
 func (c *commandeer) build(watches ...bool) error {
 	if err := c.copyStatic(); err != nil {
-		// TODO(bep) multihost
-		return fmt.Errorf("Error copying static files to %s: %s", c.PathSpec().AbsPathify(c.Cfg.GetString("publishDir")), err)
+		return fmt.Errorf("Error copying static files: %s", err)
 	}
 	watch := false
 	if len(watches) > 0 && watches[0] {
@@ -538,80 +538,54 @@
 	}
 
 	if buildWatch {
+		watchDirs, err := c.getDirList()
+		if err != nil {
+			return err
+		}
 		c.Logger.FEEDBACK.Println("Watching for changes in", c.PathSpec().AbsPathify(c.Cfg.GetString("contentDir")))
 		c.Logger.FEEDBACK.Println("Press Ctrl+C to stop")
-		utils.CheckErr(c.Logger, c.newWatcher(0))
+		utils.CheckErr(c.Logger, c.newWatcher(false, watchDirs...))
 	}
 
 	return nil
 }
 
-func (c *commandeer) getStaticSourceFs() afero.Fs {
-	source := c.Fs.Source
-	themeDir, err := c.PathSpec().GetThemeStaticDirPath()
-	staticDir := c.PathSpec().GetStaticDirPath() + helpers.FilePathSeparator
-	useTheme := true
-	useStatic := true
+func (c *commandeer) copyStatic() error {
+	return c.doWithPublishDirs(c.copyStaticTo)
+}
 
-	if err != nil {
-		if err != helpers.ErrThemeUndefined {
-			c.Logger.WARN.Println(err)
-		}
-		useTheme = false
-	} else {
-		if _, err := source.Stat(themeDir); os.IsNotExist(err) {
-			c.Logger.WARN.Println("Unable to find Theme Static Directory:", themeDir)
-			useTheme = false
-		}
+func (c *commandeer) doWithPublishDirs(f func(dirs *src.Dirs, publishDir string) error) error {
+	publishDir := c.PathSpec().AbsPathify(c.Cfg.GetString("publishDir")) + helpers.FilePathSeparator
+	// If root, remove the second '/'
+	if publishDir == "//" {
+		publishDir = helpers.FilePathSeparator
 	}
 
-	if _, err := source.Stat(staticDir); os.IsNotExist(err) {
-		c.Logger.WARN.Println("Unable to find Static Directory:", staticDir)
-		useStatic = false
-	}
+	languages := c.languages()
 
-	if !useStatic && !useTheme {
-		return nil
+	if !languages.IsMultihost() {
+		dirs, err := src.NewDirs(c.Fs, c.Cfg, c.DepsCfg.Logger)
+		if err != nil {
+			return err
+		}
+		return f(dirs, publishDir)
 	}
 
-	if !useStatic {
-		c.Logger.INFO.Println(themeDir, "is the only static directory available to sync from")
-		return afero.NewReadOnlyFs(afero.NewBasePathFs(source, themeDir))
-	}
-
-	if !useTheme {
-		c.Logger.INFO.Println(staticDir, "is the only static directory available to sync from")
-		return afero.NewReadOnlyFs(afero.NewBasePathFs(source, staticDir))
-	}
-
-	c.Logger.INFO.Println("using a UnionFS for static directory comprised of:")
-	c.Logger.INFO.Println("Base:", themeDir)
-	c.Logger.INFO.Println("Overlay:", staticDir)
-	base := afero.NewReadOnlyFs(afero.NewBasePathFs(source, themeDir))
-	overlay := afero.NewReadOnlyFs(afero.NewBasePathFs(source, staticDir))
-	return afero.NewCopyOnWriteFs(base, overlay)
-}
-
-func (c *commandeer) copyStatic() error {
-	publishDir := c.PathSpec().AbsPathify(c.Cfg.GetString("publishDir")) + helpers.FilePathSeparator
-	roots := c.roots()
-
-	if len(roots) == 0 {
-		return c.copyStaticTo(publishDir)
-	}
-
-	for _, root := range roots {
-		dir := filepath.Join(publishDir, root)
-		if err := c.copyStaticTo(dir); err != nil {
+	for _, l := range languages {
+		dir := filepath.Join(publishDir, l.Lang)
+		dirs, err := src.NewDirs(c.Fs, l, c.DepsCfg.Logger)
+		if err != nil {
 			return err
 		}
+		if err := f(dirs, dir); err != nil {
+			return err
+		}
 	}
 
 	return nil
-
 }
 
-func (c *commandeer) copyStaticTo(publishDir string) error {
+func (c *commandeer) copyStaticTo(dirs *src.Dirs, publishDir string) error {
 
 	// If root, remove the second '/'
 	if publishDir == "//" {
@@ -618,8 +592,10 @@
 		publishDir = helpers.FilePathSeparator
 	}
 
-	// Includes both theme/static & /static
-	staticSourceFs := c.getStaticSourceFs()
+	staticSourceFs, err := dirs.CreateStaticFs()
+	if err != nil {
+		return err
+	}
 
 	if staticSourceFs == nil {
 		c.Logger.WARN.Println("No static directories found to sync")
@@ -650,12 +626,17 @@
 }
 
 // getDirList provides NewWatcher() with a list of directories to watch for changes.
-func (c *commandeer) getDirList() []string {
+func (c *commandeer) getDirList() ([]string, error) {
 	var a []string
 	dataDir := c.PathSpec().AbsPathify(c.Cfg.GetString("dataDir"))
 	i18nDir := c.PathSpec().AbsPathify(c.Cfg.GetString("i18nDir"))
+	staticSyncer, err := newStaticSyncer(c)
+	if err != nil {
+		return nil, err
+	}
+
 	layoutDir := c.PathSpec().GetLayoutDirPath()
-	staticDir := c.PathSpec().GetStaticDirPath()
+	staticDirs := staticSyncer.d.AbsStaticDirs
 
 	walker := func(path string, fi os.FileInfo, err error) error {
 		if err != nil {
@@ -674,12 +655,12 @@
 				return nil
 			}
 
-			if path == staticDir && os.IsNotExist(err) {
-				c.Logger.WARN.Println("Skip staticDir:", err)
-				return nil
-			}
-
 			if os.IsNotExist(err) {
+				for _, staticDir := range staticDirs {
+					if path == staticDir && os.IsNotExist(err) {
+						c.Logger.WARN.Println("Skip staticDir:", err)
+					}
+				}
 				// Ignore.
 				return nil
 			}
@@ -726,17 +707,18 @@
 	_ = helpers.SymbolicWalk(c.Fs.Source, c.PathSpec().AbsPathify(c.Cfg.GetString("contentDir")), walker)
 	_ = helpers.SymbolicWalk(c.Fs.Source, i18nDir, walker)
 	_ = helpers.SymbolicWalk(c.Fs.Source, layoutDir, walker)
-	_ = helpers.SymbolicWalk(c.Fs.Source, staticDir, walker)
+	for _, staticDir := range staticDirs {
+		_ = helpers.SymbolicWalk(c.Fs.Source, staticDir, walker)
+	}
 
 	if c.PathSpec().ThemeSet() {
 		themesDir := c.PathSpec().GetThemeDir()
 		_ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "layouts"), walker)
-		_ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "static"), walker)
 		_ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "i18n"), walker)
 		_ = helpers.SymbolicWalk(c.Fs.Source, filepath.Join(themesDir, "data"), walker)
 	}
 
-	return a
+	return a, nil
 }
 
 func (c *commandeer) recreateAndBuildSites(watching bool) (err error) {
@@ -798,11 +780,18 @@
 }
 
 // newWatcher creates a new watcher to watch filesystem events.
-func (c *commandeer) newWatcher(port int) error {
+// if serve is set it will also start one or more HTTP servers to serve those
+// files.
+func (c *commandeer) newWatcher(serve bool, dirList ...string) error {
 	if runtime.GOOS == "darwin" {
 		tweakLimit()
 	}
 
+	staticSyncer, err := newStaticSyncer(c)
+	if err != nil {
+		return err
+	}
+
 	watcher, err := watcher.New(1 * time.Second)
 	var wg sync.WaitGroup
 
@@ -814,7 +803,7 @@
 
 	wg.Add(1)
 
-	for _, d := range c.getDirList() {
+	for _, d := range dirList {
 		if d != "" {
 			_ = watcher.Add(d)
 		}
@@ -874,7 +863,7 @@
 							if err := watcher.Add(path); err != nil {
 								return err
 							}
-						} else if !c.isStatic(path) {
+						} else if !staticSyncer.isStatic(path) {
 							// Hugo's rebuilding logic is entirely file based. When you drop a new folder into
 							// /content on OSX, the above logic will handle future watching of those files,
 							// but the initial CREATE is lost.
@@ -891,7 +880,7 @@
 						}
 					}
 
-					if c.isStatic(ev.Name) {
+					if staticSyncer.isStatic(ev.Name) {
 						staticEvents = append(staticEvents, ev)
 					} else {
 						dynamicEvents = append(dynamicEvents, ev)
@@ -899,13 +888,6 @@
 				}
 
 				if len(staticEvents) > 0 {
-					publishDir := c.PathSpec().AbsPathify(c.Cfg.GetString("publishDir")) + helpers.FilePathSeparator
-
-					// If root, remove the second '/'
-					if publishDir == "//" {
-						publishDir = helpers.FilePathSeparator
-					}
-
 					c.Logger.FEEDBACK.Println("\nStatic file changes detected")
 					const layout = "2006-01-02 15:04:05.000 -0700"
 					c.Logger.FEEDBACK.Println(time.Now().Format(layout))
@@ -912,88 +894,15 @@
 
 					if c.Cfg.GetBool("forceSyncStatic") {
 						c.Logger.FEEDBACK.Printf("Syncing all static files\n")
-						// TODO(bep) multihost
 						err := c.copyStatic()
 						if err != nil {
-							utils.StopOnErr(c.Logger, err, fmt.Sprintf("Error copying static files to %s", publishDir))
+							utils.StopOnErr(c.Logger, err, "Error copying static files to publish dir")
 						}
 					} else {
-						staticSourceFs := c.getStaticSourceFs()
-
-						if staticSourceFs == nil {
-							c.Logger.WARN.Println("No static directories found to sync")
-							return
+						if err := staticSyncer.syncsStaticEvents(staticEvents); err != nil {
+							c.Logger.ERROR.Println(err)
+							continue
 						}
-
-						syncer := fsync.NewSyncer()
-						syncer.NoTimes = c.Cfg.GetBool("noTimes")
-						syncer.NoChmod = c.Cfg.GetBool("noChmod")
-						syncer.SrcFs = staticSourceFs
-						syncer.DestFs = c.Fs.Destination
-
-						// prevent spamming the log on changes
-						logger := helpers.NewDistinctFeedbackLogger()
-
-						for _, ev := range staticEvents {
-							// Due to our approach of layering both directories and the content's rendered output
-							// into one we can't accurately remove a file not in one of the source directories.
-							// If a file is in the local static dir and also in the theme static dir and we remove
-							// it from one of those locations we expect it to still exist in the destination
-							//
-							// If Hugo generates a file (from the content dir) over a static file
-							// the content generated file should take precedence.
-							//
-							// Because we are now watching and handling individual events it is possible that a static
-							// event that occupies the same path as a content generated file will take precedence
-							// until a regeneration of the content takes places.
-							//
-							// Hugo assumes that these cases are very rare and will permit this bad behavior
-							// The alternative is to track every single file and which pipeline rendered it
-							// and then to handle conflict resolution on every event.
-
-							fromPath := ev.Name
-
-							// If we are here we already know the event took place in a static dir
-							relPath, err := c.PathSpec().MakeStaticPathRelative(fromPath)
-							if err != nil {
-								c.Logger.ERROR.Println(err)
-								continue
-							}
-
-							// Remove || rename is harder and will require an assumption.
-							// Hugo takes the following approach:
-							// If the static file exists in any of the static source directories after this event
-							// Hugo will re-sync it.
-							// If it does not exist in all of the static directories Hugo will remove it.
-							//
-							// This assumes that Hugo has not generated content on top of a static file and then removed
-							// the source of that static file. In this case Hugo will incorrectly remove that file
-							// from the published directory.
-							if ev.Op&fsnotify.Rename == fsnotify.Rename || ev.Op&fsnotify.Remove == fsnotify.Remove {
-								if _, err := staticSourceFs.Stat(relPath); os.IsNotExist(err) {
-									// If file doesn't exist in any static dir, remove it
-									toRemove := filepath.Join(publishDir, relPath)
-									logger.Println("File no longer exists in static dir, removing", toRemove)
-									_ = c.Fs.Destination.RemoveAll(toRemove)
-								} else if err == nil {
-									// If file still exists, sync it
-									logger.Println("Syncing", relPath, "to", publishDir)
-									if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil {
-										c.Logger.ERROR.Println(err)
-									}
-								} else {
-									c.Logger.ERROR.Println(err)
-								}
-
-								continue
-							}
-
-							// For all other event operations Hugo will sync static.
-							logger.Println("Syncing", relPath, "to", publishDir)
-							if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil {
-								c.Logger.ERROR.Println(err)
-							}
-						}
 					}
 
 					if !buildWatch && !c.Cfg.GetBool("disableLiveReload") {
@@ -1002,7 +911,7 @@
 						// force refresh when more than one file
 						if len(staticEvents) > 0 {
 							for _, ev := range staticEvents {
-								path, _ := c.PathSpec().MakeStaticPathRelative(ev.Name)
+								path := staticSyncer.d.MakeStaticPathRelative(ev.Name)
 								livereload.RefreshPath(path)
 							}
 
@@ -1044,7 +953,7 @@
 						}
 
 						if p != nil {
-							livereload.NavigateToPath(p.RelPermalink())
+							livereload.NavigateToPathForPort(p.RelPermalink(), p.Site.ServerPort())
 						} else {
 							livereload.ForceRefresh()
 						}
@@ -1058,14 +967,8 @@
 		}
 	}()
 
-	if port > 0 {
-		if !c.Cfg.GetBool("disableLiveReload") {
-			livereload.Initialize()
-			http.HandleFunc("/livereload.js", livereload.ServeJS)
-			http.HandleFunc("/livereload", livereload.Handler)
-		}
-
-		go c.serve(port)
+	if serve {
+		go c.serve()
 	}
 
 	wg.Wait()
@@ -1082,10 +985,6 @@
 	}
 
 	return name
-}
-
-func (c *commandeer) isStatic(path string) bool {
-	return strings.HasPrefix(path, c.PathSpec().GetStaticDirPath()) || (len(c.PathSpec().GetThemesDirPath()) > 0 && strings.HasPrefix(path, c.PathSpec().GetThemesDirPath()))
 }
 
 // isThemeVsHugoVersionMismatch returns whether the current Hugo version is
--- a/commands/server.go
+++ b/commands/server.go
@@ -25,6 +25,8 @@
 	"strings"
 	"time"
 
+	"github.com/gohugoio/hugo/livereload"
+
 	"github.com/gohugoio/hugo/config"
 
 	"github.com/gohugoio/hugo/helpers"
@@ -189,7 +191,7 @@
 		if err != nil {
 			return err
 		}
-		c.Cfg.Set("baseURL", baseURL)
+		c.Set("baseURL", baseURL)
 	}
 
 	if err := memStats(); err != nil {
@@ -218,16 +220,22 @@
 
 	// Watch runs its own server as part of the routine
 	if serverWatch {
-		watchDirs := c.getDirList()
+
+		watchDirs, err := c.getDirList()
+		if err != nil {
+			return err
+		}
+
 		baseWatchDir := c.Cfg.GetString("workingDir")
+		relWatchDirs := make([]string, len(watchDirs))
 		for i, dir := range watchDirs {
-			watchDirs[i], _ = helpers.GetRelativePath(dir, baseWatchDir)
+			relWatchDirs[i], _ = helpers.GetRelativePath(dir, baseWatchDir)
 		}
 
-		rootWatchDirs := strings.Join(helpers.UniqueStrings(helpers.ExtractRootPaths(watchDirs)), ",")
+		rootWatchDirs := strings.Join(helpers.UniqueStrings(helpers.ExtractRootPaths(relWatchDirs)), ",")
 
 		jww.FEEDBACK.Printf("Watching for changes in %s%s{%s}\n", baseWatchDir, helpers.FilePathSeparator, rootWatchDirs)
-		err := c.newWatcher(serverPort)
+		err = c.newWatcher(true, watchDirs...)
 
 		if err != nil {
 			return err
@@ -238,7 +246,7 @@
 }
 
 type fileServer struct {
-	basePort int
+	ports    []int
 	baseURLs []string
 	roots    []string
 	c        *commandeer
@@ -247,7 +255,7 @@
 func (f *fileServer) createEndpoint(i int) (*http.ServeMux, string, error) {
 	baseURL := f.baseURLs[i]
 	root := f.roots[i]
-	port := f.basePort + i
+	port := f.ports[i]
 
 	publishDir := f.c.Cfg.GetString("publishDir")
 
@@ -257,11 +265,12 @@
 
 	absPublishDir := f.c.PathSpec().AbsPathify(publishDir)
 
-	// TODO(bep) multihost unify feedback
-	if renderToDisk {
-		jww.FEEDBACK.Println("Serving pages from " + absPublishDir)
-	} else {
-		jww.FEEDBACK.Println("Serving pages from memory")
+	if i == 0 {
+		if renderToDisk {
+			jww.FEEDBACK.Println("Serving pages from " + absPublishDir)
+		} else {
+			jww.FEEDBACK.Println("Serving pages from memory")
+		}
 	}
 
 	httpFs := afero.NewHttpFs(f.c.Fs.Destination)
@@ -270,7 +279,7 @@
 	doLiveReload := !buildWatch && !f.c.Cfg.GetBool("disableLiveReload")
 	fastRenderMode := doLiveReload && !f.c.Cfg.GetBool("disableFastRender")
 
-	if fastRenderMode {
+	if i == 0 && fastRenderMode {
 		jww.FEEDBACK.Println("Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender")
 	}
 
@@ -311,27 +320,14 @@
 	return mu, endpoint, nil
 }
 
-func (c *commandeer) roots() []string {
-	var roots []string
-	languages := c.languages()
-	isMultiHost := languages.IsMultihost()
-	if !isMultiHost {
-		return roots
-	}
+func (c *commandeer) serve() {
 
-	for _, l := range languages {
-		roots = append(roots, l.Lang)
-	}
-	return roots
-}
-
-func (c *commandeer) serve(port int) {
-	// TODO(bep) multihost
 	isMultiHost := Hugo.IsMultihost()
 
 	var (
 		baseURLs []string
 		roots    []string
+		ports    []int
 	)
 
 	if isMultiHost {
@@ -338,22 +334,36 @@
 		for _, s := range Hugo.Sites {
 			baseURLs = append(baseURLs, s.BaseURL.String())
 			roots = append(roots, s.Language.Lang)
+			ports = append(ports, s.Info.ServerPort())
 		}
 	} else {
-		baseURLs = []string{Hugo.Sites[0].BaseURL.String()}
+		s := Hugo.Sites[0]
+		baseURLs = []string{s.BaseURL.String()}
 		roots = []string{""}
+		ports = append(ports, s.Info.ServerPort())
 	}
 
 	srv := &fileServer{
-		basePort: port,
+		ports:    ports,
 		baseURLs: baseURLs,
 		roots:    roots,
 		c:        c,
 	}
 
+	doLiveReload := !c.Cfg.GetBool("disableLiveReload")
+
+	if doLiveReload {
+		livereload.Initialize()
+	}
+
 	for i, _ := range baseURLs {
 		mu, endpoint, err := srv.createEndpoint(i)
 
+		if doLiveReload {
+			mu.HandleFunc("/livereload.js", livereload.ServeJS)
+			mu.HandleFunc("/livereload", livereload.Handler)
+		}
+		jww.FEEDBACK.Printf("Web Server is available at %s (bind address %s)\n", endpoint, serverInterface)
 		go func() {
 			err = http.ListenAndServe(endpoint, mu)
 			if err != nil {
@@ -363,7 +373,6 @@
 		}()
 	}
 
-	// TODO(bep) multihost		jww.FEEDBACK.Printf("Web Server is available at %s (bind address %s)\n", u.String(), serverInterface)
 	jww.FEEDBACK.Println("Press Ctrl+C to stop")
 }
 
--- /dev/null
+++ b/commands/static_syncer.go
@@ -1,0 +1,135 @@
+// Copyright 2017 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package commands
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/fsnotify/fsnotify"
+	"github.com/gohugoio/hugo/helpers"
+	src "github.com/gohugoio/hugo/source"
+	"github.com/spf13/fsync"
+)
+
+type staticSyncer struct {
+	c *commandeer
+	d *src.Dirs
+}
+
+func newStaticSyncer(c *commandeer) (*staticSyncer, error) {
+	dirs, err := src.NewDirs(c.Fs, c.Cfg, c.DepsCfg.Logger)
+	if err != nil {
+		return nil, err
+	}
+
+	return &staticSyncer{c: c, d: dirs}, nil
+}
+
+func (s *staticSyncer) isStatic(path string) bool {
+	return s.d.IsStatic(path)
+}
+
+func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error {
+	c := s.c
+
+	syncFn := func(dirs *src.Dirs, publishDir string) error {
+		staticSourceFs, err := dirs.CreateStaticFs()
+		if err != nil {
+			return err
+		}
+
+		if staticSourceFs == nil {
+			c.Logger.WARN.Println("No static directories found to sync")
+			return nil
+		}
+
+		syncer := fsync.NewSyncer()
+		syncer.NoTimes = c.Cfg.GetBool("noTimes")
+		syncer.NoChmod = c.Cfg.GetBool("noChmod")
+		syncer.SrcFs = staticSourceFs
+		syncer.DestFs = c.Fs.Destination
+
+		// prevent spamming the log on changes
+		logger := helpers.NewDistinctFeedbackLogger()
+
+		for _, ev := range staticEvents {
+			// Due to our approach of layering both directories and the content's rendered output
+			// into one we can't accurately remove a file not in one of the source directories.
+			// If a file is in the local static dir and also in the theme static dir and we remove
+			// it from one of those locations we expect it to still exist in the destination
+			//
+			// If Hugo generates a file (from the content dir) over a static file
+			// the content generated file should take precedence.
+			//
+			// Because we are now watching and handling individual events it is possible that a static
+			// event that occupies the same path as a content generated file will take precedence
+			// until a regeneration of the content takes places.
+			//
+			// Hugo assumes that these cases are very rare and will permit this bad behavior
+			// The alternative is to track every single file and which pipeline rendered it
+			// and then to handle conflict resolution on every event.
+
+			fromPath := ev.Name
+
+			// If we are here we already know the event took place in a static dir
+			relPath := dirs.MakeStaticPathRelative(fromPath)
+			if relPath == "" {
+				// Not member of this virtual host.
+				continue
+			}
+
+			// Remove || rename is harder and will require an assumption.
+			// Hugo takes the following approach:
+			// If the static file exists in any of the static source directories after this event
+			// Hugo will re-sync it.
+			// If it does not exist in all of the static directories Hugo will remove it.
+			//
+			// This assumes that Hugo has not generated content on top of a static file and then removed
+			// the source of that static file. In this case Hugo will incorrectly remove that file
+			// from the published directory.
+			if ev.Op&fsnotify.Rename == fsnotify.Rename || ev.Op&fsnotify.Remove == fsnotify.Remove {
+				if _, err := staticSourceFs.Stat(relPath); os.IsNotExist(err) {
+					// If file doesn't exist in any static dir, remove it
+					toRemove := filepath.Join(publishDir, relPath)
+
+					logger.Println("File no longer exists in static dir, removing", toRemove)
+					_ = c.Fs.Destination.RemoveAll(toRemove)
+				} else if err == nil {
+					// If file still exists, sync it
+					logger.Println("Syncing", relPath, "to", publishDir)
+
+					if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil {
+						c.Logger.ERROR.Println(err)
+					}
+				} else {
+					c.Logger.ERROR.Println(err)
+				}
+
+				continue
+			}
+
+			// For all other event operations Hugo will sync static.
+			logger.Println("Syncing", relPath, "to", publishDir)
+			if err := syncer.Sync(filepath.Join(publishDir, relPath), relPath); err != nil {
+				c.Logger.ERROR.Println(err)
+			}
+		}
+
+		return nil
+	}
+
+	return c.doWithPublishDirs(syncFn)
+
+}
--- a/helpers/path.go
+++ b/helpers/path.go
@@ -170,7 +170,7 @@
 // GetStaticDirPath returns the absolute path to the static file dir
 // for the current Hugo project.
 func (p *PathSpec) GetStaticDirPath() string {
-	return p.AbsPathify(p.staticDir)
+	return p.AbsPathify(p.StaticDir())
 }
 
 // GetThemeDir gets the root directory of the current theme, if there is one.
--- a/helpers/path_test.go
+++ b/helpers/path_test.go
@@ -59,7 +59,8 @@
 		v := viper.New()
 		l := NewDefaultLanguage(v)
 		v.Set("removePathAccents", test.removeAccents)
-		p, _ := NewPathSpec(hugofs.NewMem(v), l)
+		p, err := NewPathSpec(hugofs.NewMem(v), l)
+		require.NoError(t, err)
 
 		output := p.MakePath(test.input)
 		if output != test.expected {
--- a/helpers/pathspec.go
+++ b/helpers/pathspec.go
@@ -40,7 +40,7 @@
 	themesDir  string
 	layoutDir  string
 	workingDir string
-	staticDir  string
+	staticDirs []string
 
 	// The PathSpec looks up its config settings in both the current language
 	// and then in the global Viper config.
@@ -72,6 +72,12 @@
 		return nil, fmt.Errorf("Failed to create baseURL from %q: %s", baseURLstr, err)
 	}
 
+	var staticDirs []string
+
+	for i := -1; i <= 10; i++ {
+		staticDirs = append(staticDirs, getStringOrStringSlice(cfg, "staticDir", i)...)
+	}
+
 	ps := &PathSpec{
 		Fs:                             fs,
 		Cfg:                            cfg,
@@ -87,7 +93,7 @@
 		themesDir:                      cfg.GetString("themesDir"),
 		layoutDir:                      cfg.GetString("layoutDir"),
 		workingDir:                     cfg.GetString("workingDir"),
-		staticDir:                      cfg.GetString("staticDir"),
+		staticDirs:                     staticDirs,
 		theme:                          cfg.GetString("theme"),
 	}
 
@@ -98,6 +104,25 @@
 	return ps, nil
 }
 
+func getStringOrStringSlice(cfg config.Provider, key string, id int) []string {
+
+	if id > 0 {
+		key = fmt.Sprintf("%s%d", key, id)
+	}
+
+	var out []string
+
+	sd := cfg.Get(key)
+
+	if sds, ok := sd.(string); ok {
+		out = []string{sds}
+	} else if sdsl, ok := sd.([]string); ok {
+		out = sdsl
+	}
+
+	return out
+}
+
 // PaginatePath returns the configured root path used for paginator pages.
 func (p *PathSpec) PaginatePath() string {
 	return p.paginatePath
@@ -108,7 +133,17 @@
 	return p.workingDir
 }
 
-// LayoutDir returns the relative layout dir in the currenct Hugo project.
+// StaticDir returns the relative static dir in the current configuration.
+func (p *PathSpec) StaticDir() string {
+	return p.staticDirs[len(p.staticDirs)-1]
+}
+
+// StaticDirs returns the relative static dirs for the current configuration.
+func (p *PathSpec) StaticDirs() []string {
+	return p.staticDirs
+}
+
+// LayoutDir returns the relative layout dir in the current configuration.
 func (p *PathSpec) LayoutDir() string {
 	return p.layoutDir
 }
@@ -116,4 +151,9 @@
 // Theme returns the theme name if set.
 func (p *PathSpec) Theme() string {
 	return p.theme
+}
+
+// Theme returns the theme relative theme dir.
+func (p *PathSpec) ThemesDir() string {
+	return p.themesDir
 }
--- a/helpers/pathspec_test.go
+++ b/helpers/pathspec_test.go
@@ -57,6 +57,6 @@
 	require.Equal(t, "thethemes", p.themesDir)
 	require.Equal(t, "thelayouts", p.layoutDir)
 	require.Equal(t, "thework", p.workingDir)
-	require.Equal(t, "thestatic", p.staticDir)
+	require.Equal(t, "thestatic", p.StaticDir())
 	require.Equal(t, "thetheme", p.theme)
 }
--- a/hugolib/config.go
+++ b/hugolib/config.go
@@ -14,6 +14,7 @@
 package hugolib
 
 import (
+	"errors"
 	"fmt"
 
 	"io"
@@ -88,7 +89,7 @@
 	return v, nil
 }
 
-func loadLanguageSettings(cfg config.Provider) error {
+func loadLanguageSettings(cfg config.Provider, oldLangs helpers.Languages) error {
 	multilingual := cfg.GetStringMap("languages")
 	var (
 		langs helpers.Languages
@@ -104,8 +105,57 @@
 		}
 	}
 
+	if oldLangs != nil {
+		// When in multihost mode, the languages are mapped to a server, so
+		// some structural language changes will need a restart of the dev server.
+		// The validation below isn't complete, but should cover the most
+		// important cases.
+		var invalid bool
+		if langs.IsMultihost() != oldLangs.IsMultihost() {
+			invalid = true
+		} else {
+			if langs.IsMultihost() && len(langs) != len(oldLangs) {
+				invalid = true
+			}
+		}
+
+		if invalid {
+			return errors.New("language change needing a server restart detected")
+		}
+
+		if langs.IsMultihost() {
+			// We need to transfer any server baseURL to the new language
+			for i, ol := range oldLangs {
+				nl := langs[i]
+				nl.Set("baseURL", ol.GetString("baseURL"))
+			}
+		}
+	}
+
 	cfg.Set("languagesSorted", langs)
+	cfg.Set("multilingual", len(langs) > 1)
 
+	// The baseURL may be provided at the language level. If that is true,
+	// then every language must have a baseURL. In this case we always render
+	// to a language sub folder, which is then stripped from all the Permalink URLs etc.
+	var baseURLFromLang bool
+
+	for _, l := range langs {
+		burl := l.GetLocal("baseURL")
+		if baseURLFromLang && burl == nil {
+			return errors.New("baseURL must be set on all or none of the languages")
+		}
+
+		if burl != nil {
+			baseURLFromLang = true
+		}
+	}
+
+	if baseURLFromLang {
+		cfg.Set("defaultContentLanguageInSubdir", true)
+		cfg.Set("multihost", true)
+	}
+
 	return nil
 }
 
@@ -178,5 +228,5 @@
 	v.SetDefault("debug", false)
 	v.SetDefault("disableFastRender", false)
 
-	return loadLanguageSettings(v)
+	return loadLanguageSettings(v, nil)
 }
--- a/hugolib/hugo_sites.go
+++ b/hugolib/hugo_sites.go
@@ -83,6 +83,7 @@
 
 	h := &HugoSites{
 		multilingual: langConfig,
+		multihost:    cfg.Cfg.GetBool("multihost"),
 		Sites:        sites}
 
 	for _, s := range sites {
@@ -89,9 +90,6 @@
 		s.owner = h
 	}
 
-	// TODO(bep)
-	cfg.Cfg.Set("multilingual", sites[0].multilingualEnabled())
-
 	if err := applyDepsIfNeeded(cfg, sites...); err != nil {
 		return nil, err
 	}
@@ -98,31 +96,6 @@
 
 	h.Deps = sites[0].Deps
 
-	// The baseURL may be provided at the language level. If that is true,
-	// then every language must have a baseURL. In this case we always render
-	// to a language sub folder, which is then stripped from all the Permalink URLs etc.
-	var baseURLFromLang bool
-
-	for _, s := range sites {
-		burl := s.Language.GetLocal("baseURL")
-		if baseURLFromLang && burl == nil {
-			return h, errors.New("baseURL must be set on all or none of the languages")
-		}
-
-		if burl != nil {
-			baseURLFromLang = true
-		}
-	}
-
-	if baseURLFromLang {
-		for _, s := range sites {
-			// TODO(bep) multihost check
-			s.Info.defaultContentLanguageInSubdir = true
-			s.Cfg.Set("defaultContentLanguageInSubdir", true)
-		}
-		h.multihost = true
-	}
-
 	return h, nil
 }
 
@@ -237,8 +210,9 @@
 }
 
 func (h *HugoSites) createSitesFromConfig() error {
+	oldLangs, _ := h.Cfg.Get("languagesSorted").(helpers.Languages)
 
-	if err := loadLanguageSettings(h.Cfg); err != nil {
+	if err := loadLanguageSettings(h.Cfg, oldLangs); err != nil {
 		return err
 	}
 
@@ -269,6 +243,7 @@
 	h.Deps = sites[0].Deps
 
 	h.multilingual = langConfig
+	h.multihost = h.Deps.Cfg.GetBool("multihost")
 
 	return nil
 }
--- a/hugolib/hugo_sites_build_test.go
+++ b/hugolib/hugo_sites_build_test.go
@@ -1035,7 +1035,7 @@
 
 	if err := afero.WriteFile(mf,
 		filepath.Join("layouts", "_default/list.html"),
-		[]byte("{{ $p := .Paginator }}List Page {{ $p.PageNumber }}: {{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}"),
+		[]byte("{{ $p := .Paginator }}List Page {{ $p.PageNumber }}: {{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}|Pager: {{ template \"_internal/pagination.html\" . }}"),
 		0755); err != nil {
 		t.Fatalf("Failed to write layout file: %s", err)
 	}
--- a/hugolib/hugo_sites_multihost_test.go
+++ b/hugolib/hugo_sites_multihost_test.go
@@ -69,4 +69,10 @@
 	th.assertFileContentStraight("public/fr/index.html", "French Home Page")
 	th.assertFileContentStraight("public/en/index.html", "Default Home Page")
 
+	// Check paginators
+	th.assertFileContent("public/en/page/1/index.html", `refresh" content="0; url=https://example.com/"`)
+	th.assertFileContent("public/nn/page/1/index.html", `refresh" content="0; url=https://example.no/"`)
+	th.assertFileContent("public/en/sect/page/2/index.html", "List Page 2", "Hello", "https://example.com/sect/", "\"/sect/page/3/")
+	th.assertFileContent("public/fr/sect/page/2/index.html", "List Page 2", "Bonjour", "https://example.fr/sect/")
+
 }
--- a/hugolib/page.go
+++ b/hugolib/page.go
@@ -1755,7 +1755,6 @@
 	}
 
 	if p.s.owner.IsMultihost() {
-		// TODO(bep) multihost check vs lang below
 		return true
 	}
 
--- a/hugolib/page_output.go
+++ b/hugolib/page_output.go
@@ -41,7 +41,7 @@
 }
 
 func (p *PageOutput) targetPath(addends ...string) (string, error) {
-	tp, err := p.createTargetPath(p.outputFormat, addends...)
+	tp, err := p.createTargetPath(p.outputFormat, false, addends...)
 	if err != nil {
 		return "", err
 	}
--- a/hugolib/page_paths.go
+++ b/hugolib/page_paths.go
@@ -125,12 +125,16 @@
 // createTargetPath creates the target filename for this Page for the given
 // output.Format. Some additional URL parts can also be provided, the typical
 // use case being pagination.
-func (p *Page) createTargetPath(t output.Format, addends ...string) (string, error) {
+func (p *Page) createTargetPath(t output.Format, noLangPrefix bool, addends ...string) (string, error) {
 	d, err := p.createTargetPathDescriptor(t)
 	if err != nil {
 		return "", nil
 	}
 
+	if noLangPrefix {
+		d.LangPrefix = ""
+	}
+
 	if len(addends) > 0 {
 		d.Addends = filepath.Join(addends...)
 	}
@@ -246,7 +250,7 @@
 }
 
 func (p *Page) createRelativePermalinkForOutputFormat(f output.Format) string {
-	tp, err := p.createTargetPath(f)
+	tp, err := p.createTargetPath(f, p.s.owner.IsMultihost())
 
 	if err != nil {
 		p.s.Log.ERROR.Printf("Failed to create permalink for page %q: %s", p.FullFilePath(), err)
@@ -255,10 +259,6 @@
 	// For /index.json etc. we must  use the full path.
 	if strings.HasSuffix(f.BaseFilename(), "html") {
 		tp = strings.TrimSuffix(tp, f.BaseFilename())
-	}
-
-	if p.s.owner.IsMultihost() {
-		tp = strings.TrimPrefix(tp, helpers.FilePathSeparator+p.s.Info.Language.Lang)
 	}
 
 	return p.s.PathSpec.URLizeFilename(tp)
--- a/hugolib/pagination.go
+++ b/hugolib/pagination.go
@@ -285,7 +285,11 @@
 			return
 		}
 
-		pagers, err := paginatePages(p.targetPathDescriptor, p.Data["Pages"], pagerSize)
+		pathDescriptor := p.targetPathDescriptor
+		if p.s.owner.IsMultihost() {
+			pathDescriptor.LangPrefix = ""
+		}
+		pagers, err := paginatePages(pathDescriptor, p.Data["Pages"], pagerSize)
 
 		if err != nil {
 			initError = err
@@ -333,8 +337,13 @@
 		if p.paginator != nil {
 			return
 		}
-		pagers, err := paginatePages(p.targetPathDescriptor, seq, pagerSize)
 
+		pathDescriptor := p.targetPathDescriptor
+		if p.s.owner.IsMultihost() {
+			pathDescriptor.LangPrefix = ""
+		}
+		pagers, err := paginatePages(pathDescriptor, seq, pagerSize)
+
 		if err != nil {
 			initError = err
 		}
@@ -528,7 +537,6 @@
 		targetPath := createTargetPath(pathDescriptor)
 		targetPath = strings.TrimSuffix(targetPath, d.Type.BaseFilename())
 		link := d.PathSpec.PrependBasePath(targetPath)
-
 		// Note: The targetPath is massaged with MakePathSanitized
 		return d.PathSpec.URLizeFilename(link)
 	}
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -393,6 +393,19 @@
 	return template.URL(s.s.PathSpec.BaseURL.String())
 }
 
+// ServerPort returns the port part of the BaseURL, 0 if none found.
+func (s *SiteInfo) ServerPort() int {
+	ps := s.s.PathSpec.BaseURL.URL().Port()
+	if ps == "" {
+		return 0
+	}
+	p, err := strconv.Atoi(ps)
+	if err != nil {
+		return 0
+	}
+	return p
+}
+
 // Used in tests.
 
 type siteBuilderCfg struct {
@@ -1806,7 +1819,7 @@
 	if s.Info.relativeURLs {
 		path = []byte(helpers.GetDottedRelativePath(dest))
 	} else {
-		s := s.Cfg.GetString("baseURL")
+		s := s.PathSpec.BaseURL.String()
 		if !strings.HasSuffix(s, "/") {
 			s += "/"
 		}
@@ -1864,7 +1877,7 @@
 	if s.Info.relativeURLs {
 		path = []byte(helpers.GetDottedRelativePath(dest))
 	} else if s.Info.canonifyURLs {
-		url := s.Cfg.GetString("baseURL")
+		url := s.PathSpec.BaseURL.String()
 		if !strings.HasSuffix(url, "/") {
 			url += "/"
 		}
--- a/hugolib/site_render.go
+++ b/hugolib/site_render.go
@@ -147,7 +147,7 @@
 
 		// write alias for page 1
 		addend := fmt.Sprintf("/%s/%d", paginatePath, 1)
-		target, err := p.createTargetPath(p.outputFormat, addend)
+		target, err := p.createTargetPath(p.outputFormat, false, addend)
 		if err != nil {
 			return err
 		}
--- a/livereload/livereload.go
+++ b/livereload/livereload.go
@@ -38,7 +38,9 @@
 
 import (
 	"fmt"
+	"net"
 	"net/http"
+	"net/url"
 	"path/filepath"
 
 	"github.com/gorilla/websocket"
@@ -47,8 +49,32 @@
 // Prefix to signal to LiveReload that we need to navigate to another path.
 const hugoNavigatePrefix = "__hugo_navigate"
 
-var upgrader = &websocket.Upgrader{ReadBufferSize: 1024, WriteBufferSize: 1024}
+var upgrader = &websocket.Upgrader{
+	// Hugo may potentially spin up multiple HTTP servers, so we need to exclude the
+	// port when checking the origin.
+	CheckOrigin: func(r *http.Request) bool {
+		origin := r.Header["Origin"]
+		if len(origin) == 0 {
+			return true
+		}
+		u, err := url.Parse(origin[0])
+		if err != nil {
+			return false
+		}
 
+		h1, _, err := net.SplitHostPort(u.Host)
+		if err != nil {
+			return false
+		}
+		h2, _, err := net.SplitHostPort(r.Host)
+		if err != nil {
+			return false
+		}
+
+		return h1 == h2
+	},
+	ReadBufferSize: 1024, WriteBufferSize: 1024}
+
 // Handler is a HandlerFunc handling the livereload
 // Websocket interaction.
 func Handler(w http.ResponseWriter, r *http.Request) {
@@ -79,13 +105,28 @@
 	RefreshPath(hugoNavigatePrefix + path)
 }
 
+// NavigateToPathForPort is similar to NavigateToPath but will also
+// set window.location.port to the given port value.
+func NavigateToPathForPort(path string, port int) {
+	refreshPathForPort(hugoNavigatePrefix+path, port)
+}
+
 // RefreshPath tells livereload to refresh only the given path.
 // If that path points to a CSS stylesheet or an image, only the changes
 // will be updated in the browser, not the entire page.
 func RefreshPath(s string) {
+	refreshPathForPort(s, -1)
+}
+
+func refreshPathForPort(s string, port int) {
 	// Tell livereload a file has changed - will force a hard refresh if not CSS or an image
 	urlPath := filepath.ToSlash(s)
-	wsHub.broadcast <- []byte(`{"command":"reload","path":"` + urlPath + `","originalPath":"","liveCSS":true,"liveImg":true}`)
+	portStr := ""
+	if port > 0 {
+		portStr = fmt.Sprintf(`, "overrideURL": %d`, port)
+	}
+	msg := fmt.Sprintf(`{"command":"reload","path":%q,"originalPath":"","liveCSS":true,"liveImg":true%s}`, urlPath, portStr)
+	wsHub.broadcast <- []byte(msg)
 }
 
 // ServeJS serves the liverreload.js who's reference is injected into the page.
@@ -120,13 +161,17 @@
 function(){e.identifier="less";e.version="1.0";function e(e,t){this.window=e;this.host=t}e.prototype.reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,orig
\ No newline at end of file
 	hugoLiveReloadPlugin = fmt.Sprintf(`
 /*
-t){this.window=e;this.host=t}e.prototype.reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",se
\ No newline at end of file
+t){this.window=e;this.host=t}e.prototype.reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",se
\ No newline at end of file
 ){this.window=e;this.host=t}e.prototype.reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",ser
\ No newline at end of file
-.reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+
\ No newline at end of file
-	hugoLiveReloadPlugin = fmt.Sprintf(`
+reload=function(e,t){if(this.window.less&&this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+t
\ No newline at end of file
+	hugoLiveReloadPlugin = fmt.Sprintf(`
 this.window.less.refresh){if(e.match(/\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+this.options.port})};e.prototype.performAle
\ No newline at end of file
 	hugoLiveReloadPlugin = fmt.Sprintf(`
-\.less$/i)){return this.reloadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+this.options.port})};e.prototype.performAlert=function(e){return alert(e.message)
\ No newline at end of file
+loadLess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+this.options.port})};e.prototype.performAlert=function(e){return alert(e.message)};e.prototype.shutDown=fun
\ No newline at end of file
+	hugoLiveReloadPlugin = fmt.Sprintf(`
+/*
+Hugo adds a specific prefix, "__hugo_navigate", to the path in certain situations to signal
+navigation to another content page.
 ess(e)}if(t.originalPath.match(/\.less$/i)){return this.reloadLess(t.originalPath)}}return false};e.prototype.reloadLess=function(e){var t,n,o,i;n=function(){var e,n,o,i;o=document.getElementsByTagName("link");i=[];for(e=0,n=o.length;e<n;e++){t=o[e];if(t.href&&t.rel.match(/^stylesheet\/less$/i)||t.rel.match(/stylesheet/i)&&t.type.match(/^text\/(x-)?less$/i)){i.push(t)}}return i}();if(n.length===0){return false}for(o=0,i=n.length;o<i;o++){t=n[o];t.href=this.host.generateCacheBustUrl(t.href)}this.host.console.log("LiveReload is asking LESS to recompile all stylesheets");this.window.less.refresh(true);return true};e.prototype.analyze=function(){return{disable:!!(this.window.less&&this.window.less.refresh)}};return e}()}).call(this)},{}],4:[function(e,t,n){(function(){var t,o,i,r,s,l,c={}.hasOwnProperty;t=e("./connector").Connector;l=e("./timer").Timer;i=e("./options").Options;s=e("./reloader").Reloader;r=e("./protocol").ProtocolError;n.LiveReload=o=function(){function e(e){var n,o,a;this.window=e;this.listeners={};this.plugins=[];this.pluginIdentifiers={};this.console=this.window.console&&this.window.console.log&&this.window.console.error?this.window.location.href.match(/LR-verbose/)?this.window.console:{log:function(){},error:this.window.console.error.bind(this.window.console)}:{log:function(){},error:function(){}};if(!(this.WebSocket=this.window.WebSocket||this.window.MozWebSocket)){this.console.error("LiveReload disabled because the browser does not seem to support web sockets");return}if("LiveReloadOptions"in e){this.options=new i;a=e["LiveReloadOptions"];for(n in a){if(!c.call(a,n))continue;o=a[n];this.options.set(n,o)}}else{this.options=i.extract(this.window.document);if(!this.options){this.console.error("LiveReload disabled because it could not find its own <SCRIPT> tag");return}}this.reloader=new s(this.window,this.console,l);this.connector=new t(this.options,this.WebSocket,l,{connecting:function(e){return function(){}}(this),socketConnected:function(e){return function(){}}(this),connected:function(e){return function(t){var n;if(typeof(n=e.listeners).connect==="function"){n.connect()}e.log("LiveReload is connected to "+e.options.host+":"+e.options.port+" (protocol v"+t+").");return e.analyze()}}(this),error:function(e){return function(e){if(e instanceof r){if(typeof console!=="undefined"&&console!==null){return console.log(""+e.message+".")}}else{if(typeof console!=="undefined"&&console!==null){return console.log("LiveReload internal error: "+e.message)}}}}(this),disconnected:function(e){return function(t,n){var o;if(typeof(o=e.listeners).disconnect==="function"){o.disconnect()}switch(t){case"cannot-connect":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+", will retry in "+n+" sec.");case"broken":return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+", reconnecting in "+n+" sec.");case"handshake-timeout":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake timeout), will retry in "+n+" sec.");case"handshake-failed":return e.log("LiveReload cannot connect to "+e.options.host+":"+e.options.port+" (handshake failed), will retry in "+n+" sec.");case"manual":break;case"error":break;default:return e.log("LiveReload disconnected from "+e.options.host+":"+e.options.port+" ("+t+"), reconnecting in "+n+" sec.")}}}(this),message:function(e){return function(t){switch(t.command){case"reload":return e.performReload(t);case"alert":return e.performAlert(t)}}}(this)});this.initialized=true}e.prototype.on=function(e,t){return this.listeners[e]=t};e.prototype.log=function(e){return this.console.log(""+e)};e.prototype.performReload=function(e){var t,n;this.log("LiveReload received reload request: "+JSON.stringify(e,null,2));return this.reloader.reload(e.path,{liveCSS:(t=e.liveCSS)!=null?t:true,liveImg:(n=e.liveImg)!=null?n:true,originalPath:e.originalPath||"",overrideURL:e.overrideURL||"",serverURL:"http://"+this.options.host+":"+this.options.port})};e.prototype.performAlert=function(e){return alert(e.message)};e.prototype.shutDown=function
\ No newline at end of file
 	hugoLiveReloadPlugin = fmt.Sprintf(`
 /*
--- /dev/null
+++ b/source/dirs.go
@@ -1,0 +1,191 @@
+// Copyright 2017 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package source
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/spf13/afero"
+
+	"github.com/gohugoio/hugo/config"
+	"github.com/gohugoio/hugo/helpers"
+	"github.com/gohugoio/hugo/hugofs"
+	jww "github.com/spf13/jwalterweatherman"
+)
+
+// Dirs holds the source directories for a given build.
+// In case where there are more than one of a kind, the order matters:
+// It will be used to construct a union filesystem, so the right-most directory
+// will "win" on duplicates. Typically, the theme version will be the first.
+type Dirs struct {
+	logger   *jww.Notepad
+	pathSpec *helpers.PathSpec
+
+	staticDirs    []string
+	AbsStaticDirs []string
+
+	publishDir string
+}
+
+// NewDirs creates a new dirs with the given configuration and filesystem.
+func NewDirs(fs *hugofs.Fs, cfg config.Provider, logger *jww.Notepad) (*Dirs, error) {
+	ps, err := helpers.NewPathSpec(fs, cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	d := &Dirs{pathSpec: ps, logger: logger}
+
+	return d, d.init(cfg)
+
+}
+
+func (d *Dirs) init(cfg config.Provider) error {
+
+	var (
+		statics []string
+	)
+
+	if d.pathSpec.Theme() != "" {
+		statics = append(statics, filepath.Join(d.pathSpec.ThemesDir(), d.pathSpec.Theme(), "static"))
+	}
+
+	_, isLanguage := cfg.(*helpers.Language)
+	languages, hasLanguages := cfg.Get("languagesSorted").(helpers.Languages)
+
+	if !isLanguage && !hasLanguages {
+		return errors.New("missing languagesSorted in config")
+	}
+
+	if !isLanguage {
+		// Merge all the static dirs.
+		for _, l := range languages {
+			addend, err := d.staticDirsFor(l)
+			if err != nil {
+				return err
+			}
+
+			statics = append(statics, addend...)
+		}
+	} else {
+		addend, err := d.staticDirsFor(cfg)
+		if err != nil {
+			return err
+		}
+
+		statics = append(statics, addend...)
+	}
+
+	d.staticDirs = removeDuplicatesKeepRight(statics)
+	d.AbsStaticDirs = make([]string, len(d.staticDirs))
+	for i, di := range d.staticDirs {
+		d.AbsStaticDirs[i] = d.pathSpec.AbsPathify(di) + helpers.FilePathSeparator
+	}
+
+	d.publishDir = d.pathSpec.AbsPathify(cfg.GetString("publishDir")) + helpers.FilePathSeparator
+
+	return nil
+}
+
+func (d *Dirs) staticDirsFor(cfg config.Provider) ([]string, error) {
+	var statics []string
+	ps, err := helpers.NewPathSpec(d.pathSpec.Fs, cfg)
+	if err != nil {
+		return statics, err
+	}
+
+	statics = append(statics, ps.StaticDirs()...)
+
+	return statics, nil
+}
+
+// CreateStaticFs will create a union filesystem with the static paths configured.
+// Any missing directories will be logged as warnings.
+func (d *Dirs) CreateStaticFs() (afero.Fs, error) {
+	var (
+		source   = d.pathSpec.Fs.Source
+		absPaths []string
+	)
+
+	for _, staticDir := range d.AbsStaticDirs {
+		if _, err := source.Stat(staticDir); os.IsNotExist(err) {
+			d.logger.WARN.Printf("Unable to find Static Directory: %s", staticDir)
+		} else {
+			absPaths = append(absPaths, staticDir)
+		}
+
+	}
+
+	if len(absPaths) == 0 {
+		return nil, nil
+	}
+
+	return d.createOverlayFs(absPaths), nil
+
+}
+
+// IsStatic returns whether the given filename is located in one of the static
+// source dirs.
+func (d *Dirs) IsStatic(filename string) bool {
+	for _, absPath := range d.AbsStaticDirs {
+		if strings.HasPrefix(filename, absPath) {
+			return true
+		}
+	}
+	return false
+}
+
+// MakeStaticPathRelative creates a relative path from the given filename.
+// It will return an empty string if the filename is not a member of dirs.
+func (d *Dirs) MakeStaticPathRelative(filename string) string {
+	for _, currentPath := range d.AbsStaticDirs {
+		if strings.HasPrefix(filename, currentPath) {
+			return strings.TrimPrefix(filename, currentPath)
+		}
+	}
+
+	return ""
+
+}
+
+func (d *Dirs) createOverlayFs(absPaths []string) afero.Fs {
+	source := d.pathSpec.Fs.Source
+
+	if len(absPaths) == 1 {
+		return afero.NewReadOnlyFs(afero.NewBasePathFs(source, absPaths[0]))
+	}
+
+	base := afero.NewReadOnlyFs(afero.NewBasePathFs(source, absPaths[0]))
+	overlay := d.createOverlayFs(absPaths[1:])
+
+	return afero.NewCopyOnWriteFs(base, overlay)
+}
+
+func removeDuplicatesKeepRight(in []string) []string {
+	seen := make(map[string]bool)
+	var out []string
+	for i := len(in) - 1; i >= 0; i-- {
+		v := in[i]
+		if seen[v] {
+			continue
+		}
+		out = append([]string{v}, out...)
+		seen[v] = true
+	}
+
+	return out
+}
--- /dev/null
+++ b/source/dirs_test.go
@@ -1,0 +1,177 @@
+// Copyright 2017 The Hugo Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package source
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/helpers"
+
+	"fmt"
+
+	"io/ioutil"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/gohugoio/hugo/config"
+	"github.com/spf13/afero"
+
+	jww "github.com/spf13/jwalterweatherman"
+
+	"github.com/gohugoio/hugo/hugofs"
+	"github.com/spf13/viper"
+	"github.com/stretchr/testify/require"
+)
+
+var logger = jww.NewNotepad(jww.LevelInfo, jww.LevelError, os.Stdout, ioutil.Discard, "", log.Ldate|log.Ltime)
+
+func TestStaticDirs(t *testing.T) {
+	assert := require.New(t)
+
+	tests := []struct {
+		setup    func(cfg config.Provider, fs *hugofs.Fs) config.Provider
+		expected []string
+	}{
+
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("staticDir", "s1")
+			return cfg
+		}, []string{"s1"}},
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("staticDir", []string{"s2", "s1", "s2"})
+			return cfg
+		}, []string{"s1", "s2"}},
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("theme", "mytheme")
+			cfg.Set("themesDir", "themes")
+			cfg.Set("staticDir", []string{"s1", "s2"})
+			return cfg
+		}, []string{filepath.FromSlash("themes/mytheme/static"), "s1", "s2"}},
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("staticDir", "s1")
+
+			l1 := helpers.NewLanguage("en", cfg)
+			l1.Set("staticDir", []string{"l1s1", "l1s2"})
+			return l1
+
+		}, []string{"l1s1", "l1s2"}},
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("staticDir", "s1")
+
+			l1 := helpers.NewLanguage("en", cfg)
+			l1.Set("staticDir2", []string{"l1s1", "l1s2"})
+			return l1
+
+		}, []string{"s1", "l1s1", "l1s2"}},
+		{func(cfg config.Provider, fs *hugofs.Fs) config.Provider {
+			cfg.Set("staticDir", "s1")
+
+			l1 := helpers.NewLanguage("en", cfg)
+			l1.Set("staticDir2", []string{"l1s1", "l1s2"})
+			l2 := helpers.NewLanguage("nn", cfg)
+			l2.Set("staticDir3", []string{"l2s1", "l2s2"})
+			l2.Set("staticDir", []string{"l2"})
+
+			cfg.Set("languagesSorted", helpers.Languages{l1, l2})
+			return cfg
+
+		}, []string{"s1", "l1s1", "l1s2", "l2", "l2s1", "l2s2"}},
+	}
+
+	for i, test := range tests {
+		if i != 0 {
+			break
+		}
+		msg := fmt.Sprintf("Test %d", i)
+		v := viper.New()
+		fs := hugofs.NewMem(v)
+		cfg := test.setup(v, fs)
+		cfg.Set("workingDir", filepath.FromSlash("/work"))
+		_, isLanguage := cfg.(*helpers.Language)
+		if !isLanguage && !cfg.IsSet("languagesSorted") {
+			cfg.Set("languagesSorted", helpers.Languages{helpers.NewDefaultLanguage(cfg)})
+		}
+		dirs, err := NewDirs(fs, cfg, logger)
+		assert.NoError(err)
+		assert.Equal(test.expected, dirs.staticDirs, msg)
+		assert.Len(dirs.AbsStaticDirs, len(dirs.staticDirs))
+
+		for i, d := range dirs.staticDirs {
+			abs := dirs.AbsStaticDirs[i]
+			assert.Equal(filepath.Join("/work", d)+helpers.FilePathSeparator, abs)
+			assert.True(dirs.IsStatic(filepath.Join(abs, "logo.png")))
+			rel := dirs.MakeStaticPathRelative(filepath.Join(abs, "logo.png"))
+			assert.Equal("logo.png", rel)
+		}
+
+		assert.False(dirs.IsStatic(filepath.FromSlash("/some/other/dir/logo.png")))
+
+	}
+
+}
+
+func TestStaticDirsFs(t *testing.T) {
+	assert := require.New(t)
+	v := viper.New()
+	fs := hugofs.NewMem(v)
+	v.Set("workingDir", filepath.FromSlash("/work"))
+	v.Set("theme", "mytheme")
+	v.Set("themesDir", "themes")
+	v.Set("staticDir", []string{"s1", "s2"})
+	v.Set("languagesSorted", helpers.Languages{helpers.NewDefaultLanguage(v)})
+
+	writeToFs(t, fs.Source, "/work/s1/f1.txt", "s1-f1")
+	writeToFs(t, fs.Source, "/work/s2/f2.txt", "s2-f2")
+	writeToFs(t, fs.Source, "/work/s1/f2.txt", "s1-f2")
+	writeToFs(t, fs.Source, "/work/themes/mytheme/static/f1.txt", "theme-f1")
+	writeToFs(t, fs.Source, "/work/themes/mytheme/static/f3.txt", "theme-f3")
+
+	dirs, err := NewDirs(fs, v, logger)
+	assert.NoError(err)
+
+	sfs, err := dirs.CreateStaticFs()
+	assert.NoError(err)
+
+	assert.Equal("s1-f1", readFileFromFs(t, sfs, "f1.txt"))
+	assert.Equal("s2-f2", readFileFromFs(t, sfs, "f2.txt"))
+	assert.Equal("theme-f3", readFileFromFs(t, sfs, "f3.txt"))
+
+}
+
+func TestRemoveDuplicatesKeepRight(t *testing.T) {
+	in := []string{"a", "b", "c", "a"}
+	out := removeDuplicatesKeepRight(in)
+
+	require.Equal(t, []string{"b", "c", "a"}, out)
+}
+
+func writeToFs(t testing.TB, fs afero.Fs, filename, content string) {
+	if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0755); err != nil {
+		t.Fatalf("Failed to write file: %s", err)
+	}
+}
+
+func readFileFromFs(t testing.TB, fs afero.Fs, filename string) string {
+	filename = filepath.FromSlash(filename)
+	b, err := afero.ReadFile(fs, filename)
+	if err != nil {
+		afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error {
+			fmt.Println("    ", path, " ", info)
+			return nil
+		})
+		t.Fatalf("Failed to read file: %s", err)
+	}
+	return string(b)
+}
--- a/tpl/urls/init_test.go
+++ b/tpl/urls/init_test.go
@@ -18,6 +18,7 @@
 
 	"github.com/gohugoio/hugo/deps"
 	"github.com/gohugoio/hugo/tpl/internal"
+	"github.com/spf13/viper"
 	"github.com/stretchr/testify/require"
 )
 
@@ -26,7 +27,7 @@
 	var ns *internal.TemplateFuncsNamespace
 
 	for _, nsf := range internal.TemplateFuncsNamespaceRegistry {
-		ns = nsf(&deps.Deps{})
+		ns = nsf(&deps.Deps{Cfg: viper.New()})
 		if ns.Name == name {
 			found = true
 			break
--- a/tpl/urls/urls.go
+++ b/tpl/urls/urls.go
@@ -26,13 +26,15 @@
 // New returns a new instance of the urls-namespaced template functions.
 func New(deps *deps.Deps) *Namespace {
 	return &Namespace{
-		deps: deps,
+		deps:      deps,
+		multihost: deps.Cfg.GetBool("multihost"),
 	}
 }
 
 // Namespace provides template functions for the "urls" namespace.
 type Namespace struct {
-	deps *deps.Deps
+	deps      *deps.Deps
+	multihost bool
 }
 
 // AbsURL takes a given string and converts it to an absolute URL.
@@ -109,7 +111,7 @@
 		return "", err
 	}
 
-	return template.HTML(ns.deps.PathSpec.RelURL(s, true)), nil
+	return template.HTML(ns.deps.PathSpec.RelURL(s, !ns.multihost)), nil
 }
 
 // AbsLangURL takes a given string and converts it to an absolute URL according
@@ -121,5 +123,5 @@
 		return "", err
 	}
 
-	return template.HTML(ns.deps.PathSpec.AbsURL(s, true)), nil
+	return template.HTML(ns.deps.PathSpec.AbsURL(s, !ns.multihost)), nil
 }
--