feat(theme): --modes light,dark for --pages gallery captures

Sets the bn-theme cookie + emulated prefers-color-scheme before navigation
so each page can be captured in both color modes (Wave B screenshot sets).
Mode-suffixed filenames (NN-<slug>-<mode>.png); empty --modes keeps the
old single site-default capture and naming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-06 08:53:49 +08:00
parent ddf2dd59ef
commit dda752bfc1
2 changed files with 77 additions and 19 deletions

View File

@ -23,7 +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 pages, screenshotsDir, modes string
var mobile bool var mobile bool
var width, height int var width, height int
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -101,31 +101,44 @@ the rest of the CLI.`,
// files the .bnp packer ships under screenshots/ and the registry // files the .bnp packer ships under screenshots/ and the registry
// ingests into the plugin gallery at publish. // ingests into the plugin gallery at publish.
if pages != "" { if pages != "" {
modeList, err := parseModes(modes)
if err != nil {
return err
}
if err := os.MkdirAll(screenshotsDir, 0o755); err != nil { if err := os.MkdirAll(screenshotsDir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", screenshotsDir, err) return fmt.Errorf("create %s: %w", screenshotsDir, err)
} }
for i, pageSlug := range strings.Split(pages, ",") { n := 0
for _, pageSlug := range strings.Split(pages, ",") {
pageSlug = strings.TrimSpace(pageSlug) pageSlug = strings.TrimSpace(pageSlug)
if pageSlug == "" { if pageSlug == "" {
continue continue
} }
pURL := shot.PreviewURL(gallery, pageSlug, themeName) for _, mode := range modeList {
fmt.Fprintf(os.Stderr, "capturing page %s: %s\n", pageSlug, pURL) pURL := shot.PreviewURL(gallery, pageSlug, themeName)
ppng, err := shot.Capture(ctx, shot.Options{ fmt.Fprintf(os.Stderr, "capturing page %s (%s): %s\n", pageSlug, modeLabel(mode), pURL)
URL: pURL, ppng, err := shot.Capture(ctx, shot.Options{
Width: width, URL: pURL,
Height: height, Width: width,
WaitSelector: waitSelector, Height: height,
Timeout: 45 * time.Second, WaitSelector: waitSelector,
}) Timeout: 45 * time.Second,
if err != nil { Mode: mode,
return err })
if err != nil {
return err
}
n++
name := fmt.Sprintf("%02d-%s.png", n, slugFileName(pageSlug))
if mode != "" {
name = fmt.Sprintf("%02d-%s-%s.png", n, slugFileName(pageSlug), mode)
}
dest := filepath.Join(screenshotsDir, name)
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))
} }
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
@ -142,9 +155,34 @@ the rest of the CLI.`,
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(&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)") cmd.Flags().StringVar(&screenshotsDir, "screenshots-dir", "screenshots", "Directory for --pages captures (packed into the .bnp)")
cmd.Flags().StringVar(&modes, "modes", "", `Comma-separated color modes for --pages captures: "light", "dark" or "light,dark" (sets the bn-theme cookie per capture; empty = site default, unsuffixed filenames)`)
return cmd return cmd
} }
// parseModes validates the --modes flag: empty means one site-default capture
// (mode ""), otherwise each entry must be "light" or "dark".
func parseModes(modes string) ([]string, error) {
if strings.TrimSpace(modes) == "" {
return []string{""}, nil
}
var out []string
for _, m := range strings.Split(modes, ",") {
m = strings.TrimSpace(m)
if m != "light" && m != "dark" {
return nil, fmt.Errorf("--modes: %q is not a valid mode (want light or dark)", m)
}
out = append(out, m)
}
return out, nil
}
func modeLabel(mode string) string {
if mode == "" {
return "default"
}
return mode
}
// slugFileName turns a page slug into a filename fragment ("/" → "home", // slugFileName turns a page slug into a filename fragment ("/" → "home",
// "/blog/post" → "blog-post"). // "/blog/post" → "blog-post").
func slugFileName(slug string) string { func slugFileName(slug string) string {

View File

@ -11,6 +11,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
) )
@ -22,6 +24,7 @@ type Options struct {
FullPage bool // capture the full scroll height, not just the viewport FullPage bool // capture the full scroll height, not just the viewport
WaitSelector string // CSS selector to wait for before capturing (e.g. "section") WaitSelector string // CSS selector to wait for before capturing (e.g. "section")
Timeout time.Duration // overall navigation+capture timeout Timeout time.Duration // overall navigation+capture timeout
Mode string // "" (site default), "light" or "dark" — sets the bn-theme cookie + emulated prefers-color-scheme before navigation
} }
// PreviewURL composes the gallery-page URL with the render-only override. // PreviewURL composes the gallery-page URL with the render-only override.
@ -67,8 +70,25 @@ func Capture(ctx context.Context, opts Options) ([]byte, error) {
var buf []byte var buf []byte
tasks := chromedp.Tasks{ tasks := chromedp.Tasks{
chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)), chromedp.EmulateViewport(int64(opts.Width), int64(opts.Height)),
chromedp.Navigate(opts.URL),
} }
if opts.Mode == "light" || opts.Mode == "dark" {
u, err := url.Parse(opts.URL)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", opts.URL, err)
}
host := u.Hostname()
mode := opts.Mode
tasks = append(tasks,
chromedp.ActionFunc(func(ctx context.Context) error {
return network.SetCookie("bn-theme", mode).
WithDomain(host).WithPath("/").Do(ctx)
}),
emulation.SetEmulatedMedia().WithFeatures([]*emulation.MediaFeature{
{Name: "prefers-color-scheme", Value: mode},
}),
)
}
tasks = append(tasks, chromedp.Navigate(opts.URL))
if opts.WaitSelector != "" { if opts.WaitSelector != "" {
tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery)) tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery))
} else { } else {