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

@ -58,4 +58,5 @@ Here's an (incomplete) list of features:
- [How to use GoBlog](./usage.md) - [How to use GoBlog](./usage.md)
- [How to configure GoBlog](./config.md) - [How to configure GoBlog](./config.md)
- [Administration paths](./admin-paths.md) - [Administration paths](./admin-paths.md)
- [GoBlog's storage system](./storage.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/bufferpool"
"go.goblog.app/app/pkgs/contenttype" "go.goblog.app/app/pkgs/contenttype"
"go.goblog.app/app/pkgs/htmlbuilder"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
ws "nhooyr.io/websocket" 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) p.RenderedTitle = a.renderMdTitle(t)
} }
// Render post (using post's blog config) // Render post (using post's blog config)
hb := newHtmlBuilder(w) hb := htmlbuilder.NewHtmlBuilder(w)
a.renderEditorPreview(hb, a.cfg.Blogs[p.Blog], p) a.renderEditorPreview(hb, a.cfg.Blogs[p.Blog], p)
} }

2
go.mod
View File

@ -59,7 +59,7 @@ require (
// master // master
github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38 github.com/yuin/goldmark-emoji v1.0.2-0.20210607094911-0487583eca38
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa 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/sync v0.0.0-20220722155255-886fb9371eb4
golang.org/x/text v0.3.7 golang.org/x/text v0.3.7
gopkg.in/yaml.v3 v3.0.1 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-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-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-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-20220811182439-13a9a731de15 h1:cik0bxZUSJVDyaHf1hZPSDsU8SZHGQZQMeueXCE7yBQ=
golang.org/x/net v0.0.0-20220805013720-a33c5aa5df48/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= 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-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/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) h = h.Append(a.securityHeaders)
} }
// Add plugin middlewares // Add plugin middlewares
middlewarePlugins := getPluginsForType[plugintypes.Middleware](a, "middleware") middlewarePlugins := getPluginsForType[plugintypes.Middleware](a, middlewarePlugin)
sort.Slice(middlewarePlugins, func(i, j int) bool { sort.Slice(middlewarePlugins, func(i, j int) bool {
// Sort with descending prio // Sort with descending prio
return middlewarePlugins[i].Prio() > middlewarePlugins[j].Prio() return middlewarePlugins[i].Prio() > middlewarePlugins[j].Prio()

View File

@ -15,6 +15,7 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"go.goblog.app/app/pkgs/bufferpool" "go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/highlighting" "go.goblog.app/app/pkgs/highlighting"
"go.goblog.app/app/pkgs/htmlbuilder"
) )
func (a *goBlog) initMarkdown() { func (a *goBlog) initMarkdown() {
@ -183,13 +184,13 @@ func (c *customRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
dest = resolved[0] dest = resolved[0]
} }
} }
hb := newHtmlBuilder(w) hb := htmlbuilder.NewHtmlBuilder(w)
hb.writeElementOpen("a", "href", dest) hb.WriteElementOpen("a", "href", dest)
imgEls := []any{"src", dest, "alt", string(n.Text(source)), "loading", "lazy"} imgEls := []any{"src", dest, "alt", string(n.Text(source)), "loading", "lazy"}
if len(n.Title) > 0 { if len(n.Title) > 0 {
imgEls = append(imgEls, "title", string(n.Title)) imgEls = append(imgEls, "title", string(n.Title))
} }
hb.writeElementOpen("img", imgEls...) hb.WriteElementOpen("img", imgEls...)
hb.writeElementClose("a") hb.WriteElementClose("a")
return ast.WalkSkipChildren, nil 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 ( import (
"context" "context"
"database/sql" "database/sql"
"net/http"
)
// Interface to GoBlog "go.goblog.app/app/pkgs/htmlbuilder"
)
// App is used to access GoBlog's app instance. // App is used to access GoBlog's app instance.
type App interface { type App interface {
@ -23,29 +22,27 @@ type Database interface {
QueryRowContext(context.Context, string, ...any) (*sql.Row, error) QueryRowContext(context.Context, string, ...any) (*sql.Row, error)
} }
// Plugin types // Post
type Post interface {
// SetApp is used in all plugin types to allow GetParameters() map[string][]string
// 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 // RenderType
// GoBlog set plugin configuration. type RenderType string
type SetConfig interface {
SetConfig(map[string]any) // RenderData
type RenderData interface {
// Empty
} }
type Exec interface { // RenderNextFunc
SetApp type RenderNextFunc func(*htmlbuilder.HtmlBuilder)
SetConfig
Exec()
}
type Middleware interface { // Render main element content on post page, data = PostRenderData
SetApp const PostMainElementRenderType RenderType = "post-main-content"
SetConfig
Handler(http.Handler) http.Handler // PostRenderData is RenderData containing a Post
Prio() int 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 ( import (
"context" "context"
"database/sql" "database/sql"
"go.goblog.app/app/pkgs/htmlbuilder"
"go.goblog.app/app/pkgs/plugintypes" "go.goblog.app/app/pkgs/plugintypes"
"net/http" "net/http"
"reflect" "reflect"
@ -34,21 +35,34 @@ import (
func init() { func init() {
Symbols["go.goblog.app/app/pkgs/plugintypes/plugintypes"] = map[string]reflect.Value{ Symbols["go.goblog.app/app/pkgs/plugintypes/plugintypes"] = map[string]reflect.Value{
// function, constant and variable definitions
"PostMainElementRenderType": reflect.ValueOf(plugintypes.PostMainElementRenderType),
// type definitions // type definitions
"App": reflect.ValueOf((*plugintypes.App)(nil)), "App": reflect.ValueOf((*plugintypes.App)(nil)),
"Database": reflect.ValueOf((*plugintypes.Database)(nil)), "Database": reflect.ValueOf((*plugintypes.Database)(nil)),
"Exec": reflect.ValueOf((*plugintypes.Exec)(nil)), "Exec": reflect.ValueOf((*plugintypes.Exec)(nil)),
"Middleware": reflect.ValueOf((*plugintypes.Middleware)(nil)), "Middleware": reflect.ValueOf((*plugintypes.Middleware)(nil)),
"SetApp": reflect.ValueOf((*plugintypes.SetApp)(nil)), "Post": reflect.ValueOf((*plugintypes.Post)(nil)),
"SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(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 // interface wrapper definitions
"_App": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_App)(nil)), "_App": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_App)(nil)),
"_Database": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Database)(nil)), "_Database": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Database)(nil)),
"_Exec": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Exec)(nil)), "_Exec": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Exec)(nil)),
"_Middleware": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Middleware)(nil)), "_Middleware": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Middleware)(nil)),
"_SetApp": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetApp)(nil)), "_Post": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_Post)(nil)),
"_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(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) 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 // _go_goblog_app_app_pkgs_plugintypes_SetApp is an interface wrapper for SetApp type
type _go_goblog_app_app_pkgs_plugintypes_SetApp struct { type _go_goblog_app_app_pkgs_plugintypes_SetApp struct {
IValue interface{} 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) { func (W _go_goblog_app_app_pkgs_plugintypes_SetConfig) SetConfig(a0 map[string]any) {
W.WSetConfig(a0) 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/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" "go.goblog.app/app/pkgs/yaegiwrappers"
) )
const (
execPlugin = "exec"
middlewarePlugin = "middleware"
uiPlugin = "ui"
)
func (a *goBlog) initPlugins() error { func (a *goBlog) initPlugins() error {
a.pluginHost = plugins.NewPluginHost(yaegiwrappers.Symbols) a.pluginHost = plugins.NewPluginHost(yaegiwrappers.Symbols)
a.pluginHost.AddPluginType("exec", (*plugintypes.Exec)(nil)) a.pluginHost.AddPluginType(execPlugin, (*plugintypes.Exec)(nil))
a.pluginHost.AddPluginType("middleware", (*plugintypes.Middleware)(nil)) a.pluginHost.AddPluginType(middlewarePlugin, (*plugintypes.Middleware)(nil))
a.pluginHost.AddPluginType(uiPlugin, (*plugintypes.UI)(nil))
for _, pc := range a.cfg.Plugins { for _, pc := range a.cfg.Plugins {
if pluginInterface, err := a.pluginHost.LoadPlugin(&plugins.PluginConfig{ 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 { for _, p := range execs {
go p.Exec() go p.Exec()
} }
@ -38,15 +45,30 @@ func (a *goBlog) initPlugins() error {
} }
func getPluginsForType[T any](a *goBlog, pluginType string) (list []T) { 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) return plugins.GetPluginsForType[T](a.pluginHost, pluginType)
} }
// Implement all needed interfaces for goblog // Implement all needed interfaces
var _ plugintypes.App = &goBlog{}
func (a *goBlog) GetDatabase() plugintypes.Database { func (a *goBlog) GetDatabase() plugintypes.Database {
return a.db 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" "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) { func TestExecPlugin(t *testing.T) {
app := &goBlog{ app := &goBlog{
cfg: createDefaultTestConfig(t), cfg: createDefaultTestConfig(t),

View File

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

View File

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

1485
ui.go

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
"go.goblog.app/app/pkgs/bufferpool" "go.goblog.app/app/pkgs/bufferpool"
"go.goblog.app/app/pkgs/htmlbuilder"
) )
type summaryTyp string type summaryTyp string
@ -17,7 +18,7 @@ const (
) )
// post summary on index pages // 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 { if bc == nil || p == nil {
return return
} }
@ -25,21 +26,21 @@ func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ sum
typ = defaultSummary typ = defaultSummary
} }
// Start article // Start article
hb.writeElementOpen("article", "class", "h-entry border-bottom") hb.WriteElementOpen("article", "class", "h-entry border-bottom")
if p.Priority > 0 { if p.Priority > 0 {
// Is pinned post // Is pinned post
hb.writeElementOpen("p") hb.WriteElementOpen("p")
hb.writeEscaped("📌 ") hb.WriteEscaped("📌 ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "pinned")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "pinned"))
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
if p.RenderedTitle != "" { if p.RenderedTitle != "" {
// Has title // Has title
hb.writeElementOpen("h2", "class", "p-name") hb.WriteElementOpen("h2", "class", "p-name")
hb.writeElementOpen("a", "class", "u-url", "href", p.Path) hb.WriteElementOpen("a", "class", "u-url", "href", p.Path)
hb.writeEscaped(p.RenderedTitle) hb.WriteEscaped(p.RenderedTitle)
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("h2") hb.WriteElementClose("h2")
} }
// Show photos in photo summary // Show photos in photo summary
photos := a.photoLinks(p) 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") a.renderPostMeta(hb, p, bc, "summary")
if typ != photoSummary && a.showFull(p) { if typ != photoSummary && a.showFull(p) {
// Show full content // Show full content
hb.writeElementOpen("div", "class", "e-content") hb.WriteElementOpen("div", "class", "e-content")
a.postHtmlToWriter(hb, p, false) a.postHtmlToWriter(hb, p, false)
hb.writeElementClose("div") hb.WriteElementClose("div")
} else { } else {
// Show summary // Show summary
hb.writeElementOpen("p", "class", "p-summary") hb.WriteElementOpen("p", "class", "p-summary")
hb.writeEscaped(a.postSummary(p)) hb.WriteEscaped(a.postSummary(p))
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
// Show link to full post // Show link to full post
hb.writeElementOpen("p") hb.WriteElementOpen("p")
prefix := bufferpool.Get() prefix := bufferpool.Get()
if len(photos) > 0 { if len(photos) > 0 {
// Contains photos // Contains photos
@ -74,19 +75,19 @@ func (a *goBlog) renderSummary(hb *htmlBuilder, bc *configBlog, p *post, typ sum
} }
if prefix.Len() > 0 { if prefix.Len() > 0 {
prefix.WriteRune(' ') prefix.WriteRune(' ')
hb.writeEscaped(prefix.String()) hb.WriteEscaped(prefix.String())
} }
bufferpool.Put(prefix) bufferpool.Put(prefix)
hb.writeElementOpen("a", "class", "u-url", "href", p.Path) hb.WriteElementOpen("a", "class", "u-url", "href", p.Path)
hb.writeEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "view")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(bc.Lang, "view"))
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("p") hb.WriteElementClose("p")
// Finish article // Finish article
hb.writeElementClose("article") hb.WriteElementClose("article")
} }
// list of post taxonomy values (tags, series, etc.) // 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 { if b == nil || p == nil {
return return
} }
@ -95,372 +96,372 @@ func (a *goBlog) renderPostTax(hb *htmlBuilder, p *post, b *configBlog) {
// Get all sorted taxonomy values for this post // Get all sorted taxonomy values for this post
if taxValues := sortedStrings(p.Parameters[tax.Name]); len(taxValues) > 0 { if taxValues := sortedStrings(p.Parameters[tax.Name]); len(taxValues) > 0 {
// Start new paragraph // Start new paragraph
hb.writeElementOpen("p") hb.WriteElementOpen("p")
// Add taxonomy name // Add taxonomy name
hb.writeElementOpen("strong") hb.WriteElementOpen("strong")
hb.writeEscaped(a.renderMdTitle(tax.Title)) hb.WriteEscaped(a.renderMdTitle(tax.Title))
hb.writeElementClose("strong") hb.WriteElementClose("strong")
hb.write(": ") hb.WriteUnescaped(": ")
// Add taxonomy values // Add taxonomy values
for i, taxValue := range taxValues { for i, taxValue := range taxValues {
if i > 0 { if i > 0 {
hb.write(", ") hb.WriteUnescaped(", ")
} }
hb.writeElementOpen( hb.WriteElementOpen(
"a", "a",
"class", "p-category", "class", "p-category",
"rel", "tag", "rel", "tag",
"href", b.getRelativePath(fmt.Sprintf("/%s/%s", tax.Name, urlize(taxValue))), "href", b.getRelativePath(fmt.Sprintf("/%s/%s", tax.Name, urlize(taxValue))),
) )
hb.writeEscaped(a.renderMdTitle(taxValue)) hb.WriteEscaped(a.renderMdTitle(taxValue))
hb.writeElementClose("a") hb.WriteElementClose("a")
} }
// End paragraph // End paragraph
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
} }
} }
// post meta information. // post meta information.
// typ can be "summary", "post" or "preview". // 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" { if b == nil || p == nil || typ != "summary" && typ != "post" && typ != "preview" {
return return
} }
if typ == "summary" || typ == "post" { if typ == "summary" || typ == "post" {
hb.writeElementOpen("div", "class", "p") hb.WriteElementOpen("div", "class", "p")
} }
// Published time // Published time
if published := toLocalTime(p.Published); !published.IsZero() { if published := toLocalTime(p.Published); !published.IsZero() {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "publishedon")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "publishedon"))
hb.write(" ") hb.WriteUnescaped(" ")
hb.writeElementOpen("time", "class", "dt-published", "datetime", published.Format(time.RFC3339)) hb.WriteElementOpen("time", "class", "dt-published", "datetime", published.Format(time.RFC3339))
hb.writeEscaped(published.Format(isoDateFormat)) hb.WriteEscaped(published.Format(isoDateFormat))
hb.writeElementClose("time") hb.WriteElementClose("time")
// Section // Section
if p.Section != "" { if p.Section != "" {
if section := b.Sections[p.Section]; section != nil { if section := b.Sections[p.Section]; section != nil {
hb.write(" in ") // TODO: Replace with a proper translation hb.WriteUnescaped(" in ") // TODO: Replace with a proper translation
hb.writeElementOpen("a", "href", b.getRelativePath(section.Name)) hb.WriteElementOpen("a", "href", b.getRelativePath(section.Name))
hb.writeEscaped(a.renderMdTitle(section.Title)) hb.WriteEscaped(a.renderMdTitle(section.Title))
hb.writeElementClose("a") hb.WriteElementClose("a")
} }
} }
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// Updated time // Updated time
if updated := toLocalTime(p.Updated); !updated.IsZero() { if updated := toLocalTime(p.Updated); !updated.IsZero() {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "updatedon")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "updatedon"))
hb.write(" ") hb.WriteUnescaped(" ")
hb.writeElementOpen("time", "class", "dt-updated", "datetime", updated.Format(time.RFC3339)) hb.WriteElementOpen("time", "class", "dt-updated", "datetime", updated.Format(time.RFC3339))
hb.writeEscaped(updated.Format(isoDateFormat)) hb.WriteEscaped(updated.Format(isoDateFormat))
hb.writeElementClose("time") hb.WriteElementClose("time")
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// IndieWeb Meta // IndieWeb Meta
a.renderPostReplyContext(hb, p, "") a.renderPostReplyContext(hb, p, "")
a.renderPostLikeContext(hb, p, "") a.renderPostLikeContext(hb, p, "")
// Like ("u-like-of") // Like ("u-like-of")
if likeLink := a.likeLink(p); likeLink != "" { if likeLink := a.likeLink(p); likeLink != "" {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "likeof")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "likeof"))
hb.writeEscaped(": ") hb.WriteEscaped(": ")
hb.writeElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink) hb.WriteElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink)
if likeTitle := a.likeTitle(p); likeTitle != "" { if likeTitle := a.likeTitle(p); likeTitle != "" {
hb.writeEscaped(likeTitle) hb.WriteEscaped(likeTitle)
} else { } else {
hb.writeEscaped(likeLink) hb.WriteEscaped(likeLink)
} }
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// Geo // Geo
if geoURIs := a.geoURIs(p); len(geoURIs) != 0 { if geoURIs := a.geoURIs(p); len(geoURIs) != 0 {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped("📍 ") hb.WriteEscaped("📍 ")
for i, geoURI := range geoURIs { for i, geoURI := range geoURIs {
if i > 0 { 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("a", "class", "p-location h-geo", "target", "_blank", "rel", "nofollow noopener noreferrer", "href", geoOSMLink(geoURI))
hb.writeElementOpen("span", "class", "p-name") hb.WriteElementOpen("span", "class", "p-name")
hb.writeEscaped(a.geoTitle(geoURI, b.Lang)) hb.WriteEscaped(a.geoTitle(geoURI, b.Lang))
hb.writeElementClose("span") hb.WriteElementClose("span")
hb.writeElementOpen("data", "class", "p-longitude", "value", fmt.Sprintf("%f", geoURI.Longitude)) hb.WriteElementOpen("data", "class", "p-longitude", "value", fmt.Sprintf("%f", geoURI.Longitude))
hb.writeElementClose("data") hb.WriteElementClose("data")
hb.writeElementOpen("data", "class", "p-latitude", "value", fmt.Sprintf("%f", geoURI.Latitude)) hb.WriteElementOpen("data", "class", "p-latitude", "value", fmt.Sprintf("%f", geoURI.Latitude))
hb.writeElementClose("data") hb.WriteElementClose("data")
hb.writeElementClose("a") hb.WriteElementClose("a")
} }
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// Post specific elements // Post specific elements
if typ == "post" { if typ == "post" {
// Translations // Translations
if translations := a.postTranslations(p); len(translations) > 0 { if translations := a.postTranslations(p); len(translations) > 0 {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "translations")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "translations"))
hb.writeEscaped(": ") hb.WriteEscaped(": ")
for i, translation := range translations { for i, translation := range translations {
if i > 0 { if i > 0 {
hb.writeEscaped(", ") hb.WriteEscaped(", ")
} }
hb.writeElementOpen("a", "translate", "no", "href", translation.Path) hb.WriteElementOpen("a", "translate", "no", "href", translation.Path)
hb.writeEscaped(translation.RenderedTitle) hb.WriteEscaped(translation.RenderedTitle)
hb.writeElementClose("a") hb.WriteElementClose("a")
} }
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// Short link // Short link
if shortLink := a.shortPostURL(p); shortLink != "" { if shortLink := a.shortPostURL(p); shortLink != "" {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "shorturl")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "shorturl"))
hb.writeEscaped(" ") hb.WriteEscaped(" ")
hb.writeElementOpen("a", "rel", "shortlink", "href", shortLink) hb.WriteElementOpen("a", "rel", "shortlink", "href", shortLink)
hb.writeEscaped(shortLink) hb.WriteEscaped(shortLink)
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// Status // Status
if p.Status != statusPublished { if p.Status != statusPublished {
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "status")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "status"))
hb.writeEscaped(": ") hb.WriteEscaped(": ")
hb.writeEscaped(string(p.Status)) hb.WriteEscaped(string(p.Status))
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
} }
if typ == "summary" || typ == "post" { if typ == "summary" || typ == "post" {
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
} }
// Reply ("u-in-reply-to") // 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 == "" { if htmlWrapperElement == "" {
htmlWrapperElement = "div" htmlWrapperElement = "div"
} }
if replyLink := a.replyLink(p); replyLink != "" { if replyLink := a.replyLink(p); replyLink != "" {
hb.writeElementOpen(htmlWrapperElement) hb.WriteElementOpen(htmlWrapperElement)
hb.writeEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "replyto")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "replyto"))
hb.writeEscaped(": ") hb.WriteEscaped(": ")
hb.writeElementOpen("a", "class", "u-in-reply-to", "rel", "noopener", "target", "_blank", "href", replyLink) hb.WriteElementOpen("a", "class", "u-in-reply-to", "rel", "noopener", "target", "_blank", "href", replyLink)
if replyTitle := a.replyTitle(p); replyTitle != "" { if replyTitle := a.replyTitle(p); replyTitle != "" {
hb.writeEscaped(replyTitle) hb.WriteEscaped(replyTitle)
} else { } else {
hb.writeEscaped(replyLink) hb.WriteEscaped(replyLink)
} }
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose(htmlWrapperElement) hb.WriteElementClose(htmlWrapperElement)
} }
} }
// Like ("u-like-of") // 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 == "" { if htmlWrapperElement == "" {
htmlWrapperElement = "div" htmlWrapperElement = "div"
} }
if likeLink := a.likeLink(p); likeLink != "" { if likeLink := a.likeLink(p); likeLink != "" {
hb.writeElementOpen(htmlWrapperElement) hb.WriteElementOpen(htmlWrapperElement)
hb.writeEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "likeof")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(a.getBlogFromPost(p).Lang, "likeof"))
hb.writeEscaped(": ") hb.WriteEscaped(": ")
hb.writeElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink) hb.WriteElementOpen("a", "class", "u-like-of", "rel", "noopener", "target", "_blank", "href", likeLink)
if likeTitle := a.likeTitle(p); likeTitle != "" { if likeTitle := a.likeTitle(p); likeTitle != "" {
hb.writeEscaped(likeTitle) hb.WriteEscaped(likeTitle)
} else { } else {
hb.writeEscaped(likeLink) hb.WriteEscaped(likeLink)
} }
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose(htmlWrapperElement) hb.WriteElementClose(htmlWrapperElement)
} }
} }
// warning for old posts // 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() { if b == nil || b.hideOldContentWarning || p == nil || !p.Old() {
return return
} }
hb.writeElementOpen("strong", "class", "p border-top border-bottom") hb.WriteElementOpen("strong", "class", "p border-top border-bottom")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "oldcontent")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "oldcontent"))
hb.writeElementClose("strong") hb.WriteElementClose("strong")
} }
func (a *goBlog) renderInteractions(hb *htmlBuilder, rd *renderData) { func (a *goBlog) renderInteractions(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
// Start accordion // Start accordion
hb.writeElementOpen("details", "class", "p", "id", "interactions") hb.WriteElementOpen("details", "class", "p", "id", "interactions")
hb.writeElementOpen("summary") hb.WriteElementOpen("summary")
hb.writeElementOpen("strong") hb.WriteElementOpen("strong")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "interactions")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "interactions"))
hb.writeElementClose("strong") hb.WriteElementClose("strong")
hb.writeElementClose("summary") hb.WriteElementClose("summary")
// Render mentions // Render mentions
var renderMentions func(m []*mention) var renderMentions func(m []*mention)
renderMentions = func(m []*mention) { renderMentions = func(m []*mention) {
if len(m) == 0 { if len(m) == 0 {
return return
} }
hb.writeElementOpen("ul") hb.WriteElementOpen("ul")
for _, mention := range m { for _, mention := range m {
hb.writeElementOpen("li") hb.WriteElementOpen("li")
hb.writeElementOpen("a", "href", mention.Url, "target", "_blank", "rel", "nofollow noopener noreferrer ugc") hb.WriteElementOpen("a", "href", mention.Url, "target", "_blank", "rel", "nofollow noopener noreferrer ugc")
hb.writeEscaped(defaultIfEmpty(mention.Author, mention.Url)) hb.WriteEscaped(defaultIfEmpty(mention.Author, mention.Url))
hb.writeElementClose("a") hb.WriteElementClose("a")
if mention.Title != "" { if mention.Title != "" {
hb.write(" ") hb.WriteUnescaped(" ")
hb.writeElementOpen("strong") hb.WriteElementOpen("strong")
hb.writeEscaped(mention.Title) hb.WriteEscaped(mention.Title)
hb.writeElementClose("strong") hb.WriteElementClose("strong")
} }
if mention.Content != "" { if mention.Content != "" {
hb.write(" ") hb.WriteUnescaped(" ")
hb.writeElementOpen("i") hb.WriteElementOpen("i")
hb.writeEscaped(mention.Content) hb.WriteEscaped(mention.Content)
hb.writeElementClose("i") hb.WriteElementClose("i")
} }
if len(mention.Submentions) > 0 { if len(mention.Submentions) > 0 {
renderMentions(mention.Submentions) renderMentions(mention.Submentions)
} }
hb.writeElementClose("li") hb.WriteElementClose("li")
} }
hb.writeElementClose("ul") hb.WriteElementClose("ul")
} }
renderMentions(a.db.getWebmentionsByAddress(rd.Canonical)) renderMentions(a.db.getWebmentionsByAddress(rd.Canonical))
// Show form to send a webmention // Show form to send a webmention
hb.writeElementOpen("form", "class", "fw p", "method", "post", "action", "/webmention") hb.WriteElementOpen("form", "class", "fw p", "method", "post", "action", "/webmention")
hb.writeElementOpen("label", "for", "wm-source", "class", "p") hb.WriteElementOpen("label", "for", "wm-source", "class", "p")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "interactionslabel")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "interactionslabel"))
hb.writeElementClose("label") hb.WriteElementClose("label")
hb.writeElementOpen("input", "id", "wm-source", "type", "url", "name", "source", "placeholder", "URL", "required", "") 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", "hidden", "name", "target", "value", rd.Canonical)
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "send")) hb.WriteElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "send"))
hb.writeElementClose("form") hb.WriteElementClose("form")
// Show form to create a new comment // Show form to create a new comment
hb.writeElementOpen("form", "class", "fw p", "method", "post", "action", "/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", "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", "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("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.WriteElementOpen("textarea", "name", "comment", "required", "", "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "comment"))
hb.writeElementClose("textarea") hb.WriteElementClose("textarea")
hb.writeElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "docomment")) hb.WriteElementOpen("input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "docomment"))
hb.writeElementClose("form") hb.WriteElementClose("form")
// Finish accordion // Finish accordion
hb.writeElementClose("details") hb.WriteElementClose("details")
} }
// author h-card // author h-card
func (a *goBlog) renderAuthor(hb *htmlBuilder) { func (a *goBlog) renderAuthor(hb *htmlbuilder.HtmlBuilder) {
user := a.cfg.User user := a.cfg.User
if user == nil { if user == nil {
return return
} }
hb.writeElementOpen("div", "class", "p-author h-card hide") hb.WriteElementOpen("div", "class", "p-author h-card hide")
if user.Picture != "" { if user.Picture != "" {
hb.writeElementOpen("data", "class", "u-photo", "value", user.Picture) hb.WriteElementOpen("data", "class", "u-photo", "value", user.Picture)
hb.writeElementClose("data") hb.WriteElementClose("data")
} }
if user.Name != "" { if user.Name != "" {
hb.writeElementOpen("a", "class", "p-name u-url", "rel", "me", "href", defaultIfEmpty(user.Link, "/")) hb.WriteElementOpen("a", "class", "p-name u-url", "rel", "me", "href", defaultIfEmpty(user.Link, "/"))
hb.writeEscaped(user.Name) hb.WriteEscaped(user.Name)
hb.writeElementClose("a") hb.WriteElementClose("a")
} }
hb.writeElementClose("div") hb.WriteElementClose("div")
} }
// head meta tags for a post // 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 { if p == nil {
return return
} }
if canonical != "" { if canonical != "" {
hb.writeElementOpen("meta", "property", "og:url", "content", canonical) hb.WriteElementOpen("meta", "property", "og:url", "content", canonical)
hb.writeElementOpen("meta", "property", "twitter:url", "content", canonical) hb.WriteElementOpen("meta", "property", "twitter:url", "content", canonical)
} }
if p.RenderedTitle != "" { if p.RenderedTitle != "" {
hb.writeElementOpen("meta", "property", "og:title", "content", p.RenderedTitle) hb.WriteElementOpen("meta", "property", "og:title", "content", p.RenderedTitle)
hb.writeElementOpen("meta", "property", "twitter:title", "content", p.RenderedTitle) hb.WriteElementOpen("meta", "property", "twitter:title", "content", p.RenderedTitle)
} }
if summary := a.postSummary(p); summary != "" { if summary := a.postSummary(p); summary != "" {
hb.writeElementOpen("meta", "name", "description", "content", summary) hb.WriteElementOpen("meta", "name", "description", "content", summary)
hb.writeElementOpen("meta", "property", "og:description", "content", summary) hb.WriteElementOpen("meta", "property", "og:description", "content", summary)
hb.writeElementOpen("meta", "property", "twitter:description", "content", summary) hb.WriteElementOpen("meta", "property", "twitter:description", "content", summary)
} }
if published := toLocalTime(p.Published); !published.IsZero() { 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() { 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) { for _, img := range a.photoLinks(p) {
hb.writeElementOpen("meta", "itemprop", "image", "content", img) hb.WriteElementOpen("meta", "itemprop", "image", "content", img)
hb.writeElementOpen("meta", "property", "og:image", "content", img) hb.WriteElementOpen("meta", "property", "og:image", "content", img)
hb.writeElementOpen("meta", "property", "twitter:image", "content", img) hb.WriteElementOpen("meta", "property", "twitter:image", "content", img)
} }
} }
// TOR notice in the footer // 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 == "") { if !a.cfg.Server.Tor || (!rd.TorUsed && rd.TorAddress == "") {
return return
} }
if rd.TorUsed { if rd.TorUsed {
hb.writeElementOpen("p", "id", "tor") hb.WriteElementOpen("p", "id", "tor")
hb.writeEscaped("🔐 ") hb.WriteEscaped("🔐 ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "connectedviator")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "connectedviator"))
hb.writeElementClose("p") hb.WriteElementClose("p")
} else if rd.TorAddress != "" { } else if rd.TorAddress != "" {
hb.writeElementOpen("p", "id", "tor") hb.WriteElementOpen("p", "id", "tor")
hb.writeEscaped("🔓 ") hb.WriteEscaped("🔓 ")
hb.writeElementOpen("a", "href", rd.TorAddress) hb.WriteElementOpen("a", "href", rd.TorAddress)
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "connectviator")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "connectviator"))
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeEscaped(" ") hb.WriteEscaped(" ")
hb.writeElementOpen("a", "href", "https://www.torproject.org/", "target", "_blank", "rel", "nofollow noopener noreferrer") hb.WriteElementOpen("a", "href", "https://www.torproject.org/", "target", "_blank", "rel", "nofollow noopener noreferrer")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "whatistor")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "whatistor"))
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
} }
func (a *goBlog) renderTitleTag(hb *htmlBuilder, blog *configBlog, optionalTitle string) { func (a *goBlog) renderTitleTag(hb *htmlbuilder.HtmlBuilder, blog *configBlog, optionalTitle string) {
hb.writeElementOpen("title") hb.WriteElementOpen("title")
if optionalTitle != "" { if optionalTitle != "" {
hb.writeEscaped(optionalTitle) hb.WriteEscaped(optionalTitle)
hb.writeEscaped(" - ") hb.WriteEscaped(" - ")
} }
hb.writeEscaped(a.renderMdTitle(blog.Title)) hb.WriteEscaped(a.renderMdTitle(blog.Title))
hb.writeElementClose("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 // Navigation
if hasPrev { if hasPrev {
hb.writeElementOpen("p") hb.WriteElementOpen("p")
hb.writeElementOpen("a", "href", prev) // TODO: rel=prev? hb.WriteElementOpen("a", "href", prev) // TODO: rel=prev?
hb.writeEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "prev")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "prev"))
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
if hasNext { if hasNext {
hb.writeElementOpen("p") hb.WriteElementOpen("p")
hb.writeElementOpen("a", "href", next) // TODO: rel=next? hb.WriteElementOpen("a", "href", next) // TODO: rel=next?
hb.writeEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "next")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(blog.Lang, "next"))
hb.writeElementClose("a") hb.WriteElementClose("a")
hb.writeElementClose("p") hb.WriteElementClose("p")
} }
} }
func (*goBlog) renderPostTitle(hb *htmlBuilder, p *post) { func (*goBlog) renderPostTitle(hb *htmlbuilder.HtmlBuilder, p *post) {
if p == nil || p.RenderedTitle == "" { if p == nil || p.RenderedTitle == "" {
return return
} }
hb.writeElementOpen("h1", "class", "p-name") hb.WriteElementOpen("h1", "class", "p-name")
hb.writeEscaped(p.RenderedTitle) hb.WriteEscaped(p.RenderedTitle)
hb.writeElementClose("h1") 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() { if p == nil || !p.hasTrack() {
return return
} }
@ -469,174 +470,174 @@ func (a *goBlog) renderPostGPX(hb *htmlBuilder, p *post, b *configBlog) {
return return
} }
// Track stats // Track stats
hb.writeElementOpen("p") hb.WriteElementOpen("p")
if track.Name != "" { if track.Name != "" {
hb.writeElementOpen("strong") hb.WriteElementOpen("strong")
hb.writeEscaped(track.Name) hb.WriteEscaped(track.Name)
hb.writeElementClose("strong") hb.WriteElementClose("strong")
hb.write(" ") hb.WriteUnescaped(" ")
} }
if track.Kilometers != "" { if track.Kilometers != "" {
hb.write("🏁 ") hb.WriteUnescaped("🏁 ")
hb.writeEscaped(track.Kilometers) hb.WriteEscaped(track.Kilometers)
hb.write(" ") hb.WriteUnescaped(" ")
hb.writeEscaped(a.ts.GetTemplateStringVariant(b.Lang, "kilometers")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(b.Lang, "kilometers"))
hb.write(" ") hb.WriteUnescaped(" ")
} }
if track.Hours != "" { if track.Hours != "" {
hb.write("⏱ ") hb.WriteUnescaped("⏱ ")
hb.writeEscaped(track.Hours) hb.WriteEscaped(track.Hours)
} }
hb.writeElementClose("p") hb.WriteElementClose("p")
// Map (only show if it has features) // Map (only show if it has features)
if track.hasMapFeatures() { if track.hasMapFeatures() {
hb.writeElementOpen( hb.WriteElementOpen(
"div", "id", "map", "class", "p", "div", "id", "map", "class", "p",
"data-paths", track.PathsJSON, "data-paths", track.PathsJSON,
"data-points", track.PointsJSON, "data-points", track.PointsJSON,
"data-minzoom", track.MinZoom, "data-maxzoom", track.MaxZoom, "data-minzoom", track.MinZoom, "data-maxzoom", track.MaxZoom,
"data-attribution", track.MapAttribution, "data-attribution", track.MapAttribution,
) )
hb.writeElementClose("div") hb.WriteElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/geomap.js")) hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/geomap.js"))
hb.writeElementClose("script") hb.WriteElementClose("script")
} }
} }
func (a *goBlog) renderPostReactions(hb *htmlBuilder, p *post) { func (a *goBlog) renderPostReactions(hb *htmlbuilder.HtmlBuilder, p *post) {
if !a.reactionsEnabledForPost(p) { if !a.reactionsEnabledForPost(p) {
return return
} }
hb.writeElementOpen("div", "id", "reactions", "class", "actions", "data-path", p.Path, "data-allowed", strings.Join(allowedReactions, ",")) hb.WriteElementOpen("div", "id", "reactions", "class", "actions", "data-path", p.Path, "data-allowed", strings.Join(allowedReactions, ","))
hb.writeElementClose("div") hb.WriteElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/reactions.js")) hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/reactions.js"))
hb.writeElementClose("script") hb.WriteElementClose("script")
} }
func (a *goBlog) renderPostVideo(hb *htmlBuilder, p *post) { func (a *goBlog) renderPostVideo(hb *htmlbuilder.HtmlBuilder, p *post) {
if !p.hasVideoPlaylist() { if !p.hasVideoPlaylist() {
return return
} }
hb.writeElementOpen("div", "id", "video", "data-url", p.firstParameter(videoPlaylistParam)) hb.WriteElementOpen("div", "id", "video", "data-url", p.firstParameter(videoPlaylistParam))
hb.writeElementClose("div") hb.WriteElementClose("div")
hb.writeElementOpen("script", "defer", "", "src", a.assetFileName("js/video.js")) hb.WriteElementOpen("script", "defer", "", "src", a.assetFileName("js/video.js"))
hb.writeElementClose("script") hb.WriteElementClose("script")
} }
func (a *goBlog) renderPostSectionSettings(hb *htmlBuilder, rd *renderData, srd *settingsRenderData) { func (a *goBlog) renderPostSectionSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData, srd *settingsRenderData) {
hb.writeElementOpen("h2") hb.WriteElementOpen("h2")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "postsections")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "postsections"))
hb.writeElementClose("h2") hb.WriteElementClose("h2")
// Update default section // Update default section
hb.writeElementOpen("h3") hb.WriteElementOpen("h3")
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "default")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "default"))
hb.writeElementClose("h3") hb.WriteElementClose("h3")
hb.writeElementOpen("form", "class", "fw p", "method", "post") hb.WriteElementOpen("form", "class", "fw p", "method", "post")
hb.writeElementOpen("select", "name", "defaultsection") hb.WriteElementOpen("select", "name", "defaultsection")
for _, section := range srd.sections { for _, section := range srd.sections {
hb.writeElementOpen("option", "value", section.Name, lo.If(section.Name == srd.defaultSection, "selected").Else(""), "") hb.WriteElementOpen("option", "value", section.Name, lo.If(section.Name == srd.defaultSection, "selected").Else(""), "")
hb.writeEscaped(section.Name) hb.WriteEscaped(section.Name)
hb.writeElementClose("option") hb.WriteElementClose("option")
} }
hb.writeElementClose("select") hb.WriteElementClose("select")
hb.writeElementOpen( hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"), "input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateDefaultSectionPath), "formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateDefaultSectionPath),
) )
hb.writeElementClose("form") hb.WriteElementClose("form")
for _, section := range srd.sections { for _, section := range srd.sections {
hb.writeElementOpen("details") hb.WriteElementOpen("details")
hb.writeElementOpen("summary") hb.WriteElementOpen("summary")
hb.writeElementOpen("h3") hb.WriteElementOpen("h3")
hb.writeEscaped(section.Name) hb.WriteEscaped(section.Name)
hb.writeElementClose("h3") hb.WriteElementClose("h3")
hb.writeElementClose("summary") 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 // 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 // Description
hb.writeElementOpen( hb.WriteElementOpen(
"textarea", "textarea",
"name", "sectiondescription", "name", "sectiondescription",
"class", "monospace", "class", "monospace",
"placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiondescription"), "placeholder", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectiondescription"),
) )
hb.writeEscaped(section.Description) hb.WriteEscaped(section.Description)
hb.writeElementClose("textarea") hb.WriteElementClose("textarea")
// Path template // 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 // Show full
hb.writeElementOpen("input", "type", "checkbox", "name", "sectionshowfull", "id", "showfull-"+section.Name, lo.If(section.ShowFull, "checked").Else(""), "") 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.WriteElementOpen("label", "for", "showfull-"+section.Name)
hb.writeEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionshowfull")) hb.WriteEscaped(a.ts.GetTemplateStringVariant(rd.Blog.Lang, "sectionshowfull"))
hb.writeElementClose("label") hb.WriteElementClose("label")
// Actions // Actions
hb.writeElementOpen("div", "class", "p") hb.WriteElementOpen("div", "class", "p")
// Update // Update
hb.writeElementOpen( hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"), "input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateSectionPath), "formaction", rd.Blog.getRelativePath(settingsPath+settingsUpdateSectionPath),
) )
// Delete // Delete
hb.writeElementOpen( hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "delete"), "input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "delete"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsDeleteSectionPath), "formaction", rd.Blog.getRelativePath(settingsPath+settingsDeleteSectionPath),
"class", "confirm", "data-confirmmessage", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "confirmdelete"), "class", "confirm", "data-confirmmessage", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "confirmdelete"),
) )
hb.writeElementClose("div") hb.WriteElementClose("div")
hb.writeElementClose("form") hb.WriteElementClose("form")
hb.writeElementClose("details") hb.WriteElementClose("details")
} }
// Create new section // Create new section
hb.writeElementOpen("form", "class", "fw p", "method", "post") hb.WriteElementOpen("form", "class", "fw p", "method", "post")
// Name // 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 // 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 // Create button
hb.writeElementOpen("div") hb.WriteElementOpen("div")
hb.writeElementOpen( hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "create"), "input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "create"),
"formaction", rd.Blog.getRelativePath(settingsPath+settingsCreateSectionPath), "formaction", rd.Blog.getRelativePath(settingsPath+settingsCreateSectionPath),
) )
hb.writeElementClose("div") hb.WriteElementClose("div")
hb.writeElementClose("form") hb.WriteElementClose("form")
} }
func (a *goBlog) renderCollapsibleBooleanSetting(hb *htmlBuilder, rd *renderData, path, title, description, name string, value bool) { func (a *goBlog) renderCollapsibleBooleanSetting(hb *htmlbuilder.HtmlBuilder, rd *renderData, path, title, description, name string, value bool) {
hb.writeElementOpen("details") hb.WriteElementOpen("details")
hb.writeElementOpen("summary") hb.WriteElementOpen("summary")
hb.writeElementOpen("h3") hb.WriteElementOpen("h3")
hb.writeEscaped(title) hb.WriteEscaped(title)
hb.writeElementClose("h3") hb.WriteElementClose("h3")
hb.writeElementClose("summary") 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("input", "type", "checkbox", "name", name, "id", "cb-"+name, lo.If(value, "checked").Else(""), "")
hb.writeElementOpen("label", "for", "cb-"+name) hb.WriteElementOpen("label", "for", "cb-"+name)
hb.writeEscaped(description) hb.WriteEscaped(description)
hb.writeElementClose("label") hb.WriteElementClose("label")
hb.writeElementOpen("div", "class", "p") hb.WriteElementOpen("div", "class", "p")
hb.writeElementOpen( hb.WriteElementOpen(
"input", "type", "submit", "value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "update"), "formaction", path, "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 ( import (
"bytes" "bytes"
"io"
"os" "os"
"strings" "strings"
"testing" "testing"
@ -11,11 +10,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.goblog.app/app/pkgs/bufferpool" "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) { func Test_renderPostTax(t *testing.T) {
app := &goBlog{ app := &goBlog{
cfg: createDefaultTestConfig(t), cfg: createDefaultTestConfig(t),
@ -33,7 +30,7 @@ func Test_renderPostTax(t *testing.T) {
buf := bufferpool.Get() buf := bufferpool.Get()
defer bufferpool.Put(buf) defer bufferpool.Put(buf)
hb := newHtmlBuilder(buf) hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderPostTax(hb, p, app.cfg.Blogs["default"]) app.renderPostTax(hb, p, app.cfg.Blogs["default"])
@ -56,7 +53,7 @@ func Test_renderOldContentWarning(t *testing.T) {
} }
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf) hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderOldContentWarning(hb, p, app.cfg.Blogs["default"]) app.renderOldContentWarning(hb, p, app.cfg.Blogs["default"])
res := buf.String() res := buf.String()
@ -122,7 +119,7 @@ func Test_renderInteractions(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf) hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderInteractions(hb, &renderData{ app.renderInteractions(hb, &renderData{
Blog: app.cfg.Blogs["default"], Blog: app.cfg.Blogs["default"],
@ -149,7 +146,7 @@ func Test_renderAuthor(t *testing.T) {
_ = app.initConfig(false) _ = app.initConfig(false)
buf := &bytes.Buffer{} buf := &bytes.Buffer{}
hb := newHtmlBuilder(buf) hb := htmlbuilder.NewHtmlBuilder(buf)
app.renderAuthor(hb) app.renderAuthor(hb)
res := buf.String() res := buf.String()