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:
parent
2b9a65dcca
commit
3ce6f9f4a0
@ -123,6 +123,24 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
|
|||||||
// grant crosses from mod → manifest; the loader then reads one source.
|
// grant crosses from mod → manifest; the loader then reads one source.
|
||||||
manifest.DataDir = mod.Plugin.DataDir
|
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)
|
manifestBytes, err := proto.Marshal(manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
|
return nil, fmt.Errorf("marshal manifest.pb: %w", err)
|
||||||
|
|||||||
@ -383,16 +383,31 @@ func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
|
|||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.ThemePresets, err = readJSONFile(my.ThemePresets, "theme_presets"); err != nil {
|
// Only overwrite a field when manifest.yaml actually declares it. For a
|
||||||
return err
|
// 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 {
|
if my.BundledFonts != "" {
|
||||||
return err
|
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil {
|
if my.SettingsSchema != "" {
|
||||||
return err
|
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 {
|
if my.CSS != nil {
|
||||||
m.CssManifest = &abiv1.CssManifest{
|
m.CssManifest = &abiv1.CssManifest{
|
||||||
NpmPackages: my.CSS.NpmPackages,
|
NpmPackages: my.CSS.NpmPackages,
|
||||||
|
|||||||
79
cmd/ninja/internal/bnp/mixed_test.go
Normal file
79
cmd/ninja/internal/bnp/mixed_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -97,6 +97,44 @@ loader's checks standalone.
|
|||||||
definitions and re-apply seed. Wasm↔codeless transitions require
|
definitions and re-apply seed. Wasm↔codeless transitions require
|
||||||
uninstall + reinstall.
|
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`)
|
## Seed schema (`seed/seed.json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user