Gohyde supports plugins in three languages: Go, Python, and Ruby. All three runtimes share the same hook/filter/tag/converter/generator/command surface area. Pick the language that matches what you're integrating with — Go for in-process speed and full type access, Python/Ruby for ecosystem reach.
At build time the site builder constructs a host.Registry and calls each runtime loader to populate it:
| Runtime | Discovery | Mechanism |
|---|---|---|
| Go | _plugins/*.so |
plugin.Open loads the shared library, looks up the exported Plugin symbol, calls Register(reg) |
| Python | _plugins/*.py |
Subprocess python3 plugin.py, JSON-RPC over stdio |
| Ruby | _plugins/*.rb |
Subprocess ruby plugin.rb, JSON-RPC over stdio |
For Python/Ruby the SDK script (sdk/python/gohyde.py, sdk/ruby/lib/gohyde.rb) handles the RPC dispatch loop. You only write the handler bodies.
The plugins_dir config key overrides _plugins. plugin_paths adds extra search paths.
on_config → config loaded, before any pages are read
on_page_read → each source file parsed, before render
on_pre_render → before Liquid runs on a page
on_post_render → after Liquid + Markdown, before layout wrap
on_post_write → after the page is written to _site/
on_site_build → full site data is assembled, before render pass
generator → produce extra pages (called once per build)
A hook callback receives a HookContext and returns an error (Go) or the (possibly mutated) context dict (Python/Ruby). Mutations to ctx.Page / ctx.Content are visible to subsequent hooks and to the renderer.
on_site_build contexton_site_build fires once, after all content is assembled but before the render
pass — the right place for plugins that need the whole site (sitemaps, search
indexes, link graphs). For Python/Ruby the bridge serializes the full site data
into the params dict:
ctx["pages"] # list of page drops — all non-post pages
ctx["posts"] # list of page drops — all posts
ctx["collections"] # { name: [page drop, …] } — custom collections
ctx["site"] # site config map (from _config.yml)
ctx["dest_dir"] # absolute output directory path
ctx["src_dir"] # absolute source directory path
Example — a Python plugin that writes a sitemap:
import os
import gohyde
class Sitemap(gohyde.Plugin):
name = "sitemap"
version = "1.0.0"
@gohyde.hook("on_site_build")
def build(self, ctx):
urls = [p["url"] for p in ctx["pages"] + ctx["posts"]]
base = ctx["site"].get("url", "") + ctx["site"].get("baseurl", "")
body = "\n".join(f" <url><loc>{base}{u}</loc></url>" for u in urls)
out = os.path.join(ctx["dest_dir"], "sitemap.xml")
with open(out, "w") as f:
f.write(f'<?xml version="1.0"?>\n<urlset>\n{body}\n</urlset>\n')
print(f"Sitemap: {len(urls)} URLs", flush=True)
return ctx
Don't print to stdout from a hook except as flushed status lines. The RPC
channel is stdout — a strayprint(some_dict)corrupts the JSON stream and
surfaces asbridge unmarshal: invalid character …. Useflush=Truefor
status, and send everything else to stderr.
The Go side sets SiteData, DestDir, SrcDir, and SiteDrop on the
HookContext for this hook. The Python/Ruby scanner buffer is sized to 16 MB so
large sites (hundreds of pages) serialize without truncation.
| Capability | Registry method | When it fires |
|---|---|---|
| Hook | On(name, fn) |
At the named lifecycle point |
| Liquid filter | RegisterFilter(name, fn) |
Anywhere {{ x | name }} appears in a template |
| Liquid tag | RegisterTag(name, fn) |
Anywhere {% name args %} appears |
| Converter | RegisterConverter(ext, fn) |
A source file with ext is rendered |
| Generator | RegisterGenerator(fn) |
Once, after content load, before render |
| CLI command | RegisterCommand(name, fn) |
gohyde plugin run <name> [args] |
Filter and tag names should not collide with built-ins. The last registration wins.
Go plugins compile to .so shared libraries and load into the main process. Fastest, full access to internal/content types — but only run on platforms where plugin.Open works (Linux, macOS; not Windows).
Module setup. A Go plugin is its own main package that imports the Gohyde SDK:
package main
import (
"fmt"
"math"
"strings"
"stwoo.net/gohyde/internal/content"
"stwoo.net/gohyde/plugin/host"
"stwoo.net/gohyde/sdk/go/gohydesdk"
)
// Plugin is the symbol Gohyde's loader looks up via plugin.Open.
var Plugin gohydesdk.PluginExport = &readTimePlugin{}
type readTimePlugin struct{}
func (p *readTimePlugin) Name() string { return "read-time" }
func (p *readTimePlugin) Version() string { return "1.0.0" }
func (p *readTimePlugin) Register(reg *host.Registry) {
reg.On(host.HookOnPageRead, func(ctx *host.HookContext) error {
pg := ctx.Page
if pg == nil || pg.Type != content.TypePost {
return nil
}
words := len(strings.Fields(pg.RawContent))
mins := int(math.Ceil(float64(words) / 200))
pg.Data["read_time_label"] = fmt.Sprintf("%d min read", mins)
return nil
})
reg.RegisterFilter("word_count", func(input interface{}, _ ...interface{}) interface{} {
return len(strings.Fields(fmt.Sprint(input)))
})
}
Build:
go build -buildmode=plugin -o _plugins/read-time.so ./path/to/plugin/
Place the .so under _plugins/. Rebuild whenever you change the plugin source.
Gotchas.
go build from the same module that builds Gohyde to keep them in sync.plugin.Open requires CGO enabled. CGO_ENABLED=1 (default on most systems).gohydesdk.PluginExport, not a type. The loader does sym.(*gohydesdk.PluginExport).See examples/plugins/read-time/main.go for a complete plugin.
Python plugins run as a subprocess speaking JSON-RPC over stdio. Slower than Go plugins per call, but write-once-run-anywhere.
Skeleton:
import gohyde
class MyPlugin(gohyde.Plugin):
name = "my_plugin"
version = "1.0.0"
@gohyde.hook("on_page_read")
def on_page(self, ctx):
ctx["page"]["title"] = ctx["page"]["title"].upper()
return ctx
@gohyde.filter("shout")
def shout(self, input, *args):
return str(input).upper() + "!!!"
@gohyde.tag("greet")
def greet(self, args, ctx):
return f"<span>Hello, {args.strip()}!</span>"
if __name__ == "__main__":
MyPlugin().run()
Conventions.
Plugin.run() reads JSON-RPC requests from stdin and dispatches to the right handler.(self, ctx) and return the (possibly modified) ctx — even if you didn't change it. Returning None is treated as no-op.(self, input, *args) and return the filtered value.(self, args_string, ctx) and return the rendered string.Install: drop the .py file under _plugins/. The Python interpreter is python3 by default; override with the PYTHON_BIN env var. The gohyde module is auto-extracted to your user cache dir on first build and added to PYTHONPATH — no install step.
See examples/plugins/smart_excerpt.py.
Same JSON-RPC subprocess model as Python.
Skeleton:
require 'gohyde'
class MyPlugin < Gohyde::Plugin
name "my_plugin"
version "1.0.0"
hook :on_page_read do |ctx|
ctx["page"]["title"] = ctx["page"]["title"].upcase
ctx
end
filter :shout do |input, *args|
input.to_s.upcase + "!!!"
end
tag :greet do |args|
"<span>Hello, #{args.strip}!</span>"
end
generator do |ctx|
# Return an array of page hashes to inject.
[{ "path" => "extra/index.html",
"content" => "<h1>Generated</h1>",
"url" => "/extra/", "title" => "Extra" }]
end
end
Gohyde.run(MyPlugin)
The DSL methods (hook, filter, tag, generator, command) register into class-level hashes. Gohyde.run(MyPlugin) starts the RPC loop.
Gotcha — no return inside a tag/filter block. A Ruby return inside a
block (Proc) raises LocalJumpError: unexpected return. Tag code ported from a
Jekyll plugin that does an early return inside .each must use find /
.lazy instead:
# ✗ raises "unexpected return in {% entrylink %}"
tag :entrylink do |args|
@pages.each { |p| return p["url"] if p["slug"] == args.strip }
end
# ✓ no early return
tag :entrylink do |args|
page = @pages.find { |p| p["slug"] == args.strip }
page ? page["url"] : ""
end
# ✓ nested search without early return
tag :svg do |args|
Dir.glob("#{@src_dir}/**/#{args.strip}").lazy.flat_map { |f| File.read(f) }.first || ""
end
Cache site data you need in tags during on_site_build (@pages = ctx["pages"],
@src_dir = ctx["src_dir"]), since tag blocks don't receive the site context.
See examples/plugins/tag_cloud.rb.
What's in ctx depends on which hook fired. Common keys (Python/Ruby see these as a dict; Go gets a *host.HookContext struct):
| Key | Type | Set by |
|---|---|---|
page |
page drop (map) | page-level hooks |
site_data |
full site data (map) | on_site_build, generators |
pages / posts / collections |
lists of page drops | on_site_build |
site |
site config map | on_site_build |
dest_dir / src_dir |
absolute paths | on_site_build |
config |
raw config map | on_config |
content |
rendered HTML so far | on_pre_render, on_post_render |
extra |
hook-specific arbitrary data | depends |
Page drop fields (always available): title, date, url, path, content, excerpt, categories, tags, layout, collection, published, plus every front-matter key.
Mutation rules.
ctx.Page.Data["foo"] = ... directly. The pointer is shared.ctx["page"]["foo"] = ...) and return the ctx. The bridge merges the returned dict back into the live page.# Scaffold a new plugin from a template.
gohyde plugin new my_plugin --lang go # Creates _plugins/my_plugin/main.go
gohyde plugin new my_plugin --lang python # Creates _plugins/my_plugin.py
gohyde plugin new my_plugin --lang ruby # Creates _plugins/my_plugin.rb
# List installed plugins.
gohyde plugin list
# Run a plugin's CLI command.
gohyde plugin run my_plugin -- arg1 arg2
For Go plugins you must compile the .so after editing:
cd _plugins/my_plugin && go build -buildmode=plugin -o ../my_plugin.so .
The make example-plugin-go target in the repo's Makefile shows the canonical build invocation.
plugin was built with a different version of package X — the plugin and the host must share the exact same dependency tree. Build the plugin from inside the Gohyde module (using its go.sum) or pin all deps identically.--verbose. Run gohyde build --verbose and look for traceback lines.None/nil returns.plugin.Open fails on a freshly built .so — typically Go version mismatch. Check go version matches what Gohyde was built with.For deeper questions, the registry implementation in plugin/host/registry.go and the Go SDK in sdk/go/gohydesdk/plugin.go are short and authoritative.