core/docs/codeless-bnp.md
Alex Dunmow 3ce6f9f4a0 feat(bnp): fold manifest.yaml into wasm builds for mixed-form artifacts (WO-WZ-021)
A wasm plugin.build now folds an optional root manifest.yaml into the
DESCRIBE-derived manifest.pb exactly as BuildCodeless does, so a reduced
"mixed" plugin can keep a minimal guest for genuine logic while shipping the
full declarative surface set host-rendered: theme_presets, bundled_fonts,
master_pages, system/page templates, template_overrides, email_wrappers, css,
required_icon_packs (and the referenced root JSON is embedded into manifest.pb).

applyManifestYAML now only overwrites a scalar/bytes key when manifest.yaml
actually declares it, so folding onto a guest-populated manifest supplements
and overrides but never WIPES a guest DESCRIBE field. A repo with no
manifest.yaml is a no-op — existing pure-wasm plugins build byte-for-byte as
before. Codeless builds are unaffected (empty manifest → identical result).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:50:34 +08:00

8.4 KiB

Codeless .bnp — Declarative Plugins (WO-WZ-020)

A codeless .bnp is a plugin artifact with no plugin.wasm: pure declaration the host runs. No wazero compile, no instance pool, no capability binding — zero guest code ever executes. This is the end-state of "injection, not reliance" for themes, content sites, and template-only block packs: the plugin is data the host runs, not a program that links a library. No Go, no block/core dependency, no toolchain beyond the ninja CLI.

The classifier — exactly one rule

ninja plugin build classifies by repo shape:

  • No Go source at the repo root → codeless. The manifest is synthesized from plugin.mod + the declarative files below; the artifact packs without a wasm. --codeless asserts this and fails if Go is present.
  • Go source → wasm, exactly as before. A converted repo DELETES its Go — partial conversions keep a smaller wasm and still ship blocks/ + seed/ alongside it (both artifact kinds carry the declarative dirs).

The declarative / logic split

