Add UI plugins (#33) and improve documentation for plugins (#32)

This commit is contained in:
Jan-Lukas Else 2022-08-12 12:48:16 +02:00 committed by Jan-Lukas Else
parent 2158b156c5
commit a45d28d04f
24 changed files with 1519 additions and 1189 deletions

View File

@ -59,3 +59,4 @@ Here's an (incomplete) list of features:
- [How to configure GoBlog](./config.md)
- [Administration paths](./admin-paths.md)
- [GoBlog's storage system](./storage.md)
- [GoBlog Plugins](./plugins.md)

44
docs/plugins.md Normal file
View File

@ -0,0 +1,44 @@
# GoBlog Plugins
GoBlog has a (still experimental) plugin system, that allows adding new functionality to GoBlog without adding anything to the GoBlog source and recompiling GoBlog. Plugins work using the [Yaegi](https://github.com/traefik/yaegi) package by Traefik and are interpreted at run time.
## Configuration
Plugins can be added to GoBlog by adding a "plugins" section to the configuration.
```yaml
plugins:
- path: ./plugins/syndication
type: ui
import: syndication
config:
parameter: syndication
- path: ./plugins/demo
type: ui
import: demoui
- path: ./plugins/demo
type: middleware
import: demomiddleware
config:
prio: 99
```
You need to specify the path to the plugin (remember to mount the path to your GoBlog container when using Docker), the type of the plugin, the import (the Go packakge) and you can additionally provide configuration for the plugin.
## Types of plugins
- `exec` (Command that is executed in a Go routine when starting GoBlog) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#Exec
- `middleware` (HTTP middleware to intercept or modify HTTP requests) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#Middleware
- `ui` (Render additional HTML) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UI
## Plugins
Some simple plugins are included in the main GoBlog repository. Some can be found elsewhere.
### Syndication links (plugins/syndication)
Adds hidden `u-syndication` `data` elements to post page when the configured post parameter (default: "syndication") is available.
#### Config
`parameter` (string): Name for the post parameter containing the syndication links.

View File

@ -12,6 +12,7 @@ import (
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/contenttype"
"go.goblog.app/app/pkgs/htmlbuilder"
"gopkg.in/yaml.v3"
ws "nhooyr.io/websocket"
)
@ -77,7 +78,7 @@ func (a *goBlog) createMarkdownPreview(w io.Writer, blog string, markdown io.Rea
p.RenderedTitle = a.renderMdTitle(t)
}
// Render post (using post's blog config)
hb := newHtmlBuilder(w)
hb := htmlbuilder.NewHtmlBuilder(w)
a.renderEditorPreview(hb, a.cfg.Blogs[p.Blog], p)
}

2
go.mod
View File

@ -59,7 +59,7 @@ require (
// master
github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48
golang.org/x/net v0.0.0-20220811182439-13a9a731de15
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
golang.org/x/text v0.3.7
gopkg.in/yaml.v3 v3.0.1

4
go.sum
View File

@ -619,8 +619,8 @@ golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48 h1:N9Vc/rorQUDes6B9CNdIxAn5jODGj2wzfrei2x4wNj4=
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220811182439-13a9a731de15 h1:cik0bxZUSJVDyaHf1hZPSDsU8SZHGQZQMeueXCE7yBQ=
golang.org/x/net v0.0.0-20220811182439-13a9a731de15/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@ -46,7 +46,7 @@ func (a *goBlog) startServer() (err error) {
h = h.Append(a.securityHeaders)
}
// Add plugin middlewares
middlewarePlugins := getPluginsForType[plugintypes.Middleware](a, "middleware")
middlewarePlugins := getPluginsForType[plugintypes.Middleware](a, middlewarePlugin)
sort.Slice(middlewarePlugins, func(i, j int) bool {
// Sort with descending prio
return middlewarePlugins[i].Prio() > middlewarePlugins[j].Prio()

View File

@ -15,6 +15,7 @@ import (
"github.com/yuin/goldmark/util"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/highlighting"
"go.goblog.app/app/pkgs/htmlbuilder"
)
func (a *goBlog) initMarkdown() {
@ -183,13 +184,13 @@ func (c *customRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
dest = resolved[0]
}
}
hb := newHtmlBuilder(w)
hb.writeElementOpen("a", "href", dest)
hb := htmlbuilder.NewHtmlBuilder(w)
hb.WriteElementOpen("a", "href", dest)
imgEls := []any{"src", dest, "alt", string(n.Text(source)), "loading", "lazy"}
if len(n.Title) > 0 {
imgEls = append(imgEls, "title", string(n.Title))
}
hb.writeElementOpen("img", imgEls...)
hb.writeElementClose("a")
hb.WriteElementOpen("img", imgEls...)
hb.WriteElementClose("a")
return ast.WalkSkipChildren, nil
}

View File

@ -0,0 +1,72 @@
package htmlbuilder
import (
"fmt"
"io"
textTemplate "text/template"
)
type HtmlBuilder struct {
w io.Writer
}
func NewHtmlBuilder(w io.Writer) *HtmlBuilder {
return &HtmlBuilder{
w: w,
}
}
func (h *HtmlBuilder) getWriter() io.Writer {
return h.w
}
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) WriteUnescaped(s string) {
_, _ = h.WriteString(s)
}
func (h *HtmlBuilder) WriteEscaped(s string) {
textTemplate.HTMLEscape(h, []byte(s))
}
func (h *HtmlBuilder) WriteAttribute(attr string, val any) {
h.WriteUnescaped(` `)
h.WriteUnescaped(attr)
h.WriteUnescaped(`=`)
if valStr, ok := val.(string); ok {
h.WriteUnescaped(`"`)
h.WriteEscaped(valStr)
h.WriteUnescaped(`"`)
} else {
h.WriteEscaped(fmt.Sprint(val))
}
}
func (h *HtmlBuilder) WriteElementOpen(tag string, attrs ...any) {
h.WriteUnescaped(`<`)
h.WriteUnescaped(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.WriteUnescaped(`>`)
}
func (h *HtmlBuilder) WriteElementClose(tag string) {
h.WriteUnescaped(`</`)
h.WriteUnescaped(tag)
h.WriteUnescaped(`>`)
}

View File

@ -0,0 +1,6 @@
package htmlbuilder
import "io"
var _ io.Writer = &HtmlBuilder{}
var _ io.StringWriter = &HtmlBuilder{}

View File

@ -3,10 +3,9 @@ package plugintypes
import (
"context"
"database/sql"
"net/http"
)
// Interface to GoBlog
"go.goblog.app/app/pkgs/htmlbuilder"
)
// App is used to access GoBlog's app instance.
type App interface {
@ -23,29 +22,27 @@ type Database interface {
QueryRowContext(context.Context, string, ...any) (*sql.Row, error)
}
// Plugin types
// SetApp is used in all plugin types to allow
// GoBlog set it's app instance to be accessible by the plugin.
type SetApp interface {
SetApp(App)
// Post
type Post interface {
GetParameters() map[string][]string
}
// SetConfig is used in all plugin types to allow
// GoBlog set plugin configuration.
type SetConfig interface {
SetConfig(map[string]any)
// RenderType
type RenderType string
// RenderData
type RenderData interface {
// Empty
}
type Exec interface {
SetApp
SetConfig
Exec()
}
// RenderNextFunc
type RenderNextFunc func(*htmlbuilder.HtmlBuilder)
type Middleware interface {
SetApp
SetConfig
Handler(http.Handler) http.Handler
Prio() int
// Render main element content on post page, data = PostRenderData
const PostMainElementRenderType RenderType = "post-main-content"
// PostRenderData is RenderData containing a Post
type PostRenderData interface {
RenderData
GetPost() Post
}

View File

@ -0,0 +1,38 @@
package plugintypes
import (
"net/http"
"go.goblog.app/app/pkgs/htmlbuilder"
)
// SetApp is used in all plugin types to allow
// GoBlog set it's app instance to be accessible by the plugin.
type SetApp interface {
SetApp(App)
}
// SetConfig is used in all plugin types to allow
// GoBlog set plugin configuration.
type SetConfig interface {
SetConfig(map[string]any)
}
type Exec interface {
SetApp
SetConfig
Exec()
}
type Middleware interface {
SetApp
SetConfig
Handler(http.Handler) http.Handler
Prio() int
}
type UI interface {
SetApp
SetConfig
Render(*htmlbuilder.HtmlBuilder, RenderType, RenderData, RenderNextFunc)
}

View File

@ -0,0 +1,40 @@
// Code generated by 'yaegi extract go.goblog.app/app/pkgs/htmlbuilder'. DO NOT EDIT.
// MIT License
//
// Copyright (c) 2020 - 2022 Jan-Lukas Else
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package yaegiwrappers
import (
"go.goblog.app/app/pkgs/htmlbuilder"
"reflect"
)
func init() {
Symbols["go.goblog.app/app/pkgs/htmlbuilder/htmlbuilder"] = map[string]reflect.Value{
// function, constant and variable definitions
"NewHtmlBuilder": reflect.ValueOf(htmlbuilder.NewHtmlBuilder),
// type definitions
"HtmlBuilder": reflect.ValueOf((*htmlbuilder.HtmlBuilder)(nil)),
}
}

View File

@ -27,6 +27,7 @@ package yaegiwrappers
import (
"context"
"database/sql"
"go.goblog.app/app/pkgs/htmlbuilder"
"go.goblog.app/app/pkgs/plugintypes"
"net/http"
"reflect"
@ -34,21 +35,34 @@ import (
func init() {
Symbols["go.goblog.app/app/pkgs/plugintypes/plugintypes"] = map[string]reflect.Value{
// function, constant and variable definitions
"PostMainElementRenderType": reflect.ValueOf(plugintypes.PostMainElementRenderType),
// type definitions
"App": reflect.ValueOf((*plugintypes.App)(nil)),
"Database": reflect.ValueOf((*plugintypes.Database)(nil)),
"Exec": reflect.ValueOf((*plugintypes.Exec)(nil)),
"Middleware": reflect.ValueOf((*plugintypes.Middleware)(nil)),
"Post": reflect.ValueOf((*plugintypes.Post)(nil)),
"PostRenderData": reflect.ValueOf((*plugintypes.PostRenderData)(nil)),
"RenderData": reflect.ValueOf((*plugintypes.RenderData)(nil)),
"RenderNextFunc": reflect.ValueOf((*plugintypes.RenderNextFunc)(nil)),
"RenderType": reflect.ValueOf((*plugintypes.RenderType)(nil)),
"SetApp": reflect.ValueOf((*plugintypes.SetApp)(nil)),
"SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(nil)),
"UI": reflect.ValueOf((*plugintypes.UI)(nil)),
// interface wrapper definitions
"_App": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_App)(nil)),
"_Database": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Database)(nil)),
"_Exec": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Exec)(nil)),
"_Middleware": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Middleware)(nil)),
"_Post": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Post)(nil)),
"_PostRenderData": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_PostRenderData)(nil)),
"_RenderData": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_RenderData)(nil)),
"_SetApp": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetApp)(nil)),
"_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(nil)),
"_UI": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UI)(nil)),
}
}
@ -132,6 +146,31 @@ func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) SetConfig(a0 map[string]
W.WSetConfig(a0)
}
// _go_goblog_app_app_pkgs_plugintypes_Post is an interface wrapper for Post type
type _go_goblog_app_app_pkgs_plugintypes_Post struct {
IValue interface{}
WGetParameters func() map[string][]string
}
func (W _go_goblog_app_app_pkgs_plugintypes_Post) GetParameters() map[string][]string {
return W.WGetParameters()
}
// _go_goblog_app_app_pkgs_plugintypes_PostRenderData is an interface wrapper for PostRenderData type
type _go_goblog_app_app_pkgs_plugintypes_PostRenderData struct {
IValue interface{}
WGetPost func() plugintypes.Post
}
func (W _go_goblog_app_app_pkgs_plugintypes_PostRenderData) GetPost() plugintypes.Post {
return W.WGetPost()
}
// _go_goblog_app_app_pkgs_plugintypes_RenderData is an interface wrapper for RenderData type
type _go_goblog_app_app_pkgs_plugintypes_RenderData struct {
IValue interface{}
}
// _go_goblog_app_app_pkgs_plugintypes_SetApp is an interface wrapper for SetApp type
type _go_goblog_app_app_pkgs_plugintypes_SetApp struct {
IValue interface{}
@ -151,3 +190,21 @@ type _go_goblog_app_app_pkgs_plugintypes_SetConfig struct {
func (W _go_goblog_app_app_pkgs_plugintypes_SetConfig) SetConfig(a0 map[string]any) {
W.WSetConfig(a0)
}
// _go_goblog_app_app_pkgs_plugintypes_UI is an interface wrapper for UI type
type _go_goblog_app_app_pkgs_plugintypes_UI struct {
IValue interface{}
WRender func(a0 *htmlbuilder.HtmlBuilder, a1 plugintypes.RenderType, a2 plugintypes.RenderData, a3 plugintypes.RenderNextFunc)
WSetApp func(a0 plugintypes.App)
WSetConfig func(a0 map[string]any)
}
func (W _go_goblog_app_app_pkgs_plugintypes_UI) Render(a0 *htmlbuilder.HtmlBuilder, a1 plugintypes.RenderType, a2 plugintypes.RenderData, a3 plugintypes.RenderNextFunc) {
W.WRender(a0, a1, a2, a3)
}
func (W _go_goblog_app_app_pkgs_plugintypes_UI) SetApp(a0 plugintypes.App) {
W.WSetApp(a0)
}
func (W _go_goblog_app_app_pkgs_plugintypes_UI) SetConfig(a0 map[string]any) {
W.WSetConfig(a0)
}

View File

@ -9,3 +9,4 @@ var (
)
//go:generate yaegi extract -license ../../LICENSE -name yaegiwrappers go.goblog.app/app/pkgs/plugintypes
//go:generate yaegi extract -license ../../LICENSE -name yaegiwrappers go.goblog.app/app/pkgs/htmlbuilder

View File

@ -6,11 +6,18 @@ import (
"go.goblog.app/app/pkgs/yaegiwrappers"
)
const (
execPlugin = "exec"
middlewarePlugin = "middleware"
uiPlugin = "ui"
)
func (a *goBlog) initPlugins() error {
a.pluginHost = plugins.NewPluginHost(yaegiwrappers.Symbols)
a.pluginHost.AddPluginType("exec", (*plugintypes.Exec)(nil))
a.pluginHost.AddPluginType("middleware", (*plugintypes.Middleware)(nil))
a.pluginHost.AddPluginType(execPlugin, (*plugintypes.Exec)(nil))
a.pluginHost.AddPluginType(middlewarePlugin, (*plugintypes.Middleware)(nil))
a.pluginHost.AddPluginType(uiPlugin, (*plugintypes.UI)(nil))
for _, pc := range a.cfg.Plugins {
if pluginInterface, err := a.pluginHost.LoadPlugin(&plugins.PluginConfig{
@ -29,7 +36,7 @@ func (a *goBlog) initPlugins() error {
}
}
execs := getPluginsForType[plugintypes.Exec](a, "exec")
execs := getPluginsForType[plugintypes.Exec](a, execPlugin)
for _, p := range execs {
go p.Exec()
}
@ -38,15 +45,30 @@ func (a *goBlog) initPlugins() error {
}
func getPluginsForType[T any](a *goBlog, pluginType string) (list []T) {
if a == nil || a.pluginHost == nil {
return nil
}
return plugins.GetPluginsForType[T](a.pluginHost, pluginType)
}
// Implement all needed interfaces for goblog
var _ plugintypes.App = &goBlog{}
// Implement all needed interfaces
func (a *goBlog) GetDatabase() plugintypes.Database {
return a.db
}
var _ plugintypes.Database = &database{}
func (p *post) GetParameters() map[string][]string {
return p.Parameters
}
type pluginPostRenderData struct {
p *post
}
func (d *pluginPostRenderData) GetPost() plugintypes.Post {
return d.p
}
func (p *post) pluginRenderData() plugintypes.PostRenderData {
return &pluginPostRenderData{p: p}
}

View File

@ -0,0 +1,36 @@
package demoui
import (
"go.goblog.app/app/pkgs/htmlbuilder"
"go.goblog.app/app/pkgs/plugintypes"
)
func GetPlugin() plugintypes.UI {
return &plugin{}
}
type plugin struct{}
func (*plugin) SetApp(_ plugintypes.App) {
// Ignore
}
func (*plugin) SetConfig(_ map[string]any) {
// Ignore
}
func (*plugin) Render(hb *htmlbuilder.HtmlBuilder, t plugintypes.RenderType, _ plugintypes.RenderData, render plugintypes.RenderNextFunc) {
switch t {
case plugintypes.PostMainElementRenderType:
hb.WriteElementOpen("p")
hb.WriteEscaped("Start of post main element")
hb.WriteElementClose("p")
render(hb)
hb.WriteElementOpen("p")
hb.WriteEscaped("End of post main element")
hb.WriteElementClose("p")
return
default:
render(hb)
}
}

View File

@ -0,0 +1,54 @@
package syndication
import (
"fmt"
"go.goblog.app/app/pkgs/htmlbuilder"
"go.goblog.app/app/pkgs/plugintypes"
)
func GetPlugin() plugintypes.UI {
return &plugin{}
}
type plugin struct {
config map[string]any
}
func (*plugin) SetApp(_ plugintypes.App) {
// Ignore
}
func (p *plugin) SetConfig(config map[string]any) {
p.config = config
}
func (p *plugin) Render(hb *htmlbuilder.HtmlBuilder, t plugintypes.RenderType, data plugintypes.RenderData, render plugintypes.RenderNextFunc) {
switch t {
case plugintypes.PostMainElementRenderType:
render(hb)
pd, ok := data.(plugintypes.PostRenderData)
if !ok {
fmt.Println("syndication plugin: data is not PostRenderData!")
return
}
parameterName := "syndication" // default
if configParameterAny, ok := p.config["parameter"]; ok {
if configParameter, ok := configParameterAny.(string); ok {
parameterName = configParameter // override default from config
}
}
syndicationLinks, ok := pd.GetPost().GetParameters()[parameterName]
if !ok || len(syndicationLinks) == 0 {
// No syndication links
return
}
for _, link := range syndicationLinks {
hb.WriteElementOpen("data", "value", link, "class", "u-syndication hide")
hb.WriteElementClose("data")
}
return
default:
render(hb)
}
}

View File

@ -8,6 +8,11 @@ import (
"go.goblog.app/app/pkgs/plugintypes"
)
var _ plugintypes.App = &goBlog{}
var _ plugintypes.Database = &database{}
var _ plugintypes.Post = &post{}
var _ plugintypes.PostRenderData = &pluginPostRenderData{}
func TestExecPlugin(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),

View File

@ -9,6 +9,7 @@ import (
gogeouri "git.jlel.se/jlelse/go-geouri"
"github.com/araddon/dateparse"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/htmlbuilder"
"gopkg.in/yaml.v3"
)
@ -44,34 +45,34 @@ func (a *goBlog) postHtml(p *post, absolute bool) (res string) {
func (a *goBlog) postHtmlToWriter(w io.Writer, p *post, absolute bool) {
// Build HTML
hb := newHtmlBuilder(w)
hb := htmlbuilder.NewHtmlBuilder(w)
// Add audio to the top
for _, a := range p.Parameters[a.cfg.Micropub.AudioParam] {
hb.writeElementOpen("audio", "controls", "preload", "none")
hb.writeElementOpen("source", "src", a)
hb.writeElementClose("source")
hb.writeElementClose("audio")
hb.WriteElementOpen("audio", "controls", "preload", "none")
hb.WriteElementOpen("source", "src", a)
hb.WriteElementClose("source")
hb.WriteElementClose("audio")
}
// Render markdown
_ = a.renderMarkdownToWriter(w, p.Content, absolute)
// Add bookmark links to the bottom
for _, l := range p.Parameters[a.cfg.Micropub.BookmarkParam] {
hb.writeElementOpen("p")
hb.writeElementOpen("a", "class", "u-bookmark-of", "href", l, "target", "_blank", "rel", "noopener noreferrer")
hb.writeEscaped(l)
hb.writeElementClose("a")
hb.writeElementClose("p")
hb.WriteElementOpen("p")
hb.WriteElementOpen("a", "class", "u-bookmark-of", "href", l, "target", "_blank", "rel", "noopener noreferrer")
hb.WriteEscaped(l)
hb.WriteElementClose("a")
hb.WriteElementClose("p")
}
}
func (a *goBlog) feedHtml(w io.Writer, p *post) {
hb := newHtmlBuilder(w)
hb := htmlbuilder.NewHtmlBuilder(w)
// Add TTS audio to the top
for _, a := range p.Parameters[ttsParameter] {
hb.writeElementOpen("audio", "controls", "preload", "none")
hb.writeElementOpen("source", "src", a)
hb.writeElementClose("source")
hb.writeElementClose("audio")
hb.WriteElementOpen("audio", "controls", "preload", "none")
hb.WriteElementOpen("source", "src", a)
hb.WriteElementClose("source")
hb.WriteElementClose("audio")
}
// Add IndieWeb context
a.renderPostReplyContext(hb, p, "p")
@ -81,16 +82,16 @@ func (a *goBlog) feedHtml(w io.Writer, p *post) {
// Add link to interactions and comments
blogConfig := a.getBlogFromPost(p)
if cc := blogConfig.Comments; cc != nil && cc.Enabled {
hb.writeElementOpen("p")
hb.writeElementOpen("a", "href", a.getFullAddress(p.Path)+"#interactions")
hb.writeEscaped(a.ts.GetTemplateStringVariant(blogConfig.Lang, "interactions"))
hb.writeElementClose("a")
hb.writeElementClose("p")
hb.WriteElementOpen("p")
hb.WriteElementOpen("a", "href", a.getFullAddress(p.Path)+"#interactions")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(blogConfig.Lang, "interactions"))
hb.WriteElementClose("a")
hb.WriteElementClose("p")
}
}
func (a *goBlog) minFeedHtml(w io.Writer, p *post) {
hb := newHtmlBuilder(w)
hb := htmlbuilder.NewHtmlBuilder(w)
// Add IndieWeb context
a.renderPostReplyContext(hb, p, "p")
a.renderPostLikeContext(hb, p, "p")

View File

@ -5,6 +5,7 @@ import (
"net/http"
"go.goblog.app/app/pkgs/contenttype"
"go.goblog.app/app/pkgs/htmlbuilder"
)
type renderData struct {
@ -27,11 +28,11 @@ func (d *renderData) LoggedIn() bool {
return d.app.isLoggedIn(d.req)
}
func (a *goBlog) render(w http.ResponseWriter, r *http.Request, f func(*htmlBuilder, *renderData), data *renderData) {
func (a *goBlog) render(w http.ResponseWriter, r *http.Request, f func(*htmlbuilder.HtmlBuilder, *renderData), data *renderData) {
a.renderWithStatusCode(w, r, http.StatusOK, f, data)
}
func (a *goBlog) renderWithStatusCode(w http.ResponseWriter, r *http.Request, statusCode int, f func(*htmlBuilder, *renderData), data *renderData) {
func (a *goBlog) renderWithStatusCode(w http.ResponseWriter, r *http.Request, statusCode int, f func(*htmlbuilder.HtmlBuilder, *renderData), data *renderData) {
// Check render data
a.checkRenderData(r, data)
// Set content type
@ -41,7 +42,7 @@ func (a *goBlog) renderWithStatusCode(w http.ResponseWriter, r *http.Request, st
// Render
pipeReader, pipeWriter := io.Pipe()
go func() {
f(newHtmlBuilder(pipeWriter), data)
f(htmlbuilder.NewHtmlBuilder(pipeWriter), data)
_ = pipeWriter.Close()
}()
_ = pipeReader.CloseWithError(a.min.Get().Minify(contenttype.HTML, w, pipeReader))

1417
ui.go

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import (
"github.com/samber/lo"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/htmlbuilder"
)
type summaryTyp string
@ -17,7 +18,7 @@ const (
)
// post summary on index pages
func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ summaryTyp) {
func (a *goBlog) renderSummary(hb *htmlbuilder.HtmlBuilder, bc *configBlog, p *post, typ summaryTyp) {
if bc == nil || p == nil {
return
}
@ -25,21 +26,21 @@ func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ sum
typ = defaultSummary
}
// Start article
hb.writeElementOpen("article", "class", "h-entry border-bottom")
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")
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")
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)
@ -52,17 +53,17 @@ func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ sum
a.renderPostMeta(hb, p, bc, "summary")
if typ != photoSummary && a.showFull(p) {
// Show full content
hb.writeElementOpen("div", "class", "e-content")
hb.WriteElementOpen("div", "class", "e-content")
a.postHtmlToWriter(hb, p, false)
hb.writeElementClose("div")
hb.WriteElementClose("div")
} else {
// Show summary
hb.writeElementOpen("p", "class", "p-summary")
hb.writeEscaped(a.postSummary(p))
hb.writeElementClose("p")
hb.WriteElementOpen("p", "class", "p-summary")
hb.WriteEscaped(a.postSummary(p))
hb.WriteElementClose("p")
}
// Show link to full post
hb.writeElementOpen("p")
hb.WriteElementOpen("p")
prefix := bufferpool.Get()
if len(photos) > 0 {
// Contains photos
@ -74,19 +75,19 @@ func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ sum
}
if prefix.Len() > 0 {
prefix.WriteRune(' ')
hb.writeEscaped(prefix.String())
hb.WriteEscaped(prefix.String())
}
bufferpool.Put(prefix)
hb.writeElementOpen("a", "class", "u-url", "href", p.Path)
hb.writeEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "view"))
hb.writeElementClose("a")
hb.writeElementClose("p")
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")
hb.WriteElementClose("article")
}
// list of post taxonomy values (tags, series, etc.)
func (a *goBlog) renderPostTax(hb *htmlBuilder, p *post, b *configBlog) {
func (a *goBlog) renderPostTax(hb *htmlbuilder.HtmlBuilder, p *post, b *configBlog) {
if b == nil || p == nil {
return
}
@ -95,372 +96,372 @@ func (a *goBlog) renderPostTax(hb *htmlBuilder, p *post, b *configBlog) {
// Get all sorted taxonomy values for this post
if taxValues := sortedStrings(p.Parameters[tax.Name]); len(taxValues) > 0 {
// Start new paragraph
hb.writeElementOpen("p")
hb.WriteElementOpen("p")
// Add taxonomy name
hb.writeElementOpen("strong")
hb.writeEscaped(a.renderMdTitle(tax.Title))
hb.writeElementClose("strong")
hb.write(": ")
hb.WriteElementOpen("strong")
hb.WriteEscaped(a.renderMdTitle(tax.Title))
hb.WriteElementClose("strong")
hb.WriteUnescaped(": ")
// Add taxonomy values
for i, taxValue := range taxValues {
if i > 0 {
hb.write(", ")
hb.WriteUnescaped(", ")
}
hb.writeElementOpen(
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")
hb.WriteEscaped(a.renderMdTitle(taxValue))
hb.WriteElementClose("a")
}
// End paragraph
hb.writeElementClose("p")
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) {
func (a *goBlog) renderPostMeta(hb *htmlbuilder.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")
hb.WriteElementOpen("div", "class", "p")
}
// Published time
if published := toLocalTime(p.Published); !published.IsZero() {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "publishedon"))
hb.write(" ")
hb.writeElementOpen("time", "class", "dt-published", "datetime", published.Format(time.RFC3339))
hb.writeEscaped(published.Format(isoDateFormat))
hb.writeElementClose("time")
hb.WriteElementOpen("div")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "publishedon"))
hb.WriteUnescaped(" ")
hb.WriteElementOpen("time", "class", "dt-published", "datetime", published.Format(time.RFC3339))
hb.WriteEscaped(published.Format(isoDateFormat))
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.WriteUnescaped(" 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")
hb.WriteElementClose("div")
}
// Updated time
if updated := toLocalTime(p.Updated); !updated.IsZero() {
hb.writeElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "updatedon"))
hb.write(" ")
hb.writeElementOpen("time", "class", "dt-updated", "datetime", updated.Format(time.RFC3339))
hb.writeEscaped(updated.Format(isoDateFormat))
hb.writeElementClose("time")
hb.writeElementClose("div")
hb.WriteElementOpen("div")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "updatedon"))
hb.WriteUnescaped(" ")
hb.WriteElementOpen("time", "class", "dt-updated", "datetime", updated.Format(time.RFC3339))
hb.WriteEscaped(updated.Format(isoDateFormat))
hb.WriteElementClose("time")
hb.WriteElementClose("div")
}
// IndieWeb Meta
a.renderPostReplyContext(hb, p, "")
a.renderPostLikeContext(hb, p, "")
// 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)
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)
hb.WriteEscaped(likeTitle)
} else {
hb.writeEscaped(likeLink)
hb.WriteEscaped(likeLink)
}
hb.writeElementClose("a")
hb.writeElementClose("div")
hb.WriteElementClose("a")
hb.WriteElementClose("div")
}
// Geo
if geoURIs := a.geoURIs(p); len(geoURIs) != 0 {
hb.writeElementOpen("div")
hb.writeEscaped("📍 ")
hb.WriteElementOpen("div")
hb.WriteEscaped("📍 ")
for i, geoURI := range geoURIs {
if i > 0 {
hb.writeEscaped(", ")
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.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")
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(": ")
hb.WriteElementOpen("div")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "translations"))
hb.WriteEscaped(": ")
for i, translation := range translations {
if i > 0 {
hb.writeEscaped(", ")
hb.WriteEscaped(", ")
}
hb.writeElementOpen("a", "translate", "no", "href", translation.Path)
hb.writeEscaped(translation.RenderedTitle)
hb.writeElementClose("a")
hb.WriteElementOpen("a", "translate", "no", "href", translation.Path)
hb.WriteEscaped(translation.RenderedTitle)
hb.WriteElementClose("a")
}
hb.writeElementClose("div")
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")
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")
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")
hb.WriteElementClose("div")
}
}
// Reply ("u-in-reply-to")
func (a *goBlog) renderPostReplyContext(hb *htmlBuilder, p *post, htmlWrapperElement string) {
func (a *goBlog) renderPostReplyContext(hb *htmlbuilder.HtmlBuilder, p *post, htmlWrapperElement string) {
if htmlWrapperElement == "" {
htmlWrapperElement = "div"
}
if replyLink := a.replyLink(p); replyLink != "" {
hb.writeElementOpen(htmlWrapperElement)
hb.writeEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "replyto"))
hb.writeEscaped(": ")
hb.writeElementOpen("a", "class", "u-in-reply-to", "rel", "noopener", "target", "_blank", "href", replyLink)
hb.WriteElementOpen(htmlWrapperElement)
hb.WriteEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).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)
hb.WriteEscaped(replyTitle)
} else {
hb.writeEscaped(replyLink)
hb.WriteEscaped(replyLink)
}
hb.writeElementClose("a")
hb.writeElementClose(htmlWrapperElement)
hb.WriteElementClose("a")
hb.WriteElementClose(htmlWrapperElement)
}
}
// Like ("u-like-of")
func (a *goBlog) renderPostLikeContext(hb *htmlBuilder, p *post, htmlWrapperElement string) {
func (a *goBlog) renderPostLikeContext(hb *htmlbuilder.HtmlBuilder, p *post, htmlWrapperElement string) {
if htmlWrapperElement == "" {
htmlWrapperElement = "div"
}
if likeLink := a.likeLink(p); likeLink != "" {
hb.writeElementOpen(htmlWrapperElement)
hb.writeEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "likeof"))
hb.writeEscaped(": ")
hb.writeElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink)
hb.WriteElementOpen(htmlWrapperElement)
hb.WriteEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).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)
hb.WriteEscaped(likeTitle)
} else {
hb.writeEscaped(likeLink)
hb.WriteEscaped(likeLink)
}
hb.writeElementClose("a")
hb.writeElementClose(htmlWrapperElement)
hb.WriteElementClose("a")
hb.WriteElementClose(htmlWrapperElement)
}
}
// warning for old posts
func (a *goBlog) renderOldContentWarning(hb *htmlBuilder, p *post, b *configBlog) {
func (a *goBlog) renderOldContentWarning(hb *htmlbuilder.HtmlBuilder, p *post, b *configBlog) {
if b == nil || b.hideOldContentWarning || 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")
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, rd *renderData) {
func (a *goBlog) renderInteractions(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
// Start accordion
hb.writeElementOpen("details", "class", "p", "id", "interactions")
hb.writeElementOpen("summary")
hb.writeElementOpen("strong")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "interactions"))
hb.writeElementClose("strong")
hb.writeElementClose("summary")
hb.WriteElementOpen("details", "class", "p", "id", "interactions")
hb.WriteElementOpen("summary")
hb.WriteElementOpen("strong")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.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")
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")
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")
hb.WriteUnescaped(" ")
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")
hb.WriteUnescaped(" ")
hb.WriteElementOpen("i")
hb.WriteEscaped(mention.Content)
hb.WriteElementClose("i")
}
if len(mention.Submentions) > 0 {
renderMentions(mention.Submentions)
}
hb.writeElementClose("li")
hb.WriteElementClose("li")
}
hb.writeElementClose("ul")
hb.WriteElementClose("ul")
}
renderMentions(a.db.getWebmentionsByAddress(rd.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(rd.Blog.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", rd.Canonical)
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "send"))
hb.writeElementClose("form")
hb.WriteElementOpen("form", "class", "fw p", "method", "post", "action", "/webmention")
hb.WriteElementOpen("label", "for", "wm-source", "class", "p")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.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", rd.Canonical)
hb.WriteElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.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", rd.Canonical)
hb.writeElementOpen("input", "type", "text", "name", "name", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "nameopt"))
hb.writeElementOpen("input", "type", "url", "name", "website", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "websiteopt"))
hb.writeElementOpen("textarea", "name", "comment", "required", "", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comment"))
hb.writeElementClose("textarea")
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "docomment"))
hb.writeElementClose("form")
hb.WriteElementOpen("form", "class", "fw p", "method", "post", "action", "/comment")
hb.WriteElementOpen("input", "type", "hidden", "name", "target", "value", rd.Canonical)
hb.WriteElementOpen("input", "type", "text", "name", "name", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "nameopt"))
hb.WriteElementOpen("input", "type", "url", "name", "website", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "websiteopt"))
hb.WriteElementOpen("textarea", "name", "comment", "required", "", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comment"))
hb.WriteElementClose("textarea")
hb.WriteElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "docomment"))
hb.WriteElementClose("form")
// Finish accordion
hb.writeElementClose("details")
hb.WriteElementClose("details")
}
// author h-card
func (a *goBlog) renderAuthor(hb *htmlBuilder) {
func (a *goBlog) renderAuthor(hb *htmlbuilder.HtmlBuilder) {
user := a.cfg.User
if user == nil {
return
}
hb.writeElementOpen("div", "class", "p-author h-card hide")
hb.WriteElementOpen("div", "class", "p-author h-card hide")
if user.Picture != "" {
hb.writeElementOpen("data", "class", "u-photo", "value", user.Picture)
hb.writeElementClose("data")
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.WriteElementOpen("a", "class", "p-name u-url", "rel", "me", "href", defaultIfEmpty(user.Link, "/"))
hb.WriteEscaped(user.Name)
hb.WriteElementClose("a")
}
hb.writeElementClose("div")
hb.WriteElementClose("div")
}
// head meta tags for a post
func (a *goBlog) renderPostHeadMeta(hb *htmlBuilder, p *post, canonical string) {
func (a *goBlog) renderPostHeadMeta(hb *htmlbuilder.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)
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)
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)
hb.WriteElementOpen("meta", "name", "description", "content", summary)
hb.WriteElementOpen("meta", "property", "og:description", "content", summary)
hb.WriteElementOpen("meta", "property", "twitter:description", "content", summary)
}
if published := toLocalTime(p.Published); !published.IsZero() {
hb.writeElementOpen("meta", "itemprop", "datePublished", "content", published.Format(time.RFC3339))
hb.WriteElementOpen("meta", "itemprop", "datePublished", "content", published.Format(time.RFC3339))
}
if updated := toLocalTime(p.Updated); !updated.IsZero() {
hb.writeElementOpen("meta", "itemprop", "dateModified", "content", updated.Format(time.RFC3339))
hb.WriteElementOpen("meta", "itemprop", "dateModified", "content", updated.Format(time.RFC3339))
}
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)
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) {
func (a *goBlog) renderTorNotice(hb *htmlbuilder.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")
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")
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")
func (a *goBlog) renderTitleTag(hb *htmlbuilder.HtmlBuilder, blog *configBlog, optionalTitle string) {
hb.WriteElementOpen("title")
if optionalTitle != "" {
hb.writeEscaped(optionalTitle)
hb.writeEscaped(" - ")
hb.WriteEscaped(optionalTitle)
hb.WriteEscaped(" - ")
}
hb.writeEscaped(a.renderMdTitle(blog.Title))
hb.writeElementClose("title")
hb.WriteEscaped(a.renderMdTitle(blog.Title))
hb.WriteElementClose("title")
}
func (a *goBlog) renderPagination(hb *htmlBuilder, blog *configBlog, hasPrev, hasNext bool, prev, next string) {
func (a *goBlog) renderPagination(hb *htmlbuilder.HtmlBuilder, blog *configBlog, hasPrev, hasNext bool, prev, next string) {
// Navigation
if hasPrev {
hb.writeElementOpen("p")
hb.writeElementOpen("a", "href", prev) // TODO: rel=prev?
hb.writeEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "prev"))
hb.writeElementClose("a")
hb.writeElementClose("p")
hb.WriteElementOpen("p")
hb.WriteElementOpen("a", "href", prev) // TODO: rel=prev?
hb.WriteEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "prev"))
hb.WriteElementClose("a")
hb.WriteElementClose("p")
}
if hasNext {
hb.writeElementOpen("p")
hb.writeElementOpen("a", "href", next) // TODO: rel=next?
hb.writeEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "next"))
hb.writeElementClose("a")
hb.writeElementClose("p")
hb.WriteElementOpen("p")
hb.WriteElementOpen("a", "href", next) // TODO: rel=next?
hb.WriteEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "next"))
hb.WriteElementClose("a")
hb.WriteElementClose("p")
}
}
func (*goBlog) renderPostTitle(hb *htmlBuilder, p *post) {
func (*goBlog) renderPostTitle(hb *htmlbuilder.HtmlBuilder, p *post) {
if p == nil || p.RenderedTitle == "" {
return
}
hb.writeElementOpen("h1", "class", "p-name")
hb.writeEscaped(p.RenderedTitle)
hb.writeElementClose("h1")
hb.WriteElementOpen("h1", "class", "p-name")
hb.WriteEscaped(p.RenderedTitle)
hb.WriteElementClose("h1")
}
func (a *goBlog) renderPostGPX(hb *htmlBuilder, p *post, b *configBlog) {
func (a *goBlog) renderPostGPX(hb *htmlbuilder.HtmlBuilder, p *post, b *configBlog) {
if p == nil || !p.hasTrack() {
return
}
@ -469,174 +470,174 @@ func (a *goBlog) renderPostGPX(hb *htmlBuilder, p *post, b *configBlog) {
return
}
// Track stats
hb.writeElementOpen("p")
hb.WriteElementOpen("p")
if track.Name != "" {
hb.writeElementOpen("strong")
hb.writeEscaped(track.Name)
hb.writeElementClose("strong")
hb.write(" ")
hb.WriteElementOpen("strong")
hb.WriteEscaped(track.Name)
hb.WriteElementClose("strong")
hb.WriteUnescaped(" ")
}
if track.Kilometers != "" {
hb.write("🏁 ")
hb.writeEscaped(track.Kilometers)
hb.write(" ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "kilometers"))
hb.write(" ")
hb.WriteUnescaped("🏁 ")
hb.WriteEscaped(track.Kilometers)
hb.WriteUnescaped(" ")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "kilometers"))
hb.WriteUnescaped(" ")
}
if track.Hours != "" {
hb.write("⏱ ")
hb.writeEscaped(track.Hours)
hb.WriteUnescaped("⏱ ")
hb.WriteEscaped(track.Hours)
}
hb.writeElementClose("p")
hb.WriteElementClose("p")
// Map (only show if it has features)
if track.hasMapFeatures() {
hb.writeElementOpen(
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/geomap.js"))
hb.writeElementClose("script")
hb.WriteElementClose("div")
hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/geomap.js"))
hb.WriteElementClose("script")
}
}
func (a *goBlog) renderPostReactions(hb *htmlBuilder, p *post) {
func (a *goBlog) renderPostReactions(hb *htmlbuilder.HtmlBuilder, p *post) {
if !a.reactionsEnabledForPost(p) {
return
}
hb.writeElementOpen("div", "id", "reactions", "class", "actions", "data-path", p.Path, "data-allowed", strings.Join(allowedReactions, ","))
hb.writeElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/reactions.js"))
hb.writeElementClose("script")
hb.WriteElementOpen("div", "id", "reactions", "class", "actions", "data-path", p.Path, "data-allowed", strings.Join(allowedReactions, ","))
hb.WriteElementClose("div")
hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/reactions.js"))
hb.WriteElementClose("script")
}
func (a *goBlog) renderPostVideo(hb *htmlBuilder, p *post) {
func (a *goBlog) renderPostVideo(hb *htmlbuilder.HtmlBuilder, p *post) {
if !p.hasVideoPlaylist() {
return
}
hb.writeElementOpen("div", "id", "video", "data-url", p.firstParameter(videoPlaylistParam))
hb.writeElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/video.js"))
hb.writeElementClose("script")
hb.WriteElementOpen("div", "id", "video", "data-url", p.firstParameter(videoPlaylistParam))
hb.WriteElementClose("div")
hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/video.js"))
hb.WriteElementClose("script")
}
func (a *goBlog) renderPostSectionSettings(hb *htmlBuilder, rd *renderData, srd *settingsRenderData) {
hb.writeElementOpen("h2")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "postsections"))
hb.writeElementClose("h2")
func (a *goBlog) renderPostSectionSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData, srd *settingsRenderData) {
hb.WriteElementOpen("h2")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "postsections"))
hb.WriteElementClose("h2")
// Update default section
hb.writeElementOpen("h3")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "default"))
hb.writeElementClose("h3")
hb.WriteElementOpen("h3")
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "default"))
hb.WriteElementClose("h3")
hb.writeElementOpen("form", "class", "fw p", "method", "post")
hb.writeElementOpen("select", "name", "defaultsection")
hb.WriteElementOpen("form", "class", "fw p", "method", "post")
hb.WriteElementOpen("select", "name", "defaultsection")
for _, section := range srd.sections {
hb.writeElementOpen("option", "value", section.Name, lo.If(section.Name == srd.defaultSection, "selected").Else(""), "")
hb.writeEscaped(section.Name)
hb.writeElementClose("option")
hb.WriteElementOpen("option", "value", section.Name, lo.If(section.Name == srd.defaultSection, "selected").Else(""), "")
hb.WriteEscaped(section.Name)
hb.WriteElementClose("option")
}
hb.writeElementClose("select")
hb.writeElementOpen(
hb.WriteElementClose("select")
hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateDefaultSectionPath),
)
hb.writeElementClose("form")
hb.WriteElementClose("form")
for _, section := range srd.sections {
hb.writeElementOpen("details")
hb.WriteElementOpen("details")
hb.writeElementOpen("summary")
hb.writeElementOpen("h3")
hb.writeEscaped(section.Name)
hb.writeElementClose("h3")
hb.writeElementClose("summary")
hb.WriteElementOpen("summary")
hb.WriteElementOpen("h3")
hb.WriteEscaped(section.Name)
hb.WriteElementClose("h3")
hb.WriteElementClose("summary")
hb.writeElementOpen("form", "class", "fw p", "method", "post")
hb.WriteElementOpen("form", "class", "fw p", "method", "post")
hb.writeElementOpen("input", "type", "hidden", "name", "sectionname", "value", section.Name)
hb.WriteElementOpen("input", "type", "hidden", "name", "sectionname", "value", section.Name)
// Title
hb.writeElementOpen("input", "type", "text", "name", "sectiontitle", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiontitle"), "required", "", "value", section.Title)
hb.WriteElementOpen("input", "type", "text", "name", "sectiontitle", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiontitle"), "required", "", "value", section.Title)
// Description
hb.writeElementOpen(
hb.WriteElementOpen(
"textarea",
"name", "sectiondescription",
"class", "monospace",
"placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiondescription"),
)
hb.writeEscaped(section.Description)
hb.writeElementClose("textarea")
hb.WriteEscaped(section.Description)
hb.WriteElementClose("textarea")
// Path template
hb.writeElementOpen("input", "type", "text", "name", "sectionpathtemplate", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionpathtemplate"), "value", section.PathTemplate)
hb.WriteElementOpen("input", "type", "text", "name", "sectionpathtemplate", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionpathtemplate"), "value", section.PathTemplate)
// Show full
hb.writeElementOpen("input", "type", "checkbox", "name", "sectionshowfull", "id", "showfull-"+section.Name, lo.If(section.ShowFull, "checked").Else(""), "")
hb.writeElementOpen("label", "for", "showfull-"+section.Name)
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionshowfull"))
hb.writeElementClose("label")
hb.WriteElementOpen("input", "type", "checkbox", "name", "sectionshowfull", "id", "showfull-"+section.Name, lo.If(section.ShowFull, "checked").Else(""), "")
hb.WriteElementOpen("label", "for", "showfull-"+section.Name)
hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionshowfull"))
hb.WriteElementClose("label")
// Actions
hb.writeElementOpen("div", "class", "p")
hb.WriteElementOpen("div", "class", "p")
// Update
hb.writeElementOpen(
hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateSectionPath),
)
// Delete
hb.writeElementOpen(
hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "delete"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsDeleteSectionPath),
"class", "confirm", "data-confirmmessage", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "confirmdelete"),
)
hb.writeElementClose("div")
hb.WriteElementClose("div")
hb.writeElementClose("form")
hb.writeElementClose("details")
hb.WriteElementClose("form")
hb.WriteElementClose("details")
}
// Create new section
hb.writeElementOpen("form", "class", "fw p", "method", "post")
hb.WriteElementOpen("form", "class", "fw p", "method", "post")
// Name
hb.writeElementOpen("input", "type", "text", "name", "sectionname", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionname"), "required", "")
hb.WriteElementOpen("input", "type", "text", "name", "sectionname", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionname"), "required", "")
// Title
hb.writeElementOpen("input", "type", "text", "name", "sectiontitle", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiontitle"), "required", "")
hb.WriteElementOpen("input", "type", "text", "name", "sectiontitle", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiontitle"), "required", "")
// Create button
hb.writeElementOpen("div")
hb.writeElementOpen(
hb.WriteElementOpen("div")
hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "create"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsCreateSectionPath),
)
hb.writeElementClose("div")
hb.writeElementClose("form")
hb.WriteElementClose("div")
hb.WriteElementClose("form")
}
func (a *goBlog) renderCollapsibleBooleanSetting(hb *htmlBuilder, rd *renderData, path, title, description, name string, value bool) {
hb.writeElementOpen("details")
func (a *goBlog) renderCollapsibleBooleanSetting(hb *htmlbuilder.HtmlBuilder, rd *renderData, path, title, description, name string, value bool) {
hb.WriteElementOpen("details")
hb.writeElementOpen("summary")
hb.writeElementOpen("h3")
hb.writeEscaped(title)
hb.writeElementClose("h3")
hb.writeElementClose("summary")
hb.WriteElementOpen("summary")
hb.WriteElementOpen("h3")
hb.WriteEscaped(title)
hb.WriteElementClose("h3")
hb.WriteElementClose("summary")
hb.writeElementOpen("form", "class", "fw p", "method", "post")
hb.WriteElementOpen("form", "class", "fw p", "method", "post")
hb.writeElementOpen("input", "type", "checkbox", "name", name, "id", "cb-"+name, lo.If(value, "checked").Else(""), "")
hb.writeElementOpen("label", "for", "cb-"+name)
hb.writeEscaped(description)
hb.writeElementClose("label")
hb.WriteElementOpen("input", "type", "checkbox", "name", name, "id", "cb-"+name, lo.If(value, "checked").Else(""), "")
hb.WriteElementOpen("label", "for", "cb-"+name)
hb.WriteEscaped(description)
hb.WriteElementClose("label")
hb.writeElementOpen("div", "class", "p")
hb.writeElementOpen(
hb.WriteElementOpen("div", "class", "p")
hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"), "formaction", path,
)
hb.writeElementClose("div")
hb.WriteElementClose("div")
hb.writeElementClose("form")
hb.WriteElementClose("form")
hb.writeElementClose("details")
hb.WriteElementClose("details")
}

View File

@ -1,72 +0,0 @@
package main
import (
"fmt"
"io"
textTemplate "text/template"
)
type htmlBuilder struct {
w io.Writer
}
func newHtmlBuilder(w io.Writer) *htmlBuilder {
return &htmlBuilder{
w: w,
}
}
func (h *htmlBuilder) getWriter() io.Writer {
return h.w
}
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) write(s string) {
_, _ = h.WriteString(s)
}
func (h *htmlBuilder) writeEscaped(s string) {
textTemplate.HTMLEscape(h, []byte(s))
}
func (h *htmlBuilder) writeAttribute(attr string, val any) {
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 ...any) {
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(`</`)
h.write(tag)
h.write(`>`)
}

View File

@ -2,7 +2,6 @@ package main
import (
"bytes"
"io"
"os"
"strings"
"testing"
@ -11,11 +10,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/htmlbuilder"
)
var _ io.Writer = &htmlBuilder{}
var _ io.StringWriter = &htmlBuilder{}
func Test_renderPostTax(t *testing.T) {
app := &goBlog{
cfg: createDefaultTestConfig(t),
@ -33,7 +30,7 @@ func Test_renderPostTax(t *testing.T) {
buf := bufferpool.Get()
defer bufferpool.Put(buf)
hb := newHtmlBuilder(buf)
hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderPostTax(hb, p, app.cfg.Blogs["default"])
@ -56,7 +53,7 @@ func Test_renderOldContentWarning(t *testing.T) {
}
buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf)
hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderOldContentWarning(hb, p, app.cfg.Blogs["default"])
res := buf.String()
@ -122,7 +119,7 @@ func Test_renderInteractions(t *testing.T) {
require.NoError(t, err)
buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf)
hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderInteractions(hb, &renderData{
Blog: app.cfg.Blogs["default"],
@ -149,7 +146,7 @@ func Test_renderAuthor(t *testing.T) {
_ = app.initConfig(false)
buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf)
hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderAuthor(hb)
res := buf.String()