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:
parent
feeb31139d
commit
e8aba45f21
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
121
internal/bnp/screenshots.go
Normal 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
|
||||
}
|
||||
152
internal/bnp/screenshots_test.go
Normal file
152
internal/bnp/screenshots_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user