Alex Dunmow cbc598f5d5 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>
2026-07-05 15:36:43 +08:00

12 KiB

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 pathRegistry.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/urllink, variantstyle, textlabel
  • card: bodytext, imagemedia

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 wrappertemplates/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)

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.