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>
This commit is contained in:
Alex Dunmow 2026-07-04 19:50:34 +08:00
parent 2b9a65dcca
commit 3ce6f9f4a0
4 changed files with 157 additions and 7 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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": `<main>{{ title }}</main>`,
"templates/email/newsletter.ninjatpl": `<table>{{ body|safe }}</table>`,
})
// 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))
}
}

View File

@ -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/<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`)
```json