GoBlog/ui.go

442 lines
14 KiB
Go

package main
import (
"fmt"
"html/template"
"strings"
)
// This file includes some functions that render parts of the HTML
type htmlBuilder struct {
strings.Builder
}
func (h *htmlBuilder) write(s string) {
_, _ = h.WriteString(s)
}
func (h *htmlBuilder) writeEscaped(s string) {
if len(s) == 0 {
return
}
template.HTMLEscape(h, []byte(s))
}
func (h *htmlBuilder) writeAttribute(attr, val string) {
h.write(` `)
h.write(attr)
h.write(`="`)
h.writeEscaped(val)
h.write(`"`)
}
func (h *htmlBuilder) writeElementOpen(tag string, attrs ...string) {
h.write(`<`)
h.write(tag)
for i := 0; i < len(attrs); i += 2 {
h.writeAttribute(attrs[i], attrs[i+1])
}
h.write(`>`)
}
func (h *htmlBuilder) writeElementClose(tag string) {
h.write(`</`)
h.write(tag)
h.write(`>`)
}
func (h *htmlBuilder) html() template.HTML {
return template.HTML(h.String())
}
// Render the HTML for the editor preview
func (a *goBlog) renderEditorPreview(hb *htmlBuilder, bc *configBlog, p *post) {
if p.RenderedTitle != "" {
hb.writeElementOpen("h1")
hb.writeEscaped(p.RenderedTitle)
hb.writeElementClose("h1")
}
a.renderPostMeta(hb, p, bc, "preview")
if p.Content != "" {
hb.writeElementOpen("div")
hb.write(string(a.postHtml(p, true)))
hb.writeElementClose("div")
}
a.renderPostTax(hb, p, bc)
}
type summaryTyp string
const (
defaultSummary summaryTyp = "summary"
photoSummary summaryTyp = "photosummary"
)
// Render the HTML for the post summary on index pages
func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ summaryTyp) {
if bc == nil || p == nil {
return
}
if typ == "" {
typ = defaultSummary
}
// Start article
hb.writeElementOpen("article", "class", "h-entry border-bottom")
if p.Priority > 0 {
// Is pinned post
hb.writeElementOpen("p")
hb.writeEscaped("📌 ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "pinned"))
hb.writeElementClose("p")
}
if p.RenderedTitle != "" {
// Has title
hb.writeElementOpen("h2", "class", "p-name")
hb.writeElementOpen("a", "class", "u-url", "href", p.Path)
hb.writeEscaped(p.RenderedTitle)
hb.writeElementClose("a")
hb.writeElementClose("h2")
}
// Show photos in photo summary
photos := a.photoLinks(p)
if typ == photoSummary && len(photos) > 0 {
for _, photo := range photos {
hb.write(string(a.safeRenderMarkdownAsHTML(fmt.Sprintf("![](%s)", photo))))
}
}
// Post meta
a.renderPostMeta(hb, p, bc, "summary")
if typ != photoSummary && a.showFull(p) {
// Show full content
hb.writeElementOpen("div", "class", "e-content")
hb.write(string(a.postHtml(p, false)))
hb.writeElementClose("div")
} else {
// Show summary
hb.writeElementOpen("p", "class", "p-summary")
hb.writeEscaped(a.postSummary(p))
hb.writeElementClose("p")
}
// Show link to full post
hb.writeElementOpen("p")
if len(photos) > 0 {
// Contains photos
hb.writeEscaped("🖼️ ")
}
hb.writeElementOpen("a", "class", "u-url", "href", p.Path)
hb.writeEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "view"))
hb.writeElementClose("a")
hb.writeElementClose("p")
// Finish article
hb.writeElementClose("article")
}
// Render the HTML to show the list of post taxonomy values (tags, series, etc.)
func (a *goBlog) renderPostTax(hb *htmlBuilder, p *post, b *configBlog) {
if b == nil || p == nil {
return
}
// Iterate over all taxonomies
for _, tax := range b.Taxonomies {
// Get all sorted taxonomy values for this post
if taxValues := sortedStrings(p.Parameters[tax.Name]); len(taxValues) > 0 {
// Start new paragraph
hb.writeElementOpen("p")
// Add taxonomy name
hb.writeElementOpen("strong")
hb.writeEscaped(a.renderMdTitle(tax.Title))
hb.writeElementClose("strong")
hb.write(": ")
// Add taxonomy values
for i, taxValue := range taxValues {
if i > 0 {
hb.write(", ")
}
hb.writeElementOpen(
"a",
"class", "p-category",
"rel", "tag",
"href", b.getRelativePath(fmt.Sprintf("/%s/%s", tax.Name, urlize(taxValue))),
)
hb.writeEscaped(a.renderMdTitle(taxValue))
hb.writeElementClose("a")
}
// End paragraph
hb.writeElementClose("p")
}
}
}
// Render the HTML for the post meta information.
// typ can be "summary", "post" or "preview".
func (a *goBlog) renderPostMeta(hb *htmlBuilder, p *post, b *configBlog, typ string) {
if b == nil || p == nil || typ != "summary" && typ != "post" && typ != "preview" {
return
}
if typ == "summary" || typ == "post" {
hb.writeElementOpen("div", "class", "p")
}
// Published time
if published := p.Published; published != "" {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "publishedon"))
hb.write(" ")
hb.writeElementOpen("time", "class", "dt-published", "datetime", dateFormat(published, "2006-01-02T15:04:05Z07:00"))
hb.writeEscaped(isoDateFormat(published))
hb.writeElementClose("time")
// Section
if p.Section != "" {
if section := b.Sections[p.Section]; section != nil {
hb.write(" in ") // TODO: Replace with a proper translation
hb.writeElementOpen("a", "href", b.getRelativePath(section.Name))
hb.writeEscaped(a.renderMdTitle(section.Title))
hb.writeElementClose("a")
}
}
hb.writeElementClose("div")
}
// Updated time
if updated := p.Updated; updated != "" {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "updatedon"))
hb.write(" ")
hb.writeElementOpen("time", "class", "dt-updated", "datetime", dateFormat(updated, "2006-01-02T15:04:05Z07:00"))
hb.writeEscaped(isoDateFormat(updated))
hb.writeElementClose("time")
hb.writeElementClose("div")
}
// IndieWeb Meta
// Reply ("u-in-reply-to")
if replyLink := a.replyLink(p); replyLink != "" {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "replyto"))
hb.writeEscaped(": ")
hb.writeElementOpen("a", "class", "u-in-reply-to", "rel", "noopener", "target", "_blank", "href", replyLink)
if replyTitle := a.replyTitle(p); replyTitle != "" {
hb.writeEscaped(replyTitle)
} else {
hb.writeEscaped(replyLink)
}
hb.writeElementClose("a")
hb.writeElementClose("div")
}
// Like ("u-like-of")
if likeLink := a.likeLink(p); likeLink != "" {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "likeof"))
hb.writeEscaped(": ")
hb.writeElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink)
if likeTitle := a.likeTitle(p); likeTitle != "" {
hb.writeEscaped(likeTitle)
} else {
hb.writeEscaped(likeLink)
}
hb.writeElementClose("div")
}
// Geo
if geoURI := a.geoURI(p); geoURI != nil {
hb.writeElementOpen("div")
hb.writeEscaped("📍 ")
hb.writeElementOpen("a", "class", "p-location h-geo", "target", "_blank", "rel", "nofollow noopener noreferrer", "href", geoOSMLink(geoURI))
hb.writeElementOpen("span", "class", "p-name")
hb.writeEscaped(a.geoTitle(geoURI, b.Lang))
hb.writeElementClose("span")
hb.writeElementOpen("data", "class", "p-longitude", "value", fmt.Sprintf("%f", geoURI.Longitude))
hb.writeElementClose("data")
hb.writeElementOpen("data", "class", "p-latitude", "value", fmt.Sprintf("%f", geoURI.Latitude))
hb.writeElementClose("data")
hb.writeElementClose("a")
hb.writeElementClose("div")
}
// Post specific elements
if typ == "post" {
// Translations
if translations := a.postTranslations(p); len(translations) > 0 {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "translations"))
hb.writeEscaped(": ")
for i, translation := range translations {
if i > 0 {
hb.writeEscaped(", ")
}
hb.writeElementOpen("a", "translate", "no", "href", translation.Path)
hb.writeEscaped(translation.RenderedTitle)
hb.writeElementClose("a")
}
hb.writeElementClose("div")
}
// Short link
if shortLink := a.shortPostURL(p); shortLink != "" {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "shorturl"))
hb.writeEscaped(" ")
hb.writeElementOpen("a", "rel", "shortlink", "href", shortLink)
hb.writeEscaped(shortLink)
hb.writeElementClose("a")
hb.writeElementClose("div")
}
// Status
if p.Status != statusPublished {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "status"))
hb.writeEscaped(": ")
hb.writeEscaped(string(p.Status))
hb.writeElementClose("div")
}
}
if typ == "summary" || typ == "post" {
hb.writeElementClose("div")
}
}
// Render the HTML to show a warning for old posts
func (a *goBlog) renderOldContentWarning(hb *htmlBuilder, p *post, b *configBlog) {
if b == nil || p == nil || !p.Old() {
return
}
hb.writeElementOpen("strong", "class", "p border-top border-bottom")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "oldcontent"))
hb.writeElementClose("strong")
}
// Render the HTML to show interactions
func (a *goBlog) renderInteractions(hb *htmlBuilder, b *configBlog, canonical string) {
if b == nil || canonical == "" {
return
}
// Start accordion
hb.writeElementOpen("details", "class", "p", "id", "interactions")
hb.writeElementOpen("summary")
hb.writeElementOpen("strong")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "interactions"))
hb.writeElementClose("strong")
hb.writeElementClose("summary")
// Render mentions
var renderMentions func(m []*mention)
renderMentions = func(m []*mention) {
if len(m) == 0 {
return
}
hb.writeElementOpen("ul")
for _, mention := range m {
hb.writeElementOpen("li")
hb.writeElementOpen("a", "href", mention.Url, "target", "_blank", "rel", "nofollow noopener noreferrer ugc")
hb.writeEscaped(defaultIfEmpty(mention.Author, mention.Url))
hb.writeElementClose("a")
if mention.Title != "" {
hb.write(" ")
hb.writeElementOpen("strong")
hb.writeEscaped(mention.Title)
hb.writeElementClose("strong")
}
if mention.Content != "" {
hb.write(" ")
hb.writeElementOpen("i")
hb.writeEscaped(mention.Content)
hb.writeElementClose("i")
}
if len(mention.Submentions) > 0 {
renderMentions(mention.Submentions)
}
hb.writeElementClose("li")
}
hb.writeElementClose("ul")
}
renderMentions(a.db.getWebmentionsByAddress(canonical))
// Show form to send a webmention
hb.writeElementOpen("form", "class", "fw p", "method", "post", "action", "/webmention")
hb.writeElementOpen("label", "for", "wm-source", "class", "p")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "interactionslabel"))
hb.writeElementClose("label")
hb.writeElementOpen("input", "id", "wm-source", "type", "url", "name", "source", "placeholder", "URL", "required", "")
hb.writeElementOpen("input", "type", "hidden", "name", "target", "value", canonical)
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(b.Lang, "send"))
hb.writeElementClose("form")
// Show form to create a new comment
hb.writeElementOpen("form", "class", "fw p", "method", "post", "action", "/comment")
hb.writeElementOpen("input", "type", "hidden", "name", "target", "value", canonical)
hb.writeElementOpen("input", "type", "text", "name", "name", "placeholder", a.ts.GetTemplateStringVariant(b.Lang, "nameopt"))
hb.writeElementOpen("input", "type", "url", "name", "website", "placeholder", a.ts.GetTemplateStringVariant(b.Lang, "websiteopt"))
hb.writeElementOpen("textarea", "name", "comment", "required", "", "placeholder", a.ts.GetTemplateStringVariant(b.Lang, "comment"))
hb.writeElementClose("textarea")
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(b.Lang, "docomment"))
hb.writeElementClose("form")
// Finish accordion
hb.writeElementClose("details")
}
// Render HTML for author h-card
func (a *goBlog) renderAuthor(hb *htmlBuilder) {
user := a.cfg.User
if user == nil {
return
}
hb.writeElementOpen("div", "class", "p-author h-card hide")
if user.Picture != "" {
hb.writeElementOpen("data", "class", "u-photo", "value", user.Picture)
hb.writeElementClose("data")
}
if user.Name != "" {
hb.writeElementOpen("a", "class", "p-name u-url", "rel", "me", "href", defaultIfEmpty(user.Link, "/"))
hb.writeEscaped(user.Name)
hb.writeElementClose("a")
}
hb.writeElementClose("div")
}
// Render HTML that includes the head meta tags for a post
func (a *goBlog) renderPostHeadMeta(hb *htmlBuilder, p *post, canonical string) {
if p == nil {
return
}
if canonical != "" {
hb.writeElementOpen("meta", "property", "og:url", "content", canonical)
hb.writeElementOpen("meta", "property", "twitter:url", "content", canonical)
}
if p.RenderedTitle != "" {
hb.writeElementOpen("meta", "property", "og:title", "content", p.RenderedTitle)
hb.writeElementOpen("meta", "property", "twitter:title", "content", p.RenderedTitle)
}
if summary := a.postSummary(p); summary != "" {
hb.writeElementOpen("meta", "name", "description", "content", summary)
hb.writeElementOpen("meta", "property", "og:description", "content", summary)
hb.writeElementOpen("meta", "property", "twitter:description", "content", summary)
}
if p.Published != "" {
hb.writeElementOpen("meta", "itemprop", "datePublished", "content", dateFormat(p.Published, "2006-01-02T15:04:05-07:00"))
}
if p.Updated != "" {
hb.writeElementOpen("meta", "itemprop", "dateModified", "content", dateFormat(p.Updated, "2006-01-02T15:04:05-07:00"))
}
for _, img := range a.photoLinks(p) {
hb.writeElementOpen("meta", "itemprop", "image", "content", img)
hb.writeElementOpen("meta", "property", "og:image", "content", img)
hb.writeElementOpen("meta", "property", "twitter:image", "content", img)
}
}
// Render HTML for TOR notice in the footer
func (a *goBlog) renderTorNotice(hb *htmlBuilder, b *configBlog, torUsed bool, torAddress string) {
if !a.cfg.Server.Tor || b == nil || !torUsed && torAddress == "" {
return
}
if torUsed {
hb.writeElementOpen("p", "id", "tor")
hb.writeEscaped("🔐 ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "connectedviator"))
hb.writeElementClose("p")
} else if torAddress != "" {
hb.writeElementOpen("p", "id", "tor")
hb.writeEscaped("🔓 ")
hb.writeElementOpen("a", "href", torAddress)
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "connectviator"))
hb.writeElementClose("a")
hb.writeEscaped(" ")
hb.writeElementOpen("a", "href", "https://www.torproject.org/", "target", "_blank", "rel", "nofollow noopener noreferrer")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "whatistor"))
hb.writeElementClose("a")
hb.writeElementClose("p")
}
}