Compare commits
5 Commits
7bc233df1a
...
5154c2c813
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5154c2c813 | ||
|
|
34c2b73801 | ||
|
|
cbc598f5d5 | ||
|
|
cb1acbab6b | ||
|
|
b23e0219b7 |
@ -1,63 +1,459 @@
|
||||
---
|
||||
name: developing-blockninja-plugins
|
||||
description: Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* repos, plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/core SDK usage in standalone plugin repos.
|
||||
description: 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/core` — import prefix `git.dev.alexdunmow.com/block/core/...` is the ONLY one allowed; never `block/cms/...`
|
||||
- **Canonical guide:** `~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md`
|
||||
- **Publish workflow + hard rules:** `~/src/blockninja/plugins/CLAUDE.md`
|
||||
- **Exemplars:** `plugins/messenger` (compact), `plugins/symposium` (service-heavy: RPC, jobs, AI, embeddings)
|
||||
- **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).
|
||||
|
||||
## Doc routing
|
||||
**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`.
|
||||
|
||||
| Working on | Read first (under `cms/docs/`) |
|
||||
## 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
|
||||
//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 |
|
||||
|---|---|
|
||||
| Scaffold, registration, blocks, `CoreServices` | PLUGIN_DEVELOPMENT.md |
|
||||
| Depositing media into the library (seed `EnsureMedia` / runtime `CoreServices.Media`) | PLUGIN_DEVELOPMENT.md §Depositing Media |
|
||||
| Plugin migrations / DB tables — each plugin owns a Postgres schema (named after it), never `public` | PLUGIN_DEVELOPMENT.md §Migrations |
|
||||
| **Platform data tables** — provision via `Provisioner.EnsureDataTable` (`RegisterWithProvisioner`); the row-as-JSONB store the admin Data Platform / buckets / **data Views** read, idempotent + **View-rootable**. NOT the plugin's own Postgres schema above | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables |
|
||||
| Public HTTP routes (webhooks, widgets) | PLUGIN_HTTP_HANDLERS.md |
|
||||
| Load/Unload, runtime state, goroutines | PLUGIN_LIFECYCLE_HOOKS.md |
|
||||
| Themes, templates, master pages, CSS | TEMPLATE_PLUGINS.md |
|
||||
| Block editor / settings UI (Module Federation) | PLUGIN_EDITOR_SDK.md |
|
||||
| .so build pipeline, loader internals | compiled-plugin-architecture.md |
|
||||
| Release, registry, install | `plugins/CLAUDE.md` (not cms/docs) |
|
||||
| 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` `href`→`link` 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](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](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](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`, `CoreServiceBindings` — **host-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 path** — `EnsurePage`,
|
||||
`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().Pool` —
|
||||
**nil-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
|
||||
`Load` — `HOOK_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 `pluginsdk` — `pluginsdk/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`):
|
||||
|
||||
```toml
|
||||
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`:
|
||||
|
||||
```go
|
||||
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`):
|
||||
|
||||
```toml
|
||||
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).
|
||||
|
||||
```bash
|
||||
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 publish` — `my.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 the build compiles against — `go doc git.dev.alexdunmow.com/block/core/plugin CoreServices` from the plugin dir; (2) nearest exemplar usage in messenger/symposium; (3) the CMS-side implementation in `cms/backend` for semantics.
|
||||
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** ⇒ two sanctioned paths only: extend `block/core` (long-term; needs SDK release + re-pin + image rebuild), or vendor the cms-internal package into the plugin's `internal/` with a provenance header (established convention — check-safety vendors cms `internal/theme` this way). NEVER `replace` directives; NEVER `block/cms` imports.
|
||||
**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.
|
||||
|
||||
**Version pinning:** `go.mod` pins `block/core` to exactly what the CMS uses:
|
||||
`grep 'block/core ' ~/src/blockninja/cms/backend/go.mod`
|
||||
## Doc routing
|
||||
|
||||
## Gates — run before commit / bump / publish
|
||||
| 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](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](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) |
|
||||
|
||||
```bash
|
||||
make # CGO build; templ/sqlc drift surfaces here
|
||||
cd ~/src/blockninja/check-safety && go run . <plugin-path> # MUST exit 0
|
||||
make archive-check # proves `git archive HEAD` (= what publish ships) compiles
|
||||
```
|
||||
|
||||
check-safety traps: plugin `web/` lint extends `../../../cms/web/eslint.config.js`, so it only runs from the canonical sibling layout; raw `<button>` in plugin UI fails (use `@block-ninja/ui` Button); `any` usage warns.
|
||||
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
|
||||
|
||||
```bash
|
||||
ninja plugin bump patch # commits plugin.mod — does NOT git-tag
|
||||
git tag vX.Y.Z # manual; must equal plugin.mod version (hard rule 6)
|
||||
git push origin main vX.Y.Z # explicit — --follow-tags skips lightweight tags
|
||||
ninja plugin publish # ships `git archive HEAD`: untracked files DON'T ship;
|
||||
# web/dist and ALL generated Go must be committed
|
||||
```
|
||||
- `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`, minimal files per PLUGIN_DEVELOPMENT.md §Minimal Layout, copy the Makefile from messenger. First publish lands `private`; review via orchestrator dashboard.
|
||||
New plugin: `ninja plugin init`, copy `main.go` + layout from testplugin and the Makefile
|
||||
from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.
|
||||
|
||||
142
developing-blockninja-plugins/html-blocks.md
Normal file
142
developing-blockninja-plugins/html-blocks.md
Normal file
@ -0,0 +1,142 @@
|
||||
# Blocks via the built-in `html` block
|
||||
|
||||
**Core insight:** most section/content/marketing blocks need **no custom block type, no Go
|
||||
render function, and no Module Federation editor.** You render a template (or static HTML) into
|
||||
the CMS's built-in `html` block (`BlockKey: "html"`, content key `_html_content`). The CMS ships
|
||||
the `html` block; you ship templates. This is the default for theme/site section blocks.
|
||||
|
||||
The reflex to write `blocks.BlockMeta{Key:"testimonials"}` + a `func(ctx, content) string` render
|
||||
func + `br.Register(...)` is usually the wrong altitude. Reach for it only when the decision guide
|
||||
below says so.
|
||||
|
||||
## Choose the path
|
||||
|
||||
| Use the built-in `html` block when… | Register a custom block (`BlockMeta` + `BlockFunc`) when… |
|
||||
|---|---|
|
||||
| The block is markup/template-driven (hero, features, footer, CTA, legal, nav). | You need a bespoke **structured field-form** the admin fills in (and HTML editing won't do). |
|
||||
| Admin edits raw HTML, **or** edits a linked **menu / data table / setting** the template reads. | You need server-side render logic a pongo2 template can't express (complex queries/branching). |
|
||||
| You want zero Go render code and zero custom editor JS. | You're shipping a reusable editor UI via Module Federation (`PLUGIN_EDITOR_SDK.md`). |
|
||||
| The project requires "no bespoke block types" (some sites mandate this). | |
|
||||
|
||||
Custom-block downside to weigh: a custom key like `myplugin:testimonials` renders a red
|
||||
`block-fallback` box on any page whose stored block references that key once the type is no longer
|
||||
registered (version skew, a removed type, a renamed plugin). The built-in `html` block is always
|
||||
registered, so it never falls back. Converting a site from custom keys to `html` is exactly what
|
||||
removes those boxes — but existing stored blocks/masters must be re-provisioned (see Gotchas).
|
||||
|
||||
## The two render modes
|
||||
|
||||
A provisioned `html` block stores a pongo2 template string in `_html_content`. The built-in block
|
||||
**re-renders that template live on every request** with the block's other content fields + a
|
||||
`context` object (`context.url`, `context.now`, …) injected. You choose *when* the template runs:
|
||||
|
||||
- **Pre-rendered (frozen):** render the template to HTML **at provision time** and store the
|
||||
result. Cheapest; correct for static sections with no request-time data.
|
||||
- **Live:** store the **raw template** (+ its fields) and let the html block render it per request.
|
||||
Required for anything that depends on request-time data: menus, the current URL for active-nav
|
||||
state, `context.now` for a year, geo, user. Pre-rendering these freezes them empty/blank.
|
||||
|
||||
```go
|
||||
// htmlBlockDefinition bundles a .ninjatpl (embedded) with its default field values.
|
||||
type htmlBlockDefinition struct {
|
||||
meta blocks.BlockMeta
|
||||
defaultHTML string // mustBlockTemplate("templates/blocks/x.ninjatpl")
|
||||
defaultContent map[string]any // template variables
|
||||
}
|
||||
|
||||
// PRE-RENDERED page block: render now, store the HTML. (core SDK blocks.RenderTemplate)
|
||||
func chBlock(def htmlBlockDefinition, title, slot string, sort int32, overrides map[string]any) plugin.PageBlockConfig {
|
||||
merged := mergeContent(def.defaultContent, overrides)
|
||||
rendered, err := blocks.RenderTemplate(def.defaultHTML, merged)
|
||||
if err != nil {
|
||||
rendered = def.defaultHTML
|
||||
}
|
||||
return plugin.PageBlockConfig{
|
||||
BlockKey: "html", Title: title, Slot: slot, SortOrder: sort,
|
||||
Content: map[string]any{"_html_content": rendered},
|
||||
}
|
||||
}
|
||||
|
||||
// LIVE block: store the RAW template + fields, so the html block re-renders it each request
|
||||
// (needed for menus / context.url / context.now). Used for nav + footer.
|
||||
func liveBlock(def htmlBlockDefinition, title, slot string, sort int32) plugin.PageBlockConfig {
|
||||
content := mergeContent(def.defaultContent, nil)
|
||||
content["_html_content"] = def.defaultHTML // raw template, NOT pre-rendered
|
||||
return plugin.PageBlockConfig{
|
||||
BlockKey: "html", Title: title, Slot: slot, SortOrder: sort, Content: content,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`MasterPageBlock` has the identical shape (`BlockKey/Title/Content/HtmlContent/Slot/SortOrder`); use
|
||||
the same two helpers for master-page nav/footer. (`HtmlContent *string` is an alternative to
|
||||
`Content["_html_content"]` — the dedicated field; either works, the renderer feeds both into
|
||||
`_html_content`.)
|
||||
|
||||
## Template features (pongo2)
|
||||
|
||||
`.ninjatpl` files are pongo2 (Django/Jinja2): `{{ var }}`, `{{ v|default:"x" }}`, `{% if %}`,
|
||||
`{% for x in items %}`, `{% empty %}`, filters (`date`, `truncatechars`, `markdown`, `img`, …), and
|
||||
tags (`{% icon %}`, `{% img %}`, `{% button %}`, `{% hcaptcha %}`). Embed them and load with a tiny
|
||||
helper:
|
||||
|
||||
```go
|
||||
//go:embed templates/blocks/*.ninjatpl
|
||||
var blockTemplatesFS embed.FS
|
||||
func mustBlockTemplate(p string) string { b, err := blockTemplatesFS.ReadFile(p); if err != nil { panic(p) }; return string(b) }
|
||||
```
|
||||
|
||||
## Driving navigation from a real editable menu
|
||||
|
||||
A **live** `html` block can render an admin-editable menu — no custom nav block. The built-in html
|
||||
block injects a `menus` map (keyed by menu name) when the template references `menus`:
|
||||
|
||||
```django
|
||||
{% for item in menus.main %}
|
||||
<a href="{{ item.url }}"{% if item.url != "/" and item.url in context.url %} class="active"{% endif %}>{{ item.label }}</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Seed the menu in `Provision()` (idempotent by label; auto-creates the menu). Seed **after**
|
||||
`EnsurePage` so internal links bind to page ids:
|
||||
|
||||
```go
|
||||
p.EnsureMenuItem("main", plugin.MenuItemConfig{Label: "Features", PageSlug: "/features", SortOrder: 0})
|
||||
p.EnsureMenuItem("main", plugin.MenuItemConfig{Label: "Docs", URL: "https://docs...", SortOrder: 1})
|
||||
```
|
||||
|
||||
The admin then edits the menu in the CMS menu manager and the live block reflects it immediately
|
||||
(no restart, no re-provision). Menu names must be **identifier-like (no hyphens)** for pongo2
|
||||
`menus.NAME` dotted access — use `footer_legal`, not `footer-legal`. `EnsureMenuItem` only *adds*
|
||||
(idempotent by label) and never reorders/removes, so a pre-existing/polluted menu keeps its stray
|
||||
items — clean those via the `MenusService` RPCs (`DeleteMenuItem`, `ReorderMenuItems`), not by
|
||||
re-seeding.
|
||||
|
||||
> The `menus`-in-html-block capability is a CMS-core feature (`cms/backend/blocks/builtin/html.go`
|
||||
> injects it). If a target CMS predates it, that's a core change (rebuild the backend), not an SDK
|
||||
> bump — the plugin imports `block/core` only, so a cms-internal change keeps the plugin's wasm artifact compatible.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Master pages are create-if-not-exist.** `InitializeMasterPagesFromPlugins` seeds a plugin's
|
||||
master pages only when they don't already exist; it never updates an existing master's blocks. So
|
||||
changing a master's block defs (e.g. custom-key → `html`, or pre-rendered → live) does **not**
|
||||
apply to a tenant whose master already exists. Fix: delete the stale masters
|
||||
(`PagesService/DeletePage` on each master id from `ListMasterPages`) then restart — init recreates
|
||||
them from the new defs. Safe: child pages' `master_page_id` is nulled on delete and resolution
|
||||
falls back to the template's default master, which init re-sets.
|
||||
- **Publish vs live.** Public pages render a **published snapshot** — page *content* edits
|
||||
(block field values) need a `PagesService/PublishPage` after provisioning. **Template/CSS** changes
|
||||
and **live** `html`-block templates (menus, `context.*`) show on deploy without republish.
|
||||
- **ReconcileBlocks overwrites admin edits.** `EnsurePage{ReconcileBlocks: true}` wipes & recreates
|
||||
the page's whole block set from `Content` every startup — correct while *you* own the content
|
||||
(build phase), wrong once an admin hand-edits (it silently reverts them). With it `false`, seeded
|
||||
blocks are created only at page-creation time, so a new block won't appear on an already-existing
|
||||
page (admin adds it from the palette, or ship a one-time reconcile then revert).
|
||||
|
||||
## Exemplar
|
||||
|
||||
`~/src/blockninja/sites/bcms-public` — every section is a built-in `html` block: `blocks.go`
|
||||
(`htmlBlockDefinition` + defaults), `provision.go` (`chBlock` pre-rendered page sections +
|
||||
`provisionMenus`), `master_pages.go` (`mpBlock` pre-rendered / `mpLiveBlock` live nav+footer),
|
||||
`templates/blocks/*.ninjatpl`.
|
||||
236
developing-blockninja-plugins/theme-overrides.md
Normal file
236
developing-blockninja-plugins/theme-overrides.md
Normal file
@ -0,0 +1,236 @@
|
||||
# Codeless themes: template overrides, the ninjatpl engine, seed, screenshots
|
||||
|
||||
Read this before building or editing a codeless **theme** (a `.bnp` that re-skins the
|
||||
built-in blocks and ships page templates, presets, fonts, an email wrapper, and demo
|
||||
content). Distilled from the theme-fleet build (16 themes, 2026-07-05). Companion to
|
||||
`cms/docs/TEMPLATE_PLUGINS.md` and `cms/docs/theme-previews.md`.
|
||||
|
||||
## What a codeless theme is
|
||||
|
||||
No Go, no `plugin.wasm`, **no `go.mod`, no `block_core` pin**. The repo is:
|
||||
|
||||
```
|
||||
manifest.yaml # theme_presets, bundled_fonts, master_pages, system_templates,
|
||||
# page_templates, template_overrides, css.input_css_append, email wrappers
|
||||
plugin.mod # name/display_name/kind="theme"/scope="@themes"/categories/tags/required_icon_packs
|
||||
templates/<theme>/*.html # the page templates (default, full-width, landing, article, blog-index, contact, auth)
|
||||
templates/overrides/<theme>/*.ninjatpl # one per built-in block key you re-skin
|
||||
templates/email/<system>.ninjatpl # email wrapper
|
||||
blocks/*.ninjatpl + *.schema.json # persona blocks ONLY (things no built-in covers)
|
||||
presets.json fonts.json master_pages.json
|
||||
seed/seed.json seed/workflows.json # demo content (demo:true)
|
||||
assets/ # fonts, css, static
|
||||
```
|
||||
|
||||
Compatibility is not pinned in the repo — it is enforced by manifest synthesis in
|
||||
`ninja plugin build` and the registry gates. `kind = "theme"` is frozen at first publish.
|
||||
|
||||
## Re-skinning a built-in block = a template override
|
||||
|
||||
Ship `templates/overrides/<theme>/<key>.ninjatpl` **plus** a `template_overrides` entry in
|
||||
`manifest.yaml` (`template_key: <theme>`, `block_key: <key>`). Keys use the **dashed
|
||||
canonical form** (`video-embed`, `feature-grid`, `author-bio-hero`). Some accept underscore
|
||||
aliases, but overrides target the dashed key.
|
||||
|
||||
A re-skin is **presentation only**. Copy the built-in's default template as your starting
|
||||
point (`cms/backend/blocks/builtin/manifest/{blocks.yaml,schemas,sample}` for declarative
|
||||
builtins; the compiled builtin's Go for the rest) and preserve **every** content-field read,
|
||||
provider variable, custom tag (`{% img %}`, `{% button %}`, `{% form %}`, `{% signup_form %}`,
|
||||
`{% auth_form %}`), and inline `<script>` verbatim. Only classes and markup change. If you
|
||||
rename or drop a variable, that part renders empty.
|
||||
|
||||
Persona blocks (`blocks/`) are for furniture **no built-in covers** (a reservation strip, a
|
||||
tour-dates list). Anything that duplicates a built-in (footer, hero, gallery, divider, menu,
|
||||
stats, pull-quote) must become an override of that built-in, not a parallel block. Delete the
|
||||
duplicate block + its schema.
|
||||
|
||||
## How overrides dispatch — know this or you ship empty lists
|
||||
|
||||
There are two kinds of built-in, and they reach your override by different paths:
|
||||
|
||||
1. **Definition-backed built-ins** (most of them: navbar, footer, hero, feature-grid, the
|
||||
whole blog family, breadcrumbs, category-list, author-bio-hero, …). These declare **data
|
||||
providers** (`menus`, `posts`, `authors`, `categories`, `footer_menus`, …). The codeless
|
||||
loader registers your override as a template **source**, so render flows through the
|
||||
definition engine (`RenderDefinition`), which **builds the providers** and then renders
|
||||
your `.ninjatpl` with the content-map fields **and** the provider variables merged in. A
|
||||
navbar override gets its menu items; a blog-index override gets its posts.
|
||||
- **This is cms main (`fa31808d7`, 2026-07-05) onward.** On an OLDER image the codeless
|
||||
loader registered these overrides on the *compiled* path, which skipped
|
||||
`RenderDefinition` and never built providers — so the chrome rendered around **empty
|
||||
lists**. If your provider-backed override renders empty, your cms is pre-fix.
|
||||
- **Author provider-backed overrides faithful to the built-in's provider variable names.**
|
||||
Read the built-in default + its `blocks.yaml` providers to get the loop variable exactly
|
||||
right.
|
||||
|
||||
2. **Compiled built-ins with NO definition** (`auth-form`, `auth-status`,
|
||||
`password-reset-form`, `page-suggestions`). Your override dispatches via the **compiled
|
||||
path** — `Registry.GetForTemplate(templateKey, blockKey)` returns your override closure
|
||||
before the base block, so it renders. Their data is `BlockContext`-driven (`auth.*`,
|
||||
`context.*`), not providers, so they render fully. Preserve the hardcoded endpoints and
|
||||
input names (`/api/auth/login|register|logout|request-password-reset`, honeypots, the Cap
|
||||
`<cap-widget>` markup). `page-suggestions` renders themed 404 chrome, but the dynamic
|
||||
"did you mean" list is a server-side Postgres FTS query that a codeless template cannot
|
||||
reproduce — themed chrome yes, dynamic list no.
|
||||
|
||||
(Internal note if you ever touch the loader: definition-backed keys must be registered via
|
||||
`RegisterTemplateOverrideSource` so `registry.Has("theme:key")` stays false and the
|
||||
`blocks.go` dispatch gate takes the `RenderDefinition` branch; non-definition keys keep the
|
||||
compiled `RegisterTemplateOverride` path. pongo2's tag/filter registries are process-global,
|
||||
so the source-layer renderer and the compiled `BlockTemplate` closures share the same custom
|
||||
tags/filters — an override renders identically either way.)
|
||||
|
||||
## Legacy override field reconcile (the dead-override trap)
|
||||
|
||||
If the theme carried `.so`-era overrides, the built-in field names changed. Fix the reads or
|
||||
the override renders empty:
|
||||
|
||||
- `button`: `href`/`url` → `link`, `variant` → `style`, `text` → `label`
|
||||
- `card`: `body` → `text`, `image` → `media`
|
||||
|
||||
Grep your overrides for the old names before assuming they still work.
|
||||
|
||||
## ninjatpl (pongo2) engine gotchas — hard-won, do not rediscover
|
||||
|
||||
1. `{% for %}` iterates a **`[]any` of maps ONLY**. No string iteration, no int-slice
|
||||
iteration. "Repeat N times" must be unrolled or shaped as data in a provider/sample.
|
||||
2. **Tailwind JIT cannot see interpolated classes** (`grid-cols-{{ n }}`). Use literal
|
||||
conditional classes per enum value.
|
||||
3. **No bracket subscript** (`foo[bar]`). Dynamic-key lookups are impossible — shape the data
|
||||
in a provider/sample instead.
|
||||
4. `{% if %}` **cannot nest inside a tag's arguments** (`{% img %}`, `{% button %}`). Branch
|
||||
the whole tag call.
|
||||
5. Icons are `"pack:name"` strings: split with `|split:":"|first`/`|last`. The
|
||||
`::pack:name:SIZE::` shorthand takes size **keywords** only (`sm`/`md`/`lg`/`xl`), not
|
||||
class strings. Render icons **only** as `<svg><use href="/icons/<pack>.svg#<name>"/></svg>`
|
||||
— never inline SVG. Declare packs in `plugin.mod` `required_icon_packs` and in
|
||||
`RECOMMENDED_ICONS.md`.
|
||||
6. `{# comments #}` must be **single-line**.
|
||||
7. The `media` filter is **not registered** in the block render path. Resolve images via
|
||||
`{% img %}`, never `|media`.
|
||||
8. The navbar drawer uses **fixed element ids** — one navbar per page.
|
||||
9. **Dual-mode via semantic tokens only** (`bg-background`, `text-foreground`,
|
||||
`hsl(var(--token))`). A literal `hsl(...)`/hex/`rgb(...)` in a template **fails
|
||||
check-safety** and breaks the other mode.
|
||||
10. `{% extends %}`/`{% include %}` are for page templates, not block overrides.
|
||||
11. **Bare-string seed values hard-error attribute reads.** `field.text|default:field`
|
||||
aborts the whole block (blank hero) when the seed passes `"headline": "..."` instead of
|
||||
`{"text": "..."}`. Always read styled-text fields as `field|get:"text"|default:field` —
|
||||
the process-global `get` filter degrades gracefully on both shapes.
|
||||
12. **Scroll-reveal must be robust.** A naive `.reveal` + IntersectionObserver hides
|
||||
below-fold content in screenshots and for no-JS/reduced-motion users. Required: reveal
|
||||
instantly when already in view at init, a safety-net full reveal (`load` + timeout), and
|
||||
content visible without JS (gate hiding on a JS-added class, e.g. `.theme-js`).
|
||||
13. **Theme templates ARE scanned by the host Tailwind build** (fixed cms `50c713ed0`,
|
||||
2026-07-05 — extracted `.bnp` `**/*.ninjatpl` is an injected `@source`; CSS regenerates
|
||||
on boot, theme activation, and runtime install/swap). Use normal responsive utilities in
|
||||
templates; do NOT duplicate them as hand-rolled media-query CSS in
|
||||
`css.input_css_append` — reserve that for genuinely bespoke design CSS.
|
||||
|
||||
## Theme font size hooks (`bn-*` classes and `--fs-*` variables)
|
||||
|
||||
Since 2026-08 (cms ADR 0029) admins can set per-element font sizes in Theme settings.
|
||||
The theme CSS emits `--fs-<key>` variables plus rules targeting **class hooks**; a
|
||||
template only responds if it carries the hook (or its stylesheet consumes the var).
|
||||
Canonical key list: cms `backend/internal/theme/fontsize.go`; selector map: cms
|
||||
`backend/internal/theme/css.go` `fontSizeOverrideSelectors`.
|
||||
|
||||
| Key | How it applies |
|
||||
|-----|----------------|
|
||||
| `h1`..`h6` | bare element rules in `@layer base` (Tailwind size utilities still win, by design) |
|
||||
| `hero-title` / `hero-subtitle` | `.bn-hero-title` / `.bn-hero-subtitle` (unlayered, beats utilities) |
|
||||
| `post-title` / `post-lede` | `.bn-post-title` / `.bn-post-lede` (unlayered) |
|
||||
| `index-card-title` | `.bn-post-card-title` (unlayered) |
|
||||
| `nav-link` | `.bn-nav-link` — host navbar chrome only; menu links, not brand/CTA/utility anchors |
|
||||
| `post-body`, `post-h2`, `post-h3`, `post-meta`, `page-title`, `page-lede`, `button` | consumed as `var(--fs-<key>, <fallback>)` in stylesheets (host sheets do this; yours can too) |
|
||||
|
||||
Rules for theme/plugin authors:
|
||||
|
||||
- **Re-skinning a built-in? Preserve its `bn-*` hooks.** They are content contract, like
|
||||
field reads. Dropping `bn-hero-title` from a hero re-skin silently kills the admin's
|
||||
Hero Title size control on every site using your theme.
|
||||
- **Custom templates opt in by adding the hook** to the semantically equivalent element:
|
||||
the article `<h1>` gets `bn-post-title`, the hero heading gets `bn-hero-title`, etc.
|
||||
Keep existing utility classes; the override rule is unlayered and wins only when set.
|
||||
- **Hardcoded `font-size` in your CSS on a mapped element? Wrap it**:
|
||||
`font-size: var(--fs-hero-title, 3rem);` with your current value as the fallback, so
|
||||
zero-override rendering is byte-identical. This works even when you cannot touch markup.
|
||||
- **Do not invent hooks** for unmapped elements and do not map decorative elements
|
||||
(stat numbers, section eyebrows, footers) to keys they do not represent.
|
||||
- **Published pages freeze markup**: pages published before a hook existed pick it up
|
||||
only on republish. The `var()` path applies immediately.
|
||||
|
||||
## Presets, fonts, email, motion
|
||||
|
||||
- **presets.json** — 4 to 6 presets, all 19 tokens, `mode: "both"`. Tune the dark side
|
||||
intentionally (a real dusk/night palette, not an inversion).
|
||||
- **fonts.json** — bundle woff2 (OFL/Apache only; record the license in `assets/`). **Every**
|
||||
`font-family` goes through `var(--font-heading|body|mono, <fallback>)`; never hardcode a
|
||||
family (it breaks the admin font picker). If you cannot obtain the exact locked face offline
|
||||
(no woff2 in-workspace, no network), bundle a close OFL substitute and name the intended
|
||||
face as an admin Google-Fonts assignment in `RECOMMENDED_FONTS.md`. That is an accepted
|
||||
deviation; document it.
|
||||
- **Email wrapper** — `templates/email/<system>.ninjatpl`, email-safe (tables, inline styles,
|
||||
**literal hex** — email clients ignore `var()`). Context: `body|safe`,
|
||||
`site_name`/`site_url`/`logo_url`, `colors.<camelCase>` hex tokens.
|
||||
- **Motion** — dependency-free JS only; honor `prefers-reduced-motion`; lazy-init any canvas
|
||||
showpiece on view (IntersectionObserver); ship a static no-JS fallback. Keep canvas to the
|
||||
hero/landing when a theme is a "big motion" theme; nowhere else.
|
||||
|
||||
## Seed demo content (`seed/seed.json`, `demo: true`)
|
||||
|
||||
Sections: `settings`, `media`, `pages`, `menu_items`, `data_tables`. Required pages: home,
|
||||
about, a blog index with ≥3 posts, contact, login. Contact wiring: a `data_tables` entry
|
||||
(e.g. `contact_submissions`) referenced from the contact-form block as
|
||||
`formConfig.targetTable` (rewritten to the real table id at apply), plus `seed/workflows.json`
|
||||
with a `row_inserted` trigger on that table and an `email` step
|
||||
(`recipients: {mode: "admins", scope: "all"}`).
|
||||
|
||||
**Page slugs are single-segment.** The `pages` table enforces `slug_single_segment` (no
|
||||
slashes). Blog posts under `/blog` must be seeded as **nested pages** (a child of the blog
|
||||
page with a single-segment child slug), not as a page whose slug is a full path like
|
||||
`/blog/my-post` — a path slug is rejected and 500s `InstallDemoContent` (home/about/blog-index
|
||||
seed first, then the nested post fails and aborts the rest). Confirm the current seed
|
||||
applier's nested-page handling in `cms/backend/internal/services` before authoring blog seed.
|
||||
Data-table + workflow seeding is proven by the seed-workflows e2e (dojo suite
|
||||
`seed-workflows-e2e`).
|
||||
|
||||
## Verify + commit (theme workflow)
|
||||
|
||||
```bash
|
||||
cd themes/<theme> && make # ninja plugin build --codeless → <name>-<ver>.bnp
|
||||
make archive-check # git archive HEAD packs cleanly
|
||||
ninja plugin verify <name>-<ver>.bnp
|
||||
cd ~/src/blockninja/check-safety && go run . ~/src/blockninja/themes/<theme> # MUST exit 0
|
||||
```
|
||||
|
||||
A clean theme reports `32 checks: 19 ok 13 skip -> OK`.
|
||||
|
||||
**Commit gotcha (bit me repeatedly):** the `safety-gate` commit hook resolves its target from
|
||||
the **shell cwd**. If you commit while cwd is elsewhere, or a sibling theme repo has
|
||||
pre-existing violations, the hook scans the wrong repo and denies you. Commit with
|
||||
`git -C ~/src/blockninja/themes/<theme> commit ...` (or `cd` into the repo first) so the hook
|
||||
scans your theme. Stage explicit paths; never `git add -A`. `*.bnp`/`*.so` stay gitignored;
|
||||
`kind = "theme"`.
|
||||
|
||||
## Screenshots / registry previews
|
||||
|
||||
- A codeless theme renders **only** on a cms that carries the built-ins (and, for populated
|
||||
lists, the provider-dispatch fix `fa31808d7`). A **released image can lag `main`** — check
|
||||
the released tag's commit before assuming it can render a new theme. A theme built against
|
||||
new built-ins will not render on an image that predates them.
|
||||
- Provisioning resolves the newest cms tag, then does a **local `ImageExists` check before
|
||||
pulling** (`orchestrator .../operation_provision.go`). So a **locally-built** image tagged
|
||||
as that resolved version is used as-is — you can preview against an unreleased cms without
|
||||
pushing anything.
|
||||
- Documented recipe: provision `siteType: showcase` + `ninja theme screenshot`; the registry
|
||||
`previewImageUrl` / `preview.png` is the gallery card (home hero). See
|
||||
`cms/docs/theme-previews.md`. Auth to a provisioned instance's CMS via
|
||||
`SSOService/GenerateSSOToken` → `/admin/sso?format=json&token=…` (no seeded instance admin
|
||||
password).
|
||||
- Codeless themes provision **fast** (~10 s, no CGO compile), so the old `.so`
|
||||
≤3-concurrent / 5-minute-compile cautions no longer apply. The account 10-site cap still
|
||||
does — tear galleries down between batches and poll until gone.
|
||||
- **Do NOT sideload a `.bnp`** onto an instance's plugin-source dir on an older base image:
|
||||
its `entrypoint.sh` runs `./server --build-so <name>` for any source dir and wedges on a
|
||||
codeless theme. Provision-time registry install is the clean path.
|
||||
Loading…
x
Reference in New Issue
Block a user