Alex Dunmow b23e0219b7 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) <noreply@anthropic.com>
2026-06-28 09:53:38 +08:00

8.0 KiB

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.
// 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: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:

{% 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:

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.