Codeless-expressible (declaration or host-rendered template):

  • Blocks: a .ninjatpl template + declared data providers (the CMS's own provider set — posts, site, menus, authors, … ADR 0018). The host fetches, the engine renders. Defined in blocks/blocks.yaml — the SAME manifest-FS layout core builtin block definitions use.
  • Master pages, theme presets, fonts, CSS manifest, icon packs, settings schema (manifest.yaml), assets (host-served), migrations (host-run Goose), seed data (seed/seed.json, applied via the WO-WZ-019 provisioner).
  • Template overrides (WO-WZ-021): theme-scoped re-renders of builtin blocks. Declared in manifest.yaml as template_overrides: [{template: <system-key>, block: <block-key>}]; the source lives at templates/overrides/<template>/<block>.ninjatpl and is rendered host-side with the block's content map as the template context.
  • Email wrappers (WO-WZ-021): email_wrappers: [<system-key>, …] in manifest.yaml, source at templates/email/<system>.ninjatpl. The context carries body (pre-rendered HTML — emit it with {{ body|safe }}), colors.* (snake_case hex tokens: primary, primary_foreground, secondary, secondary_foreground, background, foreground, muted, muted_foreground, border, card, card_foreground), site.* (name, logo_url, url, support_email), unsubscribe_url, preview_text. Render failure falls back to the unwrapped body.

Requires code (a wasm plugin):

  • A block whose data no declared provider can produce; a computing tag or filter; HTTP handlers; background jobs; Load/Unload logic; RAG fetchers; media hooks; bridge services; AI tools; data_dir. A codeless manifest declaring any of these is rejected at build (ninja plugin verify / CodelessHookViolation) AND at load (cms reader codelessHookViolation).

Repo layout

my-theme/
  plugin.mod            # name/version/kind (data_dir forbidden)
  manifest.yaml         # optional: theme_presets/bundled_fonts/settings_schema
                        #   (JSON file refs), master_pages (JSON file),
                        #   required_icon_packs, css {...}, dependencies [...],
                        #   system_templates/page_templates,
                        #   template_overrides [{template, block}],
                        #   email_wrappers [system keys]
  blocks/
    blocks.yaml         # key/title/category/schema/template/providers per block
    hero.schema.json
    hero.ninjatpl
  templates/            # <system>/<key>.ninjatpl layout sources for the
                        #   system/page templates declared in manifest.yaml,
                        #   rendered host-side (blog/system/normal page layouts)
    overrides/<system>/<block>.ninjatpl   # template_overrides sources
    email/<system>.ninjatpl               # email_wrappers sources
  seed/
    seed.json           # settings {merge/override/ensure}, media, pages, menu_items
    hero.jpg            # media bytes referenced by seed.json entries
  assets/               # host-served statics
  migrations/           # Goose SQL, host-run under the plugin schema

ninja plugin build<name>-<version>.bnp (tar.zst, no plugin.wasm, manifest.pb with codeless: true). ninja plugin verify re-runs the loader's checks standalone.

How the host runs it (cms plugin/codeless_loader.go)

  • The .bnp reader accepts a missing plugin.wasm iff manifest.codeless, and rejects codeless manifests with computing-hook declarations.
  • Load: blocks/blocks.LoadManifestRegistry.RegisterDefinition — rendering flows through the existing definition engine (providers + ninjatpl + layer fallback, WO-089..096). seed/seed.json → provisioner (EnsureMedia/MergeSiteSettings/EnsureSetting/EnsurePage/ EnsureMenuItem), idempotent by construction. Presets/fonts/CSS/master pages/settings schema come from the manifest exactly as for wasm plugins.
  • Unload/disable: definitions unregister; seeded content stays (it is site data, not runtime state). Uninstall: normal teardown (schema/role drop, artifact removal).
  • Hot-swap: codeless→codeless swaps run migrations first, then re-register definitions and re-apply seed. Wasm↔codeless transitions require uninstall + reinstall.

Mixed-form artifacts (WO-WZ-021)

A mixed .bnp keeps a plugin.wasm (it is NOT codeless) for genuine logic — a contact route, a Stripe handler, a background job — while everything the codeless surface can express stays declarative and host-rendered. This is how the B2 site conversions (coterieos / coteriehealth / bcms-public / judgefest) keep a minimal guest yet ship their page templates, overrides, email wrappers, master pages, presets, fonts, and CSS as data the host runs.

The classifier is unchanged: a repo with Go source at the root builds wasm. What changed is that a wasm build now folds an optional root manifest.yaml into the DESCRIBE-derived manifest.pb, exactly as BuildCodeless does — the SAME declarative key set: theme_presets / bundled_fonts / settings_schema (JSON-file refs, embedded into manifest.pb), master_pages (JSON file), system_templates / page_templates, template_overrides, email_wrappers, css, required_icon_packs, dependencies. The .ninjatpl sources ride the already-packed templates/ dir; blocks/ + seed/ ride along as before.

  • Supplement / override, never wipe. A declarative key supplements the guest's DESCRIBE output (slice keys append; scalar/bytes keys overwrite) — but a key the manifest.yaml does NOT declare leaves the guest's value untouched. A repo with no manifest.yaml builds byte-for-byte as before.
  • Host-render precedence at load (cms plugin/wasm_loader.go, registerWasmStatics). A page template / block override / email wrapper is host-rendered through the cms pongoengine (with the full synthesized page context — head_html / body_end_html / admin_banner_html) iff the artifact ships its convention-path source (templates/<system>/<key>.ninjatpl, templates/overrides/<template>/<block>.ninjatpl, templates/email/<system>.ninjatpl). Otherwise the guest forwarder stands. A reduced plugin's DESCRIBE may still list a template it no longer renders in-guest; the declarative source wins. Master pages / presets / fonts / CSS / icon packs flow from manifest.pb through the existing wasm registration path unchanged.
  • Lifecycle rides the standard source-scoped Register / UnregisterBySource pipeline, so install / disable / uninstall / hot-swap / cross-form swap tear the declarative surfaces down with no extra wiring — identical to codeless.

Seed schema (seed/seed.json)

{
  "settings": {"merge": {"k": "v"}, "override": {"k": "v"}, "ensure": {"k": "v"}},
  "media":    [{"id": "<uuid>", "file": "hero.jpg", "alt": "", "folder": ""}],
  "pages":    [{"slug": "/", "title": "Home", "template_key": "landing",
                "parent_slug": "", "reconcile_blocks": false,
                "blocks": [{"block_key": "hero", "title": "Hero",
                            "content": {}, "slot": "main", "sort_order": 1}]}],
  "menu_items": [{"menu": "main", "label": "Home", "page_slug": "/", "sort_order": 1}]
}

Media id is the deterministic, template-referable key (media:<uuid>, {% img %}); bytes live in seed/ next to the JSON. Apply order: media → settings → pages → menu items.