New plugin types: UISummary and UIFooter

This commit is contained in:
Jan-Lukas Else 2023-03-10 15:14:50 +01:00
parent 144e7f4a41
commit 8c9d17006d
11 changed files with 200 additions and 61 deletions

View File

@ -27,13 +27,15 @@ You need to specify the path to the plugin (remember to mount the path to your G
## Types of plugins ## Types of plugins
- `SetApp` (Access more GoBlog functionalities like the database) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#SetApp - `SetApp` (Access more GoBlog functionalities like the database) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#SetApp]
- `SetConfig` (Access the configuration provided for the plugin) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#SetConfig - `SetConfig` (Access the configuration provided for the plugin) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#SetConfig]
- `Exec` (Command that is executed in a Go routine when starting GoBlog) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#Exec - `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 - `Middleware` (HTTP middleware to intercept or modify HTTP requests) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/]plugintypes#Middleware
- `UI` (Modify rendered HTML) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UI - `UI` (Modify rendered HTML) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UI]
- `UI2` (Modify rendered HTML using a goquery document which improves performance and avoids multiple HTML parsing and rendering when using multiple plugins) - see https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UI2 - `UI2` (Modify rendered HTML using a goquery document which improves performance and avoids multiple HTML parsing and rendering when using multiple plugins) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UI2]
- `UISummary` (like UI2 for only the post summary on indexes) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UISummary]
- `UIFooter` (like UI2 for only the post summary on indexes) - see [https://pkg.go.dev/go.goblog.app/app/pkgs/plugintypes#UIFooter]
More types will be added later. Any plugin can implement multiple types, see the demo plugin as example. More types will be added later. Any plugin can implement multiple types, see the demo plugin as example.

View File

@ -39,18 +39,24 @@ type Post interface {
GetPath() string GetPath() string
// Get a string array map with all the post's parameters // Get a string array map with all the post's parameters
GetParameters() map[string][]string GetParameters() map[string][]string
// Get a single parameter array (a parameter can have multiple values)
GetParameter(parameter string) []string
// Get the post section name // Get the post section name
GetSection() string GetSection() string
// Get the published date string // Get the published date string
GetPublished() string GetPublished() string
// Get the updated date string // Get the updated date string
GetUpdated() string GetUpdated() string
// Get the post content (markdown)
GetContent() string
} }
// RenderContext // RenderContext
type RenderContext interface { type RenderContext interface {
// Get the path of the request // Get the path of the request
GetPath() string GetPath() string
// Get the URL
GetURL() string
// Get the blog name // Get the blog name
GetBlog() string GetBlog() string
} }

View File

@ -43,3 +43,18 @@ type UI2 interface {
// The document can be used to add or modify HTML. // The document can be used to add or modify HTML.
RenderWithDocument(renderContext RenderContext, doc *goquery.Document) RenderWithDocument(renderContext RenderContext, doc *goquery.Document)
} }
// UISummary plugins get called when rendering the summary on indexes for a post.
type UISummary interface {
// The renderContext provides information such as the path of the request or the blog name.
// The post contains information about the post for which to render the summary.
// The document can be used to add or modify the default HTML.
RenderSummaryForPost(renderContext RenderContext, post Post, doc *goquery.Document)
}
// UIFooter plugins get called when rendering the footer on each HTML page.
type UIFooter interface {
// The renderContext provides information such as the path of the request or the blog name.
// The document can be used to add or modify the default HTML.
RenderFooter(renderContext RenderContext, doc *goquery.Document)
}

View File

