feat(bnp): pack preview.png + screenshots/ into artifacts; multi-page capture

The .bnp packers never carried preview.png (the .so-era source archives did
implicitly), so any republish silently wiped a theme's registry preview.
Both builders now pack an optional root preview.png and a screenshots/ dir
(png/jpg/jpeg/webp, <=8MB each, <=12 files, validated), reported in the
build/verify summaries. ninja theme screenshot gains --pages/--screenshots-dir
to capture NN-<slug>.png per gallery page for the registry gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-05 08:21:35 +08:00
parent feeb31139d
commit e8aba45f21
7 changed files with 424 additions and 44 deletions

View File

@ -98,6 +98,8 @@ func printBuildSummary(r *bnp.BuildResult) {
fmt.Printf(" │ job types %d\n", r.JobTypes) fmt.Printf(" │ job types %d\n", r.JobTypes)
fmt.Printf(" │ hooks %s\n", hooks) fmt.Printf(" │ hooks %s\n", hooks)
fmt.Printf(" │ bundled dirs %s\n", dirs) 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.Printf(" │ data_dir grant %t\n", r.DataDir)
fmt.Println(" └───────────────────────────────────────────") fmt.Println(" └───────────────────────────────────────────")
} }
@ -121,8 +123,8 @@ func printVerifySummary(path string, r *bnp.VerifyResult) {
joined = strings.Join(dirs, ", ") joined = strings.Join(dirs, ", ")
} }
fmt.Printf("OK: %s\n", path) fmt.Printf("OK: %s\n", path)
fmt.Printf(" name=%s version=%s abi_version=%d blocks=%d data_dir=%t dirs=[%s]\n", 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.Name, r.Version, r.ABIVersion, r.BlockCount, r.DataDir, joined, r.HasPreview, r.ScreenshotCount)
} }
// humanBytes formats a byte count with a binary unit suffix. // humanBytes formats a byte count with a binary unit suffix.

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -21,6 +23,7 @@ func newThemeCmd() *cobra.Command {
func newThemeScreenshotCmd() *cobra.Command { func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string var gallery, slug, out, mobileOut, themeName, waitSelector string
var pages, screenshotsDir string
var mobile bool var mobile bool
var width, height int var width, height int
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -92,6 +95,39 @@ the rest of the CLI.`,
} }
fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng)) fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng))
} }
// Multi-page gallery capture (--pages): one PNG per slug into
// --screenshots-dir, named NN-<slug>.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 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().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(&width, "width", 1440, "Desktop viewport width")
cmd.Flags().IntVar(&height, "height", 900, "Desktop viewport height") 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 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()
}

View File

@ -41,9 +41,9 @@ type BuildOptions struct {
// BuildResult summarizes a produced artifact for the CLI summary table. // BuildResult summarizes a produced artifact for the CLI summary table.
type BuildResult struct { type BuildResult struct {
OutputPath string OutputPath string
Name string Name string
Version string Version string
// Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020). // Codeless marks a declarative artifact (no plugin.wasm, WO-WZ-020).
Codeless bool Codeless bool
WasmBytes int64 WasmBytes int64
@ -57,6 +57,11 @@ type BuildResult struct {
Hooks []string Hooks []string
DataDir bool DataDir bool
IncludedDirs []string 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 // 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) 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. // 6. Pack.
outPath := opts.Output outPath := opts.Output
@ -200,20 +213,22 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
wasmInfo, _ := os.Stat(wasmPath) wasmInfo, _ := os.Stat(wasmPath)
return &BuildResult{ return &BuildResult{
OutputPath: outPath, OutputPath: outPath,
Name: manifest.GetName(), Name: manifest.GetName(),
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
WasmBytes: wasmInfo.Size(), WasmBytes: wasmInfo.Size(),
ManifestBytes: int64(len(manifestBytes)), ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(), ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp, UncompBytes: uncomp,
BlockCount: len(manifest.GetBlocks()), BlockCount: len(manifest.GetBlocks()),
TemplateCount: len(manifest.GetTemplateKeys()), TemplateCount: len(manifest.GetTemplateKeys()),
AdminPages: len(manifest.GetAdminPages()), AdminPages: len(manifest.GetAdminPages()),
JobTypes: len(manifest.GetJobTypes()), JobTypes: len(manifest.GetJobTypes()),
Hooks: hooksPresent(manifest), Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(), DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs, IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil }, nil
} }

View File

@ -34,10 +34,10 @@ const manifestYAMLName = "manifest.yaml"
// manifestYAML mirrors the declarative PluginManifest fields a codeless // manifestYAML mirrors the declarative PluginManifest fields a codeless
// plugin can set. File-valued keys are paths relative to the repo root. // plugin can set. File-valued keys are paths relative to the repo root.
type manifestYAML struct { type manifestYAML struct {
ThemePresets string `yaml:"theme_presets"` // JSON file ThemePresets string `yaml:"theme_presets"` // JSON file
BundledFonts string `yaml:"bundled_fonts"` // JSON file BundledFonts string `yaml:"bundled_fonts"` // JSON file
SettingsSchema string `yaml:"settings_schema"` // 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"` RequiredIconPacks []string `yaml:"required_icon_packs"`
SystemTemplates []struct { SystemTemplates []struct {
Key string `yaml:"key"` Key string `yaml:"key"`
@ -240,6 +240,14 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
includedDirs = append(includedDirs, o.artifact) 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 outPath := opts.Output
if outPath == "" { if outPath == "" {
@ -255,15 +263,17 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
} }
return &BuildResult{ return &BuildResult{
OutputPath: outPath, OutputPath: outPath,
Name: manifest.GetName(), Name: manifest.GetName(),
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
Codeless: true, Codeless: true,
ManifestBytes: int64(len(manifestBytes)), ManifestBytes: int64(len(manifestBytes)),
ArtifactBytes: artInfo.Size(), ArtifactBytes: artInfo.Size(),
UncompBytes: uncomp, UncompBytes: uncomp,
BlockCount: blockCount, BlockCount: blockCount,
IncludedDirs: includedDirs, IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil }, nil
} }

121
internal/bnp/screenshots.go Normal file
View File

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

View File

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

View File

@ -47,6 +47,10 @@ type VerifyResult struct {
HasBlocks bool HasBlocks bool
HasTemplates bool HasTemplates bool
HasSeed 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: // 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{ return &VerifyResult{
Name: name, Name: name,
Version: manifest.GetVersion(), Version: manifest.GetVersion(),
ABIVersion: manifest.GetAbiVersion(), ABIVersion: manifest.GetAbiVersion(),
Codeless: manifest.GetCodeless(), Codeless: manifest.GetCodeless(),
DataDir: manifest.GetDataDir(), DataDir: manifest.GetDataDir(),
BlockCount: len(manifest.GetBlocks()), BlockCount: len(manifest.GetBlocks()),
HasMigrations: dirExists(filepath.Join(destDir, "migrations")), HasMigrations: dirExists(filepath.Join(destDir, "migrations")),
HasSchemas: dirExists(filepath.Join(destDir, "schemas")), HasSchemas: dirExists(filepath.Join(destDir, "schemas")),
HasAssets: dirExists(filepath.Join(destDir, "assets")), HasAssets: dirExists(filepath.Join(destDir, "assets")),
HasWeb: dirExists(filepath.Join(destDir, "web")), HasWeb: dirExists(filepath.Join(destDir, "web")),
HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)), HasBlocks: dirExists(filepath.Join(destDir, dirBlocks)),
HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)), HasTemplates: dirExists(filepath.Join(destDir, dirTemplates)),
HasSeed: dirExists(filepath.Join(destDir, dirSeed)), HasSeed: dirExists(filepath.Join(destDir, dirSeed)),
HasPreview: fileRegular(filepath.Join(destDir, filePreview)),
ScreenshotCount: countScreenshots(filepath.Join(destDir, dirScreenshots)),
}, nil }, 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 // CodelessHookViolation returns a named error when a codeless manifest
// declares any capability that requires guest code. Mirrored by the CMS // declares any capability that requires guest code. Mirrored by the CMS
// reader (lockstep duplication, same as the rest of this file). // reader (lockstep duplication, same as the rest of this file).