Skip to content

The plugin SDK

The sdk package is the only Gophenberg package a plugin imports. It is small on purpose, and this page is all of it.

Register receives one value:

FieldWhat it is
DatabaseURLThe PostgreSQL connection string. A plugin opens its own connection and owns its own schema
ContentA read view of published content
EnvReads the settings under the GOPHENBERG_ prefix, for the plugin’s own configuration
GetenvReads any environment variable by its full name, as it is set

Env takes a setting’s name without the prefix and trims the spaces around its value. Value returns the text, empty when the setting is unset, and Required refuses an empty one. Duration, Count and Flag read a duration above zero, a whole number above zero, or true or false, and return the default you pass when the setting is empty. Within narrows the prefix, so deps.Env.Within("FEED_").Count("ITEMS", 20) reads GOPHENBERG_FEED_ITEMS. Every error names the full setting, such as GOPHENBERG_FEED_ITEMS: must be a whole number, got "banana", so Register can return it as it is.

sdk.Plugin is required: ID, Start, Stop. Five optional interfaces add capabilities the host discovers automatically: Migrator for database migrations, RouteProvider for HTTP under /api/plugins/{id}, PublicPathProvider for the exact paths that answer without a login, TypeDeclarer for content types, field groups and fields the plugin brings with it, and CommandProvider for commands on the gophenberg command line.

Register only checks the plugin’s settings and builds it. It opens nothing, no connection, no file and no goroutine. Start does that. Commands such as list, check and migrate register every plugin and stop it again without ever starting it. Only serve starts the plugins. So Stop must work even when Start never ran, and return by the time its context ends. When list or help registers the plugins, DatabaseURL can be empty.

When a plugin fails to register or to declare its types, Gophenberg stops every plugin that registered. When one fails to start, it stops the ones already started. On the command line, a plugin that fails to register shows under “Not loaded:” in gophenberg list, and check, migrate and seed -yes fail.

posts, err := deps.Content.ListPublished(ctx, "post", 10)

Each sdk.Item carries ID, Type, Path, Slug, Title, Excerpt, Content, Fields, PublishedAt, and UpdatedAt. Path is the item’s public address, so a plugin building links prefixes it with / and nothing else. The Content has the same HTML filter applied that the public API uses, block markers intact. Title and Excerpt arrive as stored, so if your plugin serves HTML, escaping everything but Content is your job.

Fields holds the item’s field values keyed by field key, shaped the way the content API serves them and decoded the way encoding/json decodes them. A media value is an object naming the file, a relation lists the items it points at, and a Linked from field lists the items pointing this way. They are data, not markup, so escape them too before serving them as HTML.

A plugin that implements TypeDeclarer is handed a TypeRegistrar once at every start, before anything serves:

func (p plugin) DeclareTypes(ctx context.Context, types sdk.TypeRegistrar) error {
if err := types.DeclareType(ctx, sdk.TypeDeclaration{
Key: "event", SingularLabel: "Event", PluralLabel: "Events", RouteWord: "events",
}); err != nil {
return err
}
return types.DeclareGroup(ctx, sdk.GroupDeclaration{
Key: "event-details",
Title: "Event details",
Location: [][]sdk.Rule{{{Source: "content_type", Operator: "==", Value: "event"}}},
Fields: []sdk.FieldDeclaration{{Key: "venue", Label: "Venue", Kind: "text"}},
})
}

Declaring is safe to repeat. A definition that is not there yet is created, one that is there is left alone, and a changed label, required flag, setting or location is carried onto it. Two things are refused: changing a field’s kind, and changing a type’s route word, because both would strand stored content. A definition the plugin stops declaring stays in place.

What a plugin declares belongs to that plugin. The admin shows it with a badge naming the plugin and offers no way to change or delete it, though it can still be turned off. If the site already holds a type or group under the same key, the plugin’s declaration is skipped and the start log says so.

A plugin that implements CommandProvider offers commands on the gophenberg command line. Each command’s name is the plugin id, a colon, then the command:

func (p plugin) Commands() []sdk.Command {
return []sdk.Command{{
Name: "hello:greet",
Summary: "print a greeting for one name",
Args: []string{"name"},
Run: func(ctx context.Context, call sdk.Call) error {
name := strings.TrimSpace(call.Args[0])
if name == "" {
return sdk.Misuse(errors.New("hello:greet wants a name that is not blank"))
}
_, err := fmt.Fprintf(call.Stdout, "hello, %s\n", name)
return err
},
}}
}

gophenberg list shows it under the plugin id, gophenberg help hello:greet prints its page, and gophenberg hello:greet Maria runs it.

FieldWhat it is
Name<plugin id>:<command>, in lowercase words joined by hyphens
SummaryThe one line the listing prints beside the name
ArgsThe names of the positional arguments, in order, each one required
FlagsDeclares the command’s own flags on a flag.FlagSet. The flags h, help, yes, json and as belong to the command line
WritesMakes the command a dry run until -yes
JSONOffers -json
CapabilityOne of the capabilities the built-in roles carry, such as manage_users, which adds -as <email>
RunDoes the work

Run receives a sdk.Call. Args holds the arguments, Flags maps each of the command’s own flags the line set to its value as text, and Env reads the settings the same way deps.Env does. Stdout takes the answer, Stderr takes progress and warnings, and Stdin holds any input. call.JSON reports whether -json was passed, call.Encode writes one JSON document to Stdout, and call.DatabaseURL returns the database address. Start never ran, so a command opens what it needs inside Run and closes it before it returns.

A command that sets Writes runs on every call, but call.Apply stays false until -yes. The command line does not stop a write, so Run checks call.Apply and, until it is true, prints what it would change and changes nothing. The command line then adds dry run, nothing changed, pass -yes to apply on stderr.

A command that names a Capability is refused before Run unless the -as account exists, is enabled and activated, and holds a role with that capability. Each run it applies is stored as one record, which gophenberg account:records lists. Plugins cannot add capabilities. Name one the roles already carry, manage_users, manage_themes, manage_types, manage_settings or change_others_work, or every account is refused.

An error from Run exits with code 1. Wrap it in sdk.Misuse when the command line itself is wrong, a bad argument value for example. It then exits with code 2 and prints the command’s help under the error. A missing argument or an unknown flag is already refused that way. A command that breaks a rule, such as a name outside its plugin’s id, a malformed or repeated name, an empty summary, no Run, or a flag the command line owns, is dropped, and gophenberg check names it. The commands page shows all of this from the operator’s side.

The absences are deliberate, so build against them:

  • No content writes. Plugins read published content, the editor is the one writer.
  • No user or session API. The host guards your routes, and the SDK gives you nothing to act on accounts with.
  • No shared database pool. You get the URL, you own your connections and your schema.

A plugin can add screens to the admin. Name the package in the manifest’s frontend field and export an object called plugin:

export const plugin = {
id: 'hello',
routes: (parent) => [/* routes, as children of parent */],
nav: [{ label: 'Hello', to: '/hello', icon: someIcon }],
}

make generate wires it in: routes mount inside the admin layout and nav rows appear after the built-in ones. No built-in plugin uses this path yet, so expect to be the first through it.