@ -47,6 +47,8 @@ func init() {
"SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(nil)), "SetConfig": reflect.ValueOf((*plugintypes.SetConfig)(nil)),
"UI": reflect.ValueOf((*plugintypes.UI)(nil)), "UI": reflect.ValueOf((*plugintypes.UI)(nil)),
"UI2": reflect.ValueOf((*plugintypes.UI2)(nil)), "UI2": reflect.ValueOf((*plugintypes.UI2)(nil)),
"UIFooter": reflect.ValueOf((*plugintypes.UIFooter)(nil)),
"UISummary": reflect.ValueOf((*plugintypes.UISummary)(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)),
@ -59,6 +61,8 @@ func init() {
"_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(nil)), "_SetConfig": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_SetConfig)(nil)),
"_UI": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UI)(nil)), "_UI": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UI)(nil)),
"_UI2": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UI2)(nil)), "_UI2": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UI2)(nil)),
"_UIFooter": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UIFooter)(nil)),
"_UISummary": reflect.ValueOf((*_go_goblog_app_app_pkgs_plugintypes_UISummary)(nil)),
} }
} }
@ -149,6 +153,8 @@ func (W _go_goblog_app_app_pkgs_plugintypes_Middleware) Prio() int {
// _go_goblog_app_app_pkgs_plugintypes_Post is an interface wrapper for Post type // _go_goblog_app_app_pkgs_plugintypes_Post is an interface wrapper for Post type
type _go_goblog_app_app_pkgs_plugintypes_Post struct { type _go_goblog_app_app_pkgs_plugintypes_Post struct {
IValue interface{} IValue interface{}
WGetContent func() string
WGetParameter func(parameter string) []string
WGetParameters func() map[string][]string WGetParameters func() map[string][]string
WGetPath func() string WGetPath func() string
WGetPublished func() string WGetPublished func() string
@ -156,6 +162,12 @@ type _go_goblog_app_app_pkgs_plugintypes_Post struct {
WGetUpdated func() string WGetUpdated func() string
} }
func (W _go_goblog_app_app_pkgs_plugintypes_Post) GetContent() string {
return W.WGetContent()
}
func (W _go_goblog_app_app_pkgs_plugintypes_Post) GetParameter(parameter string) []string {
return W.WGetParameter(parameter)
}
func (W _go_goblog_app_app_pkgs_plugintypes_Post) GetParameters() map[string][]string { func (W _go_goblog_app_app_pkgs_plugintypes_Post) GetParameters() map[string][]string {
return W.WGetParameters() return W.WGetParameters()
} }
@ -177,6 +189,7 @@ type _go_goblog_app_app_pkgs_plugintypes_RenderContext struct {
IValue interface{} IValue interface{}
WGetBlog func() string WGetBlog func() string
WGetPath func() string WGetPath func() string
WGetURL func() string
} }
func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetBlog() string { func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetBlog() string {
@ -185,6 +198,9 @@ func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetBlog() string {
func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetPath() string { func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetPath() string {
return W.WGetPath() return W.WGetPath()
} }
func (W _go_goblog_app_app_pkgs_plugintypes_RenderContext) GetURL() string {
return W.WGetURL()
}
// _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 {
@ -225,3 +241,23 @@ type _go_goblog_app_app_pkgs_plugintypes_UI2 struct {
func (W _go_goblog_app_app_pkgs_plugintypes_UI2) RenderWithDocument(renderContext plugintypes.RenderContext, doc *goquery.Document) { func (W _go_goblog_app_app_pkgs_plugintypes_UI2) RenderWithDocument(renderContext plugintypes.RenderContext, doc *goquery.Document) {
W.WRenderWithDocument(renderContext, doc) W.WRenderWithDocument(renderContext, doc)
} }
// _go_goblog_app_app_pkgs_plugintypes_UIFooter is an interface wrapper for UIFooter type
type _go_goblog_app_app_pkgs_plugintypes_UIFooter struct {
IValue interface{}
WRenderFooter func(renderContext plugintypes.RenderContext, doc *goquery.Document)
}
func (W _go_goblog_app_app_pkgs_plugintypes_UIFooter) RenderFooter(renderContext plugintypes.RenderContext, doc *goquery.Document) {
W.WRenderFooter(renderContext, doc)
}
// _go_goblog_app_app_pkgs_plugintypes_UISummary is an interface wrapper for UISummary type
type _go_goblog_app_app_pkgs_plugintypes_UISummary struct {
IValue interface{}
WRenderSummaryForPost func(renderContext plugintypes.RenderContext, post plugintypes.Post, doc *goquery.Document)
}
func (W _go_goblog_app_app_pkgs_plugintypes_UISummary) RenderSummaryForPost(renderContext plugintypes.RenderContext, post plugintypes.Post, doc *goquery.Document) {
W.WRenderSummaryForPost(renderContext, post, doc)
}

View File

@ -22,6 +22,8 @@ const (
pluginUi2Type = "ui2" pluginUi2Type = "ui2"
pluginExecType = "exec" pluginExecType = "exec"
pluginMiddlewareType = "middleware" pluginMiddlewareType = "middleware"
pluginUiSummaryType = "uisummary"
pluginUiFooterType = "uifooter"
) )
func (a *goBlog) initPlugins() error { func (a *goBlog) initPlugins() error {
@ -37,6 +39,8 @@ func (a *goBlog) initPlugins() error {
pluginUi2Type: reflect.TypeOf((*plugintypes.UI2)(nil)).Elem(), pluginUi2Type: reflect.TypeOf((*plugintypes.UI2)(nil)).Elem(),
pluginExecType: reflect.TypeOf((*plugintypes.Exec)(nil)).Elem(), pluginExecType: reflect.TypeOf((*plugintypes.Exec)(nil)).Elem(),
pluginMiddlewareType: reflect.TypeOf((*plugintypes.Middleware)(nil)).Elem(), pluginMiddlewareType: reflect.TypeOf((*plugintypes.Middleware)(nil)).Elem(),
pluginUiSummaryType: reflect.TypeOf((*plugintypes.UISummary)(nil)).Elem(),
pluginUiFooterType: reflect.TypeOf((*plugintypes.UIFooter)(nil)).Elem(),
}, },
yaegiwrappers.Symbols, yaegiwrappers.Symbols,
subFS, subFS,
@ -106,6 +110,10 @@ func (p *post) GetParameters() map[string][]string {
return p.Parameters return p.Parameters
} }
func (p *post) GetParameter(parameter string) []string {
return p.Parameters[parameter]
}
func (p *post) GetSection() string { func (p *post) GetSection() string {
return p.Section return p.Section
} }
@ -117,3 +125,7 @@ func (p *post) GetPublished() string {
func (p *post) GetUpdated() string { func (p *post) GetUpdated() string {
return p.Updated return p.Updated
} }
func (p *post) GetContent() string {
return p.Content
}

View File

@ -22,9 +22,10 @@ func GetPlugin() (
plugintypes.UI2, plugintypes.UI2,
plugintypes.Exec, plugintypes.Exec,
plugintypes.Middleware, plugintypes.Middleware,
plugintypes.UISummary,
) { ) {
p := &plugin{} p := &plugin{}
return p, p, p, p, p, p return p, p, p, p, p, p, p
} }
// SetApp // SetApp
@ -97,3 +98,8 @@ func (p *plugin) Handler(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
// UISummary
func (p *plugin) RenderSummaryForPost(rc plugintypes.RenderContext, post plugintypes.Post, doc *goquery.Document) {
doc.Find(".h-entry").AppendHtml(fmt.Sprintf("<p>Summary for post %s on %s</p>", post.GetPath(), rc.GetURL()))
}

View File

@ -9,7 +9,7 @@ import (
"go.goblog.app/app/pkgs/plugintypes" "go.goblog.app/app/pkgs/plugintypes"
) )
func GetPlugin() (plugintypes.SetConfig, plugintypes.UI2) { func GetPlugin() (plugintypes.SetConfig, plugintypes.UIFooter) {
p := &plugin{} p := &plugin{}
return p, p return p, p
} }
@ -22,7 +22,7 @@ func (p *plugin) SetConfig(config map[string]any) {
p.config = config p.config = config
} }
func (p *plugin) RenderWithDocument(rc plugintypes.RenderContext, doc *goquery.Document) { func (p *plugin) RenderFooter(rc plugintypes.RenderContext, doc *goquery.Document) {
blog := rc.GetBlog() blog := rc.GetBlog()
if blog == "" { if blog == "" {
fmt.Println("webrings plugin: blog is empty!") fmt.Println("webrings plugin: blog is empty!")

View File

@ -20,6 +20,8 @@ type renderData struct {
WebmentionReceivingEnabled bool WebmentionReceivingEnabled bool
TorUsed bool TorUsed bool
EasterEgg bool EasterEgg bool
// For plugins
prc *pluginRenderContext
// Not directly accessible // Not directly accessible
app *goBlog app *goBlog
req *http.Request req *http.Request
@ -40,36 +42,26 @@ func (a *goBlog) renderWithStatusCode(w http.ResponseWriter, r *http.Request, st
w.Header().Set(contentType, contenttype.HTMLUTF8) w.Header().Set(contentType, contenttype.HTMLUTF8)
// Write status code // Write status code
w.WriteHeader(statusCode) w.WriteHeader(statusCode)
// Render // Render (with UI2 plugins)
renderPipeReader, renderPipeWriter := io.Pipe() renderPipeReader, renderPipeWriter := io.Pipe()
go func() { go func() {
f(htmlbuilder.NewHtmlBuilder(renderPipeWriter), data) hb, finish := a.wrapForPlugins(
renderPipeWriter,
a.getPlugins(pluginUi2Type),
func(plugin any, doc *goquery.Document) {
plugin.(plugintypes.UI2).RenderWithDocument(data.prc, doc)
},
)
f(hb, data)
finish()
_ = renderPipeWriter.Close() _ = renderPipeWriter.Close()
}() }()
// Create render context for plugins // Run io based UI plugins
rc := &pluginRenderContext{
blog: data.BlogString,
path: r.URL.Path,
}
// Run UI plugins
pluginPipeReader, pluginPipeWriter := io.Pipe() pluginPipeReader, pluginPipeWriter := io.Pipe()
go func() { go func() {
a.chainUiPlugins(a.getPlugins(pluginUiType), rc, renderPipeReader, pluginPipeWriter) a.chainUiPlugins(a.getPlugins(pluginUiType), data.prc, renderPipeReader, pluginPipeWriter)
_ = pluginPipeWriter.Close() _ = pluginPipeWriter.Close()
}() }()
// Run UI2 plugins
ui2Plugins := a.getPlugins(pluginUi2Type)
if len(ui2Plugins) > 0 {
doc, _ := goquery.NewDocumentFromReader(pluginPipeReader)
_ = pluginPipeReader.Close()
for _, plugin := range ui2Plugins {
plugin.(plugintypes.UI2).RenderWithDocument(rc, doc)
}
pluginPipeReader, pluginPipeWriter = io.Pipe()
go func() {
_ = pluginPipeWriter.CloseWithError(goquery.Render(pluginPipeWriter, doc.Selection))
}()
}
// Return minified HTML // Return minified HTML
_ = pluginPipeReader.CloseWithError(a.min.Get().Minify(contenttype.HTML, w, pluginPipeReader)) _ = pluginPipeReader.CloseWithError(a.min.Get().Minify(contenttype.HTML, w, pluginPipeReader))
} }
@ -125,6 +117,14 @@ func (a *goBlog) checkRenderData(r *http.Request, data *renderData) {
if ee := a.cfg.EasterEgg; ee != nil && ee.Enabled { if ee := a.cfg.EasterEgg; ee != nil && ee.Enabled {
data.EasterEgg = true data.EasterEgg = true
} }
// Plugins
if data.prc == nil {
data.prc = &pluginRenderContext{
blog: data.BlogString,
path: r.URL.Path,
url: a.getFullAddress(r.URL.Path),
}
}
// Data // Data
if data.Data == nil { if data.Data == nil {
data.Data = map[string]any{} data.Data = map[string]any{}
@ -136,6 +136,7 @@ func (a *goBlog) checkRenderData(r *http.Request, data *renderData) {
type pluginRenderContext struct { type pluginRenderContext struct {
blog string blog string
path string path string
url string
} }
func (d *pluginRenderContext) GetBlog() string { func (d *pluginRenderContext) GetBlog() string {
@ -145,3 +146,7 @@ func (d *pluginRenderContext) GetBlog() string {
func (d *pluginRenderContext) GetPath() string { func (d *pluginRenderContext) GetPath() string {
return d.path return d.path
} }
func (d *pluginRenderContext) GetURL() string {
return d.url
}

31
ui.go
View File

@ -140,34 +140,7 @@ func (a *goBlog) renderBase(hb *htmlbuilder.HtmlBuilder, rd *renderData, title,
main(hb) main(hb)
} }
// Footer // Footer
hb.WriteElementOpen("footer") a.renderFooter(hb, rd)
// Footer menu
if fm, ok := rd.Blog.Menus["footer"]; ok {
hb.WriteElementOpen("nav")
for i, item := range fm.Items {
if i > 0 {
hb.WriteUnescaped(" &bull; ")
}
hb.WriteElementOpen("a", "href", item.Link)
hb.WriteEscaped(a.renderMdTitle(item.Title))
hb.WriteElementClose("a")
}
hb.WriteElementClose("nav")
}
// Copyright
hb.WriteElementOpen("p", "translate", "no")
hb.WriteUnescaped("&copy; ")
hb.WriteEscaped(time.Now().Format("2006"))
hb.WriteUnescaped(" ")
if user != nil && user.Name != "" {
hb.WriteEscaped(user.Name)
} else {
hb.WriteEscaped(renderedBlogTitle)
}
hb.WriteElementClose("p")
// Tor
a.renderTorNotice(hb, rd)
hb.WriteElementClose("footer")
// Easter egg // Easter egg
if rd.EasterEgg { if rd.EasterEgg {
hb.WriteElementOpen("script", "src", a.assetFileName("js/easteregg.js"), "defer", "") hb.WriteElementOpen("script", "src", a.assetFileName("js/easteregg.js"), "defer", "")
@ -409,7 +382,7 @@ func (a *goBlog) renderIndex(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
if id.posts != nil && len(id.posts) > 0 { if id.posts != nil && len(id.posts) > 0 {
// Posts // Posts
for _, p := range id.posts { for _, p := range id.posts {
a.renderSummary(hb, rd.Blog, p, id.summaryTemplate) a.renderSummary(hb, rd, rd.Blog, p, id.summaryTemplate)
} }
} else { } else {
// No posts // No posts

View File

@ -5,8 +5,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/PuerkitoBio/goquery"
"github.com/samber/lo" "github.com/samber/lo"
"go.goblog.app/app/pkgs/htmlbuilder" "go.goblog.app/app/pkgs/htmlbuilder"
"go.goblog.app/app/pkgs/plugintypes"
) )
type summaryTyp string type summaryTyp string
@ -17,13 +19,18 @@ const (
) )
// post summary on index pages // post summary on index pages
func (a *goBlog) renderSummary(hb *htmlbuilder.HtmlBuilder, bc *configBlog, p *post, typ summaryTyp) { func (a *goBlog) renderSummary(origHb *htmlbuilder.HtmlBuilder, rd *renderData, bc *configBlog, p *post, typ summaryTyp) {
if bc == nil || p == nil { if bc == nil || p == nil {
return return
} }
if typ == "" { if typ == "" {
typ = defaultSummary typ = defaultSummary
} }
// Plugin handling
hb, finish := a.wrapForPlugins(origHb, a.getPlugins(pluginUiSummaryType), func(plugin any, doc *goquery.Document) {
plugin.(plugintypes.UISummary).RenderSummaryForPost(rd.prc, p, doc)
})
defer finish()
// 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 {
@ -674,3 +681,40 @@ func (a *goBlog) renderUserSettings(hb *htmlbuilder.HtmlBuilder, rd *renderData,
) )
hb.WriteElementClose("form") hb.WriteElementClose("form")
} }
func (a *goBlog) renderFooter(origHb *htmlbuilder.HtmlBuilder, rd *renderData) {
// Wrap plugins
hb, finish := a.wrapForPlugins(origHb, a.getPlugins(pluginUiFooterType), func(plugin any, doc *goquery.Document) {
plugin.(plugintypes.UIFooter).RenderFooter(rd.prc, doc)
})
defer finish()
// Render footer
hb.WriteElementOpen("footer")
// Footer menu
if fm, ok := rd.Blog.Menus["footer"]; ok {
hb.WriteElementOpen("nav")
for i, item := range fm.Items {
if i > 0 {
hb.WriteUnescaped(" &bull; ")
}
hb.WriteElementOpen("a", "href", item.Link)
hb.WriteEscaped(a.renderMdTitle(item.Title))
hb.WriteElementClose("a")
}
hb.WriteElementClose("nav")
}
// Copyright
hb.WriteElementOpen("p", "translate", "no")
hb.WriteUnescaped("&copy; ")
hb.WriteEscaped(time.Now().Format("2006"))
hb.WriteUnescaped(" ")
if user := a.cfg.User; user != nil && user.Name != "" {
hb.WriteEscaped(user.Name)
} else {
hb.WriteEscaped(a.renderMdTitle(rd.Blog.Title))
}
hb.WriteElementClose("p")
// Tor
a.renderTorNotice(hb, rd)
hb.WriteElementClose("footer")
}

40
uiPluginsHelper.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"io"
"sync"
"github.com/PuerkitoBio/goquery"
"go.goblog.app/app/pkgs/htmlbuilder"
)
func (*goBlog) wrapForPlugins(
originalWriter io.Writer,
plugins []any,
pluginRender func(plugin any, doc *goquery.Document),
) (wrappedHb *htmlbuilder.HtmlBuilder, finish func()) {
if len(plugins) == 0 {
// No plugins, nothing to wrap
if hb, ok := (originalWriter).(*htmlbuilder.HtmlBuilder); ok {
return hb, func() {}
}
return htmlbuilder.NewHtmlBuilder(originalWriter), func() {}
}
var wg sync.WaitGroup
pr, pw := io.Pipe()
finish = func() {
_ = pw.Close()
wg.Wait()
}
go func() {
wg.Add(1)
defer wg.Done()
doc, err := goquery.NewDocumentFromReader(pr)
_ = pr.CloseWithError(err)
for _, plugin := range plugins {
pluginRender(plugin, doc)
}
_ = goquery.Render(originalWriter, doc.Selection)
}()
return htmlbuilder.NewHtmlBuilder(pw), finish
}