Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ commit-sprout brag # one-line, standup-ready recap of recent growth
commit-sprout prompt # compact glyph for your shell prompt / tmux status
commit-sprout --prompt # same thing, as a flag (handy inside a prompt string)
commit-sprout water # buy a grace day before wilting (weekends / PTO)
commit-sprout card # export the plant as an SVG badge for your README
commit-sprout watch # live full-screen view; the plant gently sways
commit-sprout garden # a row of plants, one per repo (a windowsill)
commit-sprout --species cactus # pick your plant's species (remembered as default)
Expand Down Expand Up @@ -111,6 +112,35 @@ ever faking *progress*:
- **Health only** — grace never changes your growth stage or streak count; it
just keeps a watered plant perky through a planned break.

### README badge (SVG card)

The green-squares contribution graph is culture — `card` gives commit-sprout an
on-brand equivalent. It exports the current plant as a **self-contained SVG**
(no external fonts or assets) you can embed as a living-ish badge:

```bash
commit-sprout card # writes plant.svg in the current directory
commit-sprout card --out docs/plant.svg
commit-sprout card --out - # stream the SVG to stdout (pipe it anywhere)
commit-sprout card --no-color # mono card (single ink color)
```

The card mirrors the terminal view — the same stage, health, streak, and art —
as monospace text plus a caption, so the badge is recognizably the same plant.
Color honors the theme (green / amber / red foliage, with a magenta bloom
accent); `--no-color` (or `NO_COLOR`) yields a mono card. Writes are atomic, so
a crash mid-export never corrupts an existing `plant.svg`. Exporting a card is
read-only: it never updates the remembered plant state.

Embed it in your README:

```markdown
![my commit-sprout](./plant.svg)
```

Regenerate it however you like — a `post-commit` git hook, a nightly cron job,
or a CI step that commits the refreshed `plant.svg`.

### Watch mode (windowsill)

```bash
Expand Down
137 changes: 137 additions & 0 deletions cmd/card.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Card subcommand: export the current plant as a self-contained SVG so it can
// be embedded as a README badge (and, with --out -, streamed to stdout). It
// rides the same read pipeline as every other command, so the SVG shows exactly
// the plant the terminal render would — same stage, health, streak, and pests —
// just as a shareable, screenshot-free artifact.
//
// The green-squares contribution badge is culture; this gives commit-sprout an
// on-brand equivalent: a living-ish plant you can drop in a README.
package cmd

import (
"errors"
"fmt"
"os"
"path/filepath"

"github.com/rwrife/commit-sprout/internal/gitstat"
"github.com/rwrife/commit-sprout/internal/render"
"github.com/spf13/cobra"
)

// cardOut backs the --out flag: the destination SVG path. The special value "-"
// writes to stdout; any other value is a file path (default: plant.svg in cwd).
var cardOut string

// cardCmd implements `commit-sprout card`.
var cardCmd = &cobra.Command{
Use: "card",
Short: "Export the current plant as an embeddable SVG (README badge)",
Long: `Render the current plant to a self-contained SVG file so you can embed a
living-ish plant badge in your README. The card mirrors the terminal view — the
same stage, health, streak, and art — as monospace text plus a caption, with no
external fonts or assets.

