diff options
Diffstat (limited to 'internal/web/layout.go')
| -rw-r--r-- | internal/web/layout.go | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/internal/web/layout.go b/internal/web/layout.go new file mode 100644 index 0000000..521cb63 --- /dev/null +++ b/internal/web/layout.go @@ -0,0 +1,222 @@ +package web + +import ( + "net/url" + "strconv" + "strings" + "time" + + "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"}, + "left": {"menu", "recent", "archive", "logo", "html"}, + "right": {"menu", "recent", "archive", "logo", "html"}, + "above": {"html"}, + "below": {"html"}, + "footer": {"text", "rss", "sitemap", "html"}, +} + +// Friendly names for the dashboard. +var moduleNames = map[string]string{ + "title": "Blog title & tagline", + "logo": "Logo", + "menu": "Menu", + "archive": "Archive (posts by month)", + "recent": "Recent posts", + "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. +func moduleHasSettings(kind string) bool { + switch kind { + case "html", "text", "recent", "archive", "sitemap": + return true + } + return false +} + +// moduleSummary is the one-liner the Layout page shows next to a module's name. +func moduleSummary(m store.Module) string { + switch m.Kind { + case "recent": + return strconv.Itoa(m.Count) + " posts" + headingNote(m) + case "html", "text": + body := strings.Join(strings.Fields(m.Body), " ") + if body == "" { + return "empty" + } + if len(body) > 60 { + body = body[:57] + "…" + } + return body + } + return strings.TrimPrefix(headingNote(m), ", ") +} + +func headingNote(m store.Module) string { + if m.Title == "" { + return "" + } + return ", heading “" + 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 +} + +// 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) + 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 +} + +// 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 { + Name string // "September" + 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{Name: time.Month(m).String()}) + } + 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 +} |
