# 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`.