From c6305d18c2178621ba118debf99ff2e52a9c8a97 Mon Sep 17 00:00:00 2001 From: Alex Dunmow Date: Thu, 25 Jun 2026 17:21:43 +0800 Subject: [PATCH] feat(ninja): theme screenshot harness (chromedp) Co-Authored-By: Claude Opus 4.8 --- cmd/ninja/cmd/root.go | 1 + cmd/ninja/cmd/theme.go | 108 +++++++++++++++++++++++++++ cmd/ninja/cmd/theme_test.go | 36 +++++++++ cmd/ninja/internal/shot/shot.go | 87 +++++++++++++++++++++ cmd/ninja/internal/shot/shot_test.go | 27 +++++++ go.mod | 10 ++- go.sum | 23 +++++- 7 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 cmd/ninja/cmd/theme.go create mode 100644 cmd/ninja/cmd/theme_test.go create mode 100644 cmd/ninja/internal/shot/shot.go create mode 100644 cmd/ninja/internal/shot/shot_test.go diff --git a/cmd/ninja/cmd/root.go b/cmd/ninja/cmd/root.go index d330b0a..14893bc 100644 --- a/cmd/ninja/cmd/root.go +++ b/cmd/ninja/cmd/root.go @@ -14,6 +14,7 @@ func NewRoot() *cobra.Command { root.AddCommand(newLogoutCmd()) root.AddCommand(newWhoamiCmd()) root.AddCommand(newPluginCmd()) + root.AddCommand(newThemeCmd()) root.AddCommand(newScopeCmd()) root.AddCommand(newAccountCmd()) return root diff --git a/cmd/ninja/cmd/theme.go b/cmd/ninja/cmd/theme.go new file mode 100644 index 0000000..408b034 --- /dev/null +++ b/cmd/ninja/cmd/theme.go @@ -0,0 +1,108 @@ +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 +} diff --git a/cmd/ninja/cmd/theme_test.go b/cmd/ninja/cmd/theme_test.go new file mode 100644 index 0000000..8859451 --- /dev/null +++ b/cmd/ninja/cmd/theme_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestThemeScreenshotCommandRegistered(t *testing.T) { + root := NewRoot() + theme, _, err := root.Find([]string{"theme", "screenshot"}) + if err != nil { + t.Fatalf("find theme screenshot: %v", err) + } + if theme.Name() != "screenshot" { + t.Fatalf("resolved command = %q, want screenshot", theme.Name()) + } +} + +func TestThemeScreenshotHasGalleryAndMobileFlags(t *testing.T) { + root := NewRoot() + cmd, _, _ := root.Find([]string{"theme", "screenshot"}) + for _, name := range []string{"gallery", "mobile", "out", "slug"} { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("missing --%s flag", name) + } + } +} + +func TestThemeScreenshotDefaultOutIsPreviewPng(t *testing.T) { + root := NewRoot() + cmd, _, _ := root.Find([]string{"theme", "screenshot"}) + out, _ := cmd.Flags().GetString("out") + if !strings.HasSuffix(out, "preview.png") { + t.Fatalf("default --out = %q, want it to end with preview.png", out) + } +} diff --git a/cmd/ninja/internal/shot/shot.go b/cmd/ninja/internal/shot/shot.go new file mode 100644 index 0000000..dc05d83 --- /dev/null +++ b/cmd/ninja/internal/shot/shot.go @@ -0,0 +1,87 @@ +// Package shot captures a PNG screenshot of a rendered CMS page using a +// headless Chromium (chromedp / CDP). It is the image-capture half of the +// `ninja theme screenshot` harness; dojo's Playwright runner only ingests +// pass/fail JSON, so capture lives here. +package shot + +import ( + "context" + "fmt" + "net/url" + "strings" + "time" + + "github.com/chromedp/chromedp" +) + +// Options configures a single screenshot capture. +type Options struct { + URL string // full page URL to navigate to + Width int // viewport width in px + Height int // viewport height in px + FullPage bool // capture the full scroll height, not just the viewport + WaitSelector string // CSS selector to wait for before capturing (e.g. "section") + Timeout time.Duration // overall navigation+capture timeout +} + +// PreviewURL composes the gallery-page URL with the render-only override. +// host is the gallery site base (scheme+host), slug is the page path ("/"), +// theme is the bare theme key written to ?preview_template. +func PreviewURL(host, slug, theme string) string { + host = strings.TrimRight(host, "/") + if slug == "" { + slug = "/" + } + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + return fmt.Sprintf("%s%s?preview_template=%s", host, slug, url.QueryEscape(theme)) +} + +// Capture navigates to opts.URL in a headless Chromium and returns PNG bytes. +func Capture(ctx context.Context, opts Options) ([]byte, error) { + if opts.Width == 0 { + opts.Width = 1440 + } + if opts.Height == 0 { + opts.Height = 900 + } + if opts.Timeout == 0 { + opts.Timeout = 30 * time.Second + } + + allocCtx, cancelAlloc := chromedp.NewExecAllocator(ctx, + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.WindowSize(opts.Width, opts.Height), + chromedp.Flag("headless", true), + )..., + ) + defer cancelAlloc() + + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx) + defer cancelBrowser() + + timeoutCtx, cancelTimeout := context.WithTimeout(browserCtx, opts.Timeout) + defer cancelTimeout() + + var buf []byte + tasks := chromedp.Tasks{ + chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), + chromedp.Navigate(opts.URL), + } + if opts.WaitSelector != "" { + tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery)) + } else { + tasks = append(tasks, chromedp.WaitReady("body", chromedp.ByQuery)) + } + if opts.FullPage { + tasks = append(tasks, chromedp.FullScreenshot(&buf, 90)) + } else { + tasks = append(tasks, chromedp.CaptureScreenshot(&buf)) + } + + if err := chromedp.Run(timeoutCtx, tasks); err != nil { + return nil, fmt.Errorf("capture %s: %w", opts.URL, err) + } + return buf, nil +} diff --git a/cmd/ninja/internal/shot/shot_test.go b/cmd/ninja/internal/shot/shot_test.go new file mode 100644 index 0000000..eb6ea05 --- /dev/null +++ b/cmd/ninja/internal/shot/shot_test.go @@ -0,0 +1,27 @@ +package shot + +import "testing" + +func TestPreviewURLComposesQuery(t *testing.T) { + got := PreviewURL("https://showcase.localdev.blockninjacms.com", "/", "gotham") + want := "https://showcase.localdev.blockninjacms.com/?preview_template=gotham" + if got != want { + t.Fatalf("PreviewURL = %q, want %q", got, want) + } +} + +func TestPreviewURLTrimsTrailingSlashHost(t *testing.T) { + got := PreviewURL("https://showcase.localdev.blockninjacms.com/", "/", "noir") + want := "https://showcase.localdev.blockninjacms.com/?preview_template=noir" + if got != want { + t.Fatalf("PreviewURL = %q, want %q", got, want) + } +} + +func TestPreviewURLNonRootSlug(t *testing.T) { + got := PreviewURL("https://x.example.com", "/showcase", "lcars") + want := "https://x.example.com/showcase?preview_template=lcars" + if got != want { + t.Fatalf("PreviewURL = %q, want %q", got, want) + } +} diff --git a/go.mod b/go.mod index 30c4263..947cfa3 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,28 @@ require ( connectrpc.com/connect v1.20.0 github.com/BurntSushi/toml v1.6.0 github.com/a-h/templ v0.3.1020 + github.com/chromedp/chromedp v0.15.1 github.com/flosch/pongo2/v6 v6.1.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 + github.com/klauspost/compress v1.18.6 github.com/spf13/cobra v1.10.2 golang.org/x/mod v0.34.0 google.golang.org/protobuf v1.36.11 ) require ( + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/klauspost/compress v1.18.6 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 8883389..9008bac 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,28 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= -github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/flosch/pongo2/v6 v6.1.0 h1:A/NJbrQJJD2B2mbpw3DRFwBYG0xpCr3vwFlEr46y1HQ= github.com/flosch/pongo2/v6 v6.1.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= +github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -32,6 +44,10 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= @@ -51,6 +67,9 @@ golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=