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

pull/35/head
Jan-Lukas Else 2 months ago committed by Jan-Lukas Else
parent 2158b156c5
commit a45d28d04f
  1. 3
      docs/index.md
  2. 44
      docs/plugins.md
  3. 3
      editor.go
  4. 2
      go.mod
  5. 4
      go.sum
  6. 2
      http.go
  7. 9
      markdown.go
  8. 72
      pkgs/htmlbuilder/htmlbuilder.go
  9. 6
      pkgs/htmlbuilder/htmlbuilder_test.go
  10. 43
      pkgs/plugintypes/goblog.go
  11. 38
      pkgs/plugintypes/plugins.go
  12. 40
      pkgs/yaegiwrappers/go_goblog_app-app-pkgs-htmlbuilder.go
  13. 81
      pkgs/yaegiwrappers/go_goblog_app-app-pkgs-plugintypes.go
  14. 1
      pkgs/yaegiwrappers/wrappers.go
  15. 36
      plugins.go
  16. 36
      plugins/demo/src/demoui/demo.go
  17. 54
      plugins/syndication/src/syndication/syndication.go
  18. 5
      plugins_test.go
  19. 43
      postsFuncs.go
  20. 7
      render.go
  21. 1485
      ui.go
  22. 609
      uiComponents.go
  23. 72
      uiHtmlBuilder.go
  24. 13
      ui_test.go

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

@ -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.

@ -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)
}

@ -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

@ -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=

@ -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()

@ -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
}

@ -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(`>`)
}

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

@ -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
type Exec interface {
SetApp
SetConfig
Exec()
// RenderData
type RenderData interface {
// Empty
}
type Middleware interface {
SetApp
SetConfig
Handler(http.Handler) http.Handler
Prio() int
// RenderNextFunc
type RenderNextFunc func(*htmlbuilder.HtmlBuilder)
// 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
}

@ -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)
}

@ -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)),
}
}

@ -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)),
"SetApp": reflect.ValueOf((*plugintypes.SetApp)(nil)),
"SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(nil)),
"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)),
"_SetApp": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetApp)(nil)),
"_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(nil)),
"_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)
}

@ -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

@ -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}
}

@ -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)
}
}

@ -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)
}
}

@ -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),

@ -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")

@ -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))

1485
ui.go

File diff suppressed because it is too large Load Diff

@ -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.