package web import ( "html" "math" "net/url" "strings" "time" "unicode/utf8" "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 } // Notice is an announcement placed on the page: the section plus, for a // headed gizmo box sharing a row with the next one, its share of the row. type Notice struct { store.Section Width int // 0 = the whole row; else one of rowShares, in percent } // rowShares are the widths two boxes on one row can take: the longer text // gets the bigger share, and the pair always adds up to 100. var rowShares = []int{25, 33, 50, 67, 75} // 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. // In each group, headed gizmo boxes are paired up, two to a row, and the // pair's widths are set by how much text each carries (main column only — // a side column stacks them). func placeNotices(secs []store.Section, l Layout) map[string][]Notice { out := map[string][]Notice{} 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], Notice{Section: sec}) } for key, ns := range out { if strings.HasPrefix(key, "main-") { pairNotices(ns) } } return out } // pairNotices gives every two consecutive headed gizmo boxes their row shares. func pairNotices(ns []Notice) { for i := 0; i+1 < len(ns); i++ { if !ns[i].pairable() || !ns[i+1].pairable() { continue } ns[i].Width, ns[i+1].Width = shareRow(noticeLen(ns[i]), noticeLen(ns[i+1])) i++ } } func (n Notice) pairable() bool { return n.Style == "gizmo" && n.Title != "" } // noticeLen is how much text a box shows: its title and body, tags stripped. func noticeLen(n Notice) int { return utf8.RuneCountInString(n.Title) + utf8.RuneCountInString(html.UnescapeString(stripTags.Sanitize(n.BodyHTML))) } // shareRow picks the pair of rowShares nearest to the two lengths' ratio. func shareRow(a, b int) (int, int) { if a+b == 0 { return 50, 50 } want := float64(a) * 100 / float64(a+b) best := 50 for _, s := range rowShares { if math.Abs(float64(s)-want) < math.Abs(float64(best)-want) { best = s } } return best, 100 - best }