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(" │ 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.
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
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
|
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).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user