feat: scaffold ghstats — Go CLI for GitHub profile SVG cards

This commit is contained in:
2026-04-18 18:22:20 +07:00
parent 5597226073
commit d92ef7e324
11 changed files with 379 additions and 1 deletions
+20
View File
@@ -0,0 +1,20 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: "1.26"
cache: true
- run: go mod tidy -diff
- run: go vet ./...
- run: go build ./...
- run: go test ./...
+84 -1
View File
@@ -1 +1,84 @@
# ghstats
# ghstats
> Generate SVG cards summarizing a GitHub user's profile — written in Go.
`ghstats` is a single-binary CLI that fetches public data for a GitHub user and writes a themed set of SVGs (profile details, top languages, stats, productive time) you can embed in your README.
## Status
⚠️ Early work-in-progress. Skeleton only — cards render placeholder SVGs. Roadmap below.
## Install
```sh
go install github.com/tiennm99/ghstats@latest
```
Or build from source:
```sh
git clone https://github.com/tiennm99/ghstats
cd ghstats
go build -o ghstats .
```
## Usage
```sh
export GITHUB_TOKEN=ghp_xxx # PAT with `repo` + `read:user` for private repo stats
ghstats -user tiennm99 -theme dracula -out output
```
Flags:
| Flag | Default | Description |
| -------- | ----------------------- | ------------------------------------------------ |
| `-user` | (required) | GitHub username |
| `-token` | `$GITHUB_TOKEN` | Personal access token |
| `-out` | `output` | Output directory (cards land at `<out>/<theme>`) |
| `-theme` | `dracula` | `dracula`, `default`, `github` |
## Output
```
output/
dracula/
0-profile-details.svg
1-languages.svg
2-stats.svg
3-productive-time.svg
```
Embed in a README:
```md
![profile](./output/dracula/0-profile-details.svg)
![languages](./output/dracula/1-languages.svg)
```
## Roadmap
- [ ] GitHub GraphQL + REST client (`internal/github`)
- [ ] Profile basics, followers, repos
- [ ] Commit histogram for productive time
- [ ] Language bytes aggregation with `linguist-vendored` respect
- [ ] Private repo support via PAT
- [ ] Card renderers (`internal/card`)
- [ ] Profile details
- [ ] Top languages (by bytes + by commit)
- [ ] Stats (stars, commits, PRs, issues, contributed-to)
- [ ] Productive time heatmap
- [ ] Themes (`internal/theme`) — pull the full set from github-readme-stats
- [ ] GitHub Action wrapper for use in profile READMEs
- [ ] Tests + examples
## Credits & inspiration
Standing on the shoulders of these projects:
- [**github-profile-summary-cards**](https://github.com/vn7n24fzkq/github-profile-summary-cards) by [@vn7n24fzkq](https://github.com/vn7n24fzkq) — the card layout, theme set, and output structure are directly inspired by this tool.
- [**profile-summary-for-github**](https://github.com/tipsy/profile-summary-for-github) by [@tipsy](https://github.com/tipsy) — the original web-based profile-summary generator; inspired the breakdowns (repos by language, most-commit language, etc.).
## License
Apache-2.0 — see [LICENSE](LICENSE).
+3
View File
@@ -0,0 +1,3 @@
module github.com/tiennm99/ghstats
go 1.26
+47
View File
@@ -0,0 +1,47 @@
// Package card renders Profile data into SVG cards on disk.
package card
import (
"fmt"
"os"
"path/filepath"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
// Card renders one SVG for a Profile under the given theme.
type Card interface {
// Filename is the on-disk basename (e.g. "0-profile-details.svg").
Filename() string
// SVG returns the rendered SVG bytes.
SVG(p *github.Profile, t theme.Theme) ([]byte, error)
}
// allCards is the ordered list rendered by RenderAll.
// Keep filename prefixes numeric so the output directory lists in a predictable order.
var allCards = []Card{
profileCard{},
languagesCard{},
statsCard{},
productiveCard{},
}
// RenderAll writes every card into outDir/<themeID>/.
func RenderAll(p *github.Profile, t theme.Theme, outDir string) error {
dir := filepath.Join(outDir, t.ID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
for _, c := range allCards {
data, err := c.SVG(p, t)
if err != nil {
return fmt.Errorf("render %s: %w", c.Filename(), err)
}
path := filepath.Join(dir, c.Filename())
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package card
import (
"fmt"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type languagesCard struct{}
func (languagesCard) Filename() string { return "1-languages.svg" }
func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// TODO: render language breakdown from p.Languages.
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="100%%" height="100%%" fill="%s"/>
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Top Languages</text>
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">%d languages tracked</text>
</svg>`, t.Background, t.Title, t.Text, len(p.Languages))
return []byte(svg), nil
}
+23
View File
@@ -0,0 +1,23 @@
package card
import (
"fmt"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type productiveCard struct{}
func (productiveCard) Filename() string { return "3-productive-time.svg" }
func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// TODO: render the [7][24]int heatmap from p.Productive.
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="100%%" height="100%%" fill="%s"/>
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Productive Time</text>
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">Heatmap placeholder</text>
</svg>`, t.Background, t.Title, t.Text)
_ = p
return []byte(svg), nil
}
+22
View File
@@ -0,0 +1,22 @@
package card
import (
"fmt"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type profileCard struct{}
func (profileCard) Filename() string { return "0-profile-details.svg" }
func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// TODO: render a real profile details card.
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="100%%" height="100%%" fill="%s"/>
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="24">%s</text>
<text x="20" y="72" fill="%s" font-family="sans-serif" font-size="14">%s</text>
</svg>`, t.Background, t.Title, p.Login, t.Muted, p.Bio)
return []byte(svg), nil
}
+22
View File
@@ -0,0 +1,22 @@
package card
import (
"fmt"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
type statsCard struct{}
func (statsCard) Filename() string { return "2-stats.svg" }
func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// TODO: totals for stars, commits, PRs, issues, contributed-to repos.
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
<rect width="100%%" height="100%%" fill="%s"/>
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Stats</text>
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">%d public repos · %d followers · %d following</text>
</svg>`, t.Background, t.Title, t.Text, p.PublicRepos, p.Followers, p.Following)
return []byte(svg), nil
}
+42
View File
@@ -0,0 +1,42 @@
// Package github fetches profile data from the GitHub API.
package github
import "errors"
// Profile is the aggregate of data other packages render into cards.
// Fields are stubs; flesh them out as cards are implemented.
type Profile struct {
Login string
Name string
Bio string
Followers int
Following int
PublicRepos int
// Top languages aggregated across repos (name → bytes).
Languages map[string]int64
// Commit-count histogram indexed by [day-of-week][hour-of-day], local tz.
Productive [7][24]int
}
// Client wraps GitHub REST + GraphQL access.
type Client struct {
token string
// TODO: http.Client, rate-limit handling
}
// NewClient returns a client that authenticates with the given PAT.
// Empty token uses unauthenticated access (low rate limit).
func NewClient(token string) *Client {
return &Client{token: token}
}
// Profile loads the profile summary for a user.
func (c *Client) Profile(user string) (*Profile, error) {
if user == "" {
return nil, errors.New("empty user")
}
// TODO: fetch via GraphQL: viewer, user.repositories, contributionsCollection
return &Profile{Login: user}, nil
}
+46
View File
@@ -0,0 +1,46 @@
// Package theme defines SVG color palettes used by card renderers.
package theme
// Theme describes the colors applied to a rendered card.
type Theme struct {
ID string
Background string
Text string
Title string
Accent string
Muted string
}
// Built-in palettes. Add new themes by appending to the map.
var themes = map[string]Theme{
"dracula": {
ID: "dracula",
Background: "#282a36",
Text: "#f8f8f2",
Title: "#ff79c6",
Accent: "#bd93f9",
Muted: "#6272a4",
},
"default": {
ID: "default",
Background: "#ffffff",
Text: "#24292f",
Title: "#0969da",
Accent: "#2188ff",
Muted: "#57606a",
},
"github": {
ID: "github",
Background: "#0d1117",
Text: "#c9d1d9",
Title: "#58a6ff",
Accent: "#3fb950",
Muted: "#8b949e",
},
}
// Lookup returns the theme with the given id.
func Lookup(id string) (Theme, bool) {
t, ok := themes[id]
return t, ok
}
+48
View File
@@ -0,0 +1,48 @@
// ghstats generates SVG cards summarizing a GitHub user's profile.
package main
import (
"flag"
"fmt"
"os"
"github.com/tiennm99/ghstats/internal/card"
"github.com/tiennm99/ghstats/internal/github"
"github.com/tiennm99/ghstats/internal/theme"
)
func main() {
var (
user = flag.String("user", "", "GitHub username (required)")
token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN)")
out = flag.String("out", "output", "output directory for SVG cards")
themeID = flag.String("theme", "dracula", "theme id (dracula, default, github)")
)
flag.Parse()
if *user == "" {
fmt.Fprintln(os.Stderr, "error: -user is required")
flag.Usage()
os.Exit(2)
}
th, ok := theme.Lookup(*themeID)
if !ok {
fmt.Fprintf(os.Stderr, "error: unknown theme %q\n", *themeID)
os.Exit(2)
}
client := github.NewClient(*token)
profile, err := client.Profile(*user)
if err != nil {
fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err)
os.Exit(1)
}
if err := card.RenderAll(profile, th, *out); err != nil {
fmt.Fprintf(os.Stderr, "error: render cards: %v\n", err)
os.Exit(1)
}
fmt.Printf("wrote cards to %s/%s/\n", *out, th.ID)
}