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>
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:
-
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.ninjatplwith 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 skippedRenderDefinitionand 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.yamlproviders to get the loop variable exactly right.
- This is cms main (
-
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 isBlockContext-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-suggestionsrenders 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→labelcard: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
{% for %}iterates a[]anyof maps ONLY. No string iteration, no int-slice iteration. "Repeat N times" must be unrolled or shaped as data in a provider/sample.- Tailwind JIT cannot see interpolated classes (
grid-cols-{{ n }}). Use literal conditional classes per enum value. - No bracket subscript (
foo[bar]). Dynamic-key lookups are impossible — shape the data in a provider/sample instead. {% if %}cannot nest inside a tag's arguments ({% img %},{% button %}). Branch the whole tag call.- 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 inplugin.modrequired_icon_packsand inRECOMMENDED_ICONS.md. {# comments #}must be single-line.- The
mediafilter is not registered in the block render path. Resolve images via{% img %}, never|media. - The navbar drawer uses fixed element ids — one navbar per page.
- Dual-mode via semantic tokens only (
bg-background,text-foreground,hsl(var(--token))). A literalhsl(...)/hex/rgb(...)in a template fails check-safety and breaks the other mode. {% 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/). Everyfont-familygoes throughvar(--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 inRECOMMENDED_FONTS.md. That is an accepted deviation; document it. - Email wrapper —
templates/email/<system>.ninjatpl, email-safe (tables, inline styles, literal hex — email clients ignorevar()). 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 lagmain— 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
ImageExistscheck 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 registrypreviewImageUrl/preview.pngis the gallery card (home hero). Seecms/docs/theme-previews.md. Auth to a provisioned instance's CMS viaSSOService/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
.bnponto an instance's plugin-source dir on an older base image: itsentrypoint.shruns./server --build-so <name>for any source dir and wedges on a codeless theme. Provision-time registry install is the clean path.