diff --git a/commands/hugo.go b/commands/hugo.go index 94c9559253d..d2b4ed52a60 100644 --- a/commands/hugo.go +++ b/commands/hugo.go @@ -136,6 +136,8 @@ func InitializeConfig() { viper.SetDefault("FootnoteAnchorPrefix", "") viper.SetDefault("FootnoteReturnLinkContents", "") viper.SetDefault("NewContentEditor", "") + viper.SetDefault("Paginate", 0) + viper.SetDefault("PaginatePath", "page") viper.SetDefault("Blackfriday", new(helpers.Blackfriday)) if hugoCmdV.PersistentFlags().Lookup("buildDrafts").Changed { diff --git a/helpers/url.go b/helpers/url.go index 1b7608178f9..558ed9c9740 100644 --- a/helpers/url.go +++ b/helpers/url.go @@ -16,6 +16,7 @@ package helpers import ( "fmt" "github.com/PuerkitoBio/purell" + "github.com/spf13/viper" "net/url" "path" "strings" @@ -97,6 +98,10 @@ func AddContextRoot(baseUrl, relativePath string) string { return newPath } +func UrlizeAndPrep(in string) string { + return UrlPrep(viper.GetBool("UglyUrls"), Urlize(in)) +} + func UrlPrep(ugly bool, in string) string { if ugly { x := Uglify(SanitizeUrl(in)) diff --git a/hugolib/hugolib.test b/hugolib/hugolib.test new file mode 100755 index 00000000000..1d74fc77e12 Binary files /dev/null and b/hugolib/hugolib.test differ diff --git a/hugolib/node.go b/hugolib/node.go index ccf3e88228c..1916e8b03d7 100644 --- a/hugolib/node.go +++ b/hugolib/node.go @@ -15,6 +15,7 @@ package hugolib import ( "html/template" + "sync" "time" ) @@ -30,6 +31,8 @@ type Node struct { Date time.Time Sitemap Sitemap UrlPath + paginator *pager + paginatorInit sync.Once } func (n *Node) Now() time.Time { diff --git a/hugolib/pagination.go b/hugolib/pagination.go new file mode 100644 index 00000000000..c6db865502d --- /dev/null +++ b/hugolib/pagination.go @@ -0,0 +1,269 @@ +// Copyright © 2013-14 Steve Francia . +// +// Licensed under the Simple Public 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://opensource.org/licenses/Simple-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 ( + "errors" + "fmt" + "github.com/spf13/hugo/helpers" + "github.com/spf13/viper" + "html/template" + "math" + "path" +) + +type pager struct { + number int + *paginator +} + +type pagers []*pager + +type paginator struct { + paginatedPages []Pages + pagers + paginationUrlFactory + total int + size int +} + +type paginationUrlFactory func(int) string + +// PageNumber returns the current page's number in the pager sequence. +func (p *pager) PageNumber() int { + return p.number +} + +// Url returns the url to the current page. +func (p *pager) Url() template.HTML { + return template.HTML(p.paginationUrlFactory(p.PageNumber())) +} + +// Pages returns the elements on this page. +func (p *pager) Pages() Pages { + return p.paginatedPages[p.PageNumber()-1] +} + +// NumberOfElements gets the number of elements on this page. +func (p *pager) NumberOfElements() int { + return len(p.Pages()) +} + +// HasPrev tests whether there are page(s) before the current. +func (p *pager) HasPrev() bool { + return p.PageNumber() > 1 +} + +// Prev returns the pager for the previous page. +func (p *pager) Prev() *pager { + if !p.HasPrev() { + return nil + } + return p.pagers[p.PageNumber()-2] +} + +// HasNext tests whether there are page(s) after the current. +func (p *pager) HasNext() bool { + return p.PageNumber() < len(p.paginatedPages) +} + +// Next returns the pager for the next page. +func (p *pager) Next() *pager { + if !p.HasNext() { + return nil + } + return p.pagers[p.PageNumber()] +} + +// First returns the pager for the first page. +func (p *pager) First() *pager { + if p.TotalPages() == 0 { + return nil + } + + return p.pagers[0] +} + +// Last returns the pager for the last page. +func (p *pager) Last() *pager { + if p.TotalPages() == 0 { + return nil + } + + return p.pagers[len(p.pagers)-1] +} + +// Pagers returns a list of pagers that can be used to build a pagination menu. +func (p *paginator) Pagers() pagers { + return p.pagers +} + +// PageSize returns the size of each paginator page. +func (p *paginator) PageSize() int { + return p.size +} + +// TotalPages returns the number of pages in the paginator. +func (p *paginator) TotalPages() int { + return len(p.paginatedPages) +} + +// TotalNumberOfElements returns the number of elements on all pages in this paginator. +func (p *paginator) TotalNumberOfElements() int { + return p.total +} + +func splitPages(pages Pages, size int) []Pages { + var split []Pages + for low, j := 0, len(pages); low < j; low += size { + high := int(math.Min(float64(low+size), float64(len(pages)))) + split = append(split, pages[low:high]) + } + + return split +} + +// Paginate gets this Node's paginator if it's already created. +// If it's not, one will be created with all pages in Data["Pages"]. +func (n *Node) Paginator() (*pager, error) { + + if n.IsPage() { + return nil, errors.New("Paginators isn't supported for content pages.") + } + + var initError error + + n.paginatorInit.Do(func() { + if n.paginator != nil { + return + } + + pagers, err := paginatePages(n.Data["Pages"], n.Url) + + if err != nil { + initError = err + } + + if len(pagers) > 0 { + // the rest of the nodes will be created later + n.paginator = pagers[0] + n.Site.addToPaginationPageCount(uint64(n.paginator.TotalPages())) + } + + }) + + if initError != nil { + return nil, initError + } + + return n.paginator, nil +} + +// Paginate gets this Node's paginator if it's already created. +// If it's not, one will be created with the qiven sequence. +// Note that repeated calls will return the same result, even if the sequence is different. +func (n *Node) Paginate(seq interface{}) (*pager, error) { + + if n.IsPage() { + return nil, errors.New("Paginators isn't supported for content pages.") + } + + var initError error + + n.paginatorInit.Do(func() { + if n.paginator != nil { + return + } + pagers, err := paginatePages(seq, n.Url) + + if err != nil { + initError = err + } + + if len(pagers) > 0 { + // the rest of the nodes will be created later + n.paginator = pagers[0] + n.Site.addToPaginationPageCount(uint64(n.paginator.TotalPages())) + } + + }) + + if initError != nil { + return nil, initError + } + + return n.paginator, nil +} + +func paginatePages(seq interface{}, section string) (pagers, error) { + paginateSize := viper.GetInt("paginate") + + if paginateSize <= 0 { + return nil, errors.New("'paginate' configuration setting must be positive to paginate") + } + var pages Pages + switch seq.(type) { + case Pages: + pages = seq.(Pages) + case *Pages: + pages = *(seq.(*Pages)) + case WeightedPages: + pages = (seq.(WeightedPages)).Pages() + case PageGroup: + pages = (seq.(PageGroup)).Pages + default: + return nil, errors.New(fmt.Sprintf("unsupported type in paginate, got %T", seq)) + } + + urlFactory := newPaginationUrlFactory(section) + paginator := newPaginator(pages, paginateSize, urlFactory) + pagers := paginator.Pagers() + + return pagers, nil +} + +func newPaginator(pages Pages, size int, urlFactory paginationUrlFactory) *paginator { + + if size <= 0 { + panic("Paginator size must be positive") + } + + split := splitPages(pages, size) + + p := &paginator{total: len(pages), paginatedPages: split, size: size, paginationUrlFactory: urlFactory} + pagers := make(pagers, len(split)) + + for i := range p.paginatedPages { + pagers[i] = &pager{number: (i + 1), paginator: p} + } + + p.pagers = pagers + + return p +} + +func newPaginationUrlFactory(pathElements ...string) paginationUrlFactory { + paginatePath := viper.GetString("paginatePath") + + return func(page int) string { + var rel string + if page == 1 { + rel = fmt.Sprintf("/%s/", path.Join(pathElements...)) + } else { + rel = fmt.Sprintf("/%s/%s/%d/", path.Join(pathElements...), paginatePath, page) + } + + return helpers.UrlizeAndPrep(rel) + } +} diff --git a/hugolib/pagination_test.go b/hugolib/pagination_test.go new file mode 100644 index 00000000000..080866f6f7b --- /dev/null +++ b/hugolib/pagination_test.go @@ -0,0 +1,95 @@ +package hugolib + +import ( + "fmt" + "github.com/spf13/hugo/source" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "path/filepath" + "testing" +) + +func TestSplitPages(t *testing.T) { + + pages := createTestPages(21) + chunks := splitPages(pages, 5) + assert.Equal(t, 5, len(chunks)) + + for i := 0; i < 4; i++ { + assert.Equal(t, 5, len(chunks[i])) + } + + lastChunk := chunks[4] + assert.Equal(t, 1, len(lastChunk)) + +} + +func TestPaginator(t *testing.T) { + + pages := createTestPages(21) + urlFactory := func(page int) string { + return fmt.Sprintf("page/%d/", page) + } + + paginator := newPaginator(pages, 5, urlFactory) + paginatorPages := paginator.Pagers() + + assert.Equal(t, 5, len(paginatorPages)) + assert.Equal(t, 21, paginator.TotalNumberOfElements()) + assert.Equal(t, 5, paginator.PageSize()) + assert.Equal(t, 5, paginator.TotalPages()) + + first := paginatorPages[0] + assert.Equal(t, "page/1/", first.Url()) + assert.Equal(t, first, first.First()) + assert.Equal(t, true, first.HasNext()) + assert.Equal(t, false, first.HasPrev()) + assert.Equal(t, 5, first.NumberOfElements()) + assert.Equal(t, 1, first.PageNumber()) + + third := paginatorPages[2] + assert.Equal(t, true, third.HasNext()) + assert.Equal(t, true, third.HasPrev()) + + last := paginatorPages[4] + assert.Equal(t, "page/5/", last.Url()) + assert.Equal(t, last, last.Last()) + assert.Equal(t, false, last.HasNext()) + assert.Equal(t, true, last.HasPrev()) + assert.Equal(t, 1, last.NumberOfElements()) + assert.Equal(t, 5, last.PageNumber()) + +} + +func TestPaginationUrlFactory(t *testing.T) { + viper.Set("PaginatePath", "zoo") + unicode := newPaginationUrlFactory("новости проекта") + fooBar := newPaginationUrlFactory("foo", "bar") + + assert.Equal(t, "/%D0%BD%D0%BE%D0%B2%D0%BE%D1%81%D1%82%D0%B8-%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B0/", unicode(1)) + assert.Equal(t, "/foo/bar/", fooBar(1)) + assert.Equal(t, "/%D0%BD%D0%BE%D0%B2%D0%BE%D1%81%D1%82%D0%B8-%D0%BF%D1%80%D0%BE%D0%B5%D0%BA%D1%82%D0%B0/zoo/4/", unicode(4)) + assert.Equal(t, "/foo/bar/zoo/12345/", fooBar(12345)) + +} + +func createTestPages(num int) Pages { + pages := make(Pages, num) + + for i := 0; i < num; i++ { + pages[i] = &Page{ + Node: Node{ + UrlPath: UrlPath{ + Section: "z", + Url: fmt.Sprintf("http://base/x/y/p%d.html", num), + }, + Site: &SiteInfo{ + BaseUrl: "http://base/", + }, + }, + Source: Source{File: *source.NewFile(filepath.FromSlash(fmt.Sprintf("/x/y/p%d.md", num)))}, + } + } + + return pages +} diff --git a/hugolib/site.go b/hugolib/site.go index a6a18a2fecf..1877dbf1183 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -21,6 +21,7 @@ import ( "io" "net/url" "os" + "path/filepath" "strconv" "strings" "sync" @@ -37,6 +38,7 @@ import ( jww "github.com/spf13/jwalterweatherman" "github.com/spf13/nitro" "github.com/spf13/viper" + "sync/atomic" ) var _ = transform.AbsURL @@ -86,27 +88,28 @@ type targetList struct { } type SiteInfo struct { - BaseUrl template.URL - Taxonomies TaxonomyList - Authors AuthorList - Social SiteSocial - Indexes *TaxonomyList // legacy, should be identical to Taxonomies - Sections Taxonomy - Pages *Pages - Files []*source.File - Recent *Pages // legacy, should be identical to Pages - Menus *Menus - Hugo *HugoInfo - Title string - Author map[string]interface{} - LanguageCode string - DisqusShortname string - Copyright string - LastChange time.Time - Permalinks PermalinkOverrides - Params map[string]interface{} - BuildDrafts bool - canonifyUrls bool + BaseUrl template.URL + Taxonomies TaxonomyList + Authors AuthorList + Social SiteSocial + Indexes *TaxonomyList // legacy, should be identical to Taxonomies + Sections Taxonomy + Pages *Pages + Files []*source.File + Recent *Pages // legacy, should be identical to Pages + Menus *Menus + Hugo *HugoInfo + Title string + Author map[string]interface{} + LanguageCode string + DisqusShortname string + Copyright string + LastChange time.Time + Permalinks PermalinkOverrides + Params map[string]interface{} + BuildDrafts bool + canonifyUrls bool + paginationPageCount uint64 } // SiteSocial is a place to put social details on a site level. These are the @@ -205,6 +208,10 @@ func (s *SiteInfo) RelRef(ref string, page *Page) (string, error) { return s.refLink(ref, page, true) } +func (s *SiteInfo) addToPaginationPageCount(cnt uint64) { + atomic.AddUint64(&s.paginationPageCount, cnt) +} + type runmode struct { Watching bool } @@ -614,7 +621,7 @@ func (s *Site) getMenusFromConfig() Menus { if strings.HasPrefix(menuEntry.Url, "/") { // make it match the nodes menuEntryUrl := menuEntry.Url - menuEntryUrl = s.prepUrl(menuEntryUrl) + menuEntryUrl = helpers.UrlizeAndPrep(menuEntryUrl) if !s.Info.canonifyUrls { menuEntryUrl = helpers.AddContextRoot(string(s.Info.BaseUrl), menuEntryUrl) } @@ -943,17 +950,63 @@ func (s *Site) newTaxonomyNode(t taxRenderInfo) (*Node, string) { func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error, wg *sync.WaitGroup) { defer wg.Done() + + var n *Node + for t := range taxes { - n, base := s.newTaxonomyNode(t) - layouts := []string{"taxonomy/" + t.singular + ".html", "indexes/" + t.singular + ".html", "_default/taxonomy.html", "_default/list.html"} - b, err := s.renderPage("taxononomy "+t.singular, n, s.appendThemeTemplates(layouts)...) + + var base string + layouts := s.appendThemeTemplates( + []string{"taxonomy/" + t.singular + ".html", "indexes/" + t.singular + ".html", "_default/taxonomy.html", "_default/list.html"}) + + n, base = s.newTaxonomyNode(t) + + b, err := s.renderPage("taxononomy "+t.singular, n, layouts...) if err != nil { results <- err continue - } else { - err := s.WriteDestPage(base+".html", b) - if err != nil { - results <- err + } + + err = s.WriteDestPage(base, b) + if err != nil { + results <- err + continue + } + + if n.paginator != nil { + + paginatePath := viper.GetString("paginatePath") + + // write alias for page 1 + s.WriteDestAlias(fmt.Sprintf("%s/%s/%d/index.html", base, paginatePath, 1), s.permalink(base)) + + pagers := n.paginator.Pagers() + + for i, pager := range pagers { + if i == 0 { + // already created + continue + } + + taxonomyPagerNode, _ := s.newTaxonomyNode(t) + taxonomyPagerNode.paginator = pager + if pager.TotalPages() > 0 { + taxonomyPagerNode.Date = pager.Pages()[0].Date + } + pageNumber := i + 1 + htmlBase := fmt.Sprintf("/%s/%s/%d", base, paginatePath, pageNumber) + b, err := s.renderPage(fmt.Sprintf("taxononomy_%s_%d", t.singular, pageNumber), taxonomyPagerNode, layouts...) + if err != nil { + results <- err + continue + } + + err = s.WriteDestPage(htmlBase, b) + if err != nil { + results <- err + continue + } + } } @@ -1005,34 +1058,76 @@ func (s *Site) RenderListsOfTaxonomyTerms() (err error) { return } +func (s *Site) newSectionListNode(section string, data WeightedPages) *Node { + n := s.NewNode() + if viper.GetBool("PluralizeListTitles") { + n.Title = strings.Title(inflect.Pluralize(section)) + } else { + n.Title = strings.Title(section) + } + s.setUrls(n, section) + n.Date = data[0].Page.Date + n.Data["Pages"] = data.Pages() + + return n +} + // Render a page for each section func (s *Site) RenderSectionLists() error { for section, data := range s.Sections { - n := s.NewNode() - if viper.GetBool("PluralizeListTitles") { - n.Title = strings.Title(inflect.Pluralize(section)) - } else { - n.Title = strings.Title(section) - } - s.setUrls(n, section) - n.Date = data[0].Page.Date - n.Data["Pages"] = data.Pages() - layouts := []string{"section/" + section + ".html", "_default/section.html", "_default/list.html", "indexes/" + section + ".html", "_default/indexes.html"} - b, err := s.renderPage("section "+section, n, s.appendThemeTemplates(layouts)...) + layouts := s.appendThemeTemplates( + []string{"section/" + section + ".html", "_default/section.html", "_default/list.html", "indexes/" + section + ".html", "_default/indexes.html"}) + + n := s.newSectionListNode(section, data) + + b, err := s.renderPage(fmt.Sprintf("section%s_%d", section, 1), n, s.appendThemeTemplates(layouts)...) if err != nil { return err } - if err := s.WriteDestPage(section, b); err != nil { + if err := s.WriteDestPage(fmt.Sprintf("/%s", section), b); err != nil { return err } + if n.paginator != nil { + + paginatePath := viper.GetString("paginatePath") + + // write alias for page 1 + s.WriteDestAlias(filepath.FromSlash(fmt.Sprintf("/%s/%s/%d", section, paginatePath, 1)), s.permalink(section)) + + pagers := n.paginator.Pagers() + + for i, pager := range pagers { + if i == 0 { + // already created + continue + } + + sectionPagerNode := s.newSectionListNode(section, data) + sectionPagerNode.paginator = pager + if pager.TotalPages() > 0 { + sectionPagerNode.Date = pager.Pages()[0].Date + } + pageNumber := i + 1 + htmlBase := fmt.Sprintf("/%s/%s/%d", section, paginatePath, pageNumber) + b, err := s.renderPage(fmt.Sprintf("section_%s_%d", section, pageNumber), sectionPagerNode, layouts...) + if err != nil { + return err + } + if err := s.WriteDestPage(filepath.FromSlash(htmlBase), b); err != nil { + return err + } + + } + } + if !viper.GetBool("DisableRSS") && section != "" { // XML Feed n.Url = s.permalinkStr(section + "/index.xml") n.Permalink = s.permalink(section) rssLayouts := []string{"section/" + section + ".rss.xml", "_default/rss.xml", "rss.xml", "_internal/_default/rss.xml"} - b, err = s.renderXML("section "+section+" rss", n, s.appendThemeTemplates(rssLayouts)...) + b, err := s.renderXML("section "+section+" rss", n, s.appendThemeTemplates(rssLayouts)...) if err != nil { return err } @@ -1054,8 +1149,10 @@ func (s *Site) newHomeNode() *Node { func (s *Site) RenderHomePage() error { n := s.newHomeNode() - layouts := []string{"index.html", "_default/list.html", "_default/single.html"} - b, err := s.renderPage("homepage", n, s.appendThemeTemplates(layouts)...) + layouts := s.appendThemeTemplates([]string{"index.html", "_default/list.html", "_default/single.html"}) + + b, err := s.renderPage("homepage", n, layouts...) + if err != nil { return err } @@ -1063,6 +1160,39 @@ func (s *Site) RenderHomePage() error { return err } + if n.paginator != nil { + + paginatePath := viper.GetString("paginatePath") + + // write alias for page 1 + s.WriteDestAlias(filepath.FromSlash(fmt.Sprintf("/%s/%d", paginatePath, 1)), s.permalink("/")) + + pagers := n.paginator.Pagers() + + for i, pager := range pagers { + if i == 0 { + // already created + continue + } + + homePagerNode := s.newHomeNode() + homePagerNode.paginator = pager + if pager.TotalPages() > 0 { + homePagerNode.Date = pager.Pages()[0].Date + } + pageNumber := i + 1 + htmlBase := fmt.Sprintf("/%s/%d", paginatePath, pageNumber) + b, err := s.renderPage(fmt.Sprintf("homepage_%d", pageNumber), homePagerNode, layouts...) + if err != nil { + return err + } + if err := s.WriteDestPage(filepath.FromSlash(htmlBase), b); err != nil { + return err + } + + } + } + if !viper.GetBool("DisableRSS") { // XML Feed n.Url = s.permalinkStr("index.xml") @@ -1163,7 +1293,9 @@ func (s *Site) Stats() { jww.FEEDBACK.Println(s.draftStats()) jww.FEEDBACK.Println(s.futureStats()) jww.FEEDBACK.Printf("%d pages created \n", len(s.Pages)) - + if viper.GetInt("paginate") > 0 { + jww.FEEDBACK.Printf("%d paginator pages created \n", s.Info.paginationPageCount) + } taxonomies := viper.GetStringMapString("Taxonomies") for _, pl := range taxonomies { @@ -1172,7 +1304,7 @@ func (s *Site) Stats() { } func (s *Site) setUrls(n *Node, in string) { - n.Url = s.prepUrl(in) + n.Url = helpers.UrlizeAndPrep(in) n.Permalink = s.permalink(n.Url) n.RSSLink = s.permalink(in + ".xml") } @@ -1182,19 +1314,7 @@ func (s *Site) permalink(plink string) template.HTML { } func (s *Site) permalinkStr(plink string) string { - return helpers.MakePermalink(string(viper.GetString("BaseUrl")), s.prepUrl(plink)).String() -} - -func (s *Site) prepUrl(in string) string { - return s.PrettifyUrl(helpers.Urlize(in)) -} - -func (s *Site) PrettifyUrl(in string) string { - return helpers.UrlPrep(viper.GetBool("UglyUrls"), in) -} - -func (s *Site) PrettifyPath(in string) string { - return helpers.PathPrep(viper.GetBool("UglyUrls"), in) + return helpers.MakePermalink(string(viper.GetString("BaseUrl")), helpers.UrlizeAndPrep(plink)).String() } func (s *Site) NewNode() *Node { diff --git a/hugolib/site_url_test.go b/hugolib/site_url_test.go index 331cbc49f3a..f2f25f80d44 100644 --- a/hugolib/site_url_test.go +++ b/hugolib/site_url_test.go @@ -56,6 +56,7 @@ func TestPageCount(t *testing.T) { hugofs.DestinationFS = new(afero.MemMapFs) viper.Set("uglyurls", false) + viper.Set("paginate", 10) s := &Site{ Source: &source.InMemorySource{ByteSource: urlFakeSource}, }