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(" │ 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.

View File

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

View File

@ -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
@ -214,6 +227,8 @@ func Build(ctx context.Context, opts BuildOptions) (*BuildResult, error) {
Hooks: hooksPresent(manifest),
DataDir: manifest.GetDataDir(),
IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, nil
}

View File

@ -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 == "" {
@ -264,6 +272,8 @@ func BuildCodeless(_ context.Context, opts BuildOptions) (*BuildResult, error) {
UncompBytes: uncomp,
BlockCount: blockCount,
IncludedDirs: includedDirs,
PreviewIncluded: previewIncluded,
ScreenshotCount: screenshotCount,
}, 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
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:
@ -132,9 +136,27 @@ func Verify(bnpPath string) (*VerifyResult, error) {
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).