Add a way to merge pages by language
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Thu, 15 Mar 2018 08:37:30 +0000 (09:37 +0100)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Fri, 16 Mar 2018 09:10:01 +0000 (10:10 +0100)
As an example:

```html
{{ $pages := .Site.RegularPages | lang.Merge $frSite.RegularPages | lang.Merge $enSite.RegularPages }}
```

Will "fill in the gaps" in the current site with, from left to right, content from the French site, and lastly the English.

Fixes #4463

13 files changed:
hugolib/hugo_sites_build.go
hugolib/hugo_sites_build_test.go
hugolib/page.go
hugolib/pageCache.go
hugolib/pageCache_test.go
hugolib/pageSort.go
hugolib/pages_language_merge.go [new file with mode: 0644]
hugolib/pages_language_merge_test.go [new file with mode: 0644]
hugolib/shortcode_test.go
hugolib/site.go
hugolib/site_test.go
hugolib/testhelpers_test.go
tpl/lang/lang.go

index c8affe15aa19983a17eb6753cf17420a4f1bd6cd..1c4ee7b632ce2f469a870fae0ca8d07cca8932a2 100644 (file)
@@ -15,9 +15,12 @@ package hugolib
 
 import (
        "bytes"
+       "fmt"
 
        "errors"
 
+       jww "github.com/spf13/jwalterweatherman"
+
        "github.com/fsnotify/fsnotify"
        "github.com/gohugoio/hugo/helpers"
 )
@@ -71,6 +74,11 @@ func (h *HugoSites) Build(config BuildCfg, events ...fsnotify.Event) error {
                h.Log.FEEDBACK.Println()
        }
 
+       errorCount := h.Log.LogCountForLevel(jww.LevelError)
+       if errorCount > 0 {
+               return fmt.Errorf("logged %d error(s)", errorCount)
+       }
+
        return nil
 
 }