By default it writes plant.svg in the current directory (atomically). Use
"--out path.svg" to choose a destination, or "--out -" to stream the SVG to
stdout. Color honors the theme (green / amber / red foliage, magenta bloom);
--no-color yields a mono card.`,
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runCard(cmd)
},
}

// runCard reads the pipeline, renders the plant to SVG, and either streams it to
// stdout (--out -) or writes it atomically to the chosen file. It does not
// persist plant state: exporting a card is a read-only snapshot.
func runCard(cmd *cobra.Command) error {
r, err := runPipeline()
if err != nil {
if errors.Is(err, gitstat.ErrNotARepo) {
return notARepoMessage(cmd)
}
return err
}

svg := render.Card(r.plant, render.CardOptions{
Color: cardUseColor(),
Species: r.species,
Now: r.now,
LastCommit: r.activity.LastCommit,
})

// --out - streams to stdout so the card can be piped or captured inline.
if cardOut == "-" {
_, perr := fmt.Fprint(cmd.OutOrStdout(), svg)
return perr
}

if err := atomicWriteFile(cardOut, []byte(svg)); err != nil {
return fmt.Errorf("card: writing %s: %w", cardOut, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "🪴 Wrote %s\n", cardOut)
return nil
}

// cardUseColor decides whether the SVG takes the themed color path. Unlike the
// terminal render, a card is an artifact (a file or an embed), not a TTY, so
// TTY detection would wrongly strip color. Color is therefore on by default and
// only disabled by --no-color or the NO_COLOR environment variable.
func cardUseColor() bool {
if noColor {
return false
}
if _, ok := os.LookupEnv("NO_COLOR"); ok {
return false
}
return true
}

// atomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a reader never sees a partially written SVG and a
// crash mid-write cannot corrupt an existing card. It mirrors the durability
// approach used by internal/store.Save.
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if dir == "" {
dir = "."
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}

tmp, err := os.CreateTemp(dir, ".commit-sprout-card-*.svg.tmp")
if err != nil {
return fmt.Errorf("creating temp file in %s: %w", dir, err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()

if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("syncing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Chmod(tmpName, 0o644); err != nil {
return fmt.Errorf("setting permissions: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replacing %s: %w", path, err)
}
return nil
}

func init() {
cardCmd.Flags().StringVar(&cardOut, "out", "plant.svg",
`SVG destination path, or "-" for stdout (default: plant.svg)`)
rootCmd.AddCommand(cardCmd)
}
5 changes: 3 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,9 @@ func init() {
rootCmd.SetVersionTemplate("commit-sprout {{.Version}}\n")

// --no-color forces the plain-ASCII render path (also honored via NO_COLOR
// and automatically for non-TTY output).
rootCmd.Flags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)")
// and automatically for non-TTY output). Persistent so subcommands like
// `card` can honor it for a mono export.
rootCmd.PersistentFlags().BoolVar(&noColor, "no-color", false, "disable color output (plain ASCII)")

// --no-save renders without persisting state (read-only run).
rootCmd.PersistentFlags().BoolVar(&noSave, "no-save", false, "do not persist plant state for this run")
Expand Down
200 changes: 200 additions & 0 deletions internal/render/card.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// SVG card rendering: turn the current plant into a self-contained, embeddable
// SVG so people can drop a commit-sprout badge into their README. The card
// mirrors the terminal look — the same per-stage/health ASCII art as monospace
// <tspan> rows plus a stage/streak caption — so the SVG and the shell view are
// recognizably the same plant, just screenshot-free and shareable.
//
// The SVG is dependency-free (no external fonts/assets, no <image>): it uses a
// generic monospace font family and inlines every glyph as text, so it renders
// identically wherever it is embedded (GitHub, a static site, a raw viewer).
package render

import (
"fmt"
"strings"
"time"

"github.com/rwrife/commit-sprout/internal/plant"
"github.com/rwrife/commit-sprout/internal/species"
)

// CardOptions controls how a plant is rendered to an SVG card.
type CardOptions struct {
// Color enables the themed color path: foliage takes the same
// health-driven color used in the terminal render (green / yellow / red,
// with a magenta bloom accent). When false, the whole card is mono
// (a single ink color on a light background), mirroring --no-color.
Color bool

// Species selects which art set to draw. The zero value is the default
// species, matching the terminal render.
Species species.Kind

// Now is the reference time used to phrase the caption's last-commit line
// ("today", "3 days ago"). The zero value falls back to time.Now.
Now time.Time

// LastCommit is the timestamp of the most recent commit, used for the
// caption. The zero value renders as the friendly "no commits yet" nudge.
LastCommit time.Time
}

// SVG glyph metrics. These are chosen so a generic monospace font lays out
// cleanly: cellW/cellH are the advance width and line height in user units, and
// the viewBox is sized from the art/caption dimensions plus padding.
const (
cardCellW = 9.6 // monospace advance width per column
cardCellH = 18.0 // line height per row
cardPadX = 16.0 // left/right padding
cardPadY = 16.0 // top/bottom padding
cardFontPx = 15.0 // art font size
cardCapPx = 12.0 // caption font size
cardCapCellH = 15.0 // caption line height
)

// Card palette (SVG-friendly hex, distinct from the terminal's ANSI indices but
// chosen to read the same: healthy green, thirsty amber, wilting red, bloom
// magenta, dim caption ink).
const (
cardBG = "#fbfdf7"
cardBorder = "#d7e2c8"
cardInk = "#2f3a2a"
cardCaptionInk = "#6b7566"
cardHealthyHex = "#2e7d32"
cardThirstyHex = "#b8860b"
cardWiltingHex = "#c0392b"
cardBloomHex = "#a83fa8"
)

// cardArtColor picks the foliage color for the color path, mirroring
// colorizeArt's health/bloom logic.
func cardArtColor(ps plant.PlantState) string {
switch ps.Health {
case plant.Thirsty:
return cardThirstyHex
case plant.Wilting:
return cardWiltingHex
default:
if ps.Stage == plant.Blooming {
return cardBloomHex
}
return cardHealthyHex
}
}

// Card renders the plant state to a complete, standalone SVG document string.
//
// The art rows mirror the terminal frame's plain art (with pests overlaid), and
// the caption reuses the same stage/streak/last-commit block. Color honors the
// theme when opt.Color is true; otherwise the card is mono. The output is a full
// <?xml?>-prefixed <svg> document suitable for writing to a .svg file or
// embedding via <img>.
func Card(ps plant.PlantState, opt CardOptions) string {
// Reuse the exact art the terminal would draw (plain path + pests) so the
// card and shell views are the same plant.
art := withPests(species.ArtFor(opt.Species, ps.Stage, ps.Health), ps)
artRows := strings.Split(art, "\n")

// Reuse the shared caption block for parity with the terminal render. We
// pass a plain Options so the caption phrasing (last-commit "today"/"N days
// ago", wilt/pest hints, mood) matches exactly.
cap := caption(ps, Options{Now: opt.Now, LastCommit: opt.LastCommit})
capRows := strings.Split(cap, "\n")

// Dimensions: width is the widest of art / caption; height is stacked rows
// plus padding and a small gap between the art and caption blocks.
artCols := maxRuneWidth(artRows)
capCols := maxRuneWidth(capRows)
cols := artCols
if capCols > cols {
cols = capCols
}

artH := float64(len(artRows)) * cardCellH
gap := 10.0
capH := float64(len(capRows)) * cardCapCellH
width := cardPadX*2 + float64(cols)*cardCellW
height := cardPadY*2 + artH + gap + capH

artFill := cardInk
if opt.Color {
artFill = cardArtColor(ps)
}

var b strings.Builder
fmt.Fprintf(&b, `<?xml version="1.0" encoding="UTF-8"?>`+"\n")
fmt.Fprintf(&b,
`<svg xmlns="http://www.w3.org/2000/svg" width="%.0f" height="%.0f" `+
`viewBox="0 0 %.0f %.0f" role="img" aria-label="commit-sprout plant: %s, %s">`+"\n",
width, height, width, height,
svgEscape(ps.Stage.String()), svgEscape(ps.Health.String()))

// Background + subtle border so the badge reads as a card on any page.
fmt.Fprintf(&b,
` <rect x="0.5" y="0.5" width="%.0f" height="%.0f" rx="8" `+
`fill="%s" stroke="%s"/>`+"\n",
width-1, height-1, cardBG, cardBorder)

// Art block: one <text> with a <tspan> per row so columns line up under a
// monospace font. x is reset per row and dy advances the line.
artBaseY := cardPadY + cardFontPx
fmt.Fprintf(&b,
` <text font-family="ui-monospace,'Cascadia Code','DejaVu Sans Mono',Consolas,monospace" `+
`font-size="%.0f" fill="%s" xml:space="preserve">`+"\n",
cardFontPx, artFill)
for i, row := range artRows {
y := artBaseY + float64(i)*cardCellH
fmt.Fprintf(&b, ` <tspan x="%.1f" y="%.1f">%s</tspan>`+"\n",
cardPadX, y, svgEscape(padRow(row)))
}
fmt.Fprintf(&b, " </text>\n")

// Caption block: dimmed ink, smaller font, below the art.
capBaseY := cardPadY + artH + gap + cardCapPx
fmt.Fprintf(&b,
` <text font-family="ui-monospace,'Cascadia Code','DejaVu Sans Mono',Consolas,monospace" `+
`font-size="%.0f" fill="%s" xml:space="preserve">`+"\n",
cardCapPx, cardCaptionInk)
for i, row := range capRows {
y := capBaseY + float64(i)*cardCapCellH
fmt.Fprintf(&b, ` <tspan x="%.1f" y="%.1f">%s</tspan>`+"\n",
cardPadX, y, svgEscape(row))
}
fmt.Fprintf(&b, " </text>\n")

fmt.Fprintf(&b, "</svg>\n")
return b.String()
}

// padRow renders a blank art row as a single space so its <tspan> is non-empty
// (some renderers collapse empty tspans, which would drop the visual line).
func padRow(row string) string {
if row == "" {
return " "
}
return row
}

// maxRuneWidth returns the widest row in runes, used to size the viewBox.
func maxRuneWidth(rows []string) int {
max := 0
for _, r := range rows {
if n := len([]rune(r)); n > max {
max = n
}
}
return max
}

// svgEscape escapes the five XML special characters so arbitrary art/caption
// text (including the '&', '<', '>' that can appear in glyphs) stays valid XML.
func svgEscape(s string) string {
replacer := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
return replacer.Replace(s)
}
Loading
Loading