From d013edb7f838b739db72530e06eb47721baec7b8 Mon Sep 17 00:00:00 2001 From: bep Date: Sat, 18 Oct 2014 20:25:10 +0200 Subject: [PATCH] Implement HasMenuCurrent and IsMenuCurrent for Nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior to this commit, `HasMenuCurrent` and `IsMenuCurrent` on `Node` always returned false. This made it hard (if possible at all) to mark the currently selected menu item/group for non-Page content (home page, category pages etc.), i.e. for menus defined in the site configuration. This commit provides an implementation of these two methods. Notable design choices: * These menu items have a loose coupling to the the resources they navigate to; the `Url` is the best common identificator. To facilitate a consistent matching, and to get it in line with the menu items connected to `Page`, relative Urls (Urls starting with '/') for menu items in the site configuration are converted to permaLinks using the same rules used for others’. * `IsMenuCurrent` only looks at the children of the current node; this is in line with the implementation on `Page`. * Due to this loose coupling, `IsMenuCurrent` have to search downards in the tree to make sure that the node is inside the current menu. This could have been made simpler if it could answer `yes` to any match of any menu item matching the current resource. This commit also adds a set of unit tests for the menu system. Fixes #367 --- hugolib/menu.go | 4 + hugolib/menu_test.go | 319 +++++++++++++++++++++++++++++++++++++++++++ hugolib/node.go | 55 +++++++- hugolib/site.go | 38 ++++-- 4 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 hugolib/menu_test.go diff --git a/hugolib/menu.go b/hugolib/menu.go index 4092432c8..a32cf8f9a 100644 --- a/hugolib/menu.go +++ b/hugolib/menu.go @@ -67,6 +67,10 @@ func (me *MenuEntry) IsEqual(inme *MenuEntry) bool { return me.hopefullyUniqueId() == inme.hopefullyUniqueId() && me.Parent == inme.Parent } +func (me *MenuEntry) IsSameResource(inme *MenuEntry) bool { + return me.Url != "" && inme.Url != "" && me.Url == inme.Url +} + func (me *MenuEntry) MarshallMap(ime map[string]interface{}) { for k, v := range ime { loki := strings.ToLower(k) diff --git a/hugolib/menu_test.go b/hugolib/menu_test.go new file mode 100644 index 000000000..33486370e --- /dev/null +++ b/hugolib/menu_test.go @@ -0,0 +1,319 @@ +package hugolib + +import ( + "github.com/BurntSushi/toml" + "github.com/spf13/hugo/source" + "github.com/spf13/hugo/target" + "github.com/spf13/viper" + "strings" + "testing" +) + +const ( + CONF_MENU1 = ` +[[menu.main]] + name = "Go Home" + url = "/" + weight = 1 + pre = "
" + post = "
" +[[menu.main]] + name = "Blog" + url = "/posts" +[[menu.grandparent]] + name = "grandparent" + url = "/grandparent" + identifier = "grandparentId" +[[menu.grandparent]] + name = "parent" + url = "/parent" + identifier = "parentId" + parent = "grandparentId" +[[menu.grandparent]] + name = "Go Home3" + url = "/" + identifier = "grandchildId" + parent = "parentId" +[[menu.tax]] + name = "Tax1" + url = "/two/key/" + identifier="1" +[[menu.tax]] + name = "Tax2" + url = "/two/key" + identifier="2" +[[menu.tax]] + name = "Tax RSS" + url = "/two/key.xml" + identifier="xml"` +) + +var MENU_PAGE_1 = []byte(`+++ +title = "One" +[menu] + [menu.p_one] +weight = 1 ++++ +Front Matter with Menu Pages`) + +var MENU_PAGE_2 = []byte(`+++ +title = "Two" +weight = 2 +[menu] + [menu.p_one] + [menu.p_two] + Identity = "Two" + ++++ +Front Matter with Menu Pages`) + +var MENU_PAGE_3 = []byte(`+++ +title = "Three" +weight = 3 +[menu] + [menu.p_two] + Name = "Three" + Parent = "Two" ++++ +Front Matter with Menu Pages`) + +var MENU_PAGE_SOURCES = []source.ByteSource{ + {"sect/doc1.md", MENU_PAGE_1, "sect"}, + {"sect/doc2.md", MENU_PAGE_2, "sect"}, + {"sect/doc3.md", MENU_PAGE_3, "sect"}, +} + +type testMenuState struct { + site *Site + oldMenu interface{} + oldBaseUrl interface{} +} + +func TestPageMenu(t *testing.T) { + ts := setupMenuTests(t) + defer resetMenuTestState(ts) + + if len(ts.site.Pages) != 3 { + t.Fatalf("Posts not created, expected 3 got %d", len(ts.site.Pages)) + } + + first := ts.site.Pages[0] + second := ts.site.Pages[1] + third := ts.site.Pages[2] + + pOne := ts.findTestMenuEntryByName("p_one", "One") + pTwo := ts.findTestMenuEntryByName("p_two", "Two") + + for i, this := range []struct { + menu string + page *Page + menuItem *MenuEntry + isMenuCurrent bool + hasMenuCurrent bool + }{ + {"p_one", first, pOne, true, false}, + {"p_one", first, pTwo, false, false}, + {"p_one", second, pTwo, true, false}, + {"p_two", second, pTwo, true, false}, + {"p_two", third, pTwo, false, true}, + {"p_one", third, pTwo, false, false}, + } { + + isMenuCurrent := this.page.IsMenuCurrent(this.menu, this.menuItem) + hasMenuCurrent := this.page.HasMenuCurrent(this.menu, this.menuItem) + + if isMenuCurrent != this.isMenuCurrent { + t.Errorf("[%d] Wrong result from IsMenuCurrent: %v", i, isMenuCurrent) + } + + if hasMenuCurrent != this.hasMenuCurrent { + t.Errorf("[%d] Wrong result for menuItem %v for HasMenuCurrent: %v", i, this.menuItem, hasMenuCurrent) + } + + } + +} + +func TestTaxonomyNodeMenu(t *testing.T) { + ts := setupMenuTests(t) + defer resetMenuTestState(ts) + + for i, this := range []struct { + menu string + taxInfo taxRenderInfo + menuItem *MenuEntry + isMenuCurrent bool + hasMenuCurrent bool + }{ + {"tax", taxRenderInfo{key: "key", singular: "one", plural: "two"}, + ts.findTestMenuEntryById("tax", "1"), true, false}, + {"tax", taxRenderInfo{key: "key", singular: "one", plural: "two"}, + ts.findTestMenuEntryById("tax", "2"), true, false}, + {"tax", taxRenderInfo{key: "key", singular: "one", plural: "two"}, + &MenuEntry{Name: "Somewhere else", Url: "/somewhereelse"}, false, false}, + } { + + n, _ := ts.site.newTaxonomyNode(this.taxInfo) + + isMenuCurrent := n.IsMenuCurrent(this.menu, this.menuItem) + hasMenuCurrent := n.HasMenuCurrent(this.menu, this.menuItem) + + if isMenuCurrent != this.isMenuCurrent { + t.Errorf("[%d] Wrong result from IsMenuCurrent: %v", i, isMenuCurrent) + } + + if hasMenuCurrent != this.hasMenuCurrent { + t.Errorf("[%d] Wrong result for menuItem %v for HasMenuCurrent: %v", i, this.menuItem, hasMenuCurrent) + } + + } + + menuEntryXml := ts.findTestMenuEntryById("tax", "xml") + + if strings.HasSuffix(menuEntryXml.Url, "/") { + t.Error("RSS menu item should not be padded with trailing slash") + } +} + +func TestHomeNodeMenu(t *testing.T) { + ts := setupMenuTests(t) + defer resetMenuTestState(ts) + + home := ts.site.newHomeNode() + homeMenuEntry := &MenuEntry{Name: home.Title, Url: string(home.Permalink)} + + for i, this := range []struct { + menu string + menuItem *MenuEntry + isMenuCurrent bool + hasMenuCurrent bool + }{ + {"main", homeMenuEntry, true, false}, + {"doesnotexist", homeMenuEntry, false, false}, + {"main", &MenuEntry{Name: "Somewhere else", Url: "/somewhereelse"}, false, false}, + {"grandparent", ts.findTestMenuEntryById("grandparent", "grandparentId"), false, false}, + {"grandparent", ts.findTestMenuEntryById("grandparent", "parentId"), false, true}, + {"grandparent", ts.findTestMenuEntryById("grandparent", "grandchildId"), true, false}, + } { + + isMenuCurrent := home.IsMenuCurrent(this.menu, this.menuItem) + hasMenuCurrent := home.HasMenuCurrent(this.menu, this.menuItem) + + if isMenuCurrent != this.isMenuCurrent { + t.Errorf("[%d] Wrong result from IsMenuCurrent: %v", i, isMenuCurrent) + } + + if hasMenuCurrent != this.hasMenuCurrent { + t.Errorf("[%d] Wrong result for menuItem %v for HasMenuCurrent: %v", i, this.menuItem, hasMenuCurrent) + } + } +} + +var testMenuIdentityMatcher = func(me *MenuEntry, id string) bool { return me.Identifier == id } +var testMenuNameMatcher = func(me *MenuEntry, id string) bool { return me.Name == id } + +func (ts testMenuState) findTestMenuEntryById(mn string, id string) *MenuEntry { + return ts.findTestMenuEntry(mn, id, testMenuIdentityMatcher) +} +func (ts testMenuState) findTestMenuEntryByName(mn string, id string) *MenuEntry { + return ts.findTestMenuEntry(mn, id, testMenuNameMatcher) +} + +func (ts testMenuState) findTestMenuEntry(mn string, id string, matcher func(me *MenuEntry, id string) bool) *MenuEntry { + if menu, ok := ts.site.Menus[mn]; ok { + for _, me := range *menu { + + if matcher(me, id) { + return me + } + + descendant := ts.findDescendantTestMenuEntry(me, id, matcher) + if descendant != nil { + return descendant + } + } + } + return nil +} + +func (ts testMenuState) findDescendantTestMenuEntry(parent *MenuEntry, id string, matcher func(me *MenuEntry, id string) bool) *MenuEntry { + if parent.HasChildren() { + for _, child := range parent.Children { + + if matcher(child, id) { + return child + } + + descendant := ts.findDescendantTestMenuEntry(child, id, matcher) + if descendant != nil { + return descendant + } + } + } + return nil +} + +func getTestMenuState(s *Site, t *testing.T) *testMenuState { + menuState := &testMenuState{site: s, oldBaseUrl: viper.Get("baseurl"), oldMenu: viper.Get("menu")} + + menus, err := tomlToMap(CONF_MENU1) + + if err != nil { + t.Fatalf("Unable to Read menus: %v", err) + } + + viper.Set("menu", menus["menu"]) + viper.Set("baseurl", "http://foo.local/zoo/") + + return menuState +} + +func setupMenuTests(t *testing.T) *testMenuState { + s := createTestSite() + testState := getTestMenuState(s, t) + testSiteSetup(s, t) + + return testState +} + +func resetMenuTestState(state *testMenuState) { + viper.Set("menu", state.oldMenu) + viper.Set("baseurl", state.oldBaseUrl) +} + +func createTestSite() *Site { + files := make(map[string][]byte) + target := &target.InMemoryTarget{Files: files} + + s := &Site{ + Target: target, + Source: &source.InMemorySource{ByteSource: MENU_PAGE_SOURCES}, + } + return s +} + +func testSiteSetup(s *Site, t *testing.T) { + + s.Menus = Menus{} + s.initializeSiteInfo() + s.Shortcodes = make(map[string]ShortcodeFunc) + + if err := s.CreatePages(); err != nil { + t.Fatalf("Unable to create pages: %s", err) + } + + if err := s.BuildSiteMeta(); err != nil { + t.Fatalf("Unable to build site metadata: %s", err) + } + +} + +func tomlToMap(s string) (map[string]interface{}, error) { + var data map[string]interface{} = make(map[string]interface{}) + if _, err := toml.Decode(s, &data); err != nil { + return nil, err + } + + return data, nil + +} diff --git a/hugolib/node.go b/hugolib/node.go index 724a3e8ee..be4786390 100644 --- a/hugolib/node.go +++ b/hugolib/node.go @@ -1,4 +1,4 @@ -// Copyright © 2013 Steve Francia . +// 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. @@ -36,10 +36,59 @@ func (n *Node) Now() time.Time { return time.Now() } -func (n *Node) HasMenuCurrent(menu string, me *MenuEntry) bool { +func (n *Node) HasMenuCurrent(menuId string, inme *MenuEntry) bool { + if inme.HasChildren() { + me := MenuEntry{Name: n.Title, Url: string(n.Permalink)} + + for _, child := range inme.Children { + if me.IsSameResource(child) { + return true + } + } + } + return false } -func (n *Node) IsMenuCurrent(menu string, me *MenuEntry) bool { + +func (n *Node) IsMenuCurrent(menuId string, inme *MenuEntry) bool { + + me := MenuEntry{Name: n.Title, Url: string(n.Permalink)} + + if !me.IsSameResource(inme) { + return false + } + + // this resource may be included in several menus + // search for it to make sure that it is in the menu with the given menuId + if menu, ok := (*n.Site.Menus)[menuId]; ok { + for _, menuEntry := range *menu { + if menuEntry.IsSameResource(inme) { + return true + } + + descendantFound := n.isSameAsDescendantMenu(inme, menuEntry) + if descendantFound { + return descendantFound + } + + } + } + + return false +} + +func (n *Node) isSameAsDescendantMenu(inme *MenuEntry, parent *MenuEntry) bool { + if parent.HasChildren() { + for _, child := range parent.Children { + if child.IsSameResource(inme) { + return true + } + descendantFound := n.isSameAsDescendantMenu(inme, child) + if descendantFound { + return descendantFound + } + } + } return false } diff --git a/hugolib/site.go b/hugolib/site.go index c9473930c..a3c8d07f3 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -533,6 +533,10 @@ func (s *Site) getMenusFromConfig() Menus { } menuEntry.MarshallMap(ime) + if strings.HasPrefix(menuEntry.Url, "/") { + // make it absolute so it matches the nodes + menuEntry.Url = s.permalinkStr(menuEntry.Url) + } if ret[name] == nil { ret[name] = &Menu{} } @@ -822,16 +826,23 @@ func (s *Site) RenderTaxonomiesLists() error { return nil } +func (s *Site) newTaxonomyNode(t taxRenderInfo) (*Node, string) { + base := t.plural + "/" + t.key + n := s.NewNode() + n.Title = strings.Replace(strings.Title(t.key), "-", " ", -1) + s.setUrls(n, base) + if len(t.pages) > 0 { + n.Date = t.pages[0].Page.Date + } + n.Data[t.singular] = t.pages + n.Data["Pages"] = t.pages.Pages() + return n, base +} + func taxonomyRenderer(s *Site, taxes <-chan taxRenderInfo, results chan<- error, wg *sync.WaitGroup) { defer wg.Done() for t := range taxes { - base := t.plural + "/" + t.key - n := s.NewNode() - n.Title = strings.Replace(strings.Title(t.key), "-", " ", -1) - s.setUrls(n, base) - n.Date = t.pages[0].Page.Date - n.Data[t.singular] = t.pages - n.Data["Pages"] = t.pages.Pages() + n, base := s.newTaxonomyNode(t) layouts := []string{"taxonomy/" + t.singular + ".html", "indexes/" + t.singular + ".html", "_default/taxonomy.html", "_default/list.html"} err := s.render("taxononomy "+t.singular, n, base+".html", s.appendThemeTemplates(layouts)...) if err != nil { @@ -911,11 +922,16 @@ func (s *Site) RenderSectionLists() error { return nil } -func (s *Site) RenderHomePage() error { +func (s *Site) newHomeNode() *Node { n := s.NewNode() n.Title = n.Site.Title s.setUrls(n, "/") n.Data["Pages"] = s.Pages + return n +} + +func (s *Site) RenderHomePage() error { + n := s.newHomeNode() layouts := []string{"index.html", "_default/list.html", "_default/single.html"} err := s.render("homepage", n, "/", s.appendThemeTemplates(layouts)...) if err != nil { @@ -1040,7 +1056,11 @@ func (s *Site) setUrls(n *Node, in string) { } func (s *Site) permalink(plink string) template.HTML { - return template.HTML(helpers.MakePermalink(string(viper.GetString("BaseUrl")), s.prepUrl(plink)).String()) + return template.HTML(s.permalinkStr(plink)) +} + +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 {