diff --git a/cmd/ninja/cmd/plugin_build.go b/cmd/ninja/cmd/plugin_build.go index b92b7f7..9708c99 100644 --- a/cmd/ninja/cmd/plugin_build.go +++ b/cmd/ninja/cmd/plugin_build.go @@ -98,6 +98,8 @@ func printBuildSummary(r *bnp.BuildResult) { fmt.Printf(" │ job types %d\n", r.JobTypes) fmt.Printf(" │ hooks %s\n", hooks) fmt.Printf(" │ bundled dirs %s\n", dirs) + fmt.Printf(" │ preview.png %t\n", r.PreviewIncluded) + fmt.Printf(" │ screenshots %d\n", r.ScreenshotCount) fmt.Printf(" │ data_dir grant %t\n", r.DataDir) fmt.Println(" └───────────────────────────────────────────") } @@ -121,8 +123,8 @@ func printVerifySummary(path string, r *bnp.VerifyResult) { joined = strings.Join(dirs, ", ") } fmt.Printf("OK: %s\n", path) - fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s]\n", - r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined) + fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s] preview=%t screenshots=%d\n", + r.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined, r.HasPreview, r.ScreenshotCount) } // humanBytes formats a byte count with a binary unit suffix. diff --git a/cmd/ninja/cmd/theme.go b/cmd/ninja/cmd/theme.go index 2ca8e15..874562c 100644 --- a/cmd/ninja/cmd/theme.go +++ b/cmd/ninja/cmd/theme.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os" + "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -21,6 +23,7 @@ func newThemeCmd() *cobra.Command { func newThemeScreenshotCmd() *cobra.Command { var gallery, slug, out, mobileOut, themeName, waitSelector string + var pages, screenshotsDir string var mobile bool var width, height int cmd := &cobra.Command{ @@ -92,6 +95,39 @@ the rest of the CLI.`, } fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng)) } + + // Multi-page gallery capture (--pages): one PNG per slug into + // --screenshots-dir, named NN-.png. These are the source + // files the .bnp packer ships under screenshots/ and the registry + // ingests into the plugin gallery at publish. + if pages != "" { + if err := os.MkdirAll(screenshotsDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", screenshotsDir, err) + } + for i, pageSlug := range strings.Split(pages, ",") { + pageSlug = strings.TrimSpace(pageSlug) + if pageSlug == "" { + continue + } + pURL := shot.PreviewURL(gallery, pageSlug, themeName) + fmt.Fprintf(os.Stderr, "capturing page %s: %s\n", pageSlug, pURL) + ppng, err := shot.Capture(ctx, shot.Options{ + URL: pURL, + Width: width, + Height: height, + WaitSelector: waitSelector, + Timeout: 45 * time.Second, + }) + if err != nil { + return err + } + dest := filepath.Join(screenshotsDir, fmt.Sprintf("%02d-%s.png", i+1, slugFileName(pageSlug))) + if err := os.WriteFile(dest, ppng, 0o644); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + fmt.Printf("wrote %s (%d bytes)\n", dest, len(ppng)) + } + } return nil }, } @@ -104,5 +140,27 @@ the rest of the CLI.`, cmd.Flags().BoolVar(&mobile, "mobile", false, "Also capture preview-mobile.png at a phone viewport") cmd.Flags().IntVar(&width, "width", 1440, "Desktop viewport width") cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height") + cmd.Flags().StringVar(&pages, "pages", "", "Comma-separated page slugs to capture into --screenshots-dir (e.g. /,/blog,/about)") + cmd.Flags().StringVar(&screenshotsDir, "screenshots-dir", "screenshots", "Directory for --pages captures (packed into the .bnp)") return cmd } + +// slugFileName turns a page slug into a filename fragment ("/" → "home", +// "/blog/post" → "blog-post"). +func slugFileName(slug string) string { + s := strings.Trim(slug, "/") + if s == "" { + return "home" + } + s = strings.ReplaceAll(s, "/", "-") + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} diff --git a/internal/bnp/build.go b/internal/bnp/build.go index 1db9b45..0970207 100644 --- a/internal/bnp/build.go +++ b/internal/bnp/build.go @@ -41,9 +41,9 @@ type BuildOptions struct { // BuildResult summarizes a produced artifact for the CLI summary table. type BuildResult struct { - OutputPath string - Name string - Version string + OutputPath string + Name string + Version string // Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020). Codeless bool WasmBytes int64 @@ -57,6 +57,11 @@ type BuildResult struct { Hooks []string DataDir bool IncludedDirs []string + // PreviewIncluded/ScreenshotCount report the packed presentation members + // (root preview.png + screenshots/); the registry ingests these into the + // plugin's gallery at publish. + PreviewIncluded bool + ScreenshotCount int } // Build compiles the plugin in opts.Dir to wasm, extracts its manifest, and @@ -182,6 +187,14 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { includedDirs = append(includedDirs, o.artifact) } } + presEntries, previewIncluded, screenshotCount, err := presentationEntries(dir) + if err != nil { + return nil, err + } + entries = append(entries, presEntries...) + if screenshotCount > 0 { + includedDirs = append(includedDirs, dirScreenshots) + } // 6. Pack. outPath := opts.Output @@ -200,20 +213,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) { wasmInfo, _ := os.Stat(wasmPath) return &BuildResult{ - OutputPath: outPath, - Name: manifest.GetName(), - Version: manifest.GetVersion(), - WasmBytes: wasmInfo.Size(), - ManifestBytes: int64(len(manifestBytes)), - ArtifactBytes: artInfo.Size(), - UncompBytes: uncomp, - BlockCount: len(manifest.GetBlocks()), - TemplateCount: len(manifest.GetTemplateKeys()), - AdminPages: len(manifest.GetAdminPages()), - JobTypes: len(manifest.GetJobTypes()), - Hooks: hooksPresent(manifest), - DataDir: manifest.GetDataDir(), - IncludedDirs: includedDirs, + OutputPath: outPath, + Name: manifest.GetName(), + Version: manifest.GetVersion(), + WasmBytes: wasmInfo.Size(), + ManifestBytes: int64(len(manifestBytes)), + ArtifactBytes: artInfo.Size(), + UncompBytes: uncomp, + BlockCount: len(manifest.GetBlocks()), + TemplateCount: len(manifest.GetTemplateKeys()), + AdminPages: len(manifest.GetAdminPages()), + JobTypes: len(manifest.GetJobTypes()), + Hooks: hooksPresent(manifest), + DataDir: manifest.GetDataDir(), + IncludedDirs: includedDirs, + PreviewIncluded: previewIncluded, + ScreenshotCount: screenshotCount, }, nil } diff --git a/internal/bnp/codeless.go b/internal/bnp/codeless.go index 31ba90c..e715f16 100644 --- a/internal/bnp/codeless.go +++ b/internal/bnp/codeless.go @@ -34,10 +34,10 @@ 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 + 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) + MasterPages string `yaml:"master_pages"` // JSON file ([]masterPageJSON) RequiredIconPacks []string `yaml:"required_icon_packs"` SystemTemplates []struct { Key string `yaml:"key"` @@ -240,6 +240,14 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) { includedDirs = append(includedDirs, o.artifact) } } + presEntries, previewIncluded, screenshotCount, err := presentationEntries(dir) + if err != nil { + return nil, err + } + entries = append(entries, presEntries...) + if screenshotCount > 0 { + includedDirs = append(includedDirs, dirScreenshots) + } outPath := opts.Output if outPath == "" { @@ -255,15 +263,17 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) { } 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, + OutputPath: outPath, + Name: manifest.GetName(), + Version: manifest.GetVersion(), + Codeless: true, + ManifestBytes: int64(len(manifestBytes)), + ArtifactBytes: artInfo.Size(), + UncompBytes: uncomp, + BlockCount: blockCount, + IncludedDirs: includedDirs, + PreviewIncluded: previewIncluded, + ScreenshotCount: screenshotCount, }, nil } diff --git a/internal/bnp/screenshots.go b/internal/bnp/screenshots.go new file mode 100644 index 0000000..a68884f --- /dev/null +++ b/internal/bnp/screenshots.go @@ -0,0 +1,121 @@ +package bnp + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Presentation members: a root preview.png plus an optional screenshots/ dir. +// Neither ships behavior — the registry ingests them at publish into the +// plugin's managed gallery (spec 2026-07-05-registry-presentation-reviews). +// The .so-era source archives carried preview.png implicitly; the .bnp +// packers dropped it, which silently wiped previews on republish — these +// entries restore that and add the multi-screenshot surface. +const ( + filePreview = "preview.png" + dirScreenshots = "screenshots" + // maxScreenshotBytes caps one presentation image (registry gallery cap). + maxScreenshotBytes = 8 << 20 + // maxScreenshotFiles caps the screenshots/ dir (registry gallery cap). + maxScreenshotFiles = 12 +) + +// screenshotExtOK reports whether a filename carries an accepted gallery +// image extension. +func screenshotExtOK(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".png", ".jpg", ".jpeg", ".webp": + return true + } + return false +} + +// validateScreenshotsDir checks the optional screenshots/ dir: accepted +// extensions only, ≤ maxScreenshotBytes per file, ≤ maxScreenshotFiles total, +// no subdirectories or irregular files. Returns the file count (0 when the +// dir is absent). +func validateScreenshotsDir(dir string) (int, error) { + root := filepath.Join(dir, dirScreenshots) + info, err := os.Stat(root) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("stat %s/: %w", dirScreenshots, err) + } + if !info.IsDir() { + return 0, fmt.Errorf("%s exists but is not a directory", dirScreenshots) + } + entries, err := os.ReadDir(root) + if err != nil { + return 0, fmt.Errorf("read %s/: %w", dirScreenshots, err) + } + count := 0 + for _, e := range entries { + if e.IsDir() { + return 0, fmt.Errorf("%s/%s: subdirectories are not allowed in screenshots/", dirScreenshots, e.Name()) + } + fi, err := e.Info() + if err != nil { + return 0, fmt.Errorf("%s/%s: %w", dirScreenshots, e.Name(), err) + } + if !fi.Mode().IsRegular() { + return 0, fmt.Errorf("%s/%s: not a regular file", dirScreenshots, e.Name()) + } + if !screenshotExtOK(e.Name()) { + return 0, fmt.Errorf("%s/%s: unsupported image type (allowed: .png .jpg .jpeg .webp)", dirScreenshots, e.Name()) + } + if fi.Size() > maxScreenshotBytes { + return 0, fmt.Errorf("%s/%s: %d bytes exceeds the %d MB per-image cap", dirScreenshots, e.Name(), fi.Size(), maxScreenshotBytes>>20) + } + count++ + } + if count > maxScreenshotFiles { + return 0, fmt.Errorf("%s/ holds %d images; the gallery cap is %d", dirScreenshots, count, maxScreenshotFiles) + } + return count, nil +} + +// presentationEntries validates and returns pack entries for the optional +// root preview.png and screenshots/ dir. Both are optional; absence is not an +// error. +func presentationEntries(dir string) (entries []packEntry, previewIncluded bool, screenshotCount int, err error) { + previewPath := filepath.Join(dir, filePreview) + if fi, statErr := os.Stat(previewPath); statErr == nil { + if !fi.Mode().IsRegular() { + return nil, false, 0, fmt.Errorf("%s is not a regular file", filePreview) + } + if fi.Size() > maxScreenshotBytes { + return nil, false, 0, fmt.Errorf("%s: %d bytes exceeds the %d MB per-image cap", filePreview, fi.Size(), maxScreenshotBytes>>20) + } + entries = append(entries, packEntry{ArtifactPath: filePreview, Source: previewPath}) + previewIncluded = true + } + + screenshotCount, err = validateScreenshotsDir(dir) + if err != nil { + return nil, false, 0, err + } + if screenshotCount > 0 { + root := filepath.Join(dir, dirScreenshots) + files, readErr := os.ReadDir(root) + if readErr != nil { + return nil, false, 0, fmt.Errorf("read %s/: %w", dirScreenshots, readErr) + } + names := make([]string, 0, len(files)) + for _, f := range files { + names = append(names, f.Name()) + } + sort.Strings(names) + for _, name := range names { + entries = append(entries, packEntry{ + ArtifactPath: dirScreenshots + "/" + name, + Source: filepath.Join(root, name), + }) + } + } + return entries, previewIncluded, screenshotCount, nil +} diff --git a/internal/bnp/screenshots_test.go b/internal/bnp/screenshots_test.go new file mode 100644 index 0000000..9c4a21c --- /dev/null +++ b/internal/bnp/screenshots_test.go @@ -0,0 +1,152 @@ +package bnp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// presentationFixture extends the codeless fixture with a root preview.png +// and a screenshots/ dir. +func presentationFixture(t *testing.T) string { + t.Helper() + dir := codelessFixture(t) + writeFixture(t, dir, map[string]string{ + "preview.png": "not-really-png-but-bytes", + "screenshots/01-home.png": "home-bytes", + "screenshots/02-blog.jpg": "blog-bytes", + "screenshots/03-page.webp": "page-bytes", + }) + return dir +} + +func TestCodelessBuildPacksPresentationMembers(t *testing.T) { + dir := presentationFixture(t) + + out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp") + res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out}) + if err != nil { + t.Fatalf("BuildCodeless: %v", err) + } + if !res.PreviewIncluded || res.ScreenshotCount != 3 { + t.Errorf("result preview=%t screenshots=%d; want true/3", res.PreviewIncluded, res.ScreenshotCount) + } + found := false + for _, d := range res.IncludedDirs { + if d == dirScreenshots { + found = true + } + } + if !found { + t.Errorf("IncludedDirs %v missing %q", res.IncludedDirs, dirScreenshots) + } + + v, err := Verify(out) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if !v.HasPreview || v.ScreenshotCount != 3 { + t.Errorf("verify preview=%t screenshots=%d; want true/3", v.HasPreview, v.ScreenshotCount) + } +} + +func TestCodelessBuildWithoutPresentationMembers(t *testing.T) { + dir := codelessFixture(t) + out := filepath.Join(t.TempDir(), "fixture-theme-0.1.0.bnp") + res, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out}) + if err != nil { + t.Fatalf("BuildCodeless: %v", err) + } + if res.PreviewIncluded || res.ScreenshotCount != 0 { + t.Errorf("result preview=%t screenshots=%d; want false/0", res.PreviewIncluded, res.ScreenshotCount) + } + v, err := Verify(out) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if v.HasPreview || v.ScreenshotCount != 0 { + t.Errorf("verify preview=%t screenshots=%d; want false/0", v.HasPreview, v.ScreenshotCount) + } +} + +func TestValidateScreenshotsDirRejections(t *testing.T) { + build := func(t *testing.T, mutate func(dir string)) error { + t.Helper() + dir := codelessFixture(t) + mutate(dir) + out := filepath.Join(t.TempDir(), "out.bnp") + _, err := BuildCodeless(context.Background(), BuildOptions{Dir: dir, Output: out}) + return err + } + + t.Run("bad extension", func(t *testing.T) { + err := build(t, func(dir string) { + writeFixture(t, dir, map[string]string{"screenshots/readme.txt": "nope"}) + }) + if err == nil || !strings.Contains(err.Error(), "unsupported image type") { + t.Fatalf("err = %v; want unsupported image type", err) + } + }) + + t.Run("subdirectory", func(t *testing.T) { + err := build(t, func(dir string) { + writeFixture(t, dir, map[string]string{"screenshots/sub/x.png": "nope"}) + }) + if err == nil || !strings.Contains(err.Error(), "subdirectories are not allowed") { + t.Fatalf("err = %v; want subdirectory rejection", err) + } + }) + + t.Run("too many", func(t *testing.T) { + err := build(t, func(dir string) { + files := map[string]string{} + for i := range maxScreenshotFiles + 1 { + files[fmt.Sprintf("screenshots/%02d.png", i)] = "x" + } + writeFixture(t, dir, files) + }) + if err == nil || !strings.Contains(err.Error(), "gallery cap") { + t.Fatalf("err = %v; want gallery cap rejection", err) + } + }) + + t.Run("oversized image", func(t *testing.T) { + err := build(t, func(dir string) { + big := filepath.Join(dir, dirScreenshots, "big.png") + if mkErr := os.MkdirAll(filepath.Dir(big), 0o755); mkErr != nil { + t.Fatal(mkErr) + } + f, cErr := os.Create(big) + if cErr != nil { + t.Fatal(cErr) + } + if tErr := f.Truncate(maxScreenshotBytes + 1); tErr != nil { + t.Fatal(tErr) + } + _ = f.Close() + }) + if err == nil || !strings.Contains(err.Error(), "per-image cap") { + t.Fatalf("err = %v; want per-image cap rejection", err) + } + }) + + t.Run("oversized preview", func(t *testing.T) { + err := build(t, func(dir string) { + big := filepath.Join(dir, filePreview) + f, cErr := os.Create(big) + if cErr != nil { + t.Fatal(cErr) + } + if tErr := f.Truncate(maxScreenshotBytes + 1); tErr != nil { + t.Fatal(tErr) + } + _ = f.Close() + }) + if err == nil || !strings.Contains(err.Error(), "per-image cap") { + t.Fatalf("err = %v; want per-image cap rejection", err) + } + }) +} diff --git a/internal/bnp/verify.go b/internal/bnp/verify.go index ccbd4bb..8bfc38f 100644 --- a/internal/bnp/verify.go +++ b/internal/bnp/verify.go @@ -47,6 +47,10 @@ type VerifyResult struct { HasBlocks bool HasTemplates bool HasSeed bool + // HasPreview/ScreenshotCount report the presentation members the registry + // ingests into the plugin gallery at publish. + HasPreview bool + ScreenshotCount int } // Verify extracts bnpPath into a temp dir and applies the reader's checks: @@ -119,22 +123,40 @@ func Verify(bnpPath string) (*VerifyResult, error) { } return &VerifyResult{ - Name: name, - Version: manifest.GetVersion(), - ABIVersion: manifest.GetAbiVersion(), - Codeless: manifest.GetCodeless(), - DataDir: manifest.GetDataDir(), - BlockCount: len(manifest.GetBlocks()), - HasMigrations: dirExists(filepath.Join(destDir, "migrations")), - HasSchemas: dirExists(filepath.Join(destDir, "schemas")), - HasAssets: dirExists(filepath.Join(destDir, "assets")), - HasWeb: dirExists(filepath.Join(destDir, "web")), - HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)), - HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)), - HasSeed: dirExists(filepath.Join(destDir, dirSeed)), + Name: name, + Version: manifest.GetVersion(), + ABIVersion: manifest.GetAbiVersion(), + Codeless: manifest.GetCodeless(), + DataDir: manifest.GetDataDir(), + BlockCount: len(manifest.GetBlocks()), + HasMigrations: dirExists(filepath.Join(destDir, "migrations")), + HasSchemas: dirExists(filepath.Join(destDir, "schemas")), + HasAssets: dirExists(filepath.Join(destDir, "assets")), + HasWeb: dirExists(filepath.Join(destDir, "web")), + HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)), + HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)), + HasSeed: dirExists(filepath.Join(destDir, dirSeed)), + HasPreview: fileRegular(filepath.Join(destDir, filePreview)), + ScreenshotCount: countScreenshots(filepath.Join(destDir, dirScreenshots)), }, nil } +// countScreenshots counts accepted image files directly under an extracted +// screenshots/ dir (0 when absent). +func countScreenshots(root string) int { + entries, err := os.ReadDir(root) + if err != nil { + return 0 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && screenshotExtOK(e.Name()) { + n++ + } + } + return n +} + // CodelessHookViolation returns a named error when a codeless manifest // declares any capability that requires guest code. Mirrored by the CMS // reader (lockstep duplication, same as the rest of this file).