developing-blockninja-plugins: add theme-overrides reference

Codeless theme override authoring learned from the theme-fleet build:
- template override mechanism (templates/overrides + manifest template_overrides)
- override dispatch model (definition-backed → providers via RenderDefinition;
  compiled auth/404 built-ins via GetForTemplate)
- legacy field renames (button href→link etc.), ninjatpl engine gotchas
- presets/fonts/email/motion rules, seed single-segment page-slug constraint
- git -C commit-hook trap, check-safety gate
- screenshot/preview pipeline (local ImageExists provisioning, showcase recipe)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 15:36:43 +08:00
parent cb1acbab6b
commit cbc598f5d5
2 changed files with 309 additions and 24 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: developing-blockninja-plugins 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/core SDK usage in standalone plugin repos.
--- ---
# Developing BlockNinja Plugins # Developing BlockNinja Plugins
@ -16,16 +16,44 @@ description: Use when creating, modifying, building, or publishing BlockNinja CM
> compile. A registry version published as a source archive is **rejected** at install > compile. A registry version published as a source archive is **rejected** at install
> ("legacy source archive; republish as .bnp"). > ("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 Go `core` is a
convenience, not a dependency of the model. The canonical plugin-need → ABI-
mechanism map is `~/src/blockninja/core/docs/abi-capability-surface.md`.
Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at: 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/...` - **SDK source:** `~/src/blockninja/core`for WASM plugins, import prefix `git.dev.alexdunmow.com/block/core/...` is the ONLY one allowed; never `block/cms/...`. Codeless plugins import NOTHING (no Go at all).
- **ABI contract (authoritative):** `~/src/blockninja/core/docs/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir` - **ABI contract (authoritative):** `~/src/blockninja/core/docs/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir`; capability matrix: `core/docs/abi-capability-surface.md`
- **Architecture spec:** `~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.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` - **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) - **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). 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 `block/core` 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:
`core/docs/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:
`core/docs/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 ## Anatomy of a wasm plugin
`PluginRegistration` is **unchanged** from the .so era — same fields, same `Register`, `PluginRegistration` is **unchanged** from the .so era — same fields, same `Register`,
@ -71,7 +99,29 @@ named error at build time. Load-time work belongs in `Load`.
| Settings schema, theme preset (go:embed'd JSON) | `assets/` | | Settings schema, theme preset (go:embed'd JSON) | `assets/` |
| `data_dir = true` grant + `/data` round-trip | `plugin.mod`, `onLoad` | | `data_dir = true` grant + `/data` round-trip | `plugin.mod`, `onLoad` |
## Blocks: prefer the built-in `html` block ## 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` 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 render func + a Module Federation editor) — is usually the wrong altitude for a section/content
@ -117,12 +167,32 @@ marshaling to host functions. Full family/method table + disposition of every
- `Interceptors`, `CoreServiceBindings`**host-side**; RBAC method roles merge from the - `Interceptors`, `CoreServiceBindings`**host-side**; RBAC method roles merge from the
manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran). manifest, auth context arrives via forwarded HTTP headers (host interceptors already ran).
- `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`. - `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`.
- `Bridge.GetService` reports availability but returns `nil` (typed values can't cross — - **`deps.Provisioner` (core ≥ v0.17.x) is the seeding path** — `EnsurePage`,
open item); `RegisterService` forwards names only. `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 - `RAGService.RegisterContentFetcher` — records guest-side + a manifest declaration; the
host calls back via `HOOK_RAG_FETCH`. host calls back via `HOOK_RAG_FETCH`.
- Methods without an error channel (`Slugify`, `EvaluateAccess`, `Bridge.*`, …) degrade to - Methods without an error channel (`Slugify`, `EvaluateAccess`, `Bridge.RegisterService`, …)
zero values on transport failure. degrade to zero values on transport failure.
### Per-instance state (the #1 wasm gotcha) ### Per-instance state (the #1 wasm gotcha)
@ -138,6 +208,11 @@ others. Two sanctioned patterns (both in testplugin/symposium):
**nil-check it**: in native/DESCRIBE builds it's the zero value. See symposium **nil-check it**: in native/DESCRIBE builds it's the zero value. See symposium
`register.go`. `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 ## Sandbox constraints — write reload-safe code
- **No filesystem, no network, no env** in the guest. One opt-in exception: `data_dir = true` - **No filesystem, no network, no env** in the guest. One opt-in exception: `data_dir = true`
@ -170,13 +245,27 @@ cd ~/src/blockninja/check-safety && go run . <plugin-path> # MUST exit 0 (unch
ninja plugin bump patch # commits plugin.mod — does NOT git-tag 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 git tag vX.Y.Z && git push origin main vX.Y.Z # --follow-tags skips lightweight tags
ninja plugin build --dir . # rebuild at the bumped version ninja plugin publish # builds the .bnp itself at the bumped version and
ninja plugin publish --bnp <name>-<ver>.bnp # --bnp is REQUIRED: without it publish # uploads it — the .bnp is the ONLY publish form
# ships a source archive, which instances REJECT # (--bnp <file> ships a prebuilt artifact instead)
``` ```
The `.bnp` (tar.zst) packs `plugin.wasm`, `plugin.mod`, `manifest.pb`, plus `migrations/`, The `.bnp` (tar.zst) packs `plugin.mod` + `manifest.pb`, plus — when present —
`schemas/`, `assets/`, and `web/dist` (flattened under `web/`) when present. Statics are `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 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 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 go:embed'd**, but `web/dist` must be built (`cd web && pnpm run build`) before
@ -186,7 +275,7 @@ proves `git archive HEAD` wasm-compiles). Generated Go (`*_templ.go`, `db/*.sql.
`*connect.go`) and `web/dist` stay committed; `.gitignore` adds `*.bnp` and `*.wasm`. `*connect.go`) and `web/dist` stay committed; `.gitignore` adds `*.bnp` and `*.wasm`.
**Dev loop:** publish to the DEV orchestrator (`ninja --host **Dev loop:** publish to the DEV orchestrator (`ninja --host
https://my.localdev.blockninjacms.com plugin publish --bnp …` — `my.blockninjacms.com` https://my.localdev.blockninjacms.com plugin publish` — `my.blockninjacms.com`
without `localdev` is PRODUCTION). Install via Admin → Plugins → Browse Registry without `localdev` is PRODUCTION). Install via Admin → Plugins → Browse Registry
(`InstallFromRegistry`): the instance downloads the `.bnp`, checksum-verifies, and (`InstallFromRegistry`): the instance downloads the `.bnp`, checksum-verifies, and
**hot-loads without restart**; updates hot-swap the same way. First publish lands **hot-loads without restart**; updates hot-swap the same way. First publish lands
@ -201,10 +290,13 @@ capability disposition table in `core/docs/wasm-abi.md` (does it cross the ABI,
(3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in (3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in
`cms/backend` for semantics. `cms/backend` for semantics.
**Missing capability** ⇒ two sanctioned paths only: extend `block/core` (guest stub + host **Missing capability** ⇒ first check `core/docs/abi-capability-surface.md` — the surface
function + ABI proto — additive within ABI major 1), or vendor the cms-internal package is complete for every known plugin need since WO-WZ-019. A genuinely new capability is an
into the plugin's `internal/` with a provenance header. NEVER `replace` directives; NEVER **ABI extension** (manifest field, `host_call` method pair, or hook — additive within ABI
`block/cms` imports. Remember the wasm boundary: a new capability that returns 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` imports. Remember the wasm boundary: a value that carries
functions/handlers can't cross — it needs a hook or a manifest declaration instead. functions/handlers can't cross — it needs a hook or a manifest declaration instead.
## Doc routing ## Doc routing
@ -212,8 +304,10 @@ functions/handlers can't cross — it needs a hook or a manifest declaration ins
| Working on | Read first | | Working on | Read first |
|---|---| |---|---|
| ABI, hooks, capabilities, error codes, `data_dir`, build/pack internals | `core/docs/wasm-abi.md` | | ABI, hooks, capabilities, error codes, `data_dir`, build/pack internals | `core/docs/wasm-abi.md` |
| Which ABI mechanism serves a plugin need (matrix; codeless equivalents) | `core/docs/abi-capability-surface.md` |
| Scaffold, registration, custom block types, `CoreServices` concepts | `cms/docs/PLUGIN_DEVELOPMENT.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 | | **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 | | 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 | | 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 | | Platform data tables (`Provisioner.EnsureDataTable`, View-rootable JSONB store) | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables |
@ -230,16 +324,17 @@ compiles, go:embed'd web bundles, or core-version skew.
## Release traps ## Release traps
- `ninja plugin publish` **without `--bnp` ships a source archive** — the registry accepts - `ninja plugin publish` **builds the .bnp itself and ships it** — the source-archive
it but every instance install fails with "legacy source archive; republish as .bnp". path is gone (CLI feeb311). A wasm plugin whose Go `Registration.Version` drifts from
Always build first and pass `--bnp` (or `make publish`). 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 - **`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 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: `kind = "theme"` BEFORE its first publish. Dev-only remedy for a misregistered row:
`UPDATE registry_plugins SET kind='theme'` on `orchestrator-db`, then republish. `UPDATE registry_plugins SET kind='theme'` on `orchestrator-db`, then republish.
- `ninja plugin publish` ships metadata from `git HEAD` (README/CHANGELOG, warnings on - `ninja plugin publish` builds the artifact from the **working directory** and warns on
dirty tree) and the artifact from `--bnp` — keep the tree committed at the bumped a dirty tree (`--strict` aborts) — keep the tree committed at the bumped version so
version so the two agree; `make archive-check` proves HEAD compiles. the artifact matches the tag; `make archive-check` proves HEAD compiles.
- `ninja plugin init` is interactive and DROPS unknown `plugin.mod` keys on rewrite — - `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). re-check `plugin.mod` after running it (`data_dir` is a known key and survives).

View File

@ -0,0 +1,190 @@
# 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.
## 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.