core/cmd/ninja/internal/bnp/codeless.go
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

516 lines
17 KiB
Go

package bnp
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
abiv1 "git.dev.alexdunmow.com/block/core/abi/v1"
core "git.dev.alexdunmow.com/block/core/plugin"
"google.golang.org/protobuf/proto"
"gopkg.in/yaml.v3"
)
// Codeless artifacts (WO-WZ-020): a .bnp with NO plugin.wasm — pure
// declaration the host runs. Classification is by repo shape: a plugin repo
// with no Go source builds codeless; a repo with Go builds wasm as always
// (a converted repo DELETES its Go — that's the point). Both artifact kinds
// may carry blocks/, templates/, and seed/; codeless just has nothing else.
// Declarative artifact dirs shared by both build paths.
const (
dirBlocks = "blocks"
dirTemplates = "templates"
dirSeed = "seed"
)
// manifestYAMLName is the optional root-level declarative manifest source for
// codeless plugins (the counterpart of what DESCRIBE captures from Go code).
const manifestYAMLName = "manifest.yaml"
// manifestYAML mirrors the declarative PluginManifest fields a codeless
// plugin can set. File-valued keys are paths relative to the repo root.
type manifestYAML struct {
ThemePresets string `yaml:"theme_presets"` // JSON file
BundledFonts string `yaml:"bundled_fonts"` // JSON file
SettingsSchema string `yaml:"settings_schema"` // JSON file
MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON)
RequiredIconPacks []string `yaml:"required_icon_packs"`
SystemTemplates []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
} `yaml:"system_templates"`
PageTemplates []struct {
System string `yaml:"system"`
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Slots []string `yaml:"slots"`
} `yaml:"page_templates"`
// TemplateOverrides declare theme-scoped overrides of builtin block
// rendering. Source convention: templates/overrides/<template>/<block>.ninjatpl,
// rendered host-side with the block's content map as the template context.
TemplateOverrides []struct {
Template string `yaml:"template"` // template/system key the override binds to
Block string `yaml:"block"` // builtin block key being overridden
} `yaml:"template_overrides"`
// EmailWrappers declare branded email wrappers per system key. Source
// convention: templates/email/<system>.ninjatpl, rendered host-side with
// body/colors/site/unsubscribe_url/preview_text in the context (emit the
// pre-rendered body with |safe).
EmailWrappers []string `yaml:"email_wrappers"`
CSS *struct {
NpmPackages map[string]string `yaml:"npm_packages"`
CSSDirectives []string `yaml:"css_directives"`
InputCSSAppend string `yaml:"input_css_append"`
} `yaml:"css"`
Dependencies []struct {
Plugin string `yaml:"plugin"`
MinVersion string `yaml:"min_version"`
Required bool `yaml:"required"`
} `yaml:"dependencies"`
}
// masterPageJSON mirrors abiv1.MasterPageDefinition for the declarative file.
type masterPageJSON struct {
Key string `json:"key"`
Title string `json:"title"`
PageTemplates []string `json:"page_templates"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
}
// blocksYAML is the structural mirror of the CMS blocks.yaml manifest
// (cms blocks.LoadManifest) — a deliberate light duplication, same as the
// Verify↔reader pairing: the packer validates shape and file presence; full
// semantic validation (schema/template/provider checks) runs at install.
type blocksYAML struct {
Blocks []struct {
Key string `yaml:"key"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Schema string `yaml:"schema"`
Template string `yaml:"template"`
SampleData string `yaml:"sample_data"`
Providers []string `yaml:"providers"`
RequiredTags []string `yaml:"required_tags"`
Aliases []string `yaml:"aliases"`
} `yaml:"blocks"`
}
// seedJSON is the declarative seed schema (seed/seed.json). Applied by the
// host at load via the WO-WZ-019 provisioner (idempotent).
type seedJSON struct {
Settings *struct {
Merge map[string]any `json:"merge"`
Override map[string]any `json:"override"`
Ensure map[string]any `json:"ensure"`
} `json:"settings"`
Media []struct {
ID string `json:"id"` // UUID, required (deterministic media key)
File string `json:"file"`
Alt string `json:"alt"`
Folder string `json:"folder"`
} `json:"media"`
Pages []struct {
Slug string `json:"slug"`
ParentSlug string `json:"parent_slug"`
Title string `json:"title"`
TemplateKey string `json:"template_key"`
ReconcileBlocks bool `json:"reconcile_blocks"`
ReconcileTemplate bool `json:"reconcile_template"`
Blocks []struct {
BlockKey string `json:"block_key"`
Title string `json:"title"`
Content map[string]any `json:"content"`
HTMLContent *string `json:"html_content"`
Slot string `json:"slot"`
SortOrder int32 `json:"sort_order"`
} `json:"blocks"`
} `json:"pages"`
MenuItems []struct {
Menu string `json:"menu"`
Label string `json:"label"`
URL string `json:"url"`
PageSlug string `json:"page_slug"`
SortOrder int32 `json:"sort_order"`
} `json:"menu_items"`
}
// IsCodelessRepo reports whether dir is a declarative plugin repo: it has a
// plugin.mod but no Go source at the root (the wasm main package's home).
func IsCodelessRepo(dir string) (bool, error) {
if _, err := os.Stat(filepath.Join(dir, fileMod)); err != nil {
return false, fmt.Errorf("read plugin.mod (is %s a plugin repo?): %w", dir, err)
}
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
return false, nil
}
}
return true, nil
}
// BuildCodeless synthesizes manifest.pb from plugin.mod + the declarative
// files and packs a codeless .bnp (no plugin.wasm, no DESCRIBE probe).
func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
dir := opts.Dir
if dir == "" {
dir = "."
}
dir, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("resolve dir: %w", err)
}
modPath := filepath.Join(dir, fileMod)
modBytes, err := os.ReadFile(modPath)
if err != nil {
return nil, fmt.Errorf("read plugin.mod: %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return nil, fmt.Errorf("parse plugin.mod: %w", err)
}
if mod.Plugin.Name == "" || mod.Plugin.Version == "" {
return nil, fmt.Errorf("plugin.mod must set both name and version")
}
if mod.Plugin.DataDir {
return nil, fmt.Errorf("codeless plugin cannot request data_dir — there is no code to use it")
}
manifest := &abiv1.PluginManifest{
AbiVersion: hostAbiVersion,
Name: mod.Plugin.Name,
Version: mod.Plugin.Version,
Codeless: true,
}
blockCount, err := validateBlocksDir(dir)
if err != nil {
return nil, err
}
if err := validateSeedDir(dir); err != nil {
return nil, err
}
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)
}
entries := []packEntry{
{ArtifactPath: fileMod, Source: modPath},
{ArtifactPath: fileManifest, Data: manifestBytes},
}
var includedDirs []string
optional := []struct{ artifact, src string }{
{dirBlocks, dirBlocks},
{dirTemplates, dirTemplates},
{dirSeed, dirSeed},
{"migrations", "migrations"},
{"schemas", "schemas"},
{"assets", "assets"},
}
for _, o := range optional {
dirEntries, has, dErr := collectDir(filepath.Join(dir, o.src), o.artifact)
if dErr != nil {
return nil, dErr
}
if has {
entries = append(entries, dirEntries...)
includedDirs = append(includedDirs, o.artifact)
}
}
outPath := opts.Output
if outPath == "" {
outPath = fmt.Sprintf("%s-%s.bnp", mod.Plugin.Name, mod.Plugin.Version)
}
uncomp, err := packArtifact(outPath, entries)
if err != nil {
return nil, err
}
artInfo, err := os.Stat(outPath)
if err != nil {
return nil, fmt.Errorf("stat artifact: %w", err)
}
return &BuildResult{
OutputPath: outPath,
Name: manifest.GetName(),
Version: manifest.GetVersion(),
Codeless: true,
ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp,
BlockCount: blockCount,
IncludedDirs: includedDirs,
}, nil
}
// validateBlocksDir structurally validates blocks/blocks.yaml when present:
// every entry has key/title/schema/template, referenced files exist within
// blocks/, and schemas are valid JSON. Returns the block count (0 when the
// dir is absent).
func validateBlocksDir(dir string) (int, error) {
root := filepath.Join(dir, dirBlocks)
manifestPath := filepath.Join(root, "blocks.yaml")
if _, err := os.Stat(root); os.IsNotExist(err) {
return 0, nil
}
raw, err := os.ReadFile(manifestPath)
if err != nil {
return 0, fmt.Errorf("blocks/ present but blocks.yaml unreadable: %w", err)
}
var bf blocksYAML
if err := yaml.Unmarshal(raw, &bf); err != nil {
return 0, fmt.Errorf("parse blocks/blocks.yaml: %w", err)
}
seen := map[string]bool{}
for i, b := range bf.Blocks {
if b.Key == "" {
return 0, fmt.Errorf("blocks.yaml: entry %d has no key", i)
}
if seen[b.Key] {
return 0, fmt.Errorf("blocks.yaml: duplicate key %q", b.Key)
}
seen[b.Key] = true
if b.Title == "" || b.Schema == "" || b.Template == "" {
return 0, fmt.Errorf("blocks.yaml: %q must declare title, schema, and template", b.Key)
}
for _, f := range []string{b.Schema, b.Template, b.SampleData} {
if f == "" {
continue
}
if err := fileWithin(root, f); err != nil {
return 0, fmt.Errorf("blocks.yaml: %q: %w", b.Key, err)
}
}
schemaRaw, err := os.ReadFile(filepath.Join(root, b.Schema))
if err != nil {
return 0, fmt.Errorf("blocks.yaml: %q schema: %w", b.Key, err)
}
if !json.Valid(schemaRaw) {
return 0, fmt.Errorf("blocks.yaml: %q schema %s is not valid JSON", b.Key, b.Schema)
}
}
return len(bf.Blocks), nil
}
// validateSeedDir validates seed/seed.json when the dir is present: it
// parses, media entries carry an id + an existing file, pages carry slugs.
func validateSeedDir(dir string) error {
root := filepath.Join(dir, dirSeed)
if _, err := os.Stat(root); os.IsNotExist(err) {
return nil
}
raw, err := os.ReadFile(filepath.Join(root, "seed.json"))
if err != nil {
return fmt.Errorf("seed/ present but seed.json unreadable: %w", err)
}
var sf seedJSON
if err := json.Unmarshal(raw, &sf); err != nil {
return fmt.Errorf("parse seed/seed.json: %w", err)
}
for i, m := range sf.Media {
if m.ID == "" || m.File == "" {
return fmt.Errorf("seed.json: media entry %d needs both id and file", i)
}
if err := fileWithin(root, m.File); err != nil {
return fmt.Errorf("seed.json: media %q: %w", m.ID, err)
}
}
for i, p := range sf.Pages {
if p.Slug == "" || p.Title == "" {
return fmt.Errorf("seed.json: page entry %d needs slug and title", i)
}
}
for i, mi := range sf.MenuItems {
if mi.Menu == "" || mi.Label == "" {
return fmt.Errorf("seed.json: menu item %d needs menu and label", i)
}
}
return nil
}
// applyManifestYAML folds the optional root manifest.yaml into the manifest.
func applyManifestYAML(dir string, m *abiv1.PluginManifest) error {
raw, err := os.ReadFile(filepath.Join(dir, manifestYAMLName))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read %s: %w", manifestYAMLName, err)
}
var my manifestYAML
if err := yaml.Unmarshal(raw, &my); err != nil {
return fmt.Errorf("parse %s: %w", manifestYAMLName, err)
}
readJSONFile := func(rel, what string) ([]byte, error) {
if rel == "" {
return nil, nil
}
if err := fileWithin(dir, rel); err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
b, err := os.ReadFile(filepath.Join(dir, rel))
if err != nil {
return nil, fmt.Errorf("%s: %s: %w", manifestYAMLName, what, err)
}
if !json.Valid(b) {
return nil, fmt.Errorf("%s: %s %s is not valid JSON", manifestYAMLName, what, rel)
}
return b, nil
}
// 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 my.BundledFonts != "" {
if m.BundledFonts, err = readJSONFile(my.BundledFonts, "bundled_fonts"); 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
}
if my.CSS != nil {
m.CssManifest = &abiv1.CssManifest{
NpmPackages: my.CSS.NpmPackages,
CssDirectives: my.CSS.CSSDirectives,
InputCssAppend: my.CSS.InputCSSAppend,
}
}
for _, d := range my.Dependencies {
m.Dependencies = append(m.Dependencies, &abiv1.Dependency{
Plugin: d.Plugin, MinVersion: d.MinVersion, Required: d.Required,
})
}
for _, st := range my.SystemTemplates {
if st.Key == "" || st.Title == "" {
return fmt.Errorf("%s: system_templates entries need key and title", manifestYAMLName)
}
m.SystemTemplates = append(m.SystemTemplates, &abiv1.SystemTemplateMeta{
Key: st.Key, Title: st.Title, Description: st.Description,
})
}
for _, pt := range my.PageTemplates {
if pt.System == "" || pt.Key == "" || pt.Title == "" {
return fmt.Errorf("%s: page_templates entries need system, key, and title", manifestYAMLName)
}
// Layout source convention: templates/<system>/<key>.ninjatpl,
// rendered host-side (core/docs/codeless-bnp.md).
rel := filepath.Join(dirTemplates, pt.System, pt.Key+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: page template %s/%s: %w", manifestYAMLName, pt.System, pt.Key, err)
}
m.PageTemplates = append(m.PageTemplates, &abiv1.PageTemplateMeta{
SystemKey: pt.System, Key: pt.Key, Title: pt.Title,
Description: pt.Description, Slots: pt.Slots,
})
}
for _, to := range my.TemplateOverrides {
if to.Template == "" || to.Block == "" {
return fmt.Errorf("%s: template_overrides entries need template and block", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "overrides", to.Template, to.Block+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: template override %s/%s: %w", manifestYAMLName, to.Template, to.Block, err)
}
m.BlockTemplateOverrides = append(m.BlockTemplateOverrides, &abiv1.BlockTemplateOverride{
TemplateKey: to.Template, BlockKey: to.Block,
})
}
for _, ek := range my.EmailWrappers {
if ek == "" {
return fmt.Errorf("%s: email_wrappers entries must be non-empty system keys", manifestYAMLName)
}
rel := filepath.Join(dirTemplates, "email", ek+".ninjatpl")
if err := fileWithin(dir, rel); err != nil {
return fmt.Errorf("%s: email wrapper %s: %w", manifestYAMLName, ek, err)
}
m.EmailWrapperSystemKeys = append(m.EmailWrapperSystemKeys, ek)
}
if my.MasterPages != "" {
mpRaw, err := readJSONFile(my.MasterPages, "master_pages")
if err != nil {
return err
}
var mps []masterPageJSON
if err := json.Unmarshal(mpRaw, &mps); err != nil {
return fmt.Errorf("%s: master_pages %s: %w", manifestYAMLName, my.MasterPages, err)
}
for _, mp := range mps {
def := &abiv1.MasterPageDefinition{
Key: mp.Key, Title: mp.Title, PageTemplates: mp.PageTemplates,
}
for _, b := range mp.Blocks {
contentJSON, err := json.Marshal(b.Content)
if err != nil {
return fmt.Errorf("master page %q block %q: %w", mp.Key, b.BlockKey, err)
}
def.Blocks = append(def.Blocks, &abiv1.MasterPageBlock{
BlockKey: b.BlockKey, Title: b.Title, ContentJson: contentJSON,
HtmlContent: b.HTMLContent, Slot: b.Slot, SortOrder: b.SortOrder,
})
}
m.MasterPages = append(m.MasterPages, def)
}
}
return nil
}
// fileWithin ensures rel names an existing regular file inside root (no
// traversal escapes).
func fileWithin(root, rel string) error {
clean := filepath.Clean(rel)
if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
return fmt.Errorf("path %q escapes the plugin directory", rel)
}
fi, err := os.Stat(filepath.Join(root, clean))
if err != nil {
return fmt.Errorf("referenced file %q: %w", rel, err)
}
if !fi.Mode().IsRegular() {
return fmt.Errorf("referenced path %q is not a regular file", rel)
}
return nil
}