diff --git a/cmd/ninja/cmd/theme.go b/cmd/ninja/cmd/theme.go index 69ad85c..e1e143c 100644 --- a/cmd/ninja/cmd/theme.go +++ b/cmd/ninja/cmd/theme.go @@ -23,7 +23,7 @@ func newThemeCmd() *cobra.Command { func newThemeScreenshotCmd() *cobra.Command { var gallery, slug, out, mobileOut, themeName, waitSelector string - var pages, screenshotsDir string + var pages, screenshotsDir, modes string var mobile bool var width, height int cmd := &cobra.Command{ @@ -101,31 +101,44 @@ the rest of the CLI.`, // files the .bnp packer ships under screenshots/ and the registry // ingests into the plugin gallery at publish. if pages != "" { + modeList, err := parseModes(modes) + if err != nil { + return err + } if err := os.MkdirAll(screenshotsDir, 0o755); err != nil { 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) 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 + for _, mode := range modeList { + pURL := shot.PreviewURL(gallery, pageSlug, themeName) + fmt.Fprintf(os.Stderr, "capturing page %s (%s): %s\n", pageSlug, modeLabel(mode), pURL) + ppng, err := shot.Capture(ctx, shot.Options{ + URL: pURL, + Width: width, + Height: height, + WaitSelector: waitSelector, + Timeout: 45 * time.Second, + Mode: mode, + }) + 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 @@ -142,9 +155,34 @@ the rest of the CLI.`, 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)") + 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 } +// 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", // "/blog/post" → "blog-post"). func slugFileName(slug string) string { diff --git a/internal/shot/shot.go b/internal/shot/shot.go index dc05d83..1d01b6d 100644 --- a/internal/shot/shot.go +++ b/internal/shot/shot.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/chromedp/cdproto/emulation" + "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) @@ -22,6 +24,7 @@ type Options struct { 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 + 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. @@ -67,8 +70,25 @@ func Capture(ctx context.Context, opts Options) ([]byte, error) { var buf []byte tasks := chromedp.Tasks{ 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 != "" { tasks = append(tasks, chromedp.WaitVisible(opts.WaitSelector, chromedp.ByQuery)) } else {