package web import ( "net/url" "strings" "time" "github.com/gramanas/blogspace/internal/i18n" "github.com/gramanas/blogspace/internal/store" ) // Modules a blogger can put in each area. Header modules are the big // blocks; the side columns take the small boxes; the footer its own set. var moduleKinds = map[string][]string{ "header": {"title", "logo", "menu", "search"}, "left": {"menu", "search", "recent", "archive", "tags", "tagcloud", "logo", "html"}, "right": {"menu", "search", "recent", "archive", "tags", "tagcloud", "logo", "html"}, "above": {"html"}, "below": {"html"}, "footer": {"text", "rss", "sitemap", "html"}, } // Friendly names for the dashboard (English; translate with t / s.tr at the point of use). var moduleNames = map[string]string{ "title": "Blog title & tagline", "logo": "Logo", "menu": "Menu", "archive": "Archive (posts by month)", "recent": "Recent posts", "tags": "Tags", "tagcloud": "Tag cloud", "search": "Search box", "html": "Custom HTML", "rss": "RSS link", "text": "Footer text", "sitemap": "Site map", } var areaNames = map[string]string{ "header": "Header", "left": "Left column", "right": "Right column", "above": "Before the posts", "below": "After the posts", "footer": "Footer", } // singleModuleAreas hold at most one module (the custom HTML before/after the posts). var singleModuleAreas = map[string]bool{"above": true, "below": true} func allowedKind(area, kind string) bool { for _, k := range moduleKinds[area] { if k == kind { return true } } return false } // moduleHasSettings says whether the module has an edit page. Header modules // show no heading, so a search box there has nothing to edit. func moduleHasSettings(m store.Module) bool { switch m.Kind { case "html", "text", "recent", "archive", "sitemap", "tags", "tagcloud": return true case "search": return m.Area != "header" } return false } // moduleSummary is the one-liner the Layout page shows next to a module's name. func moduleSummary(lang string, m store.Module) string { switch m.Kind { case "recent": return i18n.Tf(lang, "%d posts", m.Count) + headingNote(lang, m) case "tags": if m.Count == 0 { return i18n.T(lang, "all tags") + headingNote(lang, m) } return i18n.Tf(lang, "%d tags", m.Count) + headingNote(lang, m) case "html", "text": body := strings.Join(strings.Fields(m.Body), " ") if body == "" { return i18n.T(lang, "empty") } if len(body) > 60 { body = body[:57] + "…" } return body } return strings.TrimPrefix(headingNote(lang, m), ", ") } func headingNote(lang string, m store.Module) string { if m.Title == "" { return "" } return ", " + i18n.Tf(lang, "heading “%s”", m.Title) } // validLinkURL accepts what a menu link may point to: a web address, a mail // address, or a path on the blog itself. func validLinkURL(s string) bool { if s == "" || len(s) > 500 || strings.ContainsAny(s, " \t\n\r\"<>") { return false } if strings.HasPrefix(s, "/") && !strings.HasPrefix(s, "//") { return true } u, err := url.Parse(s) if err != nil { return false } switch u.Scheme { case "http", "https": return u.Host != "" case "mailto": return u.Opaque != "" } return false } // Layout is the resolved page structure for one render: the modules of // each area that is switched on, and which side columns are laid out. type Layout struct { Header, Left, Right, Above, Below, Footer []store.Module // A column is laid out when it has something to show — or always, when // the theme keeps the columns' space. HasLeft, HasRight bool // A header search box sits in the menu row when there is one, so the two // header modules need to know about each other. HeaderMenu, HeaderSearch bool } // buildLayout groups modules by area, dropping areas the theme has switched off. func buildLayout(t Theme, mods []store.Module) Layout { var l Layout for _, m := range mods { if !t.AreaOn(m.Area) { continue } switch m.Area { case "header": l.Header = append(l.Header, m) l.HeaderMenu = l.HeaderMenu || m.Kind == "menu" l.HeaderSearch = l.HeaderSearch || m.Kind == "search" case "left": l.Left = append(l.Left, m) case "right": l.Right = append(l.Right, m) case "above": l.Above = append(l.Above, m) case "below": l.Below = append(l.Below, m) case "footer": l.Footer = append(l.Footer, m) } } l.HasLeft = t.KeepColumns || len(l.Left) > 0 l.HasRight = t.KeepColumns || len(l.Right) > 0 return l } func (l Layout) all() []store.Module { var out []store.Module for _, a := range [][]store.Module{l.Header, l.Left, l.Right, l.Above, l.Below, l.Footer} { out = append(out, a...) } return out } // NeedsArchive says whether any visible module lists the archive. func (l Layout) NeedsArchive() bool { for _, m := range l.all() { if m.Kind == "archive" { return true } } return false } // NeedsTags says whether any visible module shows the tags. func (l Layout) NeedsTags() bool { for _, m := range l.all() { if m.Kind == "tags" || m.Kind == "tagcloud" { return true } } return false } // HasSearch says whether a visible module offers a search box (the search // page shows its own only when none does). func (l Layout) HasSearch() bool { for _, m := range l.all() { if m.Kind == "search" { return true } } return false } // MaxRecent is the longest "recent posts" list any visible module wants (0 = none). func (l Layout) MaxRecent() int { n := 0 for _, m := range l.all() { if m.Kind == "recent" && m.Count > n { n = m.Count } } return n } // ---- archive --------------------------------------------------------------- type ArchiveMonth struct { Month time.Month // named by the template in the blog's language Posts []store.PostRef } type ArchiveYear struct { Year int Months []ArchiveMonth Count int } // groupArchive turns the newest-first post index into years → months → posts. func groupArchive(posts []store.PostRef) []ArchiveYear { var years []ArchiveYear var lastY, lastM int for _, p := range posts { y, m := p.CreatedAt.Year(), int(p.CreatedAt.Month()) if len(years) == 0 || y != lastY { years = append(years, ArchiveYear{Year: y}) lastM = 0 } yr := &years[len(years)-1] if len(yr.Months) == 0 || m != lastM { yr.Months = append(yr.Months, ArchiveMonth{Month: time.Month(m)}) } mo := &yr.Months[len(yr.Months)-1] mo.Posts = append(mo.Posts, p) yr.Count++ lastY, lastM = y, m } return years } // placeNotices groups announcements by placement ("left-top", "main-bottom", …). // Ones meant for a side column that is not laid out go to the main column instead. func placeNotices(secs []store.Section, l Layout) map[string][]store.Section { out := map[string][]store.Section{} for _, sec := range secs { col := sec.Column() if (col == "left" && !l.HasLeft) || (col == "right" && !l.HasRight) { col = "main" } key := col + "-" + sec.Position() out[key] = append(out[key], sec) } return out }