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": `{{ body|safe }}
`, + }) + + // 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/