From aa319b70fbed4a50fa14ff31e2be6cbaa9bd386f Mon Sep 17 00:00:00 2001 From: Jan-Lukas Else Date: Thu, 20 Jan 2022 18:22:10 +0100 Subject: [PATCH] Render all non-login using new method to use less allocations and memory --- authentication.go | 12 +- blogroll.go | 12 +- blogstats.go | 8 +- captcha.go | 12 +- comments.go | 2 +- contact.go | 16 +- editor.go | 2 +- errors.go | 9 +- geoMap.go | 20 +- go.mod | 2 +- go.sum | 4 +- original-assets/styles/styles.scss | 2 +- pkgs/minify/minify.go | 2 +- posts.go | 49 +- render.go | 77 +- search.go | 2 +- taxonomies.go | 8 +- templates/assets/css/styles.css | 6 +- templates/base.gohtml | 2 +- templates/blogroll.gohtml | 29 - templates/blogstats.gohtml | 17 - templates/blogstatstable.gohtml | 48 - templates/captcha.gohtml | 21 - templates/comment.gohtml | 21 - templates/contact.gohtml | 30 - templates/error.gohtml | 14 - templates/geomap.gohtml | 29 - templates/index.gohtml | 36 - templates/login.gohtml | 26 - templates/post.gohtml | 77 -- templates/search.gohtml | 21 - templates/statichome.gohtml | 27 - templates/taxonomy.gohtml | 24 - ui.go | 1425 ++++++++++++++++++++-------- uiComponents.go | 386 ++++++++ uiHtmlBuilder.go | 98 ++ ui_test.go | 32 +- 37 files changed, 1611 insertions(+), 997 deletions(-) delete mode 100644 templates/blogroll.gohtml delete mode 100644 templates/blogstats.gohtml delete mode 100644 templates/blogstatstable.gohtml delete mode 100644 templates/captcha.gohtml delete mode 100644 templates/comment.gohtml delete mode 100644 templates/contact.gohtml delete mode 100644 templates/error.gohtml delete mode 100644 templates/geomap.gohtml delete mode 100644 templates/index.gohtml delete mode 100644 templates/login.gohtml delete mode 100644 templates/post.gohtml delete mode 100644 templates/search.gohtml delete mode 100644 templates/statichome.gohtml delete mode 100644 templates/taxonomy.gohtml create mode 100644 uiComponents.go create mode 100644 uiHtmlBuilder.go diff --git a/authentication.go b/authentication.go index 55bb35a..98c77b9 100644 --- a/authentication.go +++ b/authentication.go @@ -62,12 +62,12 @@ func (a *goBlog) authMiddleware(next http.Handler) http.Handler { _ = r.ParseForm() b = []byte(r.PostForm.Encode()) } - a.render(w, r, templateLogin, &renderData{ - Data: map[string]interface{}{ - "loginmethod": r.Method, - "loginheaders": base64.StdEncoding.EncodeToString(h), - "loginbody": base64.StdEncoding.EncodeToString(b), - "totp": a.cfg.User.TOTP != "", + a.renderNew(w, r, a.renderLogin, &renderData{ + Data: &loginRenderData{ + loginMethod: r.Method, + loginHeaders: base64.StdEncoding.EncodeToString(h), + loginBody: base64.StdEncoding.EncodeToString(b), + totp: a.cfg.User.TOTP != "", }, }) }) diff --git a/blogroll.go b/blogroll.go index a3da962..f569e91 100644 --- a/blogroll.go +++ b/blogroll.go @@ -29,13 +29,13 @@ func (a *goBlog) serveBlogroll(w http.ResponseWriter, r *http.Request) { } c := bc.Blogroll can := bc.getRelativePath(defaultIfEmpty(c.Path, defaultBlogrollPath)) - a.render(w, r, templateBlogroll, &renderData{ + a.renderNew(w, r, a.renderBlogroll, &renderData{ Canonical: a.getFullAddress(can), - Data: map[string]interface{}{ - "Title": c.Title, - "Description": c.Description, - "Outlines": outlines, - "Download": can + ".opml", + Data: &blogrollRenderData{ + title: c.Title, + description: c.Description, + outlines: outlines.([]*opml.Outline), + download: can + ".opml", }, }) } diff --git a/blogstats.go b/blogstats.go index b634135..ea6ee56 100644 --- a/blogstats.go +++ b/blogstats.go @@ -25,10 +25,10 @@ func (a *goBlog) initBlogStats() { func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) canonical := bc.getRelativePath(defaultIfEmpty(bc.BlogStats.Path, defaultBlogStatsPath)) - a.render(w, r, templateBlogStats, &renderData{ + a.renderNew(w, r, a.renderBlogStats, &renderData{ Canonical: a.getFullAddress(canonical), - Data: map[string]interface{}{ - "TableUrl": canonical + blogStatsTablePath, + Data: &blogStatsRenderData{ + tableUrl: canonical + blogStatsTablePath, }, }) } @@ -43,7 +43,7 @@ func (a *goBlog) serveBlogStatsTable(w http.ResponseWriter, r *http.Request) { return } // Render - a.render(w, r, templateBlogStatsTable, &renderData{ + a.renderNew(w, r, a.renderBlogStatsTable, &renderData{ Data: data, }) } diff --git a/captcha.go b/captcha.go index 396fba8..ae1b66d 100644 --- a/captcha.go +++ b/captcha.go @@ -65,12 +65,12 @@ func (a *goBlog) captchaMiddleware(next http.Handler) http.Handler { // Render captcha _ = ses.Save(r, w) w.Header().Set("Cache-Control", "no-store,max-age=0") - a.renderWithStatusCode(w, r, http.StatusUnauthorized, templateCaptcha, &renderData{ - Data: map[string]string{ - "captchamethod": r.Method, - "captchaheaders": base64.StdEncoding.EncodeToString(h), - "captchabody": base64.StdEncoding.EncodeToString(b), - "captchaid": captchaId, + a.renderNewWithStatusCode(w, r, http.StatusUnauthorized, a.renderCaptcha, &renderData{ + Data: &captchaRenderData{ + captchaMethod: r.Method, + captchaHeaders: base64.StdEncoding.EncodeToString(h), + captchaBody: base64.StdEncoding.EncodeToString(b), + captchaId: captchaId, }, }) }) diff --git a/comments.go b/comments.go index 58367ce..2b3eb71 100644 --- a/comments.go +++ b/comments.go @@ -41,7 +41,7 @@ func (a *goBlog) serveComment(w http.ResponseWriter, r *http.Request) { return } _, bc := a.getBlog(r) - a.render(w, r, templateComment, &renderData{ + a.renderNew(w, r, a.renderComment, &renderData{ Canonical: a.getFullAddress(bc.getRelativePath(path.Join(commentPath, strconv.Itoa(id)))), Data: comment, }) diff --git a/contact.go b/contact.go index ef96e20..ae50365 100644 --- a/contact.go +++ b/contact.go @@ -17,11 +17,11 @@ const defaultContactPath = "/contact" func (a *goBlog) serveContactForm(w http.ResponseWriter, r *http.Request) { _, bc := a.getBlog(r) cc := bc.Contact - a.render(w, r, templateContact, &renderData{ - Data: map[string]interface{}{ - "title": cc.Title, - "description": cc.Description, - "privacy": cc.PrivacyPolicy, + a.renderNew(w, r, a.renderContact, &renderData{ + Data: &contactRenderData{ + title: cc.Title, + description: cc.Description, + privacy: cc.PrivacyPolicy, }, }) } @@ -63,9 +63,9 @@ func (a *goBlog) sendContactSubmission(w http.ResponseWriter, r *http.Request) { // Send notification a.sendNotification(message.String()) // Give feedback - a.render(w, r, templateContact, &renderData{ - Data: map[string]interface{}{ - "sent": true, + a.renderNew(w, r, a.renderContact, &renderData{ + Data: &contactRenderData{ + sent: true, }, }) } diff --git a/editor.go b/editor.go index dc080d5..84d0b88 100644 --- a/editor.go +++ b/editor.go @@ -73,7 +73,7 @@ func (a *goBlog) createMarkdownPreview(blog string, markdown []byte) (rendered [ // Render post var hb htmlBuilder a.renderEditorPreview(&hb, a.cfg.Blogs[blog], p) - rendered = []byte(hb.String()) + rendered = hb.Bytes() return } diff --git a/errors.go b/errors.go index 6eb2d9f..240af4c 100644 --- a/errors.go +++ b/errors.go @@ -8,11 +8,6 @@ import ( "go.goblog.app/app/pkgs/contenttype" ) -type errorData struct { - Title string - Message string -} - func (a *goBlog) serve404(w http.ResponseWriter, r *http.Request) { a.serveError(w, r, fmt.Sprintf("%s was not found", r.RequestURI), http.StatusNotFound) } @@ -40,8 +35,8 @@ func (a *goBlog) serveError(w http.ResponseWriter, r *http.Request, message stri http.Error(w, message, status) return } - a.renderWithStatusCode(w, r, status, templateError, &renderData{ - Data: &errorData{ + a.renderNewWithStatusCode(w, r, status, a.renderError, &renderData{ + Data: &errorRenderData{ Title: fmt.Sprintf("%d %s", status, http.StatusText(status)), Message: message, }, diff --git a/geoMap.go b/geoMap.go index 2eb4a86..9f15db4 100644 --- a/geoMap.go +++ b/geoMap.go @@ -25,10 +25,10 @@ func (a *goBlog) serveGeoMap(w http.ResponseWriter, r *http.Request) { } if len(allPostsWithLocation) == 0 { - a.render(w, r, templateGeoMap, &renderData{ + a.renderNew(w, r, a.renderGeoMap, &renderData{ Canonical: canonical, - Data: map[string]interface{}{ - "nolocations": true, + Data: &geoMapRenderData{ + noLocations: true, }, }) return @@ -85,14 +85,14 @@ func (a *goBlog) serveGeoMap(w http.ResponseWriter, r *http.Request) { tracksJson = string(tracksJsonBytes) } - a.render(w, r, templateGeoMap, &renderData{ + a.renderNew(w, r, a.renderGeoMap, &renderData{ Canonical: canonical, - Data: map[string]interface{}{ - "locations": locationsJson, - "tracks": tracksJson, - "attribution": a.getMapAttribution(), - "minzoom": a.getMinZoom(), - "maxzoom": a.getMaxZoom(), + Data: &geoMapRenderData{ + locations: locationsJson, + tracks: tracksJson, + attribution: a.getMapAttribution(), + minZoom: a.getMinZoom(), + maxZoom: a.getMaxZoom(), }, }) } diff --git a/go.mod b/go.mod index d545fba..b125107 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/spf13/cast v1.4.1 github.com/spf13/viper v1.10.1 github.com/stretchr/testify v1.7.0 - github.com/tdewolff/minify/v2 v2.9.28 + github.com/tdewolff/minify/v2 v2.9.29 github.com/thoas/go-funk v0.9.1 github.com/tkrajina/gpxgo v1.2.0 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 diff --git a/go.sum b/go.sum index 67ed761..36892ef 100644 --- a/go.sum +++ b/go.sum @@ -415,8 +415,8 @@ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQ github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= -github.com/tdewolff/minify/v2 v2.9.28 h1:70GT62rRyLcH4jbkTFGcy6rktHKGiaSNZb/h8pnuQYw= -github.com/tdewolff/minify/v2 v2.9.28/go.mod h1:6XAjcHM46pFcRE0eztigFPm0Q+Cxsw8YhEWT+rDkcZM= +github.com/tdewolff/minify/v2 v2.9.29 h1:QMVJaCJzWL0mXS33cX792YD074xz4lOhkyBS8hAzYAY= +github.com/tdewolff/minify/v2 v2.9.29/go.mod h1:6XAjcHM46pFcRE0eztigFPm0Q+Cxsw8YhEWT+rDkcZM= github.com/tdewolff/parse/v2 v2.5.27 h1:PL3LzzXaOpmdrknnOlIeO2muIBHAwiKp6TxN1RbU5gI= github.com/tdewolff/parse/v2 v2.5.27/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4= diff --git a/original-assets/styles/styles.scss b/original-assets/styles/styles.scss index a5ced6e..3312476 100644 --- a/original-assets/styles/styles.scss +++ b/original-assets/styles/styles.scss @@ -254,7 +254,7 @@ details summary { height: 400px; } -#post-actions { +#post-actions, #posteditactions { @extend .p; display: flex; flex-wrap: wrap; diff --git a/pkgs/minify/minify.go b/pkgs/minify/minify.go index 41a6ac8..54830eb 100644 --- a/pkgs/minify/minify.go +++ b/pkgs/minify/minify.go @@ -39,7 +39,7 @@ func (m *Minifier) Get() *minify.M { func (m *Minifier) Write(w io.Writer, mediatype string, b []byte) (int, error) { mw := m.Get().Writer(mediatype, w) - defer func() { _ = mw.Close() }() + defer mw.Close() return mw.Write(b) } diff --git a/posts.go b/posts.go index acadbf2..20c4854 100644 --- a/posts.go +++ b/posts.go @@ -73,16 +73,16 @@ func (a *goBlog) servePost(w http.ResponseWriter, r *http.Request) { if canonical == "" { canonical = a.fullPostURL(p) } - template := templatePost + renderMethod := a.renderPost if p.Path == a.getRelativePath(p.Blog, "") { - template = templateStaticHome + renderMethod = a.renderStaticHome } w.Header().Add("Link", fmt.Sprintf("<%s>; rel=shortlink", a.shortPostURL(p))) status := http.StatusOK if strings.HasSuffix(string(p.Status), statusDeletedSuffix) { status = http.StatusGone } - a.renderWithStatusCode(w, r, status, template, &renderData{ + a.renderNewWithStatusCode(w, r, status, renderMethod, &renderData{ BlogString: p.Blog, Canonical: canonical, Data: p, @@ -201,23 +201,22 @@ func (a *goBlog) serveDate(w http.ResponseWriter, r *http.Request) { } var title, dPath strings.Builder if year != 0 { - ys := fmt.Sprintf("%0004d", year) - title.WriteString(ys) - dPath.WriteString(ys) + _, _ = fmt.Fprintf(&title, "%0004d", year) + _, _ = fmt.Fprintf(&dPath, "%0004d", year) } else { - title.WriteString("XXXX") - dPath.WriteString("x") + _, _ = title.WriteString("XXXX") + _, _ = dPath.WriteString("x") } if month != 0 { - title.WriteString(fmt.Sprintf("-%02d", month)) - dPath.WriteString(fmt.Sprintf("/%02d", month)) + _, _ = fmt.Fprintf(&title, "-%02d", month) + _, _ = fmt.Fprintf(&dPath, "/%02d", month) } else if day != 0 { - title.WriteString("-XX") - dPath.WriteString("/x") + _, _ = title.WriteString("-XX") + _, _ = dPath.WriteString("/x") } if day != 0 { - title.WriteString(fmt.Sprintf("-%02d", day)) - dPath.WriteString(fmt.Sprintf("/%02d", day)) + _, _ = fmt.Fprintf(&title, "-%02d", day) + _, _ = fmt.Fprintf(&dPath, "/%02d", day) } _, bc := a.getBlog(r) a.serveIndex(w, r.WithContext(context.WithValue(r.Context(), indexConfigKey, &indexConfig{ @@ -339,18 +338,18 @@ func (a *goBlog) serveIndex(w http.ResponseWriter, r *http.Request) { if summaryTemplate == "" { summaryTemplate = defaultSummary } - a.render(w, r, templateIndex, &renderData{ + a.renderNew(w, r, a.renderIndex, &renderData{ Canonical: a.getFullAddress(path), - Data: map[string]interface{}{ - "Title": title, - "Description": description, - "Posts": posts, - "HasPrev": hasPrev, - "HasNext": hasNext, - "First": path, - "Prev": prevPath, - "Next": nextPath, - "SummaryTemplate": summaryTemplate, + Data: &indexRenderData{ + title: title, + description: description, + posts: posts, + hasPrev: hasPrev, + hasNext: hasNext, + first: path, + prev: prevPath, + next: nextPath, + summaryTemplate: summaryTemplate, }, }) } diff --git a/render.go b/render.go index c7d377d..4182b41 100644 --- a/render.go +++ b/render.go @@ -17,25 +17,11 @@ const ( templatesExt = ".gohtml" templateBase = "base" - templatePost = "post" - templateError = "error" - templateIndex = "index" - templateTaxonomy = "taxonomy" - templateSearch = "search" templateEditor = "editor" templateEditorFiles = "editorfiles" - templateLogin = "login" - templateStaticHome = "statichome" - templateBlogStats = "blogstats" - templateBlogStatsTable = "blogstatstable" - templateComment = "comment" - templateCaptcha = "captcha" templateCommentsAdmin = "commentsadmin" templateNotificationsAdmin = "notificationsadmin" templateWebmentionAdmin = "webmentionadmin" - templateBlogroll = "blogroll" - templateGeoMap = "geomap" - templateContact = "contact" templateIndieAuth = "indieauth" ) @@ -45,49 +31,10 @@ func (a *goBlog) initRendering() error { "md": a.safeRenderMarkdownAsHTML, "mdtitle": a.renderMdTitle, "html": wrapStringAsHTML, - // Post specific - "content": a.postHtml, - "shorturl": a.shortPostURL, - "gettrack": a.getTrack, // Code based rendering - "posttax": func(p *post, b *configBlog) template.HTML { + "tor": func(rd *renderData) template.HTML { var hb htmlBuilder - a.renderPostTax(&hb, p, b) - return hb.html() - }, - "postmeta": func(p *post, b *configBlog, typ string) template.HTML { - var hb htmlBuilder - a.renderPostMeta(&hb, p, b, typ) - return hb.html() - }, - "oldcontentwarning": func(p *post, b *configBlog) template.HTML { - var hb htmlBuilder - a.renderOldContentWarning(&hb, p, b) - return hb.html() - }, - "interactions": func(b *configBlog, c string) template.HTML { - var hb htmlBuilder - a.renderInteractions(&hb, b, c) - return hb.html() - }, - "author": func() template.HTML { - var hb htmlBuilder - a.renderAuthor(&hb) - return hb.html() - }, - "postheadmeta": func(p *post, c string) template.HTML { - var hb htmlBuilder - a.renderPostHeadMeta(&hb, p, c) - return hb.html() - }, - "tor": func(b *configBlog, torUsed bool, torAddress string) template.HTML { - var hb htmlBuilder - a.renderTorNotice(&hb, b, torUsed, torAddress) - return hb.html() - }, - "summary": func(bc *configBlog, p *post, typ summaryTyp) template.HTML { - var hb htmlBuilder - a.renderSummary(&hb, bc, p, typ) + a.renderTorNotice(&hb, rd) return hb.html() }, // Others @@ -97,13 +44,11 @@ func (a *goBlog) initRendering() error { "now": localNowString, "asset": a.assetFileName, "string": a.ts.GetTemplateStringVariantFunc(), - "urlize": urlize, "absolute": a.getFullAddress, "opensearch": openSearchUrl, "mbytes": mBytesString, "editortemplate": a.editorPostTemplate, "editorpostdesc": a.editorPostDesc, - "ttsenabled": a.ttsEnabled, } baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt)) if err != nil { @@ -170,6 +115,24 @@ func (a *goBlog) renderWithStatusCode(w http.ResponseWriter, r *http.Request, st } } +func (a *goBlog) renderNew(w http.ResponseWriter, r *http.Request, f func(*htmlBuilder, *renderData), data *renderData) { + a.renderNewWithStatusCode(w, r, http.StatusOK, f, data) +} + +func (a *goBlog) renderNewWithStatusCode(w http.ResponseWriter, r *http.Request, statusCode int, f func(*htmlBuilder, *renderData), data *renderData) { + // Check render data + a.checkRenderData(r, data) + // Set content type + w.Header().Set(contentType, contenttype.HTMLUTF8) + // Write status code + w.WriteHeader(statusCode) + // Render + minWriter := a.min.Get().Writer(contenttype.HTML, w) + defer minWriter.Close() + hb := newHtmlBuilder(minWriter) + f(hb, data) +} + func (a *goBlog) checkRenderData(r *http.Request, data *renderData) { if data.app == nil { data.app = a diff --git a/search.go b/search.go index a577a3c..7fa80e0 100644 --- a/search.go +++ b/search.go @@ -26,7 +26,7 @@ func (a *goBlog) serveSearch(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, path.Join(servePath, searchEncode(q)), http.StatusFound) return } - a.render(w, r, templateSearch, &renderData{ + a.renderNew(w, r, a.renderSearch, &renderData{ Canonical: a.getFullAddress(servePath), }) } diff --git a/taxonomies.go b/taxonomies.go index 5002b7a..bdd828e 100644 --- a/taxonomies.go +++ b/taxonomies.go @@ -20,11 +20,11 @@ func (a *goBlog) serveTaxonomy(w http.ResponseWriter, r *http.Request) { a.serveError(w, r, err.Error(), http.StatusInternalServerError) return } - a.render(w, r, templateTaxonomy, &renderData{ + a.renderNew(w, r, a.renderTaxonomy, &renderData{ Canonical: a.getFullAddress(r.URL.Path), - Data: map[string]interface{}{ - "Taxonomy": tax, - "ValueGroups": groupStrings(allValues), + Data: &taxonomyRenderData{ + taxonomy: tax, + valueGroups: groupStrings(allValues), }, }) } diff --git a/templates/assets/css/styles.css b/templates/assets/css/styles.css index cb4c01e..515a575 100644 --- a/templates/assets/css/styles.css +++ b/templates/assets/css/styles.css @@ -144,7 +144,7 @@ details summary > *:first-child { border-bottom: 1px solid var(--primary, #000); } -.p, #post-actions, table { +.p, #post-actions, #posteditactions, table { display: block; margin-top: 1em; margin-bottom: 1em; @@ -214,12 +214,12 @@ details summary > *:first-child { height: 400px; } -#post-actions { +#post-actions, #posteditactions { display: flex; flex-wrap: wrap; gap: 5px; } -#post-actions * { +#post-actions *, #posteditactions * { text-align: center; } diff --git a/templates/base.gohtml b/templates/base.gohtml index acfd63a..f483103 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -39,7 +39,7 @@ {{ end }}

© {{ dateformat now "2006" }} {{ with .User.Name }}{{ . }}{{ else }}{{ mdtitle .Blog.Title }}{{ end }}

- {{ tor .Blog .TorUsed .TorAddress }} + {{ tor . }} {{ if .EasterEgg }}{{ end }} diff --git a/templates/blogroll.gohtml b/templates/blogroll.gohtml deleted file mode 100644 index 427ac6a..0000000 --- a/templates/blogroll.gohtml +++ /dev/null @@ -1,29 +0,0 @@ -{{ define "title" }} - {{ mdtitle .Data.Title }} - {{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
- {{ with .Data.Title }}

{{ mdtitle . }}

{{ end }} - {{ with .Data.Description }}{{ md . }}{{ end }} -

{{ string .Blog.Lang "download" }}

- {{ $lang := .Blog.Lang }} - {{ range .Data.Outlines }} - {{ $title := .Title }} - {{ if not $title }}{{ $title = .Text }}{{ end }} -

{{ $title }} ({{ len .Outlines }})

- - {{ end }} -
- {{ if .CommentsEnabled }}{{ interactions .Blog .Canonical }}{{ end }} -{{ end }} - -{{ define "blogroll" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/blogstats.gohtml b/templates/blogstats.gohtml deleted file mode 100644 index b049e59..0000000 --- a/templates/blogstats.gohtml +++ /dev/null @@ -1,17 +0,0 @@ -{{ define "title" }} - {{ with .Blog.BlogStats.Title }}{{ mdtitle . }} - {{ end }}{{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
- {{ with .Blog.BlogStats.Title }}

{{ mdtitle . }}

{{ end }} - {{ with .Blog.BlogStats.Description }}{{ md . }}{{ end }} -

{{ string .Blog.Lang "loading" }}

- -
- {{ if .CommentsEnabled }}{{ interactions .Blog .Canonical }}{{ end }} -{{ end }} - -{{ define "blogstats" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/blogstatstable.gohtml b/templates/blogstatstable.gohtml deleted file mode 100644 index 4eb4994..0000000 --- a/templates/blogstatstable.gohtml +++ /dev/null @@ -1,48 +0,0 @@ -{{ define "blogstatstable" }} - - - - - - - - - - - - {{ $months := .Data.Months }} - {{ range $year := .Data.Years }} - - - - - - - - {{ range $month := (index $months $year.Name) }} - - - - - - - - {{ end }} - {{ end }} - - - - - - - - - - - - - - - -
{{ string .Blog.Lang "year" }}{{ string .Blog.Lang "posts" }}~{{ string .Blog.Lang "chars" }}~{{ string .Blog.Lang "words" }}~{{ string .Blog.Lang "wordsperpost" }}
{{ $year.Name }}{{ $year.Posts }}{{ $year.Chars }}{{ $year.Words }}{{ $year.WordsPerPost }}
{{ $year.Name }}-{{ $month.Name }}{{ $month.Posts }}{{ $month.Chars }}{{ $month.Words }}{{ $month.WordsPerPost }}
{{ string .Blog.Lang "withoutdate" }}{{ .Data.NoDate.Posts }}{{ .Data.NoDate.Chars }}{{ .Data.NoDate.Words }}{{ .Data.NoDate.WordsPerPost }}
{{ string .Blog.Lang "total" }}{{ .Data.Total.Posts }}{{ .Data.Total.Chars }}{{ .Data.Total.Words }}{{ .Data.Total.WordsPerPost }}
-{{ end }} \ No newline at end of file diff --git a/templates/captcha.gohtml b/templates/captcha.gohtml deleted file mode 100644 index 50b1914..0000000 --- a/templates/captcha.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{ define "title" }} - {{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
-

-
- - - - - - -
-
-{{ end }} - -{{ define "captcha" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/comment.gohtml b/templates/comment.gohtml deleted file mode 100644 index a05041d..0000000 --- a/templates/comment.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{ define "title" }} - {{ string .Blog.Lang "acommentby" }} {{ .Data.Name }} -{{ end }} - -{{ define "main" }} -
-

{{ absolute .Data.Target }}

-
- {{ string .Blog.Lang "acommentby" }} - {{ if .Data.Website }}{{ .Data.Name }}{{ else }}{{ .Data.Name }}{{ end }}: -
-

- {{ html .Data.Comment }} -

-
- {{ if .CommentsEnabled }}{{ interactions .Blog .Canonical }}{{ end }} -{{ end }} - -{{ define "comment" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/contact.gohtml b/templates/contact.gohtml deleted file mode 100644 index 30b1e61..0000000 --- a/templates/contact.gohtml +++ /dev/null @@ -1,30 +0,0 @@ -{{ define "title" }} - {{ with .Data.title }}{{ mdtitle . }} - {{ end }}{{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
- {{ if .Data.sent }} -

{{ string .Blog.Lang "messagesent" }}

- {{ else }} - {{ with .Data.title }}

{{ mdtitle . }}

{{ end }} - {{ with .Data.description }}{{ md . }}{{ end }} -
- - - - - {{ if .Data.privacy }} - {{ md .Data.privacy }} - - {{ else }} - - {{ end }} -
- {{ end }} -
-{{ end }} - -{{ define "contact" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/error.gohtml b/templates/error.gohtml deleted file mode 100644 index 93b2e44..0000000 --- a/templates/error.gohtml +++ /dev/null @@ -1,14 +0,0 @@ -{{ define "title" }} - {{ with .Data.Title }}{{ . }}{{end}} -{{ end }} - -{{ define "main" }} -
- {{ with .Data.Title }}

{{ . }}

{{ end }} - {{ with .Data.Message }}

{{ . }}

{{ end }} -
-{{ end }} - -{{ define "error" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/geomap.gohtml b/templates/geomap.gohtml deleted file mode 100644 index ac9a4c0..0000000 --- a/templates/geomap.gohtml +++ /dev/null @@ -1,29 +0,0 @@ -{{ define "title" }} - {{ mdtitle .Blog.Title }} - {{ if not .Data.nolocations }} - - - {{ end }} -{{ end }} - -{{ define "main" }} -
- {{ if .Data.nolocations }} -

{{ string .Blog.Lang "nolocations" }}

- {{ else }} -
- - {{ end }} -
- {{ if .CommentsEnabled }}{{ interactions .Blog .Canonical }}{{ end }} -{{ end }} - -{{ define "geomap" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/index.gohtml b/templates/index.gohtml deleted file mode 100644 index 1e33c48..0000000 --- a/templates/index.gohtml +++ /dev/null @@ -1,36 +0,0 @@ -{{ define "title" }} - {{ with .Data.Title }}{{ mdtitle . }} - {{ end }}{{ mdtitle .Blog.Title }} - - - -{{ end }} - -{{ define "main" }} -
- {{ with .Data.Title }}

{{ mdtitle . }}

{{ end }} - {{ with .Data.Description }}{{ md . }}{{ end }} - {{ if (or .Data.Title .Data.Description) }} -
- {{ end }} - {{ if .Data.Posts }} - {{ $summaryTemplate := .Data.SummaryTemplate }} - {{ $blog := .Blog }} - {{ range $i, $post := .Data.Posts }} - {{ summary $blog $post $summaryTemplate }} - {{ end }} - {{ else }} -

{{ string .Blog.Lang "noposts" }}

- {{ end }} - {{ if .Data.HasPrev }} -

{{ string .Blog.Lang "prev" }}

- {{ end }} - {{ if .Data.HasNext }} -

{{ string .Blog.Lang "next" }}

- {{ end }} - {{ author }} -
-{{ end }} - -{{ define "index" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/login.gohtml b/templates/login.gohtml deleted file mode 100644 index 4f7e776..0000000 --- a/templates/login.gohtml +++ /dev/null @@ -1,26 +0,0 @@ -{{ define "title" }} - {{ string .Blog.Lang "login" }} - {{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
-

{{ string .Blog.Lang "login" }}

-
- - - - - - - {{ if .Data.totp }} - - {{ end }} - -
- {{ author }} -
-{{ end }} - -{{ define "login" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/post.gohtml b/templates/post.gohtml deleted file mode 100644 index d9002fd..0000000 --- a/templates/post.gohtml +++ /dev/null @@ -1,77 +0,0 @@ -{{ define "title" }} - - {{ with .Data.RenderedTitle }}{{ . }} - {{end}}{{ mdtitle .Blog.Title }} - {{ postheadmeta .Data .Canonical }} - {{ with shorturl .Data }}{{ end }} - {{ if .Data.HasTrack }} - - - {{ end }} -{{ end }} - -{{ define "main" }} -
-
- - {{ with .Data.RenderedTitle }}

{{ . }}

{{ end }} - {{ postmeta .Data .Blog "post" }} -
- {{ string .Blog.Lang "share" }} - {{ string .Blog.Lang "translate" }} - - - -
- {{ if .Data.TTS }}
{{ end }} - {{ if .Data.Content }} - {{ oldcontentwarning .Data .Blog }} -
{{ content .Data false }}
- {{ end }} - {{ if .Data.HasTrack }} - {{ $track := (gettrack .Data) }} - {{ if $track }}{{ if $track.HasPoints }} - {{ $lang := .Blog.Lang }} -

{{ with $track.Name }}{{ . }} {{ end }}{{ with $track.Kilometers }}🏁 {{ . }} {{ string $lang "kilometers" }} {{ end }}{{ with $track.Hours }}⌛ {{ . }}{{ end }}

-
- - {{ end }}{{ end }} - {{ end }} - {{ posttax .Data .Blog }} -
- {{ author }} -
- {{ if .LoggedIn }} -
-
- - - -
-
- - - -
- {{ if .Data.Deleted }} -
- - - -
- {{ end }} - {{ if ttsenabled }} -
- - - -
- {{ end }} - -
- {{ end }} - {{ if .CommentsEnabled }}{{ interactions .Blog .Canonical }}{{ end }} -{{ end }} - -{{ define "post" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/search.gohtml b/templates/search.gohtml deleted file mode 100644 index 8adb14e..0000000 --- a/templates/search.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{ define "title" }} - {{ with .Blog.Search.Title }}{{ mdtitle . }} - {{ end }}{{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
- {{ with .Blog.Search.Title }}

{{ mdtitle . }}

{{ end }} - {{ with .Blog.Search.Description }}{{ md . }}{{ end }} - {{ if (or .Blog.Search.Title .Blog.Search.Description) }} -
- {{ end }} -
- - -
-
-{{ end }} - -{{ define "search" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/statichome.gohtml b/templates/statichome.gohtml deleted file mode 100644 index e097cc4..0000000 --- a/templates/statichome.gohtml +++ /dev/null @@ -1,27 +0,0 @@ -{{ define "title" }} - {{ mdtitle .Blog.Title }} - {{ postheadmeta .Data .Canonical }} -{{ end }} - -{{ define "main" }} -
-
- - {{ if .Data.Content }}
{{ content .Data false }}
{{ end }} -
- {{ author }} -
- {{ if .LoggedIn }} -
-
- - - -
-
- {{ end }} -{{ end }} - -{{ define "statichome" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/templates/taxonomy.gohtml b/templates/taxonomy.gohtml deleted file mode 100644 index 3b441a3..0000000 --- a/templates/taxonomy.gohtml +++ /dev/null @@ -1,24 +0,0 @@ -{{ define "title" }} - {{ mdtitle .Data.Taxonomy.Title }} - {{ mdtitle .Blog.Title }} -{{ end }} - -{{ define "main" }} -
- {{ with .Data.Taxonomy.Title }}

{{ mdtitle . }}

{{ end }} - {{ with .Data.Taxonomy.Description }}{{ md . }}{{ end }} - {{ $blog := .Blog }} - {{ $taxonomy := .Data.Taxonomy.Name }} - {{ range $i, $valueGroup := .Data.ValueGroups }} -

{{ $valueGroup.Identifier }}

-

- {{ range $i, $value := $valueGroup.Strings }} - {{ if ne $i 0 }} • {{ end }}{{ . }} - {{ end }} -

- {{ end }} -
-{{ end }} - -{{ define "taxonomy" }} - {{ template "base" . }} -{{ end }} \ No newline at end of file diff --git a/ui.go b/ui.go index 235b16c..7d62e60 100644 --- a/ui.go +++ b/ui.go @@ -2,55 +2,13 @@ package main import ( "fmt" - "html/template" "strings" + "time" + + "github.com/kaorimatz/go-opml" + "github.com/thoas/go-funk" ) -// 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(``) -} - -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") @@ -60,382 +18,1039 @@ func (a *goBlog) renderEditorPreview(hb *htmlBuilder, bc *configBlog, p *post) { a.renderPostMeta(hb, p, bc, "preview") if p.Content != "" { hb.writeElementOpen("div") - hb.write(string(a.postHtml(p, true))) + hb.writeHtml(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 +func (a *goBlog) renderBase(hb *htmlBuilder, rd *renderData, title, main func(hb *htmlBuilder)) { + // Basic HTML things + hb.write("") + hb.writeElementOpen("html", "lang", rd.Blog.Lang) + hb.writeElementOpen("meta", "charset", "utf-8") + hb.writeElementOpen("meta", "name", "viewport", "content", "width=device-width,initial-scale=1") + // CSS + hb.writeElementOpen("link", "rel", "stylesheet", "href", a.assetFileName("css/styles.css")) + // Canonical URL + if rd.Canonical != "" { + hb.writeElementOpen("link", "rel", "canonical", "href", rd.Canonical) } - 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") + // Title + if title != nil { + title(hb) } else { - // Show summary - hb.writeElementOpen("p", "class", "p-summary") - hb.writeEscaped(a.postSummary(p)) - hb.writeElementClose("p") + a.renderTitleTag(hb, rd.Blog, "") } - // 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) { + // Feeds + renderedBlogTitle := a.renderMdTitle(rd.Blog.Title) + // RSS + hb.writeElementOpen("link", "rel", "alternate", "type", "application/rss+xml", "title", fmt.Sprintf("RSS (%s)", renderedBlogTitle), "href", rd.Blog.Path+".rss") + // ATOM + hb.writeElementOpen("link", "rel", "alternate", "type", "application/atom+xml", "title", fmt.Sprintf("ATOM (%s)", renderedBlogTitle), "href", rd.Blog.Path+".atom") + // JSON Feed + hb.writeElementOpen("link", "rel", "alternate", "type", "application/feed+json", "title", fmt.Sprintf("JSON Feed (%s)", renderedBlogTitle), "href", rd.Blog.Path+".json") + // Webmentions + hb.writeElementOpen("link", "rel", "webmention", "href", a.getFullAddress("/webmention")) + // Micropub + hb.writeElementOpen("link", "rel", "micropub", "href", "/micropub") + // IndieAuth + hb.writeElementOpen("link", "rel", "authorization_endpoint", "href", "/indieauth") + hb.writeElementOpen("link", "rel", "token_endpoint", "href", "/indieauth/token") + // Rel-Me user := a.cfg.User - if user == nil { - return + if user != nil { + for _, i := range user.Identities { + hb.writeElementOpen("link", "rel", "me", "href", i) + } } - hb.writeElementOpen("div", "class", "p-author h-card hide") - if user.Picture != "" { - hb.writeElementOpen("data", "class", "u-photo", "value", user.Picture) - hb.writeElementClose("data") + // Opensearch + if os := openSearchUrl(rd.Blog); os != "" { + hb.writeElementOpen("link", "rel", "search", "type", "application/opensearchdescription+xml", "href", os, "title", renderedBlogTitle) } - if user.Name != "" { - hb.writeElementOpen("a", "class", "p-name u-url", "rel", "me", "href", defaultIfEmpty(user.Link, "/")) + // Announcement + if ann := rd.Blog.Announcement; ann != nil && ann.Text != "" { + hb.writeElementOpen("div", "id", "announcement", "data-nosnippet", "") + hb.writeHtml(a.safeRenderMarkdownAsHTML(ann.Text)) + hb.writeElementClose("div") + } + // Header + hb.writeElementOpen("header") + // Blog title + hb.writeElementOpen("h1") + hb.writeElementOpen("a", "href", rd.Blog.getRelativePath("/"), "rel", "home", "title", renderedBlogTitle, "translate", "no") + hb.writeEscaped(renderedBlogTitle) + hb.writeElementClose("a") + hb.writeElementClose("h1") + // Blog description + if rd.Blog.Description != "" { + hb.writeElementOpen("p") + hb.writeElementOpen("i") + hb.writeEscaped(rd.Blog.Description) + hb.writeElementClose("i") + hb.writeElementClose("p") + } + // Main menu + if mm, ok := rd.Blog.Menus["main"]; ok { + hb.writeElementOpen("nav") + for i, item := range mm.Items { + if i > 0 { + hb.write(" • ") + } + hb.writeElementOpen("a", "href", item.Link) + hb.writeEscaped(a.renderMdTitle(item.Title)) + hb.writeElementClose("a") + } + hb.writeElementClose("nav") + } + // Logged-in user menu + if rd.LoggedIn() { + hb.writeElementOpen("nav") + hb.writeElementOpen("a", "href", rd.Blog.getRelativePath("/editor")) + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "editor")) + hb.writeElementClose("a") + hb.write(" • ") + hb.writeElementOpen("a", "href", "/notifications") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "notifications")) + hb.writeElementClose("a") + if rd.WebmentionReceivingEnabled { + hb.write(" • ") + hb.writeElementOpen("a", "href", "/webmention") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "webmentions")) + hb.writeElementClose("a") + } + if rd.CommentsEnabled { + hb.write(" • ") + hb.writeElementOpen("a", "href", "/comment") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comments")) + hb.writeElementClose("a") + } + hb.write(" • ") + hb.writeElementOpen("a", "href", "/logout") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "logout")) + hb.writeElementClose("a") + hb.writeElementClose("nav") + } + hb.writeElementClose("header") + // Main + if main != nil { + main(hb) + } + // Footer + hb.writeElementOpen("footer") + // Footer menu + if fm, ok := rd.Blog.Menus["footer"]; ok { + hb.writeElementOpen("nav") + for i, item := range fm.Items { + if i > 0 { + hb.write(" • ") + } + hb.writeElementOpen("a", "href", item.Link) + hb.writeEscaped(a.renderMdTitle(item.Title)) + hb.writeElementClose("a") + } + hb.writeElementClose("nav") + } + // Copyright + hb.writeElementOpen("p", "translate", "no") + hb.write("© ") + hb.writeEscaped(time.Now().Format("2006")) + hb.write(" ") + if user != nil && user.Name != "" { hb.writeEscaped(user.Name) - hb.writeElementClose("a") + } else { + hb.writeEscaped(renderedBlogTitle) } - hb.writeElementClose("div") + hb.writeElementClose("p") + // Tor + a.renderTorNotice(hb, rd) + hb.writeElementClose("footer") + // Easter egg + if rd.EasterEgg { + hb.writeElementOpen("script", "src", "js/easteregg.js", "defer", "") + hb.writeElementClose("script") + } + hb.writeElementClose("html") } -// 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) - } +type errorRenderData struct { + Title string + Message string } -// 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 == "" { +func (a *goBlog) renderError(hb *htmlBuilder, rd *renderData) { + ed, ok := rd.Data.(*errorRenderData) + if !ok { 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") - } + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, ed.Title) + }, + func(hb *htmlBuilder) { + if ed.Title != "" { + hb.writeElementOpen("h1") + hb.writeEscaped(ed.Title) + hb.writeElementClose("h1") + } + if ed.Message != "" { + hb.writeElementOpen("p", "class", "monospace") + hb.writeEscaped(ed.Message) + hb.writeElementClose("p") + } + }, + ) +} + +type loginRenderData struct { + loginMethod, loginHeaders, loginBody string + totp bool +} + +func (a *goBlog) renderLogin(hb *htmlBuilder, rd *renderData) { + data, ok := rd.Data.(*loginRenderData) + if !ok { + return + } + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, a.ts.GetTemplateStringVariant(rd.Blog.Lang, "login")) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + // Title + hb.writeElementOpen("h1") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "login")) + hb.writeElementClose("h1") + // Form + hb.writeElementOpen("form", "class", "fw p", "method", "post") + // Hidden fields + hb.writeElementOpen("input", "type", "hidden", "name", "loginaction", "value", "login") + hb.writeElementOpen("input", "type", "hidden", "name", "loginmethod", "value", data.loginMethod) + hb.writeElementOpen("input", "type", "hidden", "name", "loginheaders", "value", data.loginHeaders) + hb.writeElementOpen("input", "type", "hidden", "name", "loginbody", "value", data.loginBody) + // Username + hb.writeElementOpen("input", "type", "text", "name", "username", "autocomplete", "username", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "username"), "required", "") + // Password + hb.writeElementOpen("input", "type", "password", "name", "password", "autocomplete", "current-password", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "password"), "required", "") + // TOTP + if data.totp { + hb.writeElementOpen("input", "type", "text", "inputmode", "numeric", "pattern", "[0-9]*", "name", "token", "autocomplete", "one-time-code", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "totp"), "required", "") + } + // Submit + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "login")) + hb.writeElementClose("form") + // Author (required for some IndieWeb apps) + a.renderAuthor(hb) + hb.writeElementClose("main") + }, + ) +} + +func (a *goBlog) renderSearch(hb *htmlBuilder, rd *renderData) { + sc := rd.Blog.Search + renderedSearchTitle := a.renderMdTitle(sc.Title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, renderedSearchTitle) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + titleOrDesc := false + // Title + if renderedSearchTitle != "" { + titleOrDesc = true + hb.writeElementOpen("h1") + hb.writeEscaped(renderedSearchTitle) + hb.writeElementClose("h1") + } + // Description + if sc.Description != "" { + titleOrDesc = true + hb.writeHtml(a.safeRenderMarkdownAsHTML(sc.Description)) + } + if titleOrDesc { + hb.writeElementOpen("hr") + } + // Form + hb.writeElementOpen("form", "class", "fw p", "method", "post") + // Search + args := []interface{}{"type", "text", "name", "q", "required", ""} + if sc.Placeholder != "" { + args = append(args, "placeholder", a.renderMdTitle(sc.Placeholder)) + } + hb.writeElementOpen("input", args...) + // Submit + hb.writeElementOpen("input", "type", "submit", "value", "🔍 "+a.ts.GetTemplateStringVariant(rd.Blog.Lang, "search")) + hb.writeElementClose("form") + hb.writeElementClose("main") + }, + ) +} + +func (a *goBlog) renderComment(h *htmlBuilder, rd *renderData) { + c, ok := rd.Data.(*comment) + if !ok { + return + } + a.renderBase( + h, rd, + func(hb *htmlBuilder) { + hb.writeElementOpen("title") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "acommentby")) + hb.write(" ") + hb.writeEscaped(c.Name) + hb.writeElementClose("title") + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main", "class", "h-entry") + // Target + hb.writeElementOpen("p") + hb.writeElementOpen("a", "class", "u-in-reply-to", "href", a.getFullAddress(c.Target)) + hb.writeEscaped(a.getFullAddress(c.Target)) + hb.writeElementClose("a") + hb.writeElementClose("p") + // Author + hb.writeElementOpen("p", "class", "p-author h-card") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "acommentby")) + hb.write(" ") + if c.Website != "" { + hb.writeElementOpen("a", "class", "p-name u-url", "target", "_blank", "rel", "nofollow noopener noreferrer ugc", "href", c.Website) + hb.writeEscaped(c.Name) + hb.writeElementClose("a") + } else { + hb.writeElementOpen("span", "class", "p-name") + hb.writeEscaped(c.Name) + hb.writeElementClose("span") + } + hb.writeEscaped(":") + hb.writeElementClose("p") + // Content + hb.writeElementOpen("p", "class", "e-content") + hb.write(c.Comment) // Already escaped + hb.writeElementClose("p") + hb.writeElementClose("main") + }, + ) +} + +type indexRenderData struct { + title, description string + posts []*post + hasPrev, hasNext bool + first, prev, next string + summaryTemplate summaryTyp +} + +func (a *goBlog) renderIndex(hb *htmlBuilder, rd *renderData) { + id, ok := rd.Data.(*indexRenderData) + if !ok { + return + } + renderedIndexTitle := a.renderMdTitle(id.title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + // Title + a.renderTitleTag(hb, rd.Blog, renderedIndexTitle) + // Feeds + feedTitle := "" + if renderedIndexTitle != "" { + feedTitle = " (" + renderedIndexTitle + ")" + } + // RSS + hb.writeElementOpen("link", "rel", "alternate", "type", "application/rss+xml", "title", "RSS"+feedTitle, "href", id.first+".rss") + // ATOM + hb.writeElementOpen("link", "rel", "alternate", "type", "application/atom+xml", "title", "AROM"+feedTitle, "href", id.first+".atom") + // JSON Feed + hb.writeElementOpen("link", "rel", "alternate", "type", "application/feed+json", "title", "JSON Feed"+feedTitle, "href", id.first+".json") + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main", "class", "h-feed") + titleOrDesc := false + // Title + if renderedIndexTitle != "" { + titleOrDesc = true + hb.writeElementOpen("h1", "class", "p-name") + hb.writeEscaped(renderedIndexTitle) + hb.writeElementClose("h1") + } + // Description + if id.description != "" { + titleOrDesc = true + hb.writeHtml(a.safeRenderMarkdownAsHTML(id.description)) + } + if titleOrDesc { + hb.writeElementOpen("hr") + } + if id.posts != nil && len(id.posts) > 0 { + // Posts + for _, p := range id.posts { + a.renderSummary(hb, rd.Blog, p, id.summaryTemplate) + } + } else { + // No posts + hb.writeElementOpen("p") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "noposts")) + hb.writeElementClose("p") + } + // Navigation + if id.hasPrev { + hb.writeElementOpen("p") + hb.writeElementOpen("a", "href", id.prev) // TODO: rel=prev? + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "prev")) + hb.writeElementClose("a") + hb.writeElementClose("p") + } + if id.hasNext { + hb.writeElementOpen("p") + hb.writeElementOpen("a", "href", id.next) // TODO: rel=next? + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "next")) + hb.writeElementClose("a") + hb.writeElementClose("p") + } + // Author + a.renderAuthor(hb) + hb.writeElementClose("main") + }, + ) +} + +type blogStatsRenderData struct { + tableUrl string +} + +func (a *goBlog) renderBlogStats(hb *htmlBuilder, rd *renderData) { + bsd, ok := rd.Data.(*blogStatsRenderData) + if !ok { + return + } + bs := rd.Blog.BlogStats + renderedBSTitle := a.renderMdTitle(bs.Title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, renderedBSTitle) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + // Title + if renderedBSTitle != "" { + hb.writeElementOpen("h1") + hb.writeEscaped(renderedBSTitle) + hb.writeElementClose("h1") + } + // Description + if bs.Description != "" { + hb.writeHtml(a.safeRenderMarkdownAsHTML(bs.Description)) + } + // Table + hb.writeElementOpen("p", "id", "loading", "data-table", bsd.tableUrl) + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "loading")) + hb.writeElementClose("p") + hb.writeElementOpen("script", "src", a.assetFileName("js/blogstats.js"), "defer", "") + hb.writeElementClose("script") + hb.writeElementClose("main") + // Interactions + if rd.CommentsEnabled { + a.renderInteractions(hb, rd.Blog, rd.Canonical) + } + }, + ) +} + +func (a *goBlog) renderBlogStatsTable(hb *htmlBuilder, rd *renderData) { + bsd, ok := rd.Data.(*blogStatsData) + if !ok { + return + } + hb.writeElementOpen("table") + // Table header + hb.writeElementOpen("thead") + hb.writeElementOpen("tr") + // Year + hb.writeElementOpen("th", "class", "tal") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "year")) + hb.writeElementClose("th") + // Posts + hb.writeElementOpen("th", "class", "tar") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "posts")) + hb.writeElementClose("th") + // Chars + hb.writeElementOpen("th", "class", "tar") + hb.write("~") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "chars")) + hb.writeElementClose("th") + // Words + hb.writeElementOpen("th", "class", "tar") + hb.write("~") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "words")) + hb.writeElementClose("th") + // Words/post + hb.writeElementOpen("th", "class", "tar") + hb.write("~") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "wordsperpost")) + hb.writeElementClose("th") + hb.writeElementClose("thead") + // Table body + hb.writeElementOpen("tbody") + // Iterate over years + for _, y := range bsd.Years { + // Stats for year + hb.writeElementOpen("tr", "class", "statsyear", "data-year", y.Name) + hb.writeElementOpen("td", "class", "tal") + hb.writeEscaped(y.Name) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(y.Posts) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(y.Chars) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(y.Words) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(y.WordsPerPost) + hb.writeElementClose("td") + hb.writeElementClose("tr") + // Iterate over months + for _, m := range bsd.Months[y.Name] { + // Stats for month + hb.writeElementOpen("tr", "class", "statsmonth hide", "data-year", y.Name) + hb.writeElementOpen("td", "class", "tal") + hb.writeEscaped(y.Name) + hb.write("-") + hb.writeEscaped(m.Name) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(m.Posts) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(m.Chars) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(m.Words) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(m.WordsPerPost) + hb.writeElementClose("td") + hb.writeElementClose("tr") + } + } + // Posts without date + hb.writeElementOpen("tr") + hb.writeElementOpen("td", "class", "tal") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "withoutdate")) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.NoDate.Posts) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.NoDate.Chars) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.NoDate.Words) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.NoDate.WordsPerPost) + hb.writeElementClose("td") + hb.writeElementClose("tr") + // Total + hb.writeElementOpen("tr") + hb.writeElementOpen("td", "class", "tal") + hb.writeElementOpen("strong") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "total")) + hb.writeElementClose("strong") + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.Total.Posts) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.Total.Chars) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.Total.Words) + hb.writeElementClose("td") + hb.writeElementOpen("td", "class", "tar") + hb.writeEscaped(bsd.Total.WordsPerPost) + hb.writeElementClose("td") + hb.writeElementClose("tr") + hb.writeElementClose("tbody") + hb.writeElementClose("table") +} + +type geoMapRenderData struct { + noLocations bool + locations string + tracks string + attribution string + minZoom int + maxZoom int +} + +func (a *goBlog) renderGeoMap(hb *htmlBuilder, rd *renderData) { + gmd, ok := rd.Data.(*geoMapRenderData) + if !ok { + return + } + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, "") + if !gmd.noLocations { + hb.writeElementOpen("link", "rel", "stylesheet", "href", "/-/leaflet/leaflet.css") + hb.writeElementOpen("script", "src", "/-/leaflet/leaflet.js") + hb.writeElementClose("script") + } + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + if gmd.noLocations { + hb.writeElementOpen("p") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "nolocations")) + hb.writeElementClose("p") + } else { + hb.writeElementOpen( + "div", "id", "map", "class", "p", + "data-locations", gmd.locations, + "data-tracks", gmd.tracks, + "data-minzoom", gmd.minZoom, + "data-maxzoom", gmd.maxZoom, + "data-attribution", gmd.attribution, + ) + hb.writeElementClose("div") + hb.writeElementOpen("script", "src", a.assetFileName("js/geomap.js")) + hb.writeElementClose("script") + } + hb.writeElementClose("main") + if rd.CommentsEnabled { + a.renderInteractions(hb, rd.Blog, rd.Canonical) + } + }, + ) +} + +type blogrollRenderData struct { + title string + description string + outlines []*opml.Outline + download string +} + +func (a *goBlog) renderBlogroll(hb *htmlBuilder, rd *renderData) { + bd, ok := rd.Data.(*blogrollRenderData) + if !ok { + return + } + renderedTitle := a.renderMdTitle(bd.title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, renderedTitle) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + // Title + if renderedTitle != "" { + hb.writeElementOpen("h1") + hb.writeEscaped(renderedTitle) + hb.writeElementClose("h1") + } + // Description + if bd.description != "" { + hb.writeElementOpen("p") + hb.writeHtml(a.safeRenderMarkdownAsHTML(bd.description)) + hb.writeElementClose("p") + } + // Download button + hb.writeElementOpen("p") + hb.writeElementOpen("a", "href", rd.Blog.getRelativePath(bd.download), "class", "button", "download", "") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "download")) + hb.writeElementClose("a") + hb.writeElementClose("p") + // Outlines + for _, outline := range bd.outlines { + title := outline.Title + if title == "" { + title = outline.Text + } + hb.writeElementOpen("h2", "id", urlize(title)) + hb.writeEscaped(fmt.Sprintf("%s (%d)", title, len(outline.Outlines))) + hb.writeElementClose("h2") + hb.writeElementOpen("ul") + for _, subOutline := range outline.Outlines { + subTitle := subOutline.Title + if subTitle == "" { + subTitle = subOutline.Text + } + hb.writeElementOpen("li") + hb.writeElementOpen("a", "href", subOutline.HTMLURL, "target", "_blank") + hb.writeEscaped(subTitle) + hb.writeElementClose("a") + hb.write(" (") + hb.writeElementOpen("a", "href", subOutline.XMLURL, "target", "_blank") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "feed")) + hb.writeElementClose("a") + hb.write(")") + hb.writeElementClose("li") + } + hb.writeElementClose("ul") + } + hb.writeElementClose("main") + // Interactions + if rd.CommentsEnabled { + a.renderInteractions(hb, rd.Blog, rd.Canonical) + } + }, + ) +} + +type contactRenderData struct { + title string + description string + privacy string + sent bool +} + +func (a *goBlog) renderContact(hb *htmlBuilder, rd *renderData) { + cd, ok := rd.Data.(*contactRenderData) + if !ok { + return + } + renderedTitle := a.renderMdTitle(cd.title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, renderedTitle) + }, + func(hb *htmlBuilder) { + if cd.sent { + hb.writeElementOpen("main") + hb.writeElementOpen("p") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "messagesent")) + hb.writeElementClose("p") + hb.writeElementClose("main") + return + } + hb.writeElementOpen("main") + // Title + if renderedTitle != "" { + hb.writeElementOpen("h1") + hb.writeEscaped(renderedTitle) + hb.writeElementClose("h1") + } + // Description + if cd.description != "" { + hb.writeHtml(a.safeRenderMarkdownAsHTML(cd.description)) + } + // Form + hb.writeElementOpen("form", "class", "fw p", "method", "post") + // Name (optional) + hb.writeElementOpen("input", "type", "text", "name", "name", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "nameopt")) + // Website (optional) + hb.writeElementOpen("input", "type", "url", "name", "website", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "websiteopt")) + // Email (optional) + hb.writeElementOpen("input", "type", "email", "name", "email", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "emailopt")) + // Message (required) + hb.writeElementOpen("textarea", "name", "message", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "message"), "required", "") + hb.writeElementClose("textarea") + // Send + if cd.privacy != "" { + hb.writeHtml(a.safeRenderMarkdownAsHTML(cd.privacy)) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "contactagreesend")) + } else { + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "contactsend")) + } + hb.writeElementClose("form") + hb.writeElementClose("main") + }, + ) +} + +type captchaRenderData struct { + captchaMethod string + captchaHeaders string + captchaBody string + captchaId string +} + +func (a *goBlog) renderCaptcha(hb *htmlBuilder, rd *renderData) { + crd, ok := rd.Data.(*captchaRenderData) + if !ok { + return + } + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, "") + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + // Captcha image + hb.writeElementOpen("p") + hb.writeElementOpen("img", "src", "/captcha/"+crd.captchaId+".png", "class", "captchaimg") + hb.writeElementClose("p") + // Form + hb.writeElementOpen("form", "class", "fw p", "method", "post") + // Hidden fields + hb.writeElementOpen("input", "type", "hidden", "name", "captchaaction", "value", "captcha") + hb.writeElementOpen("input", "type", "hidden", "name", "captchamethod", "value", crd.captchaMethod) + hb.writeElementOpen("input", "type", "hidden", "name", "captchaheaders", "value", crd.captchaHeaders) + hb.writeElementOpen("input", "type", "hidden", "name", "captchabody", "value", crd.captchaBody) + // Text + hb.writeElementOpen("input", "type", "text", "name", "digits", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "captchainstructions"), "required", "") + // Submit + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "submit")) + hb.writeElementClose("form") + hb.writeElementClose("main") + }, + ) +} + +type taxonomyRenderData struct { + taxonomy *configTaxonomy + valueGroups []stringGroup +} + +func (a *goBlog) renderTaxonomy(hb *htmlBuilder, rd *renderData) { + trd, ok := rd.Data.(*taxonomyRenderData) + if !ok { + return + } + renderedTitle := a.renderMdTitle(trd.taxonomy.Title) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, renderedTitle) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main") + // Title + if renderedTitle != "" { + hb.writeElementOpen("h1") + hb.writeEscaped(renderedTitle) + hb.writeElementClose("h1") + } + // Description + if trd.taxonomy.Description != "" { + hb.writeHtml(a.safeRenderMarkdownAsHTML(trd.taxonomy.Description)) + } + // List + for _, valGroup := range trd.valueGroups { + // Title + hb.writeElementOpen("h2") + hb.writeEscaped(valGroup.Identifier) + hb.writeElementClose("h2") + // List + hb.writeElementOpen("p") + for i, val := range valGroup.Strings { + if i > 0 { + hb.write(" • ") + } + hb.writeElementOpen("a", "href", fmt.Sprintf("/%s/%s", trd.taxonomy.Name, urlize(val))) + hb.writeEscaped(val) + hb.writeElementClose("a") + } + hb.writeElementClose("p") + } + }, + ) +} + +func (a *goBlog) renderPost(hb *htmlBuilder, rd *renderData) { + p, ok := rd.Data.(*post) + if !ok { + return + } + postHtml := a.postHtml(p, false) + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, p.RenderedTitle) + if strings.Contains(string(postHtml), "c-chroma") { + hb.writeElementOpen("link", "rel", "stylesheet", "href", a.assetFileName("css/chroma.css")) + } + a.renderPostHeadMeta(hb, p, rd.Canonical) + if su := a.shortPostURL(p); su != "" { + hb.writeElementOpen("link", "rel", "shortlink", "href", su) + } + if p.HasTrack() { + hb.writeElementOpen("link", "rel", "stylesheet", "href", "/-/leaflet/leaflet.css") + hb.writeElementOpen("script", "src", "/-/leaflet/leaflet.js") + hb.writeElementClose("script") + } + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main", "class", "h-entry") + hb.writeElementOpen("article") + // URL (hidden just for microformats) + hb.writeElementOpen("data", "value", a.getFullAddress(p.Path), "class", "u-url hide") + hb.writeElementClose("data") + // Title + if p.RenderedTitle != "" { + hb.writeElementOpen("h1", "class", "p-name") + hb.writeEscaped(p.RenderedTitle) + hb.writeElementClose("h1") + } + // Post meta + a.renderPostMeta(hb, p, rd.Blog, "post") + // Post actions + hb.writeElementOpen("div", "id", "post-actions") + // Share button + hb.writeElementOpen("a", "class", "button", "href", fmt.Sprintf("https://www.addtoany.com/share#url=%s%s", a.shortPostURL(p), funk.ShortIf(p.RenderedTitle != "", "&title="+p.RenderedTitle, "")), "target", "_blank", "rel", "nofollow noopener noreferrer") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "share")) + hb.writeElementClose("a") + // Translate button + hb.writeElementOpen("a", "id", "translateBtn", "class", "button", "href", fmt.Sprintf("https://translate.google.com/translate?u=%s", a.getFullAddress(p.Path)), "target", "_blank", "rel", "nofollow noopener noreferrer") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "translate")) + hb.writeElementClose("a") + hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/translate.js")) + hb.writeElementClose("script") + // Speak button + hb.writeElementOpen("button", "id", "speakBtn", "class", "hide", "data-speak", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "speak"), "data-stopspeak", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "stopspeak")) + hb.writeElementClose("button") + hb.writeElementOpen("script", "defer", "", "src", funk.ShortIf(p.TTS() != "", a.assetFileName("js/tts.js"), a.assetFileName("js/speak.js"))) + hb.writeElementClose("script") + hb.writeElementClose("div") + // TTS + if tts := p.TTS(); tts != "" { + hb.writeElementOpen("div", "class", "p hide", "id", "tts") + hb.writeElementOpen("audio", "controls", "", "preload", "none", "id", "tts-audio") + hb.writeElementOpen("source", "src", tts) + hb.writeElementClose("source") + hb.writeElementClose("audio") + hb.writeElementClose("div") + } + // Old content warning + a.renderOldContentWarning(hb, p, rd.Blog) + // Content + if p.Content != "" { + // Content + hb.writeElementOpen("div", "class", "e-content") + hb.writeHtml(postHtml) + hb.writeElementClose("div") + } + // GPS Track + if p.HasTrack() { + if track, err := a.getTrack(p); err == nil && track != nil && track.HasPoints { + // Track stats + hb.writeElementOpen("p") + if track.Name != "" { + hb.writeElementOpen("strong") + hb.writeEscaped(track.Name) + hb.writeElementClose("strong") + hb.write(" ") + } + if track.Kilometers != "" { + hb.write("🏁 ") + hb.writeEscaped(track.Kilometers) + hb.write(" ") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "kilometers")) + hb.write(" ") + } + if track.Hours != "" { + hb.write("⏱ ") + hb.writeEscaped(track.Hours) + } + hb.writeElementClose("p") + // Map + hb.writeElementOpen("div", "id", "map", "class", "p", "data-paths", track.PathsJSON, "data-points", track.PointsJSON, "data-minzoom", track.MinZoom, "data-maxzoom", track.MaxZoom, "data-attribution", track.MapAttribution) + hb.writeElementClose("div") + hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/geotrack.js")) + hb.writeElementClose("script") + } + } + // Taxonomies + a.renderPostTax(hb, p, rd.Blog) + hb.writeElementClose("article") + // Author + a.renderAuthor(hb) + hb.writeElementClose("main") + // Post edit actions + if rd.LoggedIn() { + hb.writeElementOpen("div", "id", "posteditactions") + // Update + hb.writeElementOpen("form", "method", "post", "action", rd.Blog.RelativePath("/editor")+"#update") + hb.writeElementOpen("input", "type", "hidden", "name", "editoraction", "value", "loadupdate") + hb.writeElementOpen("input", "type", "hidden", "name", "path", "value", p.Path) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update")) + hb.writeElementClose("form") + // Delete + hb.writeElementOpen("form", "method", "post", "action", rd.Blog.RelativePath("/editor")) + hb.writeElementOpen("input", "type", "hidden", "name", "action", "value", "delete") + hb.writeElementOpen("input", "type", "hidden", "name", "url", "value", rd.Canonical) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "delete"), "class", "confirm", "data-confirmmessage", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "confirmdelete")) + hb.writeElementClose("form") + // Undelete + if p.Deleted() { + hb.writeElementOpen("form", "method", "post", "action", rd.Blog.RelativePath("/editor")) + hb.writeElementOpen("input", "type", "hidden", "name", "action", "value", "undelete") + hb.writeElementOpen("input", "type", "hidden", "name", "url", "value", rd.Canonical) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "undelete")) + hb.writeElementClose("form") + } + // TTS + if a.ttsEnabled() { + hb.writeElementOpen("form", "method", "post", "action", rd.Blog.RelativePath("/editor")) + hb.writeElementOpen("input", "type", "hidden", "name", "editoraction", "value", "tts") + hb.writeElementOpen("input", "type", "hidden", "name", "url", "value", rd.Canonical) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "gentts")) + hb.writeElementClose("form") + } + hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/formconfirm.js")) + hb.writeElementClose("script") + hb.writeElementClose("div") + } + // Comments + if rd.CommentsEnabled { + a.renderInteractions(hb, rd.Blog, rd.Canonical) + } + }, + ) +} + +func (a *goBlog) renderStaticHome(hb *htmlBuilder, rd *renderData) { + p, ok := rd.Data.(*post) + if !ok { + return + } + a.renderBase( + hb, rd, + func(hb *htmlBuilder) { + a.renderTitleTag(hb, rd.Blog, "") + a.renderPostHeadMeta(hb, p, rd.Canonical) + }, + func(hb *htmlBuilder) { + hb.writeElementOpen("main", "class", "h-entry") + hb.writeElementOpen("article") + // URL (hidden just for microformats) + hb.writeElementOpen("data", "value", a.getFullAddress(p.Path), "class", "u-url hide") + hb.writeElementClose("data") + // Content + if p.Content != "" { + // Content + hb.writeElementOpen("div", "class", "e-content") + hb.writeHtml(a.postHtml(p, false)) + hb.writeElementClose("div") + } + // Author + a.renderAuthor(hb) + hb.writeElementClose("article") + hb.writeElementClose("main") + // Update + if rd.LoggedIn() { + hb.writeElementOpen("div", "id", "posteditactions") + hb.writeElementOpen("form", "method", "post", "action", rd.Blog.RelativePath("/editor")+"#update") + hb.writeElementOpen("input", "type", "hidden", "name", "editoraction", "value", "loadupdate") + hb.writeElementOpen("input", "type", "hidden", "name", "path", "value", p.Path) + hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update")) + hb.writeElementClose("form") + hb.writeElementClose("div") + } + }, + ) } diff --git a/uiComponents.go b/uiComponents.go new file mode 100644 index 0000000..2cf1d7f --- /dev/null +++ b/uiComponents.go @@ -0,0 +1,386 @@ +package main + +import "fmt" + +type summaryTyp string + +const ( + defaultSummary summaryTyp = "summary" + photoSummary summaryTyp = "photosummary" +) + +// 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") +} + +// 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") + } + } +} + +// 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") + } +} + +// 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") +} + +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") +} + +// 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") +} + +// 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) + } +} + +// TOR notice in the footer +func (a *goBlog) renderTorNotice(hb *htmlBuilder, rd *renderData) { + if !a.cfg.Server.Tor || (!rd.TorUsed && rd.TorAddress == "") { + return + } + if rd.TorUsed { + hb.writeElementOpen("p", "id", "tor") + hb.writeEscaped("🔐 ") + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "connectedviator")) + hb.writeElementClose("p") + } else if rd.TorAddress != "" { + hb.writeElementOpen("p", "id", "tor") + hb.writeEscaped("🔓 ") + hb.writeElementOpen("a", "href", rd.TorAddress) + hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.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(rd.Blog.Lang, "whatistor")) + hb.writeElementClose("a") + hb.writeElementClose("p") + } +} + +func (a *goBlog) renderTitleTag(hb *htmlBuilder, blog *configBlog, optionalTitle string) { + hb.writeElementOpen("title") + if optionalTitle != "" { + hb.writeEscaped(optionalTitle) + hb.writeEscaped(" - ") + } + hb.writeEscaped(a.renderMdTitle(blog.Title)) + hb.writeElementClose("title") +} diff --git a/uiHtmlBuilder.go b/uiHtmlBuilder.go new file mode 100644 index 0000000..d5c3ff4 --- /dev/null +++ b/uiHtmlBuilder.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "io" + textTemplate "text/template" +) + +type htmlBuilder struct { + w io.Writer + buf bytes.Buffer +} + +func newHtmlBuilder(w io.Writer) *htmlBuilder { + return &htmlBuilder{ + w: w, + } +} + +func (h *htmlBuilder) getWriter() io.Writer { + if h.w != nil { + return h.w + } + return &h.buf +} + +func (h *htmlBuilder) Write(p []byte) (int, error) { + return h.getWriter().Write(p) +} + +func (h *htmlBuilder) WriteString(s string) (int, error) { + return io.WriteString(h.getWriter(), s) +} + +func (h *htmlBuilder) Read(p []byte) (int, error) { + return h.buf.Read(p) +} + +func (h *htmlBuilder) String() string { + return h.buf.String() +} + +func (h *htmlBuilder) Bytes() []byte { + return h.buf.Bytes() +} + +func (h *htmlBuilder) html() template.HTML { + return template.HTML(h.String()) +} + +func (h *htmlBuilder) write(s string) { + _, _ = h.WriteString(s) +} + +func (h *htmlBuilder) writeHtml(s template.HTML) { + h.write(string(s)) +} + +func (h *htmlBuilder) writeEscaped(s string) { + textTemplate.HTMLEscape(h, []byte(s)) +} + +func (h *htmlBuilder) writeAttribute(attr string, val interface{}) { + h.write(` `) + h.write(attr) + h.write(`=`) + if valStr, ok := val.(string); ok { + h.write(`"`) + h.writeEscaped(valStr) + h.write(`"`) + } else { + h.writeEscaped(fmt.Sprint(val)) + } +} + +func (h *htmlBuilder) writeElementOpen(tag string, attrs ...interface{}) { + h.write(`<`) + h.write(tag) + for i := 0; i < len(attrs); i += 2 { + if i+2 > len(attrs) { + break + } + attrStr, ok := attrs[i].(string) + if !ok { + continue + } + h.writeAttribute(attrStr, attrs[i+1]) + } + h.write(`>`) +} + +func (h *htmlBuilder) writeElementClose(tag string) { + h.write(``) +} diff --git a/ui_test.go b/ui_test.go index f615ea6..603ce06 100644 --- a/ui_test.go +++ b/ui_test.go @@ -2,6 +2,7 @@ package main import ( "html/template" + "io" "os" "strings" "testing" @@ -11,6 +12,10 @@ import ( "github.com/stretchr/testify/require" ) +var _ io.Writer = &htmlBuilder{} +var _ io.StringWriter = &htmlBuilder{} +var _ io.Reader = &htmlBuilder{} + func Test_renderPostTax(t *testing.T) { app := &goBlog{ cfg: createDefaultTestConfig(t), @@ -137,30 +142,3 @@ func Test_renderAuthor(t *testing.T) { assert.Equal(t, template.HTML("
John Doe
"), res) } - -func Test_renderTorNotice(t *testing.T) { - app := &goBlog{ - cfg: createDefaultTestConfig(t), - } - _ = app.initConfig() - _ = app.initDatabase(false) - app.initComponents(false) - - app.cfg.Server.Tor = true - - var hb htmlBuilder - app.renderTorNotice(&hb, app.cfg.Blogs["default"], true, "http://abc.onion:80/test") - res := hb.html() - _, err := goquery.NewDocumentFromReader(strings.NewReader(string(res))) - require.NoError(t, err) - - assert.Equal(t, template.HTML("

🔐 Connected via Tor.

"), res) - - hb.Reset() - app.renderTorNotice(&hb, app.cfg.Blogs["default"], false, "http://abc.onion:80/test") - res = hb.html() - _, err = goquery.NewDocumentFromReader(strings.NewReader(string(res))) - require.NoError(t, err) - - assert.Equal(t, template.HTML("

🔓 Connect via Tor. What is Tor?

"), res) -}