Writing Gohyde Plugins @ stwoo.net

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.


Table of Contents

  1. How plugins are loaded
  2. The hook lifecycle
  3. Capabilities reference
  4. Go plugins
  5. Python plugins
  6. Ruby plugins
  7. HookContext payload
  8. Scaffolding & build commands
  9. Debugging

How plugins are loaded

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.


The hook lifecycle

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 context

on_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 as bridge unmarshal: invalid character …. Use flush=True for 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.


Capabilities reference

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

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.

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

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.

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.


Ruby plugins

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


HookContext payload

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.


Scaffolding & build commands

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


Debugging

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.