From b23e0219b758005fdcc187d2b1a9e5ab243fcec4 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Sun, 28 Jun 2026 09:53:38 +0800 Subject: [PATCH] developing-blockninja-plugins: add html-blocks guidance Agents default to registering a custom block type (BlockMeta + Go render func + Module Federation editor) for section/content blocks. RED baseline confirmed it. Add html-blocks.md: when to use the built-in html block vs a custom block, pre-rendered vs live render modes, driving nav/footer from editable menus, and the master-page reconcile / publish / ReconcileBlocks gotchas. SKILL.md gains a routing row + a decision callout so the html-block path is seen before an agent reaches for a custom block. GREEN-verified: fresh agents now pick the html block (with reasoning) on both a clear case and the nuanced field-form case. Co-Authored-By: Claude Opus 4.8 (1M context) --- developing-blockninja-plugins/SKILL.md | 17 ++- developing-blockninja-plugins/html-blocks.md | 142 +++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 developing-blockninja-plugins/html-blocks.md diff --git a/developing-blockninja-plugins/SKILL.md b/developing-blockninja-plugins/SKILL.md index 1bd064b..61da024 100644 --- a/developing-blockninja-plugins/SKILL.md +++ b/developing-blockninja-plugins/SKILL.md @@ -20,17 +20,30 @@ Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own | Working on | Read first (under `cms/docs/`) | |---|---| -| Scaffold, registration, blocks, `CoreServices` | PLUGIN_DEVELOPMENT.md | +| Scaffold, registration, custom block types, `CoreServices` | 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 | | 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 | +| Theme preview screenshots (showcase preset, gallery instances, `ninja theme screenshot`, registry `previewImageUrl`) | theme-previews.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: prefer the built-in `html` block + +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)**. + ## 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. @@ -60,4 +73,6 @@ ninja plugin publish # ships `git archive HEAD`: untracked files DON'T # web/dist and ALL generated Go must be committed ``` +**`kind` is frozen at first publish.** The registry stores `kind` (`plugin`|`theme`) on the first `CreatePlugin` and NEVER updates it; `publish` only *compares* plugin.mod `kind` against that frozen DB column and rejects a mismatch with `plugin.mod kind does not match registered kind`. A theme MUST declare `kind = "theme"` BEFORE its first publish — editing plugin.mod afterwards is not enough (no RPC re-aligns it). Remedy for an already-misregistered theme (dev only, no supported RPC): `UPDATE registry_plugins SET kind='theme' WHERE id=` on `orchestrator-db` (preserves version history), then republish. gotham/lcars first shipped `kind=plugin`; lcars corrected this way 2026-06. + 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. diff --git a/developing-blockninja-plugins/html-blocks.md b/developing-blockninja-plugins/html-blocks.md new file mode 100644 index 0000000..ead1dee --- /dev/null +++ b/developing-blockninja-plugins/html-blocks.md @@ -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 %} + {{ item.label }} +{% 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 `.so` 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`.