Alex Dunmow 5154c2c813 developing-blockninja-plugins: font size hooks + pluginsdk path refresh
Two changes bundled (shared-file bundling, solo-on-main):
- Theme font size hook contract from cms ADR 0029: bn-* class hooks,
  var(--fs-<key>, fallback) consumption, preserve hooks in re-skins,
  wrap hardcoded font-size declarations, republish caveat. New section
  in theme-overrides.md, summary + pointer in SKILL.md.
- Pre-existing uncommitted SKILL.md refresh: block/core references
  migrated to block/pluginsdk and cms/docs/abi/ doc paths (P3
  proto-first SDK program), description line updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:50:09 +08:00

15 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.
  11. Bare-string seed values hard-error attribute reads. field.text|default:field aborts the whole block (blank hero) when the seed passes "headline": "..." instead of {"text": "..."}. Always read styled-text fields as field|get:"text"|default:field — the process-global get filter degrades gracefully on both shapes.
  12. Scroll-reveal must be robust. A naive .reveal + IntersectionObserver hides below-fold content in screenshots and for no-JS/reduced-motion users. Required: reveal instantly when already in view at init, a safety-net full reveal (load + timeout), and content visible without JS (gate hiding on a JS-added class, e.g. .theme-js).
  13. Theme templates ARE scanned by the host Tailwind build (fixed cms 50c713ed0, 2026-07-05 — extracted .bnp **/*.ninjatpl is an injected @source; CSS regenerates on boot, theme activation, and runtime install/swap). Use normal responsive utilities in templates; do NOT duplicate them as hand-rolled media-query CSS in css.input_css_append — reserve that for genuinely bespoke design CSS.

Theme font size hooks (bn-* classes and --fs-* variables)

Since 2026-08 (cms ADR 0029) admins can set per-element font sizes in Theme settings. The theme CSS emits --fs-<key> variables plus rules targeting class hooks; a template only responds if it carries the hook (or its stylesheet consumes the var). Canonical key list: cms backend/internal/theme/fontsize.go; selector map: cms backend/internal/theme/css.go fontSizeOverrideSelectors.

Key How it applies
h1..h6 bare element rules in @layer base (Tailwind size utilities still win, by design)
hero-title / hero-subtitle .bn-hero-title / .bn-hero-subtitle (unlayered, beats utilities)
post-title / post-lede .bn-post-title / .bn-post-lede (unlayered)
index-card-title .bn-post-card-title (unlayered)
nav-link .bn-nav-link — host navbar chrome only; menu links, not brand/CTA/utility anchors
post-body, post-h2, post-h3, post-meta, page-title, page-lede, button consumed as var(--fs-<key>, <fallback>) in stylesheets (host sheets do this; yours can too)

Rules for theme/plugin authors:

  • Re-skinning a built-in? Preserve its bn-* hooks. They are content contract, like field reads. Dropping bn-hero-title from a hero re-skin silently kills the admin's Hero Title size control on every site using your theme.
  • Custom templates opt in by adding the hook to the semantically equivalent element: the article <h1> gets bn-post-title, the hero heading gets bn-hero-title, etc. Keep existing utility classes; the override rule is unlayered and wins only when set.
  • Hardcoded font-size in your CSS on a mapped element? Wrap it: font-size: var(--fs-hero-title, 3rem); with your current value as the fallback, so zero-override rendering is byte-identical. This works even when you cannot touch markup.
  • Do not invent hooks for unmapped elements and do not map decorative elements (stat numbers, section eyebrows, footers) to keys they do not represent.
  • Published pages freeze markup: pages published before a hook existed pick it up only on republish. The var() path applies immediately.

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.