Alex Dunmow 5154c2c813 developing-blockninja-plugins: font size hooks + pluginsdk path refresh
Two changes bundled (shared-file bundling, solo-on-main):
- Theme font size hook contract from cms ADR 0029: bn-* class hooks,
  var(--fs-<key>, fallback) consumption, preserve hooks in re-skins,
  wrap hardcoded font-size declarations, republish caveat. New section
  in theme-overrides.md, summary + pointer in SKILL.md.
- Pre-existing uncommitted SKILL.md refresh: block/core references
  migrated to block/pluginsdk and cms/docs/abi/ doc paths (P3
  proto-first SDK program), description line updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:50:09 +08:00

29 KiB

name description
developing-blockninja-plugins Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* or sites/* repos, codeless .bnp artifacts (blocks.yaml, seed.json, manifest.yaml), plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/pluginsdk SDK usage in standalone plugin repos.

Developing BlockNinja Plugins

Overview

⚠️ If you remember .so instructions, they are obsolete. Standalone plugins are now wasm (wazero, WASI reactor mode) — the big-bang migration landed 2026-07-03 and the .so loader/builder were deleted from cms (so_loader.go, internal/builder/, cms commit a04277ee2). There is NO make build-so, NO plugin.Open, NO CGO, NO in-container compile, NO host/plugin Go-version lock-step, NO copy-plugin-source deploy, and NO go:embed'd web bundle. Instances download a prebuilt .bnp artifact — they never compile. A registry version published as a source archive is rejected at install ("legacy source archive; republish as .bnp").

The ABI is the capability model; the Go SDK is only its ergonomic front-end. Since WO-WZ-019, every host interaction is reachable purely over the ABI — a manifest declaration, a blockninja.host_call method, or a host-invoked hook — so a plugin in any language gets 100% of the surface; linking the Go pluginsdk is a convenience, not a dependency of the model. The canonical plugin-need → ABI-mechanism map is ~/src/blockninja/cms/docs/abi/abi-capability-surface.md.

Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at:

  • SDK source: ~/src/blockninja/pluginsdk (module git.dev.alexdunmow.com/block/pluginsdk) — since the proto-first SDK program (P3, 2026-07), plugins import ONLY block/pluginsdk/...; block/core/... and block/cms/... imports fail check-safety. Codeless plugins import NOTHING (no Go at all). The ABI protos live in pluginsdk/abi/proto/v1 (Go bindings pluginsdk/abi/v1).
  • ABI contract (authoritative): ~/src/blockninja/cms/docs/abi/wasm-abi.md — reactor mode, hook catalog, capability dispositions, error codes, plugin.mod data_dir; capability matrix: cms/docs/abi/abi-capability-surface.md
  • Architecture spec: ~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md
  • Canonical guide (registration concepts, blocks, provisioning): ~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md
  • Exemplars: plugins/testplugin — THE reference wasm plugin, every surface + failure endpoints (see its README surface table); plugins/symposium — the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention)

Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own data/UI → plugin (decision table in PLUGIN_DEVELOPMENT.md).

Codeless plugins (WO-WZ-020): a repo with NO Go source builds a codeless .bnp — blocks as blocks/blocks.yaml definitions (host-rendered via data providers), seed/seed.json, manifest.yaml for presets/fonts/master pages. No wasm, no Go dependency at all. Prefer this form for themes, content sites, and template-only block packs; write Go only for genuine logic (services, jobs, computing tags, custom fetch). Contract: cms/docs/abi/codeless-bnp.md.

Choose the artifact kind FIRST: codeless vs wasm

ninja plugin build classifies by repo shape: no Go source → codeless (declarative .bnp, no plugin.wasm); Go source → wasm. Start every new plugin/theme by asking whether it needs code AT ALL — themes, content sites, and template-only block packs should be codeless (blocks as blocks.yaml definitions + providers, seed/seed.json, manifest.yaml; contract: cms/docs/abi/codeless-bnp.md). Write Go only for genuine logic: Connect services, jobs, computing tags/filters, custom fetch, HTTP handlers, media hooks, RAG fetchers. --codeless asserts the expectation. Mixed form is legal: a wasm plugin may ALSO ship blocks/ + seed/ and keep Go only for its logic (the WZ-021 "reduced wasm" target).

Anatomy of a wasm plugin

PluginRegistration is unchanged from the .so era — same fields, same Register, same CoreServices. What changed is how it's served. Boilerplate main.go (testplugin/main.go verbatim):

//go:build wasip1

package main

import "git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest"

func init() { wasmguest.Serve(Registration) }

func main() {} // never called — reactor mode

Compile: GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm . (Go ≥ 1.24; reactor mode — plain go build command mode does NOT work, the exports are never callable). You never run this by hand: ninja plugin build does it.

At publish time a DESCRIBE probe instantiates the module once and captures every static registration surface into manifest.pb; the CMS loader reads that without instantiating the module. Dynamic work (block render, HTTP, jobs, Load/Unload, RAG fetch, media hooks) crosses the ABI as protobuf hooks into a pool of instances (default 4, 512 MiB memory cap each, 30 s per-call deadline). Full hook catalog + manifest↔Registration field map: wasm-abi.md.

DESCRIBE constraint: host functions are stubbed to fail during the probe, so Register (and anything reached from it) must not call capabilities or the DB — it gets a named error at build time. Load-time work belongs in Load.

Worked example — plugins/testplugin

Surface File
Blocks (rendercontext, content) + template (testplugin_page) registration.go
Connect service via plugin.NewConnectServiceBinding (RBAC roles in manifest) service.go
Raw chi routes + failure endpoints (/panic, /slow, /forbidden-sql, /alloc) httphandler.go
Job handler, media hooks, Load/Unload, RAG fetcher, deps stashing registration.go
Goose migration + sqlc (pgx/v5) queries migrations/, sql/queries/, db/
Settings schema, theme preset (go:embed'd JSON) assets/
data_dir = true grant + /data round-trip plugin.mod, onLoad

Blocks: prefer declarative forms — definitions first, then html, custom Go LAST

For a plugin-owned block type, the preferred form is a block definition: an entry in blocks/blocks.yaml (schema + .ninjatpl template + declared data providers — posts, site, menus, authors, …), rendered host-side by the definition engine with layer fallback. Zero Go, works in codeless AND wasm artifacts, admin-forkable. Register a Go BlockFunc only when no declared provider can produce the data (then return blocks.PoweredBlock so the template still renders host-side; final-HTML string building is the last resort).

For one-off page sections on a SITE (not a reusable type), the built-in html block below still applies.

Themes re-skin built-ins via template OVERRIDES, not new blocks. A codeless theme ships templates/overrides/<theme>/<key>.ninjatpl (+ a template_overrides entry in manifest.yaml) for each built-in it restyles, plus its own page templates, presets, fonts, email wrapper, and demo seed. The override dispatch model (definition-backed built-ins render WITH their providers' data via RenderDefinition; the four compiled auth/404 built-ins dispatch via the compiled path), the legacy field renames (button hreflink etc.), the full ninjatpl engine gotcha list, seed page-slug rules, the git -C commit-hook trap, and the screenshot/preview pipeline are all in theme-overrides.md — read it BEFORE writing any theme override or .ninjatpl.

The default reflex — register a custom block type (blocks.BlockMeta + a func(ctx, content) string render func + a Module Federation editor) — is usually the wrong altitude for a section/content block. Most sections (hero, features, nav, footer, CTA, legal) are built-in html blocks: a pongo2 template rendered into _html_content, BlockKey: "html", zero Go render code and zero custom editor. Live html blocks even drive admin-editable menus and use context.*. Register a custom block only when you need a structured field-form editor or render logic a template can't express. Custom keys also risk red block-fallback boxes when a key isn't registered; built-in html never does. Decision guide, render modes, menu wiring, and gotchas: html-blocks.md.

Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below.

Theme font size hooks (bn-* / --fs-*)

Admin-set per-element font sizes (cms ADR 0029) reach templates through class hooks (bn-hero-title, bn-post-title, bn-post-lede, bn-post-card-title, bn-nav-link, bn-hero-subtitle) and var(--fs-<key>, <fallback>) consumption in CSS. Re-skins must preserve the built-in's bn-* hooks; custom templates add them to equivalent elements; hardcoded font-size on a mapped element wraps in var(--fs-<key>, <current value>). Full key table and rules: theme-overrides.md.

Database — sqlc unchanged, sandboxed role

sqlc-generated code (sql_package: "pgx/v5") works without source edits: at runtime deps.Pool is a bnwasm.Pool (pgx-shaped Pool/Tx over the ABI's db.* messages). Autocommit: type-assert the pool to your db.DBTX (registration.go openQueries). Transactions: deps.Pool.Begin(ctx)db.New(tx)Commit/Rollback (httphandler.go /db-tx). Errors surface as *pgconn.PgError with real SQLSTATEs.

Hard rules and limits:

  • Own schema ONLY. Queries run under a per-plugin Postgres role (plugin_<name>) granted only on schema <name>. public.* is permission denied (SQLSTATE 42501) by design — core data goes through CoreServices capability interfaces, never SQL.
  • No raw SQL — sqlc for static, Bob for dynamic (workspace rule, applies to plugins).
  • Savepoints/nested transactions and named args (pgx.NamedArgs) are rejected with clear errors.
  • text[] values cannot carry NULL elements (collapse to ""); the whole array can still be NULL.
  • A tx handle dies with the call chain's deadline: errors.Is(err, bnwasm.ErrTxExpired) is retryable — re-run the unit of work in a fresh transaction.
  • Migrations are plain Goose SQL in migrations/ — run by the host (never cross the boundary). db/*.sql.go is committed; regenerate with sqlc generate.

Capabilities — CoreServices works as before, know the dispositions

Plugin code compiles against the same Go interfaces (deps.Content, deps.Settings, deps.Crypto, deps.Menus, deps.RAGService, …) — the guest SDK ships them as stubs marshaling to host functions. Full family/method table + disposition of every CoreServices member: wasm-abi.md. The ones that behave differently:

  • Interceptors, CoreServiceBindingshost-side; RBAC method roles merge from the manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran).
  • Captcha is host-verified (pluginsdk ≥ v0.2.2): a guest cannot hold the host's stateful captcha server, so never import block/core/captcha (any core import/require fails check-safety). Render the widget with the ninjatpl captcha tag; enforce with auth.CaptchaVerified(r.Header) — the host consumes the posted cap-token and stamps the unforgeable X-Bn-Verified-Captcha trusted header before dispatch. Fail closed when it returns false. Reference: calcomblock HandleCreateBooking.
  • AppURL/MediaPath — delivered once in LoadRequest.host_config.
  • deps.Provisioner is the seeding pathEnsurePage, EnsureMenuItem, EnsureMedia, EnsureSetting, MergeSiteSettings, data tables, embeds, job schedules, custom colors — call it from Load. RegisterWithProvisioner is INERT under wasm: Register runs at DESCRIBE where host functions are stubbed, so its provisioner is a no-op — seed logic living there silently does nothing. Move it to Load. (EnsureEmbed crosses template embeds only; a RenderFunc embed errors — functions can't serialize.)
  • deps.ContentAuthor — imperative authoring: CreatePage, SetPageBlocks, PublishPage, SetPageSEO, UpsertPost (blog_posts table).
  • Bridge.GetService still returns nil across the sandbox (typed values can't cross — by design), but cross-plugin calls now work via Bridge.Invoke (opaque payloads, JSON by convention): the provider's service value implements plugin.BridgeInvokable and answers over HOOK_BRIDGE_CALL.
  • AI tools execute: deps.ToolRegistry.Register handlers run via HOOK_AI_TOOL_CALL. Register tools in Register (see per-instance state) or they exist on one pooled instance only.
  • Job progress crosses: JobHandlerFunc's progress(current, total, msg) reaches the job system as jobs.progress (correlated via the call context).
  • Directory extensions work: panel sections / pin decorators are invoked via their own hooks from the manifest-captured counts.
  • SettingsUpdater.UpdatePluginSettings writes the plugin's OWN settings (the host pins the name to the caller; another plugin's name is rejected).
  • RAGService.RegisterContentFetcher — records guest-side + a manifest declaration; the host calls back via HOOK_RAG_FETCH.
  • Methods without an error channel (Slugify, EvaluateAccess, Bridge.RegisterService, …) degrade to zero values on transport failure.

Per-instance state (the #1 wasm gotcha)

Concurrency = an instance pool; each instance is a separate Go runtime with its own globals. Load fires once, on one instance — globals it sets do NOT exist on the others. Two sanctioned patterns (both in testplugin/symposium):

  1. Stash deps in every deps-receiving entry point (Load, HTTPHandler, JobHandlers) so deps-less hooks (media hooks, Unload) can reach the DB — testplugin's pkgDeps/stashDeps. Instances are single-threaded, so a plain global is safe.
  2. DB-backed blocks: HOOK_RENDER_BLOCK receives no services. Get the pool inside Register (which runs on every instance) via wasmguest.HostServices().Poolnil-check it: in native/DESCRIBE builds it's the zero value. See symposium register.go.

The same rule governs the callback hooks: AI tool handlers and bridge service values must be registered in Register (via wasmguest.HostServices()), not LoadHOOK_AI_TOOL_CALL / HOOK_BRIDGE_CALL can land on ANY pooled instance, and only Register runs on all of them.

Sandbox constraints — write reload-safe code

  • No filesystem, no ambient network, no env in the guest. Outbound HTTP has ONE opt-in path — the allowed_hosts egress grant + deps.OutboundHTTP (see §Outbound HTTP egress below); there is no other way to reach the network. One opt-in filesystem exception: data_dir = true in plugin.mod grants a read-write preopen at guest /data — a per-plugin directory on a persistent volume (instance-<slug>-plugin-data), shared across the plugin's pool instances, deleted on uninstall. Off by default; the grant is visible at publish time. Absent at DESCRIBE time — tolerate the write failure (testplugin onLoad).
  • Buffered HTTP only — no SSE/WebSocket/streaming from plugin endpoints. Realtime features use the core realtime system (@block-ninja/ui hooks / core topics).
  • Panics recover to errors: an in-hook panic → INTERNAL error → 5xx; the host and other plugins survive. Deadline overruns and OOM discard the instance; the pool refills. Error-code table (INTERNAL/DECODE/UNIMPLEMENTED/DEADLINE/PERMISSION/TX_EXPIRED): wasm-abi.md.
  • Hot swap: plugin updates swap live (SwapPlugin) with no restart — never assume an instance lives long, or that in-memory state survives an update. Persist in the DB or /data.
  • No os.Getenv for secrets (nothing is there anyway) — plugin settings or CoreServices.

Outbound HTTP egress — allowed_hosts + deps.OutboundHTTP (ADR 0023)

A guest has no ambient network. To call a third-party API (Pexels, Cal.com, any webhook target) the plugin declares an egress allowlist and rides a host-mediated transport. Exemplar: plugins/calcomblock (client.go, handler.go). The whole surface lives in pluginsdkpluginsdk/egress (policy), pluginsdk/plugin (deps.OutboundHTTP, plugin.mod fields), cms host plugin/wasmhost/caps/net.go (http.request).

Declare it in plugin.mod (first-class keys — a hand-added TOML key not in the struct is dropped on the next ninja plugin init/bump):

allowed_hosts = ["api.pexels.com", "*.cal.com"]   # empty ⇒ no egress at all
max_response_mb = 25                               # optional; default cap is 10 MB

ninja plugin build validates the patterns and stamps them into the manifest (PluginManifest.allowed_hosts / max_response_mb).

Host-pattern grammar (pluginsdk/egress.ParsePattern): a bare hostname (api.pexels.com) or a single left-anchored multi-label wildcard (*.cal.com) whose base must be a registrable domain. Each entry is https/443 only unless it names an explicit port (host:8443). Rejected: bare *, public-suffix wildcards (*.com, *.co.uk), embedded schemes/paths/userinfo, a URL instead of a host. A request to a non-matching host, a non-https scheme, or (post-DNS) a private/SSRF IP fails with ABI_ERROR_CODE_EGRESS_DENIED (egress.ErrDenied) — catch it and degrade; it's distinct from a transport failure.

Make requests through deps.OutboundHTTP (an http.RoundTripper), never a bare http.DefaultTransport:

client := &http.Client{Transport: deps.OutboundHTTP, Timeout: 30 * time.Second}

The host enforces the grant, the SSRF guard, the platform denylist, and the response-size cap, performs the buffered request, and logs it (buffered only — no streaming/SSE/WebSocket; request AND response bodies are capped, oversize fails rather than truncates). deps.OutboundHTTP is nil when the plugin declared no allowed_hosts — and at DESCRIBE/native/test time. Follow calcomblock's NewCalcomClient(apiKey, rt http.RoundTripper) shape: pass deps.OutboundHTTP as the transport, and fall back to http.DefaultTransport when it's nil so DESCRIBE probes and httptest paths still work (tests reach a fake server by overriding the base URL).

API keys for the upstream service are the plugin's OWN secret: store them in plugin settings (encrypted at rest via the settings capability, as calcomblock's settings_store.go does), NOT in allowed_hosts and NOT in env. The allowlist authorizes the destination, not the credential.

Install-time consent (ADR 0023 D5/D9): declaring allowed_hosts (or a raised max_response_mb) makes the plugin egress-granted — the CMS gates it behind an install-time consent dialog (GetPluginEgressPlan reports declared vs granted with the widening diff; approval is all-or-nothing, no per-host selection). A version bump that widens the allowlist or cap re-gates the update. So keep the allowlist as tight as the integration truly needs.

Public site-root routes — public_routes + sitemap (ADR 0026)

By default a plugin's HTTP handler is reachable ONLY under the namespaced mount /api/plugins/<name>. To serve top-level public URLs (SEO pages, public APIs) the plugin claims them in plugin.mod (first-class keys, same round-trip rule as allowed_hosts — a hand-added key not in the struct is dropped on the next init/bump):

public_routes = [{ path = "/area", prefix = true }, { path = "/api/semantic-search" }]
sitemap = true    # host fetches entries from the plugin's well-known sitemap endpoint

ninja plugin build validates and stamps both into the manifest (PluginManifest.public_routes / sitemap; validator: pluginsdk/plugin.ValidatePublicRoutes). Build-rejected: relative paths, the site root /, trailing slashes (set prefix = true to claim the subtree), traversal or malformed segments, duplicates, anything equal to or under the reserved prefixes /admin, /api/plugins, /ws, and a prefix claim that would cover one (a /api prefix claim covers /api/plugins). Routes and the sitemap flag require an HTTP handler; codeless plugins cannot declare either.

The guest ABI is unchanged: matching requests dispatch to the SAME HTTPHandler hook, which already receives full unstripped request paths, so one handler routes both mounts by r.URL.Path. Conflict rules are host-side at install/load: core routes always win, first-installed plugin wins between plugins, and losing claims surface in the admin plugin UI (never silently dropped).

Status (2026-07-12): manifest + CLI shipped (pluginsdk v0.2.7, cli 71d005f). Host-side mounting and the sitemap merge land with WO-PX-002 — until that cms release is deployed, claims are stamped into the .bnp but not yet mounted. First consumer: perthplaygrounds (/area, /badges, /age, /api/directory).

Build → verify → publish

plugin.mod is still the manifest (name/version/kind/scope/categories/tags, plus the new first-class data_dir bool). kind is still frozen at first publish. Pin a recent block/pluginsdk; exact-version lock-step with the CMS is dead (testplugin builds on v0.2.5 while v0.2.7 is current) — compatibility is gated by ABI major (abi_version, currently 1).

ninja plugin build --dir .            # wasip1 compile + DESCRIBE probe → <name>-<version>.bnp
ninja plugin verify <name>-<ver>.bnp  # loader-identical checks: layout, abi_version, name match
cd ~/src/blockninja/check-safety && go run . <plugin-path>   # MUST exit 0 (unchanged mandate)

ninja plugin bump patch               # commits plugin.mod — does NOT git-tag
git tag vX.Y.Z && git push origin main vX.Y.Z   # --follow-tags skips lightweight tags
ninja plugin publish                  # builds the .bnp itself at the bumped version and
                                      # uploads it — the .bnp is the ONLY publish form
                                      # (--bnp <file> ships a prebuilt artifact instead)

The .bnp (tar.zst) packs plugin.mod + manifest.pb, plus — when present — blocks/ (definition manifest), templates/, seed/, migrations/, schemas/, assets/, and web/dist (flattened under web/); wasm artifacts add plugin.wasm, codeless ones don't (manifest.codeless is set, and verify/reader reject a mismatch in either direction). Bundling is a fixed allowlist (cli/internal/bnp/build.go), not config or git: each optional dir ships only if it exists with ≥1 regular file (symlinks skipped), read straight from the working directory — an untracked file under assets/ ships; Go source, Makefile, README, or anything outside the listed dirs never does (README/CHANGELOG reach the registry as publish metadata, not artifact contents). manifest.pb is never read from disk: wasm builds DESCRIBE-probe the module, then plugin.mod's data_dir and any root manifest.yaml declarative surfaces are folded in, with referenced JSON (presets.json etc.) embedded INTO manifest.pb rather than packed as files. Packing is deterministic (sorted entries, fixed modes) — same tree, byte-identical artifact. Statics are consumed host-side: Goose runs migrations, assets are served directly, and the Module Federation admin bundle is served from the artifact at /plugins/<name>/no longer go:embed'd, but web/dist must be built (cd web && pnpm run build) before ninja plugin build. Makefile convention: copy plugins/symposium/Makefile (build / build-frontend / verify / archive-check / publish — archive-check now proves git archive HEAD wasm-compiles). Generated Go (*_templ.go, db/*.sql.go, *connect.go) and web/dist stay committed; .gitignore adds *.bnp and *.wasm.

Dev loop: publish to the DEV orchestrator (ninja --host https://my.localdev.blockninjacms.com plugin publishmy.blockninjacms.com without localdev is PRODUCTION). Install via Admin → Plugins → Browse Registry (InstallFromRegistry): the instance downloads the .bnp, checksum-verifies, and hot-loads without restart; updates hot-swap the same way. First publish lands private; review via the orchestrator dashboard. Private account-scoped plugins: --private on init/publish.

Verifying SDK symbols

Before using an unfamiliar SDK call, check: (1) the pinned SDK — go doc git.dev.alexdunmow.com/block/pluginsdk/plugin CoreServices from the plugin dir; (2) the capability disposition table in cms/docs/abi/wasm-abi.md (does it cross the ABI, and how?); (3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in cms/backend for semantics.

Missing capability ⇒ first check cms/docs/abi/abi-capability-surface.md — the surface is complete for every known plugin need since WO-WZ-019. A genuinely new capability is an ABI extension (manifest field, host_call method pair, or hook — additive within ABI major 1), landed in the ABI proto + cms host first; the Go guest stub is then the ergonomic wrapper, not the capability itself. Alternatively vendor the cms-internal package into the plugin's internal/ with a provenance header. NEVER replace directives; NEVER block/cms or block/core imports. Remember the wasm boundary: a value that carries functions/handlers can't cross — it needs a hook or a manifest declaration instead.

Doc routing

Working on Read first
ABI, hooks, capabilities, error codes, data_dir, build/pack internals cms/docs/abi/wasm-abi.md
Which ABI mechanism serves a plugin need (matrix; codeless equivalents) cms/docs/abi/abi-capability-surface.md
Scaffold, registration, custom block types, CoreServices concepts cms/docs/PLUGIN_DEVELOPMENT.md
Section/content blocks — html block vs custom; menus; live vs pre-render html-blocks.md (in this skill) — read BEFORE writing a custom block type
Codeless THEMES — template overrides, ninjatpl gotchas, override dispatch, seed slugs, screenshots theme-overrides.md (in this skill) — read BEFORE writing any theme override or .ninjatpl
Depositing media (seed EnsureMedia / runtime CoreServices.Media) PLUGIN_DEVELOPMENT.md §Depositing Media
Plugin migrations / DB tables — own schema, per-plugin role PLUGIN_DEVELOPMENT.md §Migrations + this skill §Database
Platform data tables (Provisioner.EnsureDataTable, View-rootable JSONB store) PLUGIN_DEVELOPMENT.md §Provisioning → Data tables
Public HTTP routes (webhooks, widgets) — buffered only cms/docs/PLUGIN_HTTP_HANDLERS.md
Top-level site-root URL claims (public_routes, sitemap) cms/docs/adr/0026-plugin-public-routes-manifest-capability.md + this skill §Public site-root routes
Load/Unload, runtime state cms/docs/PLUGIN_LIFECYCLE_HOOKS.md + this skill §Per-instance state
Themes, templates, master pages, CSS cms/docs/TEMPLATE_PLUGINS.md
Theme preview screenshots (ninja theme screenshot, registry previewImageUrl) cms/docs/theme-previews.md
Block editor / settings UI (Module Federation) cms/docs/PLUGIN_EDITOR_SDK.md
Migration rationale, what stayed/died the 2026-07-03 wasm migration spec (cms/docs/superpowers/specs)

Stale docs — do NOT follow: cms/docs/compiled-plugin-architecture.md (.so pipeline, dead) and any plugins/CLAUDE.md / memory notes describing make build-so, in-container compiles, go:embed'd web bundles, or core-version skew.

Release traps

  • ninja plugin publish builds the .bnp itself and ships it — the source-archive path is gone (CLI feeb311). A wasm plugin whose Go Registration.Version drifts from plugin.mod fails the publish ("manifest version != plugin.mod version") — sync the constant when bumping. --bnp <file> ships a prebuilt artifact instead of building.
  • kind is frozen at first publish. The registry stores kind (plugin|theme) on first CreatePlugin and never updates it; publish only compares. A theme MUST declare kind = "theme" BEFORE its first publish. Dev-only remedy for a misregistered row: UPDATE registry_plugins SET kind='theme' on orchestrator-db, then republish.
  • ninja plugin publish builds the artifact from the working directory and warns on a dirty tree (--strict aborts) — keep the tree committed at the bumped version so the artifact matches the tag; make archive-check proves HEAD compiles.
  • ninja plugin init is interactive and DROPS unknown plugin.mod keys on rewrite — re-check plugin.mod after running it (data_dir is a known key and survives).
  • Stale-CLI manifest drop: a ninja binary older than the field you rely on builds a .bnp whose manifest silently omits it. allowed_hosts needs CLI >= 7cfc371; public_routes/sitemap need CLI >= 71d005f (pluginsdk v0.2.7). Rebuild/reinstall the CLI from ~/src/blockninja/cli before publishing a plugin that uses a new manifest field.

New plugin: ninja plugin init, copy main.go + layout from testplugin and the Makefile from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.