index 0cececfd762be3468956887d0e034955e7e3c160..e07c19927f5e2342374551a498bf778a47418ad2 100644 (file)
@@ -1069,6 +1069,7 @@ func createMultiTestSitesForConfig(t *testing.T, siteConfig testSiteConfig, conf
 
        mf := siteConfig.Fs
 
+       // TODO(bep) cleanup/remove duplication, use the new testBuilder in testhelpers_test
        // Add some layouts
        if err := afero.WriteFile(mf,
                filepath.Join("layouts", "_default/single.html"),
@@ -1368,7 +1369,7 @@ func readSource(t *testing.T, fs *hugofs.Fs, filename string) string {
 }
 
 func readFileFromFs(t testing.TB, fs afero.Fs, filename string) string {
-       filename = filepath.FromSlash(filename)
+       filename = filepath.Clean(filename)
        b, err := afero.ReadFile(fs, filename)
        if err != nil {
                // Print some debug info
index f8f8f9958bcccede2409fc1e2cbcd9c3799987bd..e0e002e59c624520e25fc22859b2508557f66016 100644 (file)
@@ -263,6 +263,16 @@ type Page struct {
        targetPathDescriptorPrototype *targetPathDescriptor
 }
 
+// Sites is a convenience method to get all the Hugo sites/languages configured.
+func (p *Page) Sites() SiteInfos {
+       infos := make(SiteInfos, len(p.s.owner.Sites))
+       for i, site := range p.s.owner.Sites {
+               infos[i] = &site.Info
+       }
+
+       return infos
+}
+
 // SearchKeywords implements the related.Document interface needed for fast page searches.
 func (p *Page) SearchKeywords(cfg related.IndexConfig) ([]related.Keyword, error) {
 
index df381c679ffae84fc9ceb835de5b7f8d7a1b8c0e..2ac58492002c376c9d1a3f7c36270b6d1fa97314 100644 (file)
@@ -17,31 +17,59 @@ import (
        "sync"
 )
 
+type pageCacheEntry struct {
+       in  []Pages
+       out Pages
+}
+
+func (entry pageCacheEntry) matches(pageLists []Pages) bool {
+       if len(entry.in) != len(pageLists) {
+               return false
+       }
+       for i, p := range pageLists {
+               if !fastEqualPages(p, entry.in[i]) {
+                       return false
+               }
+       }
+
+       return true
+}
+
 type pageCache struct {
        sync.RWMutex
-       m map[string][][2]Pages
+       m map[string][]pageCacheEntry
 }
 
 func newPageCache() *pageCache {
-       return &pageCache{m: make(map[string][][2]Pages)}
+       return &pageCache{m: make(map[string][]pageCacheEntry)}
 }
 
-// get gets a Pages slice from the cache matching the given key and Pages slice.
-// If none found in cache, a copy of the supplied slice is created.
+// get/getP gets a Pages slice from the cache matching the given key and
+// all the provided Pages slices.
+// If none found in cache, a copy of the first slice is created.
 //
 // If an apply func is provided, that func is applied to the newly created copy.
 //
+// The getP variant' apply func takes a pointer to Pages.
+//
 // The cache and the execution of the apply func is protected by a RWMutex.
-func (c *pageCache) get(key string, p Pages, apply func(p Pages)) (Pages, bool) {
+func (c *pageCache) get(key string, apply func(p Pages), pageLists ...Pages) (Pages, bool) {
+       return c.getP(key, func(p *Pages) {
+               if apply != nil {
+                       apply(*p)
+               }
+       }, pageLists...)
+}
+
+func (c *pageCache) getP(key string, apply func(p *Pages), pageLists ...Pages) (Pages, bool) {
        c.RLock()
        if cached, ok := c.m[key]; ok {
-               for _, ps := range cached {
-                       if fastEqualPages(p, ps[0]) {
+               for _, entry := range cached {
+                       if entry.matches(pageLists) {
                                c.RUnlock()
-                               return ps[1], true
+                               return entry.out, true
                        }
                }
-
        }
        c.RUnlock()
 
@@ -50,23 +78,25 @@ func (c *pageCache) get(key string, p Pages, apply func(p Pages)) (Pages, bool)
 
        // double-check
        if cached, ok := c.m[key]; ok {
-               for _, ps := range cached {
-                       if fastEqualPages(p, ps[0]) {
-                               return ps[1], true
+               for _, entry := range cached {
+                       if entry.matches(pageLists) {
+                               return entry.out, true
                        }
                }
        }
 
+       p := pageLists[0]
        pagesCopy := append(Pages(nil), p...)
 
        if apply != nil {
-               apply(pagesCopy)
+               apply(&pagesCopy)
        }
 
+       entry := pageCacheEntry{in: pageLists, out: pagesCopy}
        if v, ok := c.m[key]; ok {
-               c.m[key] = append(v, [2]Pages{p, pagesCopy})
+               c.m[key] = append(v, entry)
        } else {
-               c.m[key] = [][2]Pages{{p, pagesCopy}}
+               c.m[key] = []pageCacheEntry{entry}
        }
 
        return pagesCopy, false
index aa2adf6e83aab7523eba6af27d30bebae8f6ad23..52a7f449441081b7d65ca2f850476757913ef997 100644 (file)
@@ -14,6 +14,7 @@
 package hugolib
 
 import (
+       "strconv"
        "sync"
        "sync/atomic"
        "testing"
@@ -51,17 +52,17 @@ func TestPageCache(t *testing.T) {
                        defer wg.Done()
                        for k, pages := range testPageSets {
                                l1.Lock()
-                               p, c := c1.get("k1", pages, nil)
+                               p, c := c1.get("k1", nil, pages)
                                assert.Equal(t, !atomic.CompareAndSwapUint64(&o1, uint64(k), uint64(k+1)), c)
                                l1.Unlock()
-                               p2, c2 := c1.get("k1", p, nil)
+                               p2, c2 := c1.get("k1", nil, p)
                                assert.True(t, c2)
                                assert.True(t, fastEqualPages(p, p2))
                                assert.True(t, fastEqualPages(p, pages))
                                assert.NotNil(t, p)
 
                                l2.Lock()
-                               p3, c3 := c1.get("k2", pages, changeFirst)
+                               p3, c3 := c1.get("k2", changeFirst, pages)
                                assert.Equal(t, !atomic.CompareAndSwapUint64(&o2, uint64(k), uint64(k+1)), c3)
                                l2.Unlock()
                                assert.NotNil(t, p3)
@@ -71,3 +72,17 @@ func TestPageCache(t *testing.T) {
        }
        wg.Wait()
 }
+
+func BenchmarkPageCache(b *testing.B) {
+       cache := newPageCache()
+       pages := make(Pages, 30)
+       for i := 0; i < 30; i++ {
+               pages[i] = &Page{title: "p" + strconv.Itoa(i)}
+       }
+       key := "key"
+
+       b.ResetTimer()
+       for i := 0; i < b.N; i++ {
+               cache.getP(key, nil, pages)
+       }
+}
index 8e9420e3015793a8e09367f332527daf56339641..a9477059de974ae289ff39b4601cfe2d841e96fc 100644 (file)
@@ -14,8 +14,9 @@
 package hugolib
 
 import (
-       "github.com/spf13/cast"
        "sort"
+
+       "github.com/spf13/cast"
 )
 
 var spc = newPageCache()
@@ -115,7 +116,7 @@ func (p Pages) Limit(n int) Pages {
 // This may safely be executed  in parallel.
 func (p Pages) ByWeight() Pages {
        key := "pageSort.ByWeight"
-       pages, _ := spc.get(key, p, pageBy(defaultPageSort).Sort)
+       pages, _ := spc.get(key, pageBy(defaultPageSort).Sort, p)
        return pages
 }
 
@@ -132,7 +133,7 @@ func (p Pages) ByTitle() Pages {
                return p1.title < p2.title
        }
 
-       pages, _ := spc.get(key, p, pageBy(title).Sort)
+       pages, _ := spc.get(key, pageBy(title).Sort, p)
        return pages
 }
 
@@ -149,7 +150,7 @@ func (p Pages) ByLinkTitle() Pages {
                return p1.linkTitle < p2.linkTitle
        }
 
-       pages, _ := spc.get(key, p, pageBy(linkTitle).Sort)
+       pages, _ := spc.get(key, pageBy(linkTitle).Sort, p)
 
        return pages
 }
@@ -167,7 +168,7 @@ func (p Pages) ByDate() Pages {
                return p1.Date.Unix() < p2.Date.Unix()
        }
 
-       pages, _ := spc.get(key, p, pageBy(date).Sort)
+       pages, _ := spc.get(key, pageBy(date).Sort, p)
 
        return pages
 }
@@ -185,7 +186,7 @@ func (p Pages) ByPublishDate() Pages {
                return p1.PublishDate.Unix() < p2.PublishDate.Unix()
        }
 
-       pages, _ := spc.get(key, p, pageBy(pubDate).Sort)
+       pages, _ := spc.get(key, pageBy(pubDate).Sort, p)
 
        return pages
 }
@@ -203,7 +204,7 @@ func (p Pages) ByExpiryDate() Pages {
                return p1.ExpiryDate.Unix() < p2.ExpiryDate.Unix()
        }
 
-       pages, _ := spc.get(key, p, pageBy(expDate).Sort)
+       pages, _ := spc.get(key, pageBy(expDate).Sort, p)
 
        return pages
 }
@@ -221,7 +222,7 @@ func (p Pages) ByLastmod() Pages {
                return p1.Lastmod.Unix() < p2.Lastmod.Unix()
        }
 
-       pages, _ := spc.get(key, p, pageBy(date).Sort)
+       pages, _ := spc.get(key, pageBy(date).Sort, p)
 
        return pages
 }
@@ -239,7 +240,7 @@ func (p Pages) ByLength() Pages {
                return len(p1.Content) < len(p2.Content)
        }
 
-       pages, _ := spc.get(key, p, pageBy(length).Sort)
+       pages, _ := spc.get(key, pageBy(length).Sort, p)
 
        return pages
 }
@@ -253,7 +254,7 @@ func (p Pages) ByLanguage() Pages {
 
        key := "pageSort.ByLanguage"
 
-       pages, _ := spc.get(key, p, pageBy(languagePageSort).Sort)
+       pages, _ := spc.get(key, pageBy(languagePageSort).Sort, p)
 
        return pages
 }
@@ -272,7 +273,7 @@ func (p Pages) Reverse() Pages {
                }
        }
 
-       pages, _ := spc.get(key, p, reverseFunc)
+       pages, _ := spc.get(key, reverseFunc, p)
 
        return pages
 }
@@ -297,7 +298,7 @@ func (p Pages) ByParam(paramsKey interface{}) Pages {
                return s1 < s2
        }
 
-       pages, _ := spc.get(key, p, pageBy(paramsKeyComparator).Sort)
+       pages, _ := spc.get(key, pageBy(paramsKeyComparator).Sort, p)
 
        return pages
 }
diff --git a/hugolib/pages_language_merge.go b/hugolib/pages_language_merge.go
new file mode 100644 (file)
index 0000000..6e467c7
--- /dev/null
@@ -0,0 +1,61 @@
+// Copyright 2018 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 hugolib
+
+import (
+       "fmt"
+)
+
+var (
+       _ pagesLanguageMerger = (*Pages)(nil)
+)
+
+type pagesLanguageMerger interface {
+       MergeByLanguage(other Pages) Pages
+       // Needed for integration with the tpl package.
+       MergeByLanguageInterface(other interface{}) (interface{}, error)
+}
+
+// MergeByLanguage supplies missing translations in p1 with values from p2.
+// The result is sorted by the default sort order for pages.
+func (p1 Pages) MergeByLanguage(p2 Pages) Pages {
+       merge := func(pages *Pages) {
+               m := make(map[string]bool)
+               for _, p := range *pages {
+                       m[p.TranslationKey()] = true
+               }
+
+               for _, p := range p2 {
+                       if _, found := m[p.TranslationKey()]; !found {
+                               *pages = append(*pages, p)
+                       }
+               }
+
+               pages.Sort()
+       }
+
+       out, _ := spc.getP("pages.MergeByLanguage", merge, p1, p2)
+
+       return out
+}
+
+// MergeByLanguageInterface is the generic version of MergeByLanguage. It
+// is here just so it can be called from the tpl package.
+func (p1 Pages) MergeByLanguageInterface(in interface{}) (interface{}, error) {
+       p2, ok := in.(Pages)
+       if !ok {
+               return nil, fmt.Errorf("%T cannot be merged by language", in)
+       }
+       return p1.MergeByLanguage(p2), nil
+}
diff --git a/hugolib/pages_language_merge_test.go b/hugolib/pages_language_merge_test.go
new file mode 100644 (file)
index 0000000..3b55a62
--- /dev/null
@@ -0,0 +1,150 @@
+// Copyright 2018 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 hugolib
+
+import (
+       "fmt"
+       "testing"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestMergeLanguages(t *testing.T) {
+       t.Parallel()
+       assert := require.New(t)
+
+       b := newTestSiteForLanguageMerge(t, 30)
+       b.CreateSites()
+
+       b.Build(BuildCfg{SkipRender: true})
+
+       h := b.H
+
+       enSite := h.Sites[0]
+       frSite := h.Sites[1]
+       nnSite := h.Sites[2]
+
+       assert.Equal(30, len(enSite.RegularPages))
+       assert.Equal(6, len(frSite.RegularPages))
+       assert.Equal(11, len(nnSite.RegularPages))
+
+       for i := 0; i < 2; i++ {
+               mergedNN := nnSite.RegularPages.MergeByLanguage(enSite.RegularPages)
+               assert.Equal(30, len(mergedNN))
+               for i := 1; i <= 30; i++ {
+                       expectedLang := "en"
+                       if i == 2 || i%3 == 0 {
+                               expectedLang = "nn"
+                       }
+                       p := mergedNN[i-1]
+                       assert.Equal(expectedLang, p.Lang(), fmt.Sprintf("Test %d", i))
+               }
+       }
+
+       mergedFR := frSite.RegularPages.MergeByLanguage(enSite.RegularPages)
+       assert.Equal(30, len(mergedFR))
+       for i := 1; i <= 30; i++ {
+               expectedLang := "en"
+               if i%5 == 0 {
+                       expectedLang = "fr"
+               }
+               p := mergedFR[i-1]
+               assert.Equal(expectedLang, p.Lang(), fmt.Sprintf("Test %d", i))
+       }
+
+       firstNN := nnSite.RegularPages[0]
+       assert.Equal(4, len(firstNN.Sites()))
+       assert.Equal("en", firstNN.Sites().First().Language.Lang)
+
+}
+
+func TestMergeLanguagesTemplate(t *testing.T) {
+       t.Parallel()
+
+       b := newTestSiteForLanguageMerge(t, 15)
+       b.WithTemplates("home.html", `
+{{ $pages := .Site.RegularPages }}
+{{ .Scratch.Set "pages" $pages }}
+{{ if eq .Lang "nn" }}:
+{{ $enSite := index .Sites 0 }}
+{{ $frSite := index .Sites 1 }}
+{{ .Scratch.Set "pages" ($pages | lang.Merge $frSite.RegularPages| lang.Merge $enSite.RegularPages) }}
+{{ end }}
+{{ $pages := .Scratch.Get "pages" }}
+{{ range $i, $p := $pages }}{{ add $i 1 }}: {{ .Path }} {{ .Lang }} | {{ end }}
+`,
+               "shortcodes/shortcode.html", "MyShort",
+               "shortcodes/lingo.html", "MyLingo",
+       )
+
+       b.CreateSites()
+       b.Build(BuildCfg{})
+
+       b.AssertFileContent("public/nn/index.html", "p1.md en | 2: p2.nn.md nn | 3: p3.nn.md nn | 4: p4.md en | 5: p5.fr.md fr | 6: p6.nn.md nn | 7: p7.md en | 8: p8.md en | 9: p9.nn.md nn | 10: p10.fr.md fr | 11: p11.md en | 12: p12.nn.md nn | 13: p13.md en | 14: p14.md en | 15: p15.nn.md nn")
+}
+
+func newTestSiteForLanguageMerge(t testing.TB, count int) *sitesBuilder {
+       contentTemplate := `---
+title: doc%d
+weight: %d
+date: "2018-02-28"
+---
+# doc
+*some "content"*
+
+{{< shortcode >}}
+
+{{< lingo >}}
+`
+
+       builder := newTestSitesBuilder(t).WithDefaultMultiSiteConfig()
+
+       // We need some content with some missing translations.
+       // "en" is the main language, so add some English content + some Norwegian (nn, nynorsk) content.
+       var contentPairs []string
+       for i := 1; i <= count; i++ {
+               content := fmt.Sprintf(contentTemplate, i, i)
+               contentPairs = append(contentPairs, []string{fmt.Sprintf("p%d.md", i), content}...)
+               if i == 2 || i%3 == 0 {
+                       // Add page 2,3, 6, 9 ... to both languages
+                       contentPairs = append(contentPairs, []string{fmt.Sprintf("p%d.nn.md", i), content}...)
+               }
+               if i%5 == 0 {
+                       // Add some French content, too.
+                       contentPairs = append(contentPairs, []string{fmt.Sprintf("p%d.fr.md", i), content}...)
+               }
+       }
+
+       builder.WithContent(contentPairs...)
+       return builder
+}
+
+func BenchmarkMergeByLanguage(b *testing.B) {
+       const count = 100
+
+       builder := newTestSiteForLanguageMerge(b, count)
+       builder.CreateSites()
+       builder.Build(BuildCfg{SkipRender: true})
+       h := builder.H
+
+       enSite := h.Sites[0]
+       nnSite := h.Sites[2]
+
+       for i := 0; i < b.N; i++ {
+               merged := nnSite.RegularPages.MergeByLanguage(enSite.RegularPages)
+               if len(merged) != count {
+                       b.Fatal("Count mismatch")
+               }
+       }
+}
index 763394032ee09d3b2d37bf79b7698e27e1466a78..449d55abd5717d0b14d60563926cbbe01a887daf 100644 (file)
@@ -673,7 +673,8 @@ NotFound: {{< thisDoesNotExist >}}
        writeSource(t, fs, "content/sect/mycsvpage.md", fmt.Sprintf(pageTemplateCSVOnly, "Single CSV"))
        writeSource(t, fs, "content/sect/notfound.md", fmt.Sprintf(pageTemplateShortcodeNotFound, "Single CSV"))
 
-       require.NoError(t, h.Build(BuildCfg{}))
+       err := h.Build(BuildCfg{})
+       require.Equal(t, "logged 1 error(s)", err.Error())
        require.Len(t, h.Sites, 1)
 
        s := h.Sites[0]
index 48771410d96fc81ff60487ca7dea0b6daed0d93c..2e8898bd6bf02c1735cba7560930acc2a0aad081 100644 (file)
@@ -348,6 +348,16 @@ func NewSiteForCfg(cfg deps.DepsCfg) (*Site, error) {
        return s, nil
 }
 
+type SiteInfos []*SiteInfo
+
+// First is a convenience method to get the first Site, i.e. the main language.
+func (s SiteInfos) First() *SiteInfo {
+       if len(s) == 0 {
+               return nil
+       }
+       return s[0]
+}
+
 type SiteInfo struct {
        Taxonomies TaxonomyList
        Authors    AuthorList
index c157ee6d2f9446beb2788b22c188d68777891f4b..7286c4c36c68bd699a8965109ec65cce3391bd17 100644 (file)
@@ -20,7 +20,6 @@ import (
        "testing"
 
        "github.com/markbates/inflect"
-       jww "github.com/spf13/jwalterweatherman"
 
        "github.com/gohugoio/hugo/helpers"
 
@@ -54,16 +53,8 @@ func TestRenderWithInvalidTemplate(t *testing.T) {
 
        withTemplate := createWithTemplateFromNameValues("missing", templateMissingFunc)
 
-       s := buildSingleSite(t, deps.DepsCfg{Fs: fs, Cfg: cfg, WithTemplate: withTemplate}, BuildCfg{})
+       buildSingleSiteExpected(t, true, deps.DepsCfg{Fs: fs, Cfg: cfg, WithTemplate: withTemplate}, BuildCfg{})
 
-       errCount := s.Log.LogCountForLevelsGreaterThanorEqualTo(jww.LevelError)
-
-       // TODO(bep) clean up the template error handling
-       // The template errors are stored in a slice etc. so we get 4 log entries
-       // When we should get only 1
-       if errCount == 0 {
-               t.Fatalf("Expecting the template to log 1 ERROR, got %d", errCount)
-       }
 }
 
 func TestDraftAndFutureRender(t *testing.T) {
index 421f1a52757d02b756d4419db7697bf58032e0af..8f2893682497866af05e31af90bd7d6e299d11dc 100644 (file)
@@ -4,11 +4,12 @@ import (
        "path/filepath"
        "testing"
 
-       "regexp"
-
        "fmt"
+       "regexp"
        "strings"
 
+       jww "github.com/spf13/jwalterweatherman"
+
        "github.com/gohugoio/hugo/config"
        "github.com/gohugoio/hugo/deps"
        "github.com/spf13/afero"
@@ -23,10 +24,244 @@ import (
        "log"
 
        "github.com/gohugoio/hugo/hugofs"
-       jww "github.com/spf13/jwalterweatherman"
        "github.com/stretchr/testify/require"
 )
 
+const ()
+
+type sitesBuilder struct {
+       Cfg config.Provider
+       Fs  *hugofs.Fs
+       T   testing.TB
+
+       H *HugoSites
+
+       // We will add some default if not set.
+       templatesAdded bool
+       i18nAdded      bool
+       dataAdded      bool
+       contentAdded   bool
+}
+
+func newTestSitesBuilder(t testing.TB) *sitesBuilder {
+       v := viper.New()
+       fs := hugofs.NewMem(v)
+
+       return &sitesBuilder{T: t, Fs: fs}
+}
+
+func (s *sitesBuilder) WithTOMLConfig(conf string) *sitesBuilder {
+       writeSource(s.T, s.Fs, "config.toml", conf)
+       return s
+}
+
+func (s *sitesBuilder) WithDefaultMultiSiteConfig() *sitesBuilder {
+       var defaultMultiSiteConfig = `
+baseURL = "http://example.com/blog"
+
+paginate = 1
+disablePathToLower = true
+defaultContentLanguage = "en"
+defaultContentLanguageInSubdir = true
+
+[permalinks]
+other = "/somewhere/else/:filename"
+
+[blackfriday]
+angledQuotes = true
+
+[Taxonomies]
+tag = "tags"
+
+[Languages]
+[Languages.en]
+weight = 10
+title = "In English"
+languageName = "English"
+[Languages.en.blackfriday]
+angledQuotes = false
+[[Languages.en.menu.main]]
+url    = "/"
+name   = "Home"
+weight = 0
+
+[Languages.fr]
+weight = 20
+title = "Le Français"
+languageName = "Français"
+[Languages.fr.Taxonomies]
+plaque = "plaques"
+
+[Languages.nn]
+weight = 30
+title = "På nynorsk"
+languageName = "Nynorsk"
+paginatePath = "side"
+[Languages.nn.Taxonomies]
+lag = "lag"
+[[Languages.nn.menu.main]]
+url    = "/"
+name   = "Heim"
+weight = 1
+
+[Languages.nb]
+weight = 40
+title = "På bokmål"
+languageName = "Bokmål"
+paginatePath = "side"
+[Languages.nb.Taxonomies]
+lag = "lag"
+`
+
+       return s.WithTOMLConfig(defaultMultiSiteConfig)
+
+}
+
+func (s *sitesBuilder) WithContent(filenameContent ...string) *sitesBuilder {
+       s.contentAdded = true
+       for i := 0; i < len(filenameContent); i += 2 {
+               filename, content := filenameContent[i], filenameContent[i+1]
+               writeSource(s.T, s.Fs, filepath.Join("content", filename), content)
+       }
+       return s
+}
+
+func (s *sitesBuilder) WithTemplates(filenameContent ...string) *sitesBuilder {
+       s.templatesAdded = true
+       for i := 0; i < len(filenameContent); i += 2 {
+               filename, content := filenameContent[i], filenameContent[i+1]
+               writeSource(s.T, s.Fs, filepath.Join("layouts", filename), content)
+       }
+       return s
+}
+
+func (s *sitesBuilder) CreateSites() *sitesBuilder {
+       if !s.templatesAdded {
+               s.addDefaultTemplates()
+       }
+       if !s.i18nAdded {
+               s.addDefaultI18n()
+       }
+       if !s.dataAdded {
+               s.addDefaultData()
+       }
+       if !s.contentAdded {
+               s.addDefaultContent()
+       }
+
+       if s.Cfg == nil {
+               cfg, err := LoadConfig(s.Fs.Source, "", "config.toml")
+               if err != nil {
+                       s.T.Fatalf("Failed to load config: %s", err)
+               }
+               s.Cfg = cfg
+       }
+
+       sites, err := NewHugoSites(deps.DepsCfg{Fs: s.Fs, Cfg: s.Cfg})
+       if err != nil {
+               s.T.Fatalf("Failed to create sites: %s", err)
+       }
+       s.H = sites
+
+       return s
+}
+
+func (s *sitesBuilder) Build(cfg BuildCfg) *sitesBuilder {
+       if s.H == nil {
+               s.T.Fatal("Need to run builder.CreateSites first")
+       }
+       err := s.H.Build(cfg)
+       if err != nil {
+               s.T.Fatalf("Build failed: %s", err)
+       }
+
+       return s
+}
+
+func (s *sitesBuilder) addDefaultTemplates() {
+       fs := s.Fs
+       t := s.T
+
+       // Layouts
+
+       writeSource(t, fs, filepath.Join("layouts", "_default/single.html"), "Single: {{ .Title }}|{{ i18n \"hello\" }}|{{.Lang}}|{{ .Content }}")
+       writeSource(t, fs, filepath.Join("layouts", "_default/list.html"), "{{ $p := .Paginator }}List Page {{ $p.PageNumber }}: {{ .Title }}|{{ i18n \"hello\" }}|{{ .Permalink }}|Pager: {{ template \"_internal/pagination.html\" . }}")
+       writeSource(t, fs, filepath.Join("layouts", "index.html"), "{{ $p := .Paginator }}Default Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{  .Site.Data.hugo.slogan }}")
+       writeSource(t, fs, filepath.Join("layouts", "index.fr.html"), "{{ $p := .Paginator }}French Home Page {{ $p.PageNumber }}: {{ .Title }}|{{ .IsHome }}|{{ i18n \"hello\" }}|{{ .Permalink }}|{{  .Site.Data.hugo.slogan }}")
+
+       // Shortcodes
+       writeSource(t, fs, filepath.Join("layouts", "shortcodes", "shortcode.html"), "Shortcode: {{ i18n \"hello\" }}")
+       // A shortcode in multiple languages
+       writeSource(t, fs, filepath.Join("layouts", "shortcodes", "lingo.html"), "LingoDefault")
+       writeSource(t, fs, filepath.Join("layouts", "shortcodes", "lingo.fr.html"), "LingoFrench")
+}
+
+func (s *sitesBuilder) addDefaultI18n() {
+       fs := s.Fs
+       t := s.T
+
+       writeSource(t, fs, filepath.Join("i18n", "en.yaml"), `
+hello:
+  other: "Hello"
+`)
+       writeSource(t, fs, filepath.Join("i18n", "fr.yaml"), `
+hello:
+  other: "Bonjour"
+`)
+
+}
+
+func (s *sitesBuilder) addDefaultData() {
+       fs := s.Fs
+       t := s.T
+
+       writeSource(t, fs, filepath.FromSlash("data/hugo.toml"), "slogan = \"Hugo Rocks!\"")
+}
+
+func (s *sitesBuilder) addDefaultContent() {
+       fs := s.Fs
+       t := s.T
+
+       contentTemplate := `---
+title: doc1
+weight: 1
+tags:
+ - tag1
+date: "2018-02-28"
+---
+# doc1
+*some "content"*
+
+{{< shortcode >}}
+
+{{< lingo >}}
+`
+
+       writeSource(t, fs, filepath.FromSlash("content/sect/doc1.en.md"), contentTemplate)
+       writeSource(t, fs, filepath.FromSlash("content/sect/doc1.fr.md"), contentTemplate)
+       writeSource(t, fs, filepath.FromSlash("content/sect/doc1.nb.md"), contentTemplate)
+       writeSource(t, fs, filepath.FromSlash("content/sect/doc1.nn.md"), contentTemplate)
+}
+
+func (s *sitesBuilder) AssertFileContent(filename string, matches ...string) {
+       content := readDestination(s.T, s.Fs, filename)
+       for _, match := range matches {
+               if !strings.Contains(content, match) {
+                       s.T.Fatalf("No match for %q in content for %s\n%q", match, filename, content)
+               }
+       }
+}
+
+func (s *sitesBuilder) AssertFileContentRe(filename string, matches ...string) {
+       content := readDestination(s.T, s.Fs, filename)
+       for _, match := range matches {
+               r := regexp.MustCompile(match)
+               if !r.MatchString(content) {
+                       s.T.Fatalf("No match for %q in content for %s\n%q", match, filename, content)
+               }
+       }
+}
+
 type testHelper struct {
        Cfg config.Provider
        Fs  *hugofs.Fs
index 3a659b119067ef231bc0cf8172c4c2996d71e736..b29b182ab41b415f69da552ab158b7796e7866f1 100644 (file)
@@ -15,6 +15,7 @@ package lang
 
 import (
        "errors"
+       "fmt"
        "math"
        "strconv"
        "strings"
@@ -134,3 +135,15 @@ func (ns *Namespace) NumFmt(precision, number interface{}, options ...interface{
 
        return string(b), nil
 }
+
+type pagesLanguageMerger interface {
+       MergeByLanguageInterface(other interface{}) (interface{}, error)
+}
+
+func (ns *Namespace) Merge(p2, p1 interface{}) (interface{}, error) {
+       merger, ok := p1.(pagesLanguageMerger)
+       if !ok {
+               return nil, fmt.Errorf("language merge not supported for %T", p1)
+       }
+       return merger.MergeByLanguageInterface(p2)
+}