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"` 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 } 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 m.SettingsSchema, err = readJSONFile(my.SettingsSchema, "settings_schema"); err != nil { return err } 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, }) } 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 }