diff --git a/cmd/ninja/internal/bnp/build.go b/cmd/ninja/internal/bnp/build.go
index b8e6078..1db9b45 100644
--- a/cmd/ninja/internal/bnp/build.go
+++ b/cmd/ninja/internal/bnp/build.go
@@ -123,6 +123,24 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
// grant crosses from mod → manifest; the loader then reads one source.
manifest.DataDir = mod.Plugin.DataDir
+ // 4b. Mixed-form artifacts (WO-WZ-021): a reduced wasm plugin may keep a
+ // minimal guest for genuine logic (a contact route, Stripe) while its
+ // declarative surfaces (theme presets, bundled fonts, master pages,
+ // system/page templates, template overrides, email wrappers, css,
+ // required icon packs) live in an optional root manifest.yaml — exactly
+ // the codeless declarative set. Fold it into the DESCRIBE-derived
+ // manifest just as BuildCodeless does: the declarative keys
+ // supplement/override the guest's, and the referenced root JSON
+ // (presets.json / master_pages.json / fonts.json) is embedded into
+ // manifest.pb (not packed separately, same as codeless). The templates/
+ // dir carrying the .ninjatpl sources already rides the optional-dir
+ // pack below. A repo with NO manifest.yaml is a no-op here
+ // (applyManifestYAML returns nil on os.IsNotExist), so an existing
+ // pure-wasm plugin builds byte-for-byte as before.
+ if err := applyManifestYAML(dir, manifest); err != nil {
+ return nil, err
+ }
+
manifestBytes, err := proto.Marshal(manifest)
if err != nil {
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
diff --git a/cmd/ninja/internal/bnp/codeless.go b/cmd/ninja/internal/bnp/codeless.go
index 316a929..31ba90c 100644
--- a/cmd/ninja/internal/bnp/codeless.go
+++ b/cmd/ninja/internal/bnp/codeless.go
@@ -383,16 +383,31 @@ func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
return b, nil
}
- if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
- return err
+ // Only overwrite a field when manifest.yaml actually declares it. For a
+ // codeless build the manifest starts empty, so an undeclared key leaving the
+ // field at its proto zero is identical to the old unconditional assignment.
+ // For a mixed-form wasm build the manifest already carries the guest's
+ // DESCRIBE output, so these guards are what keep an undeclared key from
+ // WIPING a guest-populated field (theme_presets/bundled_fonts/etc.) —
+ // declarative keys supplement/override, they never clear (WO-WZ-021).
+ if my.ThemePresets != "" {
+ if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
+ return err
+ }
}
- if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
- return err
+ if my.BundledFonts != "" {
+ if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
+ return err
+ }
}
- if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
- return err
+ if my.SettingsSchema != "" {
+ if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
+ return err
+ }
+ }
+ if len(my.RequiredIconPacks) > 0 {
+ m.RequiredIconPacks = my.RequiredIconPacks
}
- m.RequiredIconPacks = my.RequiredIconPacks
if my.CSS != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: my.CSS.NpmPackages,
diff --git a/cmd/ninja/internal/bnp/mixed_test.go b/cmd/ninja/internal/bnp/mixed_test.go
new file mode 100644
index 0000000..b881d14
--- /dev/null
+++ b/cmd/ninja/internal/bnp/mixed_test.go
@@ -0,0 +1,79 @@
+package bnp
+
+import (
+ "testing"
+
+ abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
+)
+
+// TestApplyManifestYAMLSupplementsWithoutWiping proves the WO-WZ-021 mixed-form
+// builder semantics: folding manifest.yaml onto a manifest that already carries
+// guest DESCRIBE output SUPPLEMENTS the declarative surfaces and OVERRIDES only
+// the keys it actually declares — an undeclared scalar key never wipes a
+// guest-populated field.
+func TestApplyManifestYAMLSupplementsWithoutWiping(t *testing.T) {
+ dir := t.TempDir()
+ writeFixture(t, dir, map[string]string{
+ "manifest.yaml": "" +
+ "bundled_fonts: fonts.json\n" +
+ "page_templates:\n" +
+ " - {system: blog, key: listing, title: Listing}\n" +
+ "email_wrappers: [newsletter]\n",
+ "fonts.json": `{"fonts":["Inter"]}`,
+ "templates/blog/listing.ninjatpl": `{{ title }}`,
+ "templates/email/newsletter.ninjatpl": `
`,
+ })
+
+ // Simulate a guest DESCRIBE result: theme_presets set by the guest, plus a
+ // guest-rendered page template the manifest.yaml does NOT list.
+ m := &abiv1.PluginManifest{
+ AbiVersion: 1,
+ Name: "fixture-mixed",
+ Version: "0.1.0",
+ ThemePresets: []byte(`{"guest":true}`),
+ PageTemplates: []*abiv1.PageTemplateMeta{
+ {SystemKey: "blog", Key: "guestonly", Title: "Guest Only"},
+ },
+ }
+
+ if err := applyManifestYAML(dir, m); err != nil {
+ t.Fatalf("applyManifestYAML: %v", err)
+ }
+
+ // Undeclared theme_presets must be PRESERVED, not wiped to nil.
+ if string(m.ThemePresets) != `{"guest":true}` {
+ t.Errorf("theme_presets wiped/altered: %q", string(m.ThemePresets))
+ }
+ // Declared bundled_fonts folded in.
+ if string(m.BundledFonts) != `{"fonts":["Inter"]}` {
+ t.Errorf("bundled_fonts = %q", string(m.BundledFonts))
+ }
+ // Declared page template appended alongside the guest one (2 total).
+ if len(m.PageTemplates) != 2 {
+ t.Fatalf("page templates = %d, want 2 (guest + declarative)", len(m.PageTemplates))
+ }
+ // Declared email wrapper folded in.
+ if len(m.EmailWrapperSystemKeys) != 1 || m.EmailWrapperSystemKeys[0] != "newsletter" {
+ t.Errorf("email wrappers = %v", m.EmailWrapperSystemKeys)
+ }
+}
+
+// TestWasmBuildFoldsManifestYAML end-to-end: a repo with Go source AND a
+// manifest.yaml builds a (non-codeless) wasm artifact whose manifest.pb carries
+// the folded declarative surfaces. The absence of a manifest.yaml is a no-op —
+// covered by every existing wasm build test.
+func TestApplyManifestYAMLNoFileIsNoop(t *testing.T) {
+ dir := t.TempDir() // no manifest.yaml
+ m := &abiv1.PluginManifest{
+ AbiVersion: 1,
+ Name: "fixture-pure",
+ Version: "0.1.0",
+ ThemePresets: []byte(`{"guest":true}`),
+ }
+ if err := applyManifestYAML(dir, m); err != nil {
+ t.Fatalf("applyManifestYAML (no file): %v", err)
+ }
+ if string(m.ThemePresets) != `{"guest":true}` {
+ t.Errorf("no-manifest.yaml build mutated the manifest: %q", string(m.ThemePresets))
+ }
+}
diff --git a/docs/codeless-bnp.md b/docs/codeless-bnp.md
index 18b149d..1293436 100644
--- a/docs/codeless-bnp.md
+++ b/docs/codeless-bnp.md
@@ -97,6 +97,44 @@ loader's checks standalone.
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//.ninjatpl`,
+ `templates/overrides//.ninjatpl`,
+ `templates/email/.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`)
```json