Extending
Writing a plugin
The plugin contract, what Host and ConfigHost expose, every hook and what it may change, and a complete plugin with its tests.
A plugin is a Go type with four methods. Everything else — reacting to a render, rewriting output, adding a template function — is opt-in: you implement the interface for the hook you want, and the framework finds it by type assertion.
This page is the reference for that surface. Using plugins is the other side of it: how an application registers and configures what you write.
The contract
type Plugin interface {
Name() string
Version() string
Init(ctx context.Context, host collage.Host) error
Shutdown(ctx context.Context) error
}
Nameidentifies the plugin. It must be non-empty and unique within the application, and it is the key of the plugin's configuration section — so make it read like a module path:acme/stamp.Versionis your plugin's own version, for diagnostics.Initruns once when the application starts, after the application has registered its own pages and before the first request.Shutdownreleases whateverInitacquired. It is called for every registered plugin whether or not itsInitran or succeeded, and possibly more than once, so it must be safe withoutInitand idempotent — see Lifecycle.
Because hooks are discovered by type assertion, a misspelled hook method is not a compile error — it is a hook that never runs. Assert every interface you mean to implement:
var (
_ collage.Plugin = (*Plugin)(nil)
_ collage.AfterRenderHook = (*Plugin)(nil)
)
Two phases: Configure and Init
Some work has to happen before templates are parsed. html/template can only call
a function that was in its function map when the template was parsed, and parsing
happens inside collage.New. So there is an optional earlier phase:
type Configurer interface {
Configure(ctx context.Context, host collage.ConfigHost) error
}
Configure runs inside New, once per plugin, in registration order, before the
templates are parsed. A returned error aborts New. Nothing has been acquired
yet, so there is no rollback.
Init runs later, when the application starts — the first call to Handler,
ListenAndServe, Start, RenderPath, RenderDocumentPath or DispatchCommands,
which includes a static build, since the builder renders through RenderPath. By then the
application has registered its pages, so a plugin can read them or add its own.
A plugin that needs both implements both. A plugin that implements Configurer
must be supplied in Config.Plugins: RegisterPlugin is called after New has
already parsed the templates, so it refuses such a plugin with
ErrConfigurerRegisteredLate instead of skipping its Configure silently.
What each phase can reach
ConfigHost (Configure) |
Host (Init) |
|
|---|---|---|
DevMode, Logger, Config |
yes | yes |
AddTemplateFunc, WrapMount |
yes | — |
Pages, Page, InvalidateTags |
— | yes |
RegisterPage, RegisterDocument, Mount |
— | yes |
RegisterCommand |
— | yes |
ConfigHost is narrower on purpose. During Configure the application has
registered nothing, so pages would be an empty list and invalidation would have no
cache to reach.
ConfigHost
| Method | What it does |
|---|---|
DevMode() bool |
Whether the application runs in development mode. |
Logger() *slog.Logger |
The application's logger. |
Config(v) error |
Decodes this plugin's configuration section into v — see Configuration. |
AddTemplateFunc(name, fn) error |
Adds a template function. Returns ErrDuplicateTemplateFunc when the name was already added — by another plugin, or by this one earlier. |
WrapMount(wrap func(fs.FS) fs.FS) |
Registers a transformation applied to every mounted filesystem, in the order wrappers were registered. |
Host
| Method | What it does |
|---|---|
DevMode() bool |
Whether the application runs in development mode. |
Logger() *slog.Logger |
The application's logger. |
Config(v) error |
Decodes this plugin's configuration section into v. |
Pages() []*collage.Page |
Every registered page, each a defensive copy. |
Page(name) (*collage.Page, bool) |
One page by name, a defensive copy. |
InvalidateTags(ctx, tags...) error |
Drops every cached entry built from any of the tags. |
RegisterPage(page) error |
Registers a page the plugin contributes. |
RegisterDocument(doc) error |
Registers a document the plugin contributes. |
Mount(prefix, fsys, opts...) error |
Serves a filesystem under a URL prefix. |
RegisterCommand(cmd) error |
Contributes a command — see Commands. |
What Init receives is not the *App. It is a narrow value that forwards these
methods and nothing else, so a plugin cannot assert its way to ListenAndServe,
Shutdown, the router, the cache or the template set.
Host limits what a plugin can reach, not what it can change. Pages and
Page return copies of the page struct and of its Paths, Redirects, SEO and
DependencyTags containers, so editing those does not touch the application's own
page. The fragment pointers inside a copy are still shared, and the events below
carry the live page, not a copy — copying a page and its fragment tree on every
request would cost the hot path. Writing through an event's Page changes the page
every concurrent request is reading: that is a data race, and go test -race will
say so. Treat pages as read-only. Plugins are trusted code, not a sandbox.
Pages, documents and mounts a plugin registers are held to the same rules as the application's own: a name or a path that is already taken is a startup error, not a race decided by registration order.
The hooks
| Interface | Method | Event | Fires | May change |
|---|---|---|---|---|
PageResolvedHook |
OnPageResolved |
PageResolvedEvent |
Once per page request, right after routing — cache hits included | nothing |
BeforeRenderHook |
OnBeforeRender |
BeforeRenderEvent |
Before a fresh page render | nothing on the event; may hoist through ev.Context |
AfterRenderHook |
OnAfterRender |
AfterRenderEvent |
After a page render succeeded | ev.HTML |
DocumentRenderedHook |
OnDocumentRendered |
DocumentRenderedEvent |
After a document handler produced its body | ev.Body |
CacheWriteHook |
OnCacheWrite |
CacheWriteEvent |
Before a page or document is written to the cache | ev.Skip, ev.TTL, ev.Tags |
CacheInvalidateHook |
OnCacheInvalidate |
CacheInvalidateEvent |
After entries were invalidated by tag | nothing |
ErrorHook |
OnError |
ErrorEvent |
On a failure while serving a request | nothing |
Every hook method has the shape func(ctx context.Context, ev *Event) error.
PageResolvedHook
type PageResolvedEvent struct {
Page *collage.Page // live — do not write through it
Locale string
Path string
}
Fires once per request that routed to a page, before the cache is consulted, so
it sees cache hits as well as fresh renders. It never fires for a document, and
it does not fire during a static build — a build is not a request, and a plugin
counting requests would count renders nobody asked for. An error fails the request
with a 500, under the stage "page_resolved".
BeforeRenderHook
type BeforeRenderEvent struct {
Context *collage.RenderContext // the render about to run
Page *collage.Page
Locale string
Path string
}
Fires immediately before a fresh render and not on a cache hit — that is the
difference from PageResolvedHook. It fires for pages, for error pages, for a page
an action answers with through RenderPage, and for every page a static build
renders.
It is the only hook that gets the render context, and the reason is hoisting. A plugin that contributes to the page has to declare before the tree renders:
func (p *Plugin) OnBeforeRender(_ context.Context, ev *collage.BeforeRenderEvent) error {
ev.Context.HoistMeta("generator", p.cfg.Generator)
return nil
}
A declaration made here sits at depth zero, so any fragment that declares the same
key replaces it: the plugin provides the default, the page provides the specific
thing. It lands only where the layout calls {{hoist "head"}}. An error fails the
request with a 500, under "before_render".
AfterRenderHook
type AfterRenderEvent struct {
Page *collage.Page
Locale string
Degraded bool // some fragment failed, fallback or not
HTML []byte // replace it to post-process the page
// Data: the render's shared data, the map behind rc.Set and rc.Get
}
Fires after a page render succeeded, for pages, error pages, a page an action
answers with through RenderPage, and static builds.
Replace ev.HTML to post-process; what you leave there is what is served, and —
unless a cache-write hook skips it — what is cached. Later plugins see what earlier
ones produced.
ev.Data is the render's shared data — the same map fragments read and write with
rc.Set and rc.Get — so it is what the page was built from, for a plugin that
wants the article rather than markup to parse back. What is in it is entirely the
application's convention; the framework puts nothing there. It is the live map:
reading it is fine, keeping it past the hook is holding request state.
Two consequences of where it sits:
- It does not run again on a cache hit. Its output is what was cached. A hook that must run per request cannot be combined with a cached page.
- An empty result is a failure. If
ev.HTMLis empty after dispatch, the request fails with a 500 rather than serving a blank page.
A page an action answers with through RenderPage runs it too (since v0.10.0;
before, it ran BeforeRender only), so a validation page is minified like any
other. An error fails the request with a 500, under "after_render".
DocumentRenderedHook
type DocumentRenderedEvent struct {
Document *collage.Document
ContentType string
Locale string
Path string
Body []byte // replace it to transform the document
}
AfterRenderHook's counterpart for documents — sitemaps, feeds,
JSON. Without it, a plugin that post-processes output would cover pages and
silently skip everything else. It fires before the ETag is computed and before
the body is cached, so what you produce is what is stored and what the ETag
describes. Check ContentType to decide whether a body is yours to touch. An empty
body after dispatch, or an error, fails the request with a 500.
A document does not dispatch OnPageResolved, OnBeforeRender or OnAfterRender:
it has no page and renders no templates.
CacheWriteHook
type CacheWriteEvent struct {
Key string // the cache key; changing it changes nothing
Page *collage.Page // nil for a document
TTL time.Duration // may be adjusted
Tags []string // may be adjusted
Skip bool // set true to suppress the write
}
Fires before a rendered page or document is stored. Page is nil for a
document, and a hook that dereferences it unguarded panics on every document
request — the panic is contained, but the write it was dispatched for is abandoned,
so the document is never cached:
func (p *Plugin) OnCacheWrite(_ context.Context, ev *collage.CacheWriteEvent) error {
if ev.Page == nil {
return nil // a document; Key, TTL and Tags are still valid
}
if ev.Page.Name == "home" {
ev.TTL = time.Minute
}
return nil
}
An error, or Skip, suppresses the write and the request still succeeds: the page
has already rendered, and serving it uncached is better than turning a cache
problem into a 500. The error is reported to error hooks under "cache_write".
CacheInvalidateHook
type CacheInvalidateEvent struct {
Tags []string
}
Fires after InvalidateTags dropped the entries for some tags — whether the
application, an action or a plugin called it. It is dispatched from that call, not
from a request, and an error is joined into what InvalidateTags returns. To
trigger an invalidation yourself, call Host.InvalidateTags.
ErrorHook
type ErrorEvent struct {
Err error
Page *collage.Page // nil unless the failure was a page's own; see below
Path string
Stage string
}
Fires on a failure while serving a request: a page, a document, an action, a mount
or a handler registered with App.Handle. Stage names where it happened.
Page is set only for a failure of a page that routing resolved. It is nil when
no page was resolved, and also for a document, an action — including a page the
action answers with through RenderPage — a mount and an App.Handle handler.
Read Path to tell those apart, and guard every use of Page. The
stages the framework uses are "route", "not_found", "page_resolved",
"before_render", "render", "after_render", "cache_write", "error_page",
"asset", "handler" and "panic" — the set is not a closed enum.
"error_page" is the one worth alerting on: it means the page that reports
failures itself failed, and the client still received a plausible built-in page,
so nobody would otherwise find out.
Classify Err with errors.Is — collage.ErrNoRoute for a URL that matched
nothing, collage.ErrNotFound for content that does not exist,
collage.ErrMethodNotAllowed for a 405, collage.ErrCSRFMissing and its siblings
for a refused submission, collage.ErrAssetFailed for a mount answering 4xx or
5xx, collage.ErrPanic for a recovered panic, and the rest listed in
Errors.
An error returned from OnError is logged and swallowed, and the remaining
plugins still receive the event: an error handler that fails must not start another
round of error handling.
Dispatch rules
- Hooks run in registration order.
- Every call is panic-guarded. A panicking hook fails like one that returned an error; it does not take the process down.
- For
OnPageResolved,OnBeforeRender,OnAfterRenderandOnDocumentRendered, the first error stops dispatch and fails the request. - For
OnCacheWrite, the first error stops dispatch and suppresses the write. - For
OnCacheInvalidate, the first error stops dispatch and is returned fromInvalidateTags. - For
OnError, errors are logged and dispatch continues.
Template functions
A plugin adds a template function from Configure:
func (p *Plugin) Configure(_ context.Context, host collage.ConfigHost) error {
return host.AddTemplateFunc("readingTime", func(words int) string {
return fmt.Sprintf("%d min read", max(1, words/200))
})
}
Every template can then call {{readingTime .Words}}. The function is any value
html/template accepts in a function map.
- A name added twice — by two plugins, or twice by the same one — is
ErrDuplicateTemplateFunc, returned from the secondAddTemplateFunc.Newfails if yourConfigurereturns it, as it does when you pass the error on. The application cannot resolve it withTemplate.Funcs: the conflict is between plugins, and one of them has to give way. - The application wins otherwise. An entry in
Config.Template.Funcsunder a name a plugin added replaces the plugin's function: the application can see both and decide. - A plugin function under a built-in name replaces the built-in — except the
functions bound per render (
slot,hoist,asset,stylesheet,csrfToken,pageURL,pageURLIn,localeURL), which the render engine rebinds every time. See Template functions. - It must be called from
Configure. There is no way to add one later, because a function added after parsing is one no template can call.
Wrapping mounts
WrapMount registers a function applied to every mounted filesystem:
func (p *Plugin) Configure(_ context.Context, host collage.ConfigHost) error {
host.WrapMount(func(fsys fs.FS) fs.FS {
return minifyingFS{inner: fsys} // your own fs.FS
})
return nil
}
It wraps the filesystem rather than the response because mounts serve through
http.ServeContent, which supports Range, If-Range and partial responses.
Changing bytes per response shifts every offset, and a range request would return
the wrong slice of a file whose advertised length no longer matches. A wrapper
whose files are the transformed ones keeps that arithmetic true.
Wrappers run in the order they were registered, and a nil wrapper is ignored.
Contributing pages, documents and mounts
From Init, a plugin can add routes of its own through Host.RegisterPage,
Host.RegisterDocument and Host.Mount, built with the same builders an
application uses.
A plugin that produces files — resized images, generated icons — should serve them from a mount rather than a route. A static build copies every mount into its output after every page has rendered, so a filesystem that records what the pages asked for hands the builder exactly the right set, and the exported site needs nothing running behind it. A document at a dynamic path cannot be enumerated that way.
Commands
A plugin contributes a command from Init:
type Command struct {
Name string // as typed on the command line
Usage string // for your program's own help; the framework never prints it
Short string // one line
Run func(ctx context.Context, args []string) error
}
RegisterCommand rejects an empty name (ErrEmptyCommandName) and a name another
command already has (ErrDuplicateCommand). It is never closed by ErrAppStarted:
it works during Init, as the other Host registration calls do, and after
startup too — though a command registered after DispatchCommands has run is one
nobody dispatches.
Usage and Short are data. Neither the framework nor the collage binary prints
them; a program that wants a help listing builds it from app.Commands().
The collage CLI does not run plugin commands: it never loads your application.
The application's own main dispatches them with collage.DispatchCommands, and a
scaffolded main.go does so for any word left after the flags, so a user of your
plugin runs go run . <command>:
flag.Parse()
// ... build app and register everything ...
if args := flag.Args(); len(args) > 0 {
code, err := collage.DispatchCommands(context.Background(), app, args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(code)
}
log.Fatal(app.ListenAndServe())
DispatchCommands starts the application first, because Init is what registers
the commands. The exit codes are 0 for success; 1 for a startup failure, a
command that ran and failed, or a command with no Run; and 2 for a nil app, no
arguments, or an unclaimed name (ErrUnknownCommand). Say in your plugin's README that its commands
run through the application — a project scaffolded before v0.10.0 has to add that
block itself. See The collage CLI.
Configuration
A plugin reads its own section of Config.PluginConfig into a typed struct with
host.Config, available in both phases. Set your defaults first; Config decodes
the application's section over them:
type Config struct {
Generator string `json:"generator"`
Disabled bool `json:"disabled"`
}
func (p *Plugin) Configure(_ context.Context, host collage.ConfigHost) error {
p.cfg = Config{Generator: "collage"} // defaults
return host.Config(&p.cfg) // overlaid by the application's section, if any
}
- An absent section leaves
vuntouched, so "not configured" and "configured to the zero value" stay different statements. - A present but malformed section is an error. The operator wrote something, and running on defaults instead would be the silent failure this refuses.
- The section is decoded with
json.Unmarshalover your defaults, and follows its rules. A scalar or a slice in the JSON replaces your default — a slice is not merged. A JSON object decoded into a map adds its entries to the map you set, keeping the others. A JSON object decoded into a nested struct sets only the fields it names, leaving the rest at your defaults. - The application sees a key that names no registered plugin as a startup error
(
ErrUnknownPluginConfig). YourNameis therefore the whole of your configuration's address; changing it is a breaking change.
Document every key, its type and its default in your README. Offering a
NewWith(Config) constructor alongside New() lets an application configure you
in Go as well.
A complete plugin
acme/stamp names the generator in every page's head, offers the same name to
templates, adds a command that lists the pages, and reports a failing error page.
It uses both phases, one render hook, one error hook and a command.
// Package stamp names the generator in every page's head, offers the same name
// to templates, and adds a command that lists the application's pages.
package stamp
import (
"context"
"fmt"
"log/slog"
"github.com/Elagoht/collage/pkg/collage"
)
// Name is the plugin's name, and the key of its section in plugins-config.json.
const Name = "acme/stamp"
// Config is the plugin's configuration.
type Config struct {
// Generator is what the generator meta tag says.
Generator string `json:"generator"`
}
// Plugin is the stamp plugin. Construct it with New.
type Plugin struct {
cfg Config
logger *slog.Logger
}
// New returns the plugin with its defaults.
func New() *Plugin { return &Plugin{} }
var (
_ collage.Plugin = (*Plugin)(nil)
_ collage.Configurer = (*Plugin)(nil)
_ collage.BeforeRenderHook = (*Plugin)(nil)
_ collage.ErrorHook = (*Plugin)(nil)
)
func (p *Plugin) Name() string { return Name }
func (p *Plugin) Version() string { return "0.1.0" }
// Configure runs inside collage.New, before templates are parsed: the one
// moment a template function can still be added.
func (p *Plugin) Configure(_ context.Context, host collage.ConfigHost) error {
p.cfg = Config{Generator: "collage"} // the defaults
if err := host.Config(&p.cfg); err != nil {
return err // a section that is present but malformed
}
return host.AddTemplateFunc("generator", func() string { return p.cfg.Generator })
}
// Init runs when the application starts, after it has registered its pages.
func (p *Plugin) Init(_ context.Context, host collage.Host) error {
p.logger = host.Logger()
return host.RegisterCommand(collage.Command{
Name: "pages",
Usage: "pages",
Short: "List every registered page",
Run: func(_ context.Context, _ []string) error {
for _, page := range host.Pages() {
fmt.Println(page.Name)
}
return nil
},
})
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
// OnBeforeRender declares the meta tag before the page renders, at depth zero,
// so a fragment that declares its own generator replaces this one.
func (p *Plugin) OnBeforeRender(_ context.Context, ev *collage.BeforeRenderEvent) error {
ev.Context.HoistMeta("generator", p.cfg.Generator)
return nil
}
// OnError reports the one failure nobody would otherwise notice: the error page
// itself failing.
func (p *Plugin) OnError(_ context.Context, ev *collage.ErrorEvent) error {
if ev.Stage == "error_page" {
p.logger.Error("stamp: the error page failed", "path", ev.Path, "error", ev.Err)
}
return nil
}
An application uses it like any other plugin:
app, err := collage.New(&collage.Config{
Template: collage.TemplateConfig{Root: "templates"},
Plugins: []collage.Plugin{stamp.New()},
PluginConfig: pluginConfig, // {"acme/stamp": {"generator": "The Wire"}}
})
Lifecycle
-
Registration.
Config.PluginsinsideNew, orRegisterPluginbefore the application starts. After that,RegisterPluginreturnsErrAppStarted— also after any start that failed (since v0.12.0). -
Configure, inside
New, for plugins that implement it — in registration order, stopping at the first error. -
Init, when the application starts, in registration order. If one fails, startup is aborted and every plugin already initialised is shut down in reverse order. The failing plugin is not, since it never finished initialising.
-
Shutdown, from
App.Shutdown— whichListenAndServecalls onSIGINTorSIGTERM— in reverse registration order. It calls every registered plugin'sShutdown, whether or not that plugin'sInitran or succeeded: an application that never started, one whose start failed, and the plugins the failed start already rolled back all get the call. SoShutdownmust be safe to call withoutInitand more than once. Every plugin gets its turn even if one fails, and the errors are joined.With
ListenAndServe, plugins are shut down after the server has drained its requests, or onceServer.ShutdownTimeouthas passed if it has not — past the deadline a request may still be running. With a server you own, theAppknows of none: stop your server first, then callApp.Shutdown, or a plugin can be torn out from under an in-flight request.
Testing a plugin
Test a plugin the way an application would use it: build a real App with the
plugin in Config.Plugins, register a page whose template exercises it, and drive
the application through app.Handler() with httptest. No server listens and no
port is chosen.
package stamp_test
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Elagoht/collage/pkg/collage"
"example.com/stamp"
)
// newApp builds a one-page application with the plugin in it.
func newApp(t *testing.T, config string) *collage.App {
t.Helper()
root := t.TempDir()
page := `<html><head>{{hoist "head"}}</head><body>{{generator}}</body></html>`
if err := os.MkdirAll(filepath.Join(root, "pages"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "pages", "home.html"), []byte(page), 0o644); err != nil {
t.Fatal(err)
}
cfg := &collage.Config{
Template: collage.TemplateConfig{Root: root},
Plugins: []collage.Plugin{stamp.New()},
}
if config != "" {
cfg.PluginConfig = map[string]json.RawMessage{stamp.Name: json.RawMessage(config)}
}
app, err := collage.New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
home := collage.NewPage("home").
WithContent(collage.NewFragment("home", "pages/home.html").Build()).
WithPath("en", "/").
Build()
if err := app.RegisterPage(home); err != nil {
t.Fatalf("RegisterPage: %v", err)
}
return app
}
func get(t *testing.T, app *collage.App, path string) string {
t.Helper()
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s: status %d: %s", path, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func TestStamp_UsesItsDefaults(t *testing.T) {
body := get(t, newApp(t, ""), "/")
if !strings.Contains(body, `<meta name="generator" content="collage">`) {
t.Errorf("no default meta tag in %s", body)
}
}
func TestStamp_ReadsItsConfiguration(t *testing.T) {
body := get(t, newApp(t, `{"generator": "my site"}`), "/")
if !strings.Contains(body, `<meta name="generator" content="my site">`) {
t.Errorf("no configured meta tag in %s", body)
}
if !strings.Contains(body, "<body>my site</body>") {
t.Errorf("template function not applied in %s", body)
}
}
func TestStamp_RegistersItsCommand(t *testing.T) {
app := newApp(t, "")
if err := app.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
commands := app.Commands()
if len(commands) != 1 || commands[0].Name != "pages" {
t.Errorf("commands = %v, want one named pages", commands)
}
}
func TestStamp_RefusesLateRegistration(t *testing.T) {
app, err := collage.New(&collage.Config{Template: collage.TemplateConfig{Root: t.TempDir()}})
if err != nil {
t.Fatalf("New: %v", err)
}
if err := app.RegisterPlugin(stamp.New()); !errors.Is(err, collage.ErrConfigurerRegisteredLate) {
t.Errorf("RegisterPlugin: err = %v, want ErrConfigurerRegisteredLate", err)
}
}
A few things worth testing that are easy to forget:
- The unconfigured case. Most applications will never write a section for you.
- Documents, if you implement
OnCacheWriteorOnDocumentRendered: register a document and request it, so anilPageis caught in a test rather than as a document that is never cached. - Run with
-race. A hook that writes through an event's livePageis a data race the detector reports and nothing else will. - A static export, if you produce files:
collage.NewBuilder(app, ...)into at.TempDir()shows whether the exported site has what the served one had. See Testing and Static export.