Writing Gohyde Plugins @ stwoo.net

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.


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


The hook lifecycle

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

See examples/plugins/read-time/main.go for a complete plugin.


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>"


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.


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


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


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.