cli/internal/bnp/screenshots.go
Alex Dunmow e8aba45f21 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>
2026-07-05 08:21:35 +08:00

122 lines
4.0 KiB
Go

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
}