core/cmd/ninja/cmd/theme.go
Alex Dunmow c6305d18c2 feat(ninja): theme screenshot harness (chromedp)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:21:43 +08:00

109 lines
3.9 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
core "git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/core/cmd/ninja/internal/shot"
)
func newThemeCmd() *cobra.Command {
c := &cobra.Command{Use: "theme", Short: "Theme authoring helpers (preview screenshots)"}
c.AddCommand(newThemeScreenshotCmd())
return c
}
func newThemeScreenshotCmd() *cobra.Command {
var gallery, slug, out, mobileOut, themeName, waitSelector string
var mobile bool
var width, height int
cmd := &cobra.Command{
Use: "screenshot",
Short: "Render this theme's showcase page and write preview.png into the repo",
Long: `screenshot builds the gallery URL for this theme's showcase page
(rendered via the CMS render-only ?preview_template override), drives a headless
Chromium against it, and writes the captured PNG into the theme repo (preview.png,
git-tracked — the source of truth a human may later replace).
The theme name defaults to plugin.mod's name in the current directory. Point
--gallery at the gallery CMS site that has this theme's .so loaded and the
showcase content seeded. --host selects the orchestrator (defaults to PROD); the
gallery URL is independent of --host but the flag is accepted for parity with
the rest of the CLI.`,
RunE: func(c *cobra.Command, _ []string) error {
if themeName == "" {
modBytes, err := os.ReadFile("plugin.mod")
if err != nil {
return fmt.Errorf("read plugin.mod (run from the theme repo, or pass --theme): %w", err)
}
mod, err := core.ParseModFull(modBytes)
if err != nil {
return err
}
if mod.Plugin.Kind != "theme" {
return fmt.Errorf("plugin.mod kind = %q, want theme", mod.Plugin.Kind)
}
themeName = mod.Plugin.Name
}
if gallery == "" {
return fmt.Errorf("--gallery is required (the gallery CMS site base, e.g. https://showcase.localdev.blockninjacms.com)")
}
ctx := context.Background()
desktopURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing desktop: %s\n", desktopURL)
png, err := shot.Capture(ctx, shot.Options{
URL: desktopURL,
Width: width,
Height: height,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(out, png, 0o644); err != nil {
return fmt.Errorf("write %s: %w", out, err)
}
fmt.Printf("wrote %s (%d bytes)\n", out, len(png))
if mobile {
mURL := shot.PreviewURL(gallery, slug, themeName)
fmt.Fprintf(os.Stderr, "capturing mobile: %s\n", mURL)
mpng, err := shot.Capture(ctx, shot.Options{
URL: mURL,
Width: 390,
Height: 844,
WaitSelector: waitSelector,
Timeout: 45 * time.Second,
})
if err != nil {
return err
}
if err := os.WriteFile(mobileOut, mpng, 0o644); err != nil {
return fmt.Errorf("write %s: %w", mobileOut, err)
}
fmt.Printf("wrote %s (%d bytes)\n", mobileOut, len(mpng))
}
return nil
},
}
cmd.Flags().StringVar(&gallery, "gallery", "", "Gallery CMS site base URL (has this theme loaded + showcase seeded)")
cmd.Flags().StringVar(&slug, "slug", "/", "Showcase page slug to capture")
cmd.Flags().StringVar(&out, "out", "preview.png", "Output path for the desktop screenshot (git-tracked in the theme repo)")
cmd.Flags().StringVar(&mobileOut, "mobile-out", "preview-mobile.png", "Output path for the mobile screenshot")
cmd.Flags().StringVar(&themeName, "theme", "", "Theme key to preview (default: plugin.mod name)")
cmd.Flags().StringVar(&waitSelector, "wait", "section", "CSS selector to wait for before capturing")
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")
return cmd
}