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
// Fold says the header menu folds into a button (theme nav_fold, and a
// menu in the header); BurgerAt is the index in Header of the module that
// carries the button: the title or logo block just before the menu, so it
// sits at the right of the brand, else the menu bar itself.
Fold bool
BurgerAt int
}
// 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)
}
}
if t.NavFold {
for j, m := range l.Header {
if m.Kind != "menu" {
continue
}
l.Fold, l.BurgerAt = true, j
if j > 0 && (l.Header[j-1].Kind == "title" || l.Header[j-1].Kind == "logo" && t.Logo != "") { // a logo module without a logo renders nothing
l.BurgerAt = j - 1
}
break
}
}
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
}