Gohyde supports plugins in three languages: Go, Python, and Ruby. All three runtimes share the same hook/filter/tag/block-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 (plugin/host/registry.go) 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 (or --plugins DIR) overrides _plugins. plugin_paths adds extra search paths.
Python and Ruby plugins are started in parallel (interpreter startup is the slow part), then registered one by one in filename order — so hooks from a.py still run before hooks from b.py, every build.
on_config → config loaded, before any pages are read
on_page_read → each source file parsed, before render
on_site_build → full site data is assembled, before render pass
on_pre_render → before Liquid runs on a page
markdown_preprocess → Markdown source after Liquid, before conversion
on_post_convert → content converted to HTML, before the layout wraps it
on_post_render → after the layout chain
on_post_write → after the page is written to _site/
on_site_rendered → once, after every page has rendered
on_site_write → once, after every file is written
generator → produce extra pages (called once per build)
Jekyll equivalents: :site :after_init ≈ on_config, :site :post_read ≈
on_site_build, :documents :pre_render ≈ on_pre_render,
:documents :post_convert ≈ on_post_convert, :documents :post_render ≈
on_post_render, :documents :post_write ≈ on_post_write,
:site :post_render ≈ on_site_rendered, :site :post_write ≈
on_site_write.
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 stray
print(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 |
| Liquid block tag | RegisterBlockTag(name, fn) |
Anywhere {% name args %}...{% endname %} 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 — no subprocess, no JSON-RPC, no serialization per call, which makes this the fastest of the three runtimes by a wide margin (in-process function calls vs. spawning python3/ruby and round-tripping every hook/filter/tag call as JSON over a pipe). Trade-off: plugin.Open only works on Linux/macOS (not Windows), and the .so must be built with the exact same Go version and dependency versions as the Gohyde binary loading it (see Gotchas).
Module setup. A Go plugin is its own main package that imports the Gohyde SDK — just two packages, both fully public (neither lives under internal/), so a plugin doesn’t need to be inside the Gohyde source tree to use them:
package main
import (
"fmt"
"math"
"strings"
"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
// ctx.Page's Type field is a named string type from an internal
// package you can't import from outside the Gohyde module tree —
// compare against the plain string value instead ("posts",
// "pages", or a custom collection's name); no import needed.
if pg == nil || pg.Type != "posts" {
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{}, error) {
return len(strings.Fields(fmt.Sprint(input))), nil
})
// Go plugins share the SAME host.Registry Python/Ruby plugins reach
// through an RPC bridge — no separate wiring needed for any
// capability, including block tags (RegisterBlockTag) and converters
// (RegisterConverter). A tag/block-tag handler here gets the same real
// ctx.Bindings (page/site) Python/Ruby handlers get, for free:
reg.RegisterBlockTag("excludein", func(args, body string, ctx *host.HookContext) (string, error) {
site, _ := ctx.Bindings["site"].(map[string]interface{})
if site != nil && strings.Contains(args, fmt.Sprint(site["version"])) {
return "", nil
}
return body, nil
})
}
Filter errors are real errors. The second return value matters — a
filter that returns a non-nil error fails the build with that error
(same as gohyde’s own native divided_by/modulo do for division by
zero), it isn’t just a style convention. Returning (input, nil) to
silently pass through on failure is a choice you make, not the default.
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).replace directive today — Gohyde has no tagged releases yet, so there’s no version to go get. Point your plugin’s go.mod at a local Gohyde checkout: replace stwoo.net/gohyde => /path/to/gohyde, then go mod tidy. This also happens to be how you satisfy the “same dependency versions” requirement above with the least effort: go mod tidy pulls in the exact versions Gohyde’s own go.sum pins.See examples/plugins/read-time/main.go in the repo for a complete plugin, examples/plugins/exclude-in-go/main.go for a real ported Liquid::Block plugin using RegisterBlockTag (the same one ported to Python/Ruby as exclude_in.py/.rb — all three produce byte-identical output), or examples/plugins/see-also-go/main.go for a real ported plain Liquid::Tag plugin (a “See also” cross-link list built from RegisterTag + ctx.Bindings["site"]["pages"], no new capability needed at all).
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>"
@gohyde.block("excludein")
def excludein(self, args, content, ctx):
# A real ported Jekyll plugin (Liquid::Block subclass) — excludes
# its own body when the site's `version` matches. content is the
# body's ALREADY Liquid-rendered text; ctx is the same
# {"page": ..., "site": ...} dict `tag` handlers get. See
# examples/plugins/exclude_in.py for the full port + the original
# Jekyll source it came from.
site = ctx.get("site") or {}
return "" if str(site.get("version")) in args else content
@gohyde.generator("extra")
def extra_page(self, ctx):
# Return a list of dicts — each treated as a virtual SOURCE file
# (front_matter + content), not pre-rendered HTML: it goes through
# the normal Liquid+Markdown+layout pipeline, so `layout` here
# renders through your site's real layout template.
return [{
"path": "extra/index.html",
"front_matter": {"layout": "page", "title": "Extra", "permalink": "/extra/"},
"content": "<h1>Generated</h1>",
}]
@gohyde.converter(".rst")
def rst(self, content, page):
# Receives the page body AFTER front matter is stripped and Liquid
# has rendered it; return HTML. A .rst page gets a real .html
# permalink automatically once this is registered — same treatment
# as Markdown/HTML, not left as a literal .rst file.
return "<pre>" + content + "</pre>" # a real converter would parse it
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. A raised exception now actually fails the build with a real error (including a full traceback — see Debugging) instead of silently passing the input through unchanged, which is what used to happen.(self, args_string, ctx) and return the rendered string. ctx is {"page": {...}, "site": {...}} — whichever of those the calling template’s live scope actually has bound (either key may be absent); this is the SAME live binding a real Liquid template sees, e.g. real Jekyll’s context.registers[:site].@gohyde.block) take (self, args_string, content, ctx) and return the rendered string — for a tag that has a BODY between an opening and a matching {% endname %} (real Jekyll/Liquid’s Liquid::Block, as opposed to Liquid::Tag, which plain @gohyde.tag covers). content is the body’s ALREADY Liquid-rendered text (any {% assign %}/{% include %}/nested tags inside it have already run). This is what makes porting a real Liquid::Block-based Jekyll plugin possible at all — a plain tag has no way to see its own body.(self, ctx) — the same pages/posts/collections/site/dest_dir/src_dir payload on_site_build gets (see HookContext payload) — and return a list of page dicts (path required; front_matter/content optional).(self, content, page) — content is the page’s Liquid-rendered body (front matter already stripped), page is that page’s drop (title/url/etc.) — and return an HTML 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 and examples/plugins/tag_archive.py in the repo. For a real converter plugin, see examples/plugins/rst_docutils.py — converts .rst (reStructuredText) source to HTML via the real docutils library, for porting old docutils/Sphinx-era documentation onto Gohyde (a Gohyde-only convenience; real Jekyll has no RST support either). For a real ported Liquid::Block plugin pair, see examples/plugins/exclude_in.py (and its Ruby twin, exclude_in.rb) — a 1:1 port of two real Jekyll plugins, excludein/includein, that show or hide their body based on site config. For a real ported plain Liquid::Tag plugin, see examples/plugins/see_also.py (and its Ruby/Go twins) — a “See also” cross-link list looked up by a custom pageid front-matter field across site.pages.
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, ctx|
"<span>Hello, #{args.strip}!</span>"
end
block_tag(:excludein) do |args, content, ctx|
# A real ported Jekyll plugin (Liquid::Block subclass) -- excludes its
# own body when the site's `version` matches. content is the body's
# ALREADY Liquid-rendered text; ctx is {"page" => ..., "site" => ...},
# same as `tag` blocks get. See examples/plugins/exclude_in.rb for the
# full port + the original Jekyll source it came from.
site = ctx["site"] || {}
args.include?(site["version"].to_s) ? "" : content
end
generator(:extra) do |ctx|
# Return an array of page hashes — each a virtual SOURCE file
# (front_matter + content), not pre-rendered HTML: it goes through the
# normal Liquid+Markdown+layout pipeline, so `layout` here renders
# through your site's real layout template.
[{ "path" => "extra/index.html",
"front_matter" => { "layout" => "page", "title" => "Extra", "permalink" => "/extra/" },
"content" => "<h1>Generated</h1>" }]
end
converter(".rst") do |content, page|
# content is the page's Liquid-rendered body (front matter already
# stripped). Return HTML. A .rst page gets a real .html permalink
# automatically once this is registered.
"<pre>#{content}</pre>" # a real converter would parse it
end
end
Gohyde.run(MyPlugin)
The DSL methods (hook, filter, tag, block_tag, converter, generator, command) register into class-level hashes. Gohyde.run(MyPlugin) starts the RPC loop. A raised exception in a filter block now actually fails the build with a real error (including a full backtrace — see Debugging) instead of silently passing the input through unchanged, which is what used to happen.
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
tag/block_tag blocks now receive real ctx ({"page" => ..., "site" => ...},
whichever of those the calling template’s live scope actually has bound) as
their last argument — previously always empty, so a tag reading site
config (real Jekyll’s context.registers[:site]) had nothing to read at
all. Still cache anything you need across MANY tag calls (e.g. a lookup
table built once) during on_site_build (@pages = ctx["pages"],
@src_dir = ctx["src_dir"]) — ctx on a tag call is scoped to that one
page’s current render, not the whole site.
See examples/plugins/tag_cloud.rb in the repo. For a real ported Liquid::Block plugin using block_tag, see examples/plugins/exclude_in.rb (and its Python twin, exclude_in.py). For a real ported plain Liquid::Tag plugin, see examples/plugins/see_also.rb (and its Python/Go twins).
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 |
pages / posts / collections |
lists of page drops | on_site_build, generators |
site |
site config map | on_site_build, generators |
dest_dir / src_dir |
absolute paths | on_site_build, generators |
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.python plugin error -32000: division by zero followed by a real Traceback (most recent call last): ... File "_plugins/myplugin.py", line 15, in boom. Previously only the bare exception message crossed the RPC boundary (str(e)/e.message — no file, no line, no stack at all), and — separately — a filter raising an exception used to be silently swallowed entirely, with the unfiltered input passed through as if nothing went wrong. Both are fixed: read the error message top to bottom, the last File "..." line N before the actual exception type is where to look.liquid: {strict_filters: true} in _config.yml to get an error instead while debugging.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.