diff --git a/.github/workflows/generate-sponsor-image.yml b/.github/workflows/generate-sponsor-image.yml
index 26c3f0a9a46..e634cd64cfc 100644
--- a/.github/workflows/generate-sponsor-image.yml
+++ b/.github/workflows/generate-sponsor-image.yml
@@ -1,4 +1,4 @@
-name: Generate Sponsor Image
+name: Generate Sponsor and Contributor Images
on:
workflow_dispatch:
@@ -7,7 +7,7 @@ on:
jobs:
update-sponsors:
- name: Update Sponsors
+ name: Update Sponsors and Contributors
runs-on: ubuntu-latest
if: github.repository == 'wailsapp/wails'
permissions:
@@ -30,14 +30,20 @@ jobs:
SPONSORKIT_GITHUB_TOKEN: ${{ secrets.SPONSORS_TOKEN }}
GOWORK: "off"
- - name: Commit updated sponsor image
+ - name: Update Contributors
+ run: cd tools/sponsorkit && go run . -mode contributors -metric prs -repo wailsapp/wails -changelogs ../../docs/src/content/docs/changelog.mdx,../../website/src/pages/changelog.mdx -out ../../website/static/img/contributors.svg
+ env:
+ SPONSORKIT_GITHUB_TOKEN: ${{ secrets.SPONSORS_TOKEN }}
+ GOWORK: "off"
+
+ - name: Commit updated images
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- if git diff --quiet -- website/static/img/sponsors.svg; then
- echo "No sponsor changes to commit."
+ if git diff --quiet -- website/static/img/sponsors.svg website/static/img/contributors.svg; then
+ echo "No sponsor or contributor changes to commit."
exit 0
fi
- git add website/static/img/sponsors.svg
- git commit -m "chore: update sponsors.svg [skip ci]"
+ git add website/static/img/sponsors.svg website/static/img/contributors.svg
+ git commit -m "chore: update sponsors.svg and contributors.svg [skip ci]"
git push
diff --git a/docs/src/assets/contributors.html b/docs/src/assets/contributors.html
deleted file mode 100644
index 465a648011e..00000000000
--- a/docs/src/assets/contributors.html
+++ /dev/null
@@ -1,246 +0,0 @@
-
\ No newline at end of file
diff --git a/docs/src/content/docs/credits.mdx b/docs/src/content/docs/credits.mdx
index 188814be3c6..98fcb8c1181 100644
--- a/docs/src/content/docs/credits.mdx
+++ b/docs/src/content/docs/credits.mdx
@@ -3,10 +3,6 @@ title: Credits
description: Acknowledge the contributors and projects behind Wails
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Project owner, lead developer
- [Stffabi](https://github.com/stffabi) - Technical lead, developer and
maintainer
@@ -44,7 +40,15 @@ Special thanks:
## Contributors
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/docs/src/content/docs/de/credits.mdx b/docs/src/content/docs/de/credits.mdx
index 5c637efd248..52757d98925 100644
--- a/docs/src/content/docs/de/credits.mdx
+++ b/docs/src/content/docs/de/credits.mdx
@@ -3,10 +3,6 @@ title: Credits
description: Anerkennung der Mitwirkenden und Projekte hinter Wails
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Projektinhaber, Lead-Entwickler
- [Stffabi](https://github.com/stffabi) - Technischer Leiter, Entwickler und
Maintainer
@@ -43,7 +39,15 @@ Besonderer Dank:
## Mitwirkende
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Besondere Erwähnungen
diff --git a/docs/src/content/docs/fr/credits.mdx b/docs/src/content/docs/fr/credits.mdx
index 13ecbcc7e55..2b4535e043f 100644
--- a/docs/src/content/docs/fr/credits.mdx
+++ b/docs/src/content/docs/fr/credits.mdx
@@ -3,10 +3,6 @@ title: Crédits
description: Remercier les contributeurs et les projets à l'origine de Wails
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Propriétaire du projet, développeur principal
- [Stffabi](https://github.com/stffabi) - Responsable technique, développeur et
mainteneur
@@ -43,7 +39,15 @@ Remerciements spéciaux :
## Contributeurs
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Mentions spéciales
diff --git a/docs/src/content/docs/id/credits.mdx b/docs/src/content/docs/id/credits.mdx
index 06302a3e553..d71be112282 100644
--- a/docs/src/content/docs/id/credits.mdx
+++ b/docs/src/content/docs/id/credits.mdx
@@ -3,10 +3,6 @@ title: Kredit
description: Mengakui kontributor dan proyek di balik Wails
---
-{/* Import file kontributor yang dihasilkan otomatis */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Pemilik proyek, lead developer
- [Stffabi](https://github.com/stffabi) - Technical lead, developer dan
maintainer
@@ -43,7 +39,15 @@ Ucapan terima kasih khusus:
## Kontributor
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Penghargaan Khusus
diff --git a/docs/src/content/docs/ja/credits.mdx b/docs/src/content/docs/ja/credits.mdx
index ac0ac358b60..6ff0338c885 100644
--- a/docs/src/content/docs/ja/credits.mdx
+++ b/docs/src/content/docs/ja/credits.mdx
@@ -3,10 +3,6 @@ title: クレジット
description: Wailsを支える貢献者およびプロジェクトへの謝辞
---
-{/* 自動生成された貢献者ファイルのインポート */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - プロジェクトオーナー、リード開発者
- [Stffabi](https://github.com/stffabi) - テクニカルリード、開発者およびメンテナー
- [Travis McLane](https://github.com/tmclane) - クロスコンパイル作業、MacOS テスト
@@ -42,7 +38,15 @@ import Contributors from "../../../assets/contributors.html";
## 貢献者
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## 特別言及
diff --git a/docs/src/content/docs/ko/credits.mdx b/docs/src/content/docs/ko/credits.mdx
index b98ac734da2..2c2c2331acf 100644
--- a/docs/src/content/docs/ko/credits.mdx
+++ b/docs/src/content/docs/ko/credits.mdx
@@ -3,10 +3,6 @@ title: 크레딧
description: Wails를 가능하게 한 기여자들과 프로젝트에 감사의 말을 전합니다
---
-{/* 자동 생성된 기여자 파일 가져오기 */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - 프로젝트 오너, 리드 개발자
- [Stffabi](https://github.com/stffabi) - 기술 리드, 개발자 및 유지보수자
- [Travis McLane](https://github.com/tmclane) - 교차 컴파일 작업, MacOS 테스트
@@ -42,7 +38,15 @@ import Contributors from "../../../assets/contributors.html";
## 기여자
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## 특별 언급
diff --git a/docs/src/content/docs/pt/credits.mdx b/docs/src/content/docs/pt/credits.mdx
index a9c8cf954c2..970a6cbf727 100644
--- a/docs/src/content/docs/pt/credits.mdx
+++ b/docs/src/content/docs/pt/credits.mdx
@@ -3,10 +3,6 @@ title: Créditos
description: Reconheça os contribuidores e projetos por trás do Wails
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Proprietário do projeto, desenvolvedor principal
- [Stffabi](https://github.com/stffabi) - Líder técnico, desenvolvedor e mantenedor
- [Travis McLane](https://github.com/tmclane) - Trabalho de compilação cruzada, testes no MacOS
@@ -42,7 +38,15 @@ Agradecimentos especiais:
## Contribuidores
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Menções Especiais
diff --git a/docs/src/content/docs/ru/credits.mdx b/docs/src/content/docs/ru/credits.mdx
index a454bfd74b0..af528fe6e8a 100644
--- a/docs/src/content/docs/ru/credits.mdx
+++ b/docs/src/content/docs/ru/credits.mdx
@@ -3,10 +3,6 @@ title: Благодарности
description: Благодарности участникам и проектам, стоящим за Wails
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - Владелец проекта, ведущий разработчик
- [Stffabi](https://github.com/stffabi) - Технический руководитель, разработчик и сопровождающий
- [Travis McLane](https://github.com/tmclane) - Работа по кросс-компиляции, тестирование на MacOS
@@ -42,7 +38,15 @@ import Contributors from "../../../assets/contributors.html";
## Участники
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Особые упоминания
diff --git a/docs/src/content/docs/zh-cn/credits.mdx b/docs/src/content/docs/zh-cn/credits.mdx
index f4910043c28..5b09f8761ef 100644
--- a/docs/src/content/docs/zh-cn/credits.mdx
+++ b/docs/src/content/docs/zh-cn/credits.mdx
@@ -3,10 +3,6 @@ title: 鸣谢
description: 感谢 Wails 背后的贡献者和项目
---
-{/* 导入自动生成的贡献者文件 */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - 项目所有者,首席开发者
- [Stffabi](https://github.com/stffabi) - 技术负责人,开发者与维护者
- [Travis McLane](https://github.com/tmclane) - 跨编译工作,MacOS 测试
@@ -42,7 +38,15 @@ import Contributors from "../../../assets/contributors.html";
## 贡献者
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## 特别提及
diff --git a/docs/src/content/docs/zh-tw/credits.mdx b/docs/src/content/docs/zh-tw/credits.mdx
index 01ef3c3b481..e7ef5d4b488 100644
--- a/docs/src/content/docs/zh-tw/credits.mdx
+++ b/docs/src/content/docs/zh-tw/credits.mdx
@@ -3,10 +3,6 @@ title: 致謝
description: 感謝 Wails 背後的貢獻者與專案
---
-{/* Import the auto-generated contributors file */}
-
-import Contributors from "../../../assets/contributors.html";
-
- [Lea Anthony](https://github.com/leaanthony) - 專案負責人、主要開發者
- [Stffabi](https://github.com/stffabi) - 技術負責人、開發者與維護者
- [Travis McLane](https://github.com/tmclane) - 跨編譯工作、MacOS 測試
@@ -42,7 +38,15 @@ import Contributors from "../../../assets/contributors.html";
## 貢獻者
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## 特別提及
diff --git a/tools/sponsorkit/README.md b/tools/sponsorkit/README.md
index b074fe0a4f6..c3f92461c71 100644
--- a/tools/sponsorkit/README.md
+++ b/tools/sponsorkit/README.md
@@ -1,15 +1,19 @@
# sponsorkit
A dependency-free Go replacement for the Node [sponsorkit](https://github.com/antfu-collective/sponsorkit)
-package. It fetches live GitHub Sponsors data and renders `website/static/img/sponsors.svg`,
-the image embedded in the project READMEs.
+package. It fetches live GitHub data and renders two self-contained SVGs:
-## What it does
+- `website/static/img/sponsors.svg` — the sponsors image embedded in the project
+ READMEs and both docs sites.
+- `website/static/img/contributors.svg` — the contributors mosaic embedded on the
+ credits pages of both docs sites.
+
+## Sponsors mode (default)
1. Queries the GitHub GraphQL API for all active sponsors of the configured login.
2. Buckets them into tiers by monthly amount (see `config.go`). Bigger sponsors get
bigger avatars and fancier treatments: animated gradient rings, glow halos,
- light sweeps, orbiting sparkles and tier badges at the top; simple circles at the bottom.
+ light sweeps, orbiting sparkles at the top; simple circles at the bottom.
3. Downloads each avatar at 2x resolution and re-encodes it as JPEG (Go stdlib only),
embedding everything as data URIs so the SVG is fully self-contained.
4. Renders a dark-card SVG. Every avatar is a link to the sponsor's profile with a
@@ -21,47 +25,83 @@ the image embedded in the project READMEs.
website embeds it with `object` rather than `img`; inside `img` embeds the hover
layers stay hidden.
+## Contributors mode (`-mode contributors`)
+
+1. Fetches the repository's contributors from the REST API (bots excluded) and
+ counts each author's merged pull requests via GraphQL — one merged PR is one
+ unit of work however it landed, so squash-merged and merge-committed work
+ rank alike (`-metric commits` switches back to raw default-branch commit
+ counts, which reward granular unsquashed histories).
+2. Scans the v2 and v3 changelogs for `@login` credits. Squash-merged or
+ hand-applied patches are often credited only there, so changelog-only
+ contributors still appear; markdown profile links (`[@x](https://github.com/y)`)
+ trust the URL rather than the link text, and every changelog-only login is
+ validated against the API so typos and organisations are dropped. A
+ contributor's credit is the larger of their metric count and mention count;
+ contributors with commits but no recorded PRs (early direct pushes) stay
+ visible in the tail bands.
+3. Renders a mosaic of superellipse "squircles" on the same dark card, graded by
+ credit into bands (see `bands` in `config.go`): the most prolific contributors
+ get large named squircles with animated gradient rings and travelling light
+ arcs, the long tail gets small plain squircles. Every squircle links to the
+ contributor's profile.
+4. Hover on the bigger bands lifts the squircle, blooms the ring and pops up a
+ chip showing the commit (or changelog credit) count.
+5. With hundreds of avatars the embedded images dominate the file size, so small
+ bands use lower JPEG quality, flat-colour identicons keep their original PNG
+ when it is smaller, and the squircle geometry is shared via `defs`/`use`.
+
## Usage
```sh
cd tools/sponsorkit
SPONSORKIT_GITHUB_TOKEN= GOWORK=off go run . -out ../../website/static/img/sponsors.svg
+SPONSORKIT_GITHUB_TOKEN= GOWORK=off go run . -mode contributors -metric prs \
+ -changelogs ../../docs/src/content/docs/changelog.mdx,../../website/src/pages/changelog.mdx \
+ -out ../../website/static/img/contributors.svg
```
Flags:
-| Flag | Default | Purpose |
-|------------|----------------|------------------------------------------|
-| `-login` | `leaanthony` | GitHub account whose sponsors to render |
-| `-out` | `sponsors.svg` | Output path |
-| `-width` | `800` | SVG width in CSS pixels |
-| `-scale` | `2` | Avatar oversampling for hi-dpi |
-| `-quality` | `80` | JPEG quality for embedded avatars |
+| Flag | Default | Purpose |
+|---------------|------------------|--------------------------------------------------|
+| `-mode` | `sponsors` | `sponsors` or `contributors` |
+| `-login` | `leaanthony` | GitHub account whose sponsors to render |
+| `-repo` | `wailsapp/wails` | Repository whose contributors to render |
+| `-metric` | `prs` | Contributor ranking: `prs` or `commits` |
+| `-changelogs` | (empty) | Comma-separated changelogs scanned for `@login` credits |
+| `-out` | `sponsors.svg` | Output path |
+| `-width` | `800` | SVG width in CSS pixels |
+| `-scale` | `2` | Avatar oversampling for hi-dpi |
+| `-quality` | `80` | JPEG quality for embedded avatars |
-The token must have sponsor-tier visibility for the account (the maintainer's own
-token, e.g. the `SPONSORS_TOKEN` secret in CI). `GITHUB_TOKEN` is used as a fallback
-env var. Without tier visibility every sponsor lands in the catch-all "Helpers" tier.
+For sponsors, the token must have sponsor-tier visibility for the account (the
+maintainer's own token, e.g. the `SPONSORS_TOKEN` secret in CI); without it every
+sponsor lands in the catch-all "Helpers" tier. For contributors any token works.
+`GITHUB_TOKEN` is used as a fallback env var.
-Tier thresholds and styling live in `config.go`; the visual language (gradients,
-filters, animations) lives in `render.go`.
+Tier and band thresholds and styling live in `config.go`; the visual language
+(gradients, filters, animations) lives in `render.go` and `render_contributors.go`.
-This tool is run daily by `.github/workflows/generate-sponsor-image.yml`, which
-commits the regenerated SVG when it changes.
+Both images are regenerated daily by
+`.github/workflows/generate-sponsor-image.yml`, which commits them when they
+change.
-## Where the image is used
+## Where the images are used
-The single source of truth is `website/static/img/sponsors.svg`. It is referenced
-from:
+The single source of truth is `website/static/img/` (deployed to
+`https://wails.io/img/`). The images are referenced from:
- `README.md` and all `README.*.md` translations — `img` with the repo-relative
- path (GitHub sanitises README HTML, so `object` embeds and therefore hover and
- in-image links are not possible there).
+ path to `sponsors.svg` (GitHub sanitises README HTML, so `object` embeds and
+ therefore hover and in-image links are not possible there).
- v2 docs (`website/src/pages/credits.mdx` and the 11
`website/i18n/*/docusaurus-plugin-content-pages/credits.mdx` copies) — `object`
- embed of `/img/sponsors.svg`.
+ embeds of `/img/sponsors.svg` and `/img/contributors.svg`.
- v3 docs (`docs/src/content/docs/credits.mdx` and the 9 locale copies) — `object`
- embed of `https://wails.io/img/sponsors.svg`, so they always show the latest
- deployed image.
+ embeds of `https://wails.io/img/sponsors.svg` and
+ `https://wails.io/img/contributors.svg`, so they always show the latest
+ deployed images.
-If you add a new place that shows the sponsor image, reference that same file and
-prefer an `object` embed so the per-sponsor links and hover effects work.
+If you add a new place that shows either image, reference that same file and
+prefer an `object` embed so the per-person links and hover effects work.
diff --git a/tools/sponsorkit/avatar.go b/tools/sponsorkit/avatar.go
index 41750c076c2..91053733c42 100644
--- a/tools/sponsorkit/avatar.go
+++ b/tools/sponsorkit/avatar.go
@@ -89,5 +89,10 @@ func fetchAvatar(client *http.Client, avatarURL string, px, quality int) (string
if err := jpeg.Encode(&buf, flat, &jpeg.Options{Quality: quality}); err != nil {
return "", err
}
+ // Flat-colour identicons compress far better in their original PNG than
+ // as JPEG, so keep whichever encoding is smaller.
+ if mime := resp.Header.Get("Content-Type"); mime != "" && len(data) < buf.Len() {
+ return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data), nil
+ }
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
diff --git a/tools/sponsorkit/config.go b/tools/sponsorkit/config.go
index e4528237b64..6787b50d8a6 100644
--- a/tools/sponsorkit/config.go
+++ b/tools/sponsorkit/config.go
@@ -100,3 +100,82 @@ var tiers = []TierStyle{
Ring: "subtle", RingWidth: 1, Accent: "#8B95A9",
},
}
+
+// Band controls how contributors with at least MinCredit credits are drawn
+// on the contributors card. Unlike sponsor tiers, bands have no headings:
+// the card is one continuous mosaic of squircles graded by size.
+type Band struct {
+ // MinCredit is the minimum credit count (commits or changelog mentions)
+ // for this band. Contributors land in the first band whose minimum
+ // they meet.
+ MinCredit int
+ // Avatar is the squircle's edge length in CSS pixels.
+ Avatar float64
+ // Box is the horizontal space reserved per contributor.
+ Box float64
+ // RowGap is vertical space between rows within the band.
+ RowGap float64
+ // ShowName renders the contributor's login under the squircle.
+ ShowName bool
+ // NameSize is the font size for the login.
+ NameSize float64
+ // MaxName is the maximum rune count before the login is ellipsised.
+ MaxName int
+ // Ring selects the ring treatment: "animated", "static" or "subtle".
+ Ring string
+ // RingWidth is the ring stroke width.
+ RingWidth float64
+ // RingGradient is the id of the gradient used for the ring stroke.
+ RingGradient string
+ // Hover enables the hover power-up (lift, bloom, fast sweep, credit chip).
+ Hover bool
+}
+
+// prBandMins replaces the bands' MinCredit thresholds when ranking by
+// merged PRs (-metric prs): one merged PR is a much rarer unit than one
+// commit, so the cut-offs sit far lower.
+var prBandMins = []int{300, 100, 40, 15, 6, 2, 0}
+
+// bands is ordered from most to least prolific, with MinCredit thresholds
+// tuned for the commit metric. The last entry is the catch-all for
+// first-time and drive-by contributors.
+var bands = []Band{
+ {
+ MinCredit: 1000,
+ Avatar: 116, Box: 190, RowGap: 26,
+ ShowName: true, NameSize: 15, MaxName: 22,
+ Ring: "animated", RingWidth: 4.5, RingGradient: "ringPartner", Hover: true,
+ },
+ {
+ MinCredit: 150,
+ Avatar: 94, Box: 152, RowGap: 24,
+ ShowName: true, NameSize: 13.5, MaxName: 18,
+ Ring: "animated", RingWidth: 4, RingGradient: "ringChampion", Hover: true,
+ },
+ {
+ MinCredit: 60,
+ Avatar: 74, Box: 116, RowGap: 22,
+ ShowName: true, NameSize: 12, MaxName: 15,
+ Ring: "static", RingWidth: 3, RingGradient: "ringGold", Hover: true,
+ },
+ {
+ MinCredit: 20,
+ Avatar: 58, Box: 74, RowGap: 16,
+ Ring: "static", RingWidth: 2, RingGradient: "ringSilver", Hover: true,
+ },
+ {
+ MinCredit: 8,
+ Avatar: 46, Box: 59, RowGap: 13,
+ Ring: "subtle", RingWidth: 1.5,
+ },
+ {
+ MinCredit: 3,
+ Avatar: 37, Box: 48, RowGap: 12,
+ Ring: "subtle", RingWidth: 1.25,
+ },
+ {
+ MinCredit: 0,
+ Avatar: 30, Box: 40, RowGap: 11,
+ Ring: "subtle", RingWidth: 1,
+ },
+}
diff --git a/tools/sponsorkit/contributors.go b/tools/sponsorkit/contributors.go
new file mode 100644
index 00000000000..ab846f671be
--- /dev/null
+++ b/tools/sponsorkit/contributors.go
@@ -0,0 +1,358 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Contributor is a single contributor with their credit tally.
+type Contributor struct {
+ Login string
+ AvatarURL string
+ URL string
+ // Commits is the default-branch commit count from the contributors API.
+ Commits int
+ // PRs is the number of merged pull requests authored (only populated
+ // with -metric prs).
+ PRs int
+ // Mentions counts @login credits found in the changelogs. Squash-merged
+ // or hand-applied patches are often credited only there.
+ Mentions int
+}
+
+// creditMetric selects how contributors are ranked and sized: "commits"
+// (default-branch commit count) or "prs" (merged pull requests authored).
+// Commit counts reward granular unsquashed histories; PR counts treat one
+// merged PR as one unit of work regardless of merge style.
+var creditMetric = "commits"
+
+// Credit is the number a contributor is ranked and sized by. The changelog
+// mention count mostly describes the same work as the primary metric, so
+// take the larger of the two rather than double counting.
+func (c Contributor) Credit() int {
+ primary := c.Commits
+ if creditMetric == "prs" {
+ primary = c.PRs
+ // Contributors with commits but no recorded PRs (early direct
+ // pushes) stay visible in the tail bands rather than vanishing.
+ if primary == 0 && c.Mentions == 0 && c.Commits > 0 {
+ primary = min(c.Commits, 7)
+ }
+ }
+ if c.Mentions > primary {
+ return c.Mentions
+ }
+ return primary
+}
+
+type restContributor struct {
+ Login string `json:"login"`
+ AvatarURL string `json:"avatar_url"`
+ HTMLURL string `json:"html_url"`
+ Contributions int `json:"contributions"`
+ Type string `json:"type"`
+}
+
+// FetchContributors returns the repo's contributors (up to the API's cap of
+// 500), bots excluded, augmented with changelog @mention credits so that
+// people whose work arrived via squashed or hand-applied patches still
+// appear. Sorted by credit (descending) then login for deterministic output.
+func FetchContributors(client *http.Client, token, repo string, changelogs []string) ([]Contributor, error) {
+ byLogin := map[string]*Contributor{}
+ var order []string
+
+ for page := 1; ; page++ {
+ url := fmt.Sprintf("https://api.github.com/repos/%s/contributors?per_page=100&page=%d", repo, page)
+ var batch []restContributor
+ if err := getJSON(client, token, url, &batch); err != nil {
+ return nil, fmt.Errorf("fetching contributors: %w", err)
+ }
+ for _, c := range batch {
+ if c.Login == "" || isBot(c.Login, c.Type) {
+ continue
+ }
+ key := strings.ToLower(c.Login)
+ if _, ok := byLogin[key]; ok {
+ continue
+ }
+ byLogin[key] = &Contributor{
+ Login: c.Login,
+ AvatarURL: c.AvatarURL,
+ URL: c.HTMLURL,
+ Commits: c.Contributions,
+ }
+ order = append(order, key)
+ }
+ if len(batch) < 100 {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ if creditMetric == "prs" {
+ prCounts, err := fetchMergedPRCounts(client, token, repo)
+ if err != nil {
+ return nil, fmt.Errorf("fetching merged PRs: %w", err)
+ }
+ for key, n := range prCounts {
+ if c, ok := byLogin[key]; ok {
+ c.PRs = n
+ continue
+ }
+ // Authored merged PRs but absent from the commit history (e.g.
+ // unlinked commit email). Same validation as changelog mentions.
+ u, err := lookupUser(client, token, key)
+ if err != nil || u.Type != "User" || isBot(u.Login, u.Type) {
+ continue
+ }
+ byLogin[key] = &Contributor{
+ Login: u.Login,
+ AvatarURL: u.AvatarURL,
+ URL: u.HTMLURL,
+ PRs: n,
+ }
+ order = append(order, key)
+ }
+ }
+
+ mentions := changelogMentions(changelogs)
+ for key, n := range mentions {
+ if c, ok := byLogin[key]; ok {
+ c.Mentions = n
+ continue
+ }
+ // Credited in a changelog but absent from the commit history (e.g.
+ // their PR was squashed under someone else's authorship). Look the
+ // login up so typos and non-user tokens are dropped.
+ u, err := lookupUser(client, token, key)
+ if err != nil {
+ fmt.Printf(" skipping changelog mention @%s: %v\n", key, err)
+ continue
+ }
+ // Organisations and bots cannot be contributors; requiring a real
+ // user account also drops tokens that merely happen to start with @.
+ if u.Type != "User" || isBot(u.Login, u.Type) {
+ fmt.Printf(" skipping changelog mention @%s: %s account\n", key, u.Type)
+ continue
+ }
+ fmt.Printf(" changelog-only contributor: @%s (%d mentions)\n", u.Login, n)
+ byLogin[key] = &Contributor{
+ Login: u.Login,
+ AvatarURL: u.AvatarURL,
+ URL: u.HTMLURL,
+ Mentions: n,
+ }
+ order = append(order, key)
+ }
+
+ out := make([]Contributor, 0, len(order))
+ for _, key := range order {
+ out = append(out, *byLogin[key])
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Credit() != out[j].Credit() {
+ return out[i].Credit() > out[j].Credit()
+ }
+ return strings.ToLower(out[i].Login) < strings.ToLower(out[j].Login)
+ })
+ return out, nil
+}
+
+// mentionRe matches @login tokens. GitHub logins are alphanumeric with
+// single hyphens, up to 39 characters.
+var mentionRe = regexp.MustCompile(`(^|[^\w./@])@([A-Za-z0-9][A-Za-z0-9-]{0,38})`)
+
+// profileLinkRe matches markdown links to GitHub profiles, e.g.
+// [@someone](https://github.com/someone).
+var profileLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(https://github\.com/([A-Za-z0-9][A-Za-z0-9-]{0,38})/?\)`)
+
+// changelogMentions counts @login credits across the given files, keyed by
+// lowercased login. Files that cannot be read are skipped with a warning so
+// the tool still works from checkouts without the docs trees.
+func changelogMentions(paths []string) map[string]int {
+ counts := map[string]int{}
+ for _, path := range paths {
+ if path == "" {
+ continue
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ fmt.Printf(" warning: changelog %s: %v\n", path, err)
+ continue
+ }
+ // Credits written as markdown profile links first. The link text is
+ // sometimes a typo or display handle, so trust the URL, then strip
+ // the whole link so the text is not counted again.
+ data = profileLinkRe.ReplaceAllFunc(data, func(link []byte) []byte {
+ m := profileLinkRe.FindSubmatch(link)
+ if bytes.Contains(m[1], []byte("@")) {
+ counts[strings.ToLower(string(m[2]))]++
+ }
+ return []byte(" ")
+ })
+ for _, m := range mentionRe.FindAllSubmatchIndex(data, -1) {
+ login := strings.TrimRight(string(data[m[4]:m[5]]), "-")
+ if login == "" {
+ continue
+ }
+ // Package scopes like @wailsio/runtime are not people, and an
+ // underscore means the token is a display handle rather than a
+ // GitHub login.
+ if m[5] < len(data) && (data[m[5]] == '/' || data[m[5]] == '_') {
+ continue
+ }
+ counts[strings.ToLower(login)]++
+ }
+ }
+ return counts
+}
+
+// mergedPRQuery pages through every merged PR's author. Unlike the search
+// API this has no 1000-result cap.
+const mergedPRQuery = `query($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ pullRequests(states: MERGED, first: 100, after: $after) {
+ pageInfo { hasNextPage endCursor }
+ nodes { author { login } }
+ }
+ }
+}`
+
+type mergedPRResponse struct {
+ Data struct {
+ Repository struct {
+ PullRequests struct {
+ PageInfo struct {
+ HasNextPage bool `json:"hasNextPage"`
+ EndCursor string `json:"endCursor"`
+ } `json:"pageInfo"`
+ Nodes []struct {
+ Author *struct {
+ Login string `json:"login"`
+ } `json:"author"`
+ } `json:"nodes"`
+ } `json:"pullRequests"`
+ } `json:"repository"`
+ } `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors"`
+}
+
+// fetchMergedPRCounts returns merged-PR counts keyed by lowercased author
+// login, bots excluded.
+func fetchMergedPRCounts(client *http.Client, token, repo string) (map[string]int, error) {
+ owner, name, ok := strings.Cut(repo, "/")
+ if !ok {
+ return nil, fmt.Errorf("repo %q is not owner/name", repo)
+ }
+ counts := map[string]int{}
+ after := ""
+ for {
+ variables := map[string]any{"owner": owner, "name": name}
+ if after != "" {
+ variables["after"] = after
+ }
+ body, err := json.Marshal(map[string]any{"query": mergedPRQuery, "variables": variables})
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequest(http.MethodPost, "https://api.github.com/graphql", bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "wails-sponsorkit")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ data, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("github graphql: %s: %s", resp.Status, truncate(string(data), 300))
+ }
+ var parsed mergedPRResponse
+ if err := json.Unmarshal(data, &parsed); err != nil {
+ return nil, fmt.Errorf("parsing graphql response: %w", err)
+ }
+ if len(parsed.Errors) > 0 {
+ return nil, fmt.Errorf("github graphql: %s", parsed.Errors[0].Message)
+ }
+
+ conn := parsed.Data.Repository.PullRequests
+ for _, node := range conn.Nodes {
+ if node.Author == nil || node.Author.Login == "" || isBot(node.Author.Login, "") {
+ continue
+ }
+ counts[strings.ToLower(node.Author.Login)]++
+ }
+ if !conn.PageInfo.HasNextPage {
+ break
+ }
+ after = conn.PageInfo.EndCursor
+ time.Sleep(100 * time.Millisecond)
+ }
+ return counts, nil
+}
+
+type restUser struct {
+ Login string `json:"login"`
+ AvatarURL string `json:"avatar_url"`
+ HTMLURL string `json:"html_url"`
+ Type string `json:"type"`
+}
+
+func lookupUser(client *http.Client, token, login string) (restUser, error) {
+ var u restUser
+ err := getJSON(client, token, "https://api.github.com/users/"+login, &u)
+ return u, err
+}
+
+func isBot(login, typ string) bool {
+ l := strings.ToLower(login)
+ return typ == "Bot" ||
+ strings.HasSuffix(l, "[bot]") ||
+ strings.HasSuffix(l, "-bot") ||
+ l == "dependabot" || l == "allcontributors" || l == "github-actions"
+}
+
+func getJSON(client *http.Client, token, url string, into any) error {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "bearer "+token)
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ req.Header.Set("User-Agent", "wails-sponsorkit")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("GET %s: %s: %s", url, resp.Status, truncate(string(data), 200))
+ }
+ return json.Unmarshal(data, into)
+}
diff --git a/tools/sponsorkit/main.go b/tools/sponsorkit/main.go
index 3857557896b..88a3458a9f5 100644
--- a/tools/sponsorkit/main.go
+++ b/tools/sponsorkit/main.go
@@ -1,14 +1,16 @@
-// Command sponsorkit generates the Wails sponsors SVG from live GitHub
-// Sponsors data. It is a dependency-free Go replacement for the Node
-// sponsorkit package.
+// Command sponsorkit generates the Wails sponsors and contributors SVGs
+// from live GitHub data. It is a dependency-free Go replacement for the
+// Node sponsorkit package.
//
// Usage:
//
// SPONSORKIT_GITHUB_TOKEN=... go run . -login leaanthony -out sponsors.svg
+// SPONSORKIT_GITHUB_TOKEN=... go run . -mode contributors -repo wailsapp/wails -out contributors.svg
//
-// The token needs to belong to (or have sponsor visibility for) the
-// sponsored account so that tier amounts are included; without them every
-// sponsor lands in the catch-all tier.
+// For sponsors, the token needs to belong to (or have sponsor visibility
+// for) the sponsored account so that tier amounts are included; without
+// them every sponsor lands in the catch-all tier. For contributors any
+// token works.
package main
import (
@@ -16,11 +18,16 @@ import (
"fmt"
"net/http"
"os"
+ "strings"
"time"
)
func main() {
+ mode := flag.String("mode", "sponsors", `what to render: "sponsors" or "contributors"`)
login := flag.String("login", "leaanthony", "GitHub login whose sponsors to fetch")
+ repo := flag.String("repo", "wailsapp/wails", "owner/name repository whose contributors to fetch")
+ metric := flag.String("metric", "prs", `how contributors are ranked: "prs" (merged pull requests) or "commits"`)
+ changelogs := flag.String("changelogs", "", "comma-separated changelog paths scanned for @login credits (contributors mode)")
out := flag.String("out", "sponsors.svg", "output SVG path")
width := flag.Float64("width", 800, "SVG width in CSS pixels")
scale := flag.Int("scale", 2, "avatar oversampling factor for hi-dpi displays")
@@ -38,12 +45,45 @@ func main() {
client := &http.Client{Timeout: 30 * time.Second}
- fmt.Printf("Fetching sponsors of %s...\n", *login)
- sponsors, err := FetchSponsors(client, token, *login)
+ var svg string
+ var err error
+ switch *mode {
+ case "sponsors":
+ svg, err = generateSponsors(client, token, *login, *width, *scale, *quality)
+ case "contributors":
+ switch *metric {
+ case "commits":
+ case "prs":
+ creditMetric = "prs"
+ for i := range bands {
+ bands[i].MinCredit = prBandMins[i]
+ }
+ default:
+ fmt.Fprintf(os.Stderr, "error: unknown -metric %q\n", *metric)
+ os.Exit(1)
+ }
+ svg, err = generateContributors(client, token, *repo, splitPaths(*changelogs), *width, *scale, *quality)
+ default:
+ err = fmt.Errorf("unknown -mode %q", *mode)
+ }
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
+
+ if err := os.WriteFile(*out, []byte(svg), 0o644); err != nil {
+ fmt.Fprintf(os.Stderr, "error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("Wrote %s (%d KiB)\n", *out, len(svg)/1024)
+}
+
+func generateSponsors(client *http.Client, token, login string, width float64, scale, quality int) (string, error) {
+ fmt.Printf("Fetching sponsors of %s...\n", login)
+ sponsors, err := FetchSponsors(client, token, login)
+ if err != nil {
+ return "", err
+ }
fmt.Printf("Found %d sponsors\n", len(sponsors))
groups := GroupByTier(sponsors)
@@ -53,22 +93,74 @@ func main() {
fmt.Printf(" %-18s %d\n", tiers[i].Title, len(group))
}
for _, s := range group {
- sizes[s.Login] = int(tiers[i].Avatar) * *scale
+ sizes[s.Login] = int(tiers[i].Avatar) * scale
}
}
fmt.Println("Fetching avatars...")
- uris := FetchAvatars(client, sponsors, sizes, *quality)
+ uris := FetchAvatars(client, sponsors, sizes, quality)
- svg := Render(sponsors, RenderOptions{
- Width: *width,
+ return Render(sponsors, RenderOptions{
+ Width: width,
SidePad: 40,
- SponsorURL: "https://github.com/sponsors/" + *login,
+ SponsorURL: "https://github.com/sponsors/" + login,
AvatarURIs: uris,
- })
- if err := os.WriteFile(*out, []byte(svg), 0o644); err != nil {
- fmt.Fprintf(os.Stderr, "error: %v\n", err)
- os.Exit(1)
+ }), nil
+}
+
+func generateContributors(client *http.Client, token, repo string, changelogs []string, width float64, scale, quality int) (string, error) {
+ fmt.Printf("Fetching contributors of %s...\n", repo)
+ contributors, err := FetchContributors(client, token, repo, changelogs)
+ if err != nil {
+ return "", err
}
- fmt.Printf("Wrote %s (%d KiB)\n", *out, len(svg)/1024)
+ fmt.Printf("Found %d contributors\n", len(contributors))
+
+ groups := GroupByBand(contributors)
+ sizes := map[string]int{}
+ // With hundreds of contributors the embedded avatars dominate the file
+ // size, so the small squircles trade JPEG quality for weight.
+ byQuality := map[int][]Sponsor{}
+ for i, group := range groups {
+ if len(group) > 0 {
+ fmt.Printf(" %4d+ credits: %d\n", bands[i].MinCredit, len(group))
+ }
+ q := quality
+ switch {
+ case bands[i].Avatar < 40:
+ q = 55
+ case !bands[i].ShowName:
+ q = 68
+ }
+ for _, c := range group {
+ sizes[c.Login] = int(bands[i].Avatar) * scale
+ byQuality[q] = append(byQuality[q], Sponsor{Login: c.Login, AvatarURL: c.AvatarURL})
+ }
+ }
+
+ fmt.Println("Fetching avatars...")
+ uris := map[string]string{}
+ for q, list := range byQuality {
+ for login, uri := range FetchAvatars(client, list, sizes, q) {
+ uris[login] = uri
+ }
+ }
+
+ return RenderContributors(contributors, ContributorRenderOptions{
+ Width: width,
+ SidePad: 40,
+ ContributeURL: "https://github.com/wailsapp/wails/blob/master/CONTRIBUTING.md",
+ AvatarURIs: uris,
+ }), nil
+}
+
+func splitPaths(s string) []string {
+ if s == "" {
+ return nil
+ }
+ parts := strings.Split(s, ",")
+ for i := range parts {
+ parts[i] = strings.TrimSpace(parts[i])
+ }
+ return parts
}
diff --git a/tools/sponsorkit/render.go b/tools/sponsorkit/render.go
index 4f563f0f8bc..cf8b7f21656 100644
--- a/tools/sponsorkit/render.go
+++ b/tools/sponsorkit/render.go
@@ -21,6 +21,9 @@ type renderer struct {
opts RenderOptions
body strings.Builder
y float64
+ // clipSeq numbers per-avatar clipPath ids. Logins are not safe id
+ // material: sanitising them can collide ("foo-bar" vs "foobar").
+ clipSeq int
}
// num formats a coordinate compactly.
@@ -360,7 +363,8 @@ func (r *renderer) sponsor(s Sponsor, t TierStyle, cx, cy float64) {
if hover {
// Glass-glare stripe that sweeps across the avatar on hover, plus a
// pulse ring that emanates while hovered.
- clipID := "gc" + sanitizeID(s.Login)
+ clipID := fmt.Sprintf("gc%d", r.clipSeq)
+ r.clipSeq++
glareW := rad * 0.8
r.body.WriteString(fmt.Sprintf(``,
clipID, num(cx), num(cy), num(rad)))
diff --git a/tools/sponsorkit/render_contributors.go b/tools/sponsorkit/render_contributors.go
new file mode 100644
index 00000000000..56c4b867d48
--- /dev/null
+++ b/tools/sponsorkit/render_contributors.go
@@ -0,0 +1,348 @@
+package main
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+// ContributorRenderOptions controls the contributors SVG output.
+type ContributorRenderOptions struct {
+ Width float64
+ SidePad float64
+ // ContributeURL is the CTA target.
+ ContributeURL string
+ // AvatarURIs maps login to a data URI. Missing entries get a
+ // placeholder squircle with the contributor's initial.
+ AvatarURIs map[string]string
+}
+
+// GroupByBand buckets contributors into the configured bands. Index i of
+// the result corresponds to bands[i].
+func GroupByBand(cs []Contributor) [][]Contributor {
+ groups := make([][]Contributor, len(bands))
+ for _, c := range cs {
+ for i, b := range bands {
+ if c.Credit() >= b.MinCredit {
+ groups[i] = append(groups[i], c)
+ break
+ }
+ }
+ }
+ return groups
+}
+
+// squirclePoints samples the superellipse |x/r|^4 + |y/r|^4 = 1 centred on
+// (cx, cy). 64 samples keep the largest squircles smooth while staying
+// compact in the output.
+func squirclePoints(cx, cy, r float64) [][2]float64 {
+ const n = 64
+ pts := make([][2]float64, n)
+ for i := 0; i < n; i++ {
+ t := 2 * math.Pi * float64(i) / n
+ c, s := math.Cos(t), math.Sin(t)
+ x := math.Copysign(math.Sqrt(math.Abs(c)), c)
+ y := math.Copysign(math.Sqrt(math.Abs(s)), s)
+ pts[i] = [2]float64{cx + r*x, cy + r*y}
+ }
+ return pts
+}
+
+func squirclePathD(cx, cy, r float64) string {
+ pts := squirclePoints(cx, cy, r)
+ var b strings.Builder
+ b.WriteString("M")
+ for i, p := range pts {
+ if i > 0 {
+ b.WriteString("L")
+ }
+ b.WriteString(num(p[0]))
+ b.WriteString(" ")
+ b.WriteString(num(p[1]))
+ }
+ b.WriteString("Z")
+ return b.String()
+}
+
+// squirclePerimeter returns the sampled perimeter length of a squircle
+// with half-size r, used to size travelling dash arcs.
+func squirclePerimeter(r float64) float64 {
+ pts := squirclePoints(0, 0, r)
+ total := 0.0
+ for i := range pts {
+ next := pts[(i+1)%len(pts)]
+ total += math.Hypot(next[0]-pts[i][0], next[1]-pts[i][1])
+ }
+ return total
+}
+
+// contributorDefs holds the extra defs and styles the contributors card
+// needs on top of the shared sponsor defs: a unit squircle clip and the
+// hover credit chip.
+const contributorDefs = `
+
+
+
+
+
+`
+
+// RenderContributors produces the complete contributors SVG document.
+func RenderContributors(cs []Contributor, opts ContributorRenderOptions) string {
+ r := &renderer{opts: RenderOptions{
+ Width: opts.Width,
+ SidePad: opts.SidePad,
+ AvatarURIs: opts.AvatarURIs,
+ }}
+ groups := GroupByBand(cs)
+
+ totalCommits := 0
+ for _, c := range cs {
+ totalCommits += c.Commits
+ }
+ r.contributorHeader(len(cs), totalCommits)
+ for i, group := range groups {
+ if len(group) == 0 {
+ continue
+ }
+ r.bandSection(i, group)
+ }
+ r.contributorFooter(opts.ContributeURL)
+
+ height := r.y
+ var out strings.Builder
+ out.WriteString(fmt.Sprintf(`\n")
+ return out.String()
+}
+
+// contributorHeader draws the title and a stats line.
+func (r *renderer) contributorHeader(contributors, commits int) {
+ cx := r.opts.Width / 2
+ r.y = 62
+ r.body.WriteString(fmt.Sprintf(`Wails Contributors`, num(cx), num(r.y)))
+ r.y += 24
+ r.body.WriteString(fmt.Sprintf(`%s contributors · %s commits — thank you!`,
+ num(cx), num(r.y), comma(contributors), comma(commits)))
+ r.y += 34
+}
+
+// bandSection lays out one band's rows, centred like sponsor tiers but
+// without a heading: sizes alone carry the hierarchy.
+func (r *renderer) bandSection(bi int, group []Contributor) {
+ b := bands[bi]
+ innerW := r.opts.Width - 2*r.opts.SidePad
+ perRow := int(innerW / b.Box)
+ if perRow < 1 {
+ perRow = 1
+ }
+
+ ringPad := b.RingWidth + 3
+ cellTop := 4.0
+ nameH := 0.0
+ if b.ShowName {
+ nameH = b.NameSize + 14
+ }
+ cellH := cellTop + b.Avatar + 2*ringPad + nameH
+
+ for start := 0; start < len(group); start += perRow {
+ end := start + perRow
+ if end > len(group) {
+ end = len(group)
+ }
+ row := group[start:end]
+ n := float64(len(row))
+ startX := (r.opts.Width-n*b.Box)/2 + b.Box/2
+ cy := r.y + cellTop + ringPad + b.Avatar/2
+ for i, c := range row {
+ cx := startX + float64(i)*b.Box
+ r.contributor(c, bi, cx, cy)
+ }
+ r.y += cellH
+ if end < len(group) {
+ r.y += b.RowGap
+ }
+ }
+ r.y += 14
+}
+
+func (r *renderer) contributor(c Contributor, bi int, cx, cy float64) {
+ b := bands[bi]
+ half := b.Avatar / 2
+ ringHalf := half + 2 + b.RingWidth/2
+ perim := squirclePerimeter(ringHalf)
+
+ if b.Hover {
+ r.body.WriteString(fmt.Sprintf(``, esc(c.URL), num(half*1.5)))
+ } else {
+ r.body.WriteString(fmt.Sprintf(``, esc(c.URL)))
+ }
+ r.body.WriteString(fmt.Sprintf(`@%s · %s`, esc(c.Login), esc(creditLabel(c))))
+ if b.Hover {
+ r.body.WriteString(``)
+ }
+
+ ring := fmt.Sprintf(``,
+ ring, b.RingGradient, num(b.RingWidth+4)))
+ // Gradient squircle ring with a bright arc travelling around it.
+ // Rotating a squircle would visibly spin the shape, so the arc
+ // marches along the path via stroke-dashoffset instead.
+ r.body.WriteString(fmt.Sprintf(`%s stroke="url(#%s)" stroke-width="%s"/>`,
+ ring, b.RingGradient, num(b.RingWidth)))
+ r.body.WriteString(fmt.Sprintf(`%s stroke="#FFFFFF" stroke-opacity="0.65" stroke-width="%s" stroke-linecap="round" stroke-dasharray="%s %s">`,
+ ring, num(b.RingWidth*0.6), num(perim*0.12), num(perim*0.88), num(-perim)))
+ // Hover-only bloom and fast counter-marching sweep.
+ r.body.WriteString(fmt.Sprintf(`%s class="bloom" stroke="url(#%s)" stroke-width="%s" filter="url(#soften)"/>`,
+ ring, b.RingGradient, num(b.RingWidth+7)))
+ r.body.WriteString(fmt.Sprintf(`%s class="fast" stroke="#FFFFFF" stroke-width="%s" stroke-linecap="round" stroke-dasharray="%s %s">`,
+ ring, num(b.RingWidth*0.7), num(perim*0.07), num(perim*0.93), num(perim)))
+ case "static":
+ r.body.WriteString(fmt.Sprintf(`%s stroke="url(#%s)" stroke-width="%s"/>`,
+ ring, b.RingGradient, num(b.RingWidth)))
+ if b.Hover {
+ r.body.WriteString(fmt.Sprintf(`%s class="bloom" stroke="url(#%s)" stroke-width="%s" filter="url(#soften)"/>`,
+ ring, b.RingGradient, num(b.RingWidth+5)))
+ }
+ case "subtle":
+ r.body.WriteString(fmt.Sprintf(`%s stroke="#FFFFFF" stroke-opacity="0.16" stroke-width="%s"/>`,
+ ring, num(b.RingWidth)))
+ }
+
+ // Avatar (or placeholder squircle with the contributor's initial).
+ if uri, ok := r.opts.AvatarURIs[c.Login]; ok {
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-half), num(cy-half), num(b.Avatar), num(b.Avatar), uri))
+ } else {
+ initial := strings.ToUpper(string([]rune(c.Login)[0]))
+ r.body.WriteString(fmt.Sprintf(``, bi, num(cx), num(cy)))
+ r.body.WriteString(fmt.Sprintf(`%s`,
+ num(cx), num(cy+half*0.32), num(half*0.9), esc(initial)))
+ }
+ // Hairline between avatar and ring for definition.
+ r.body.WriteString(fmt.Sprintf(``,
+ bi, num(cx), num(cy)))
+
+ if b.Hover {
+ // Glass-glare sweep across the avatar on hover.
+ clipID := fmt.Sprintf("qc%d", r.clipSeq)
+ r.clipSeq++
+ glareW := half * 0.8
+ r.body.WriteString(fmt.Sprintf(``,
+ clipID, bi, num(cx), num(cy)))
+ r.body.WriteString(fmt.Sprintf(``,
+ clipID, num(cx-glareW/2), num(cy-half), num(glareW), num(b.Avatar)))
+ // A little credit chip that pops up above the squircle on hover.
+ label := creditLabel(c)
+ chipW := float64(len(label))*6.4 + 18
+ chipY := cy - ringHalf - 26
+ r.body.WriteString(``)
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-chipW/2), num(chipY), num(chipW), b.RingGradient))
+ r.body.WriteString(fmt.Sprintf(`%s`,
+ num(cx), num(chipY+14), esc(label)))
+ r.body.WriteString(``)
+ }
+
+ if b.ShowName {
+ y := cy + half + b.RingWidth + b.NameSize + 10
+ name := ellipsis(c.Login, b.MaxName)
+ r.body.WriteString(fmt.Sprintf(`%s`,
+ num(cx), num(y), num(b.NameSize), esc(name)))
+ if b.Hover {
+ ulW := float64(len([]rune(name))) * b.NameSize * 0.52
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-ulW/2), num(y+5), num(ulW)))
+ }
+ }
+ if b.Hover {
+ r.body.WriteString(``)
+ }
+ r.body.WriteString(``)
+}
+
+// creditLabel describes what a contributor is being credited for.
+func creditLabel(c Contributor) string {
+ primary, unit, unitOne := c.Commits, "commits", "commit"
+ if creditMetric == "prs" && (c.PRs > 0 || c.Commits == 0) {
+ primary, unit, unitOne = c.PRs, "merged PRs", "merged PR"
+ }
+ if c.Mentions > primary {
+ primary, unit, unitOne = c.Mentions, "changelog credits", "changelog credit"
+ }
+ if primary == 1 {
+ unit = unitOne
+ }
+ return comma(primary) + " " + unit
+}
+
+// comma formats n with thousands separators.
+func comma(n int) string {
+ s := fmt.Sprintf("%d", n)
+ if len(s) <= 3 {
+ return s
+ }
+ var b strings.Builder
+ lead := len(s) % 3
+ if lead > 0 {
+ b.WriteString(s[:lead])
+ }
+ for i := lead; i < len(s); i += 3 {
+ if b.Len() > 0 {
+ b.WriteString(",")
+ }
+ b.WriteString(s[i : i+3])
+ }
+ return b.String()
+}
+
+func (r *renderer) contributorFooter(url string) {
+ cx := r.opts.Width / 2
+ r.y += 34
+ btnW, btnH := 236.0, 42.0
+ r.body.WriteString(fmt.Sprintf(``, esc(url)))
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-btnW/2), num(r.y), num(btnW), num(btnH)))
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-btnW/2), num(r.y), num(btnW), num(btnH)))
+ r.body.WriteString(fmt.Sprintf(``,
+ num(cx-btnW/2), num(r.y), num(btnW), num(btnH)))
+ // A tiny git branch glyph: trunk, branch curve and nodes.
+ bx, by := cx-92, r.y+btnH/2
+ r.body.WriteString(fmt.Sprintf(``,
+ num(bx), num(by)))
+ r.body.WriteString(fmt.Sprintf(`Become a Contributor`,
+ num(cx+8), num(r.y+btnH/2+5)))
+ r.body.WriteString(``)
+ r.y += btnH + 34
+}
diff --git a/website/i18n/ar/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/ar/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/ar/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/ar/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/de/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/de/docusaurus-plugin-content-pages/credits.mdx
index 0981dc76e79..886372a2e9d 100644
--- a/website/i18n/de/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/de/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Mitwirkende
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Besondere Erwähnungen
diff --git a/website/i18n/fr/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/fr/docusaurus-plugin-content-pages/credits.mdx
index 9f24a1f7e3d..625b23b8eee 100644
--- a/website/i18n/fr/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/fr/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributeurs
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Mentions spéciales
diff --git a/website/i18n/ja/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/ja/docusaurus-plugin-content-pages/credits.mdx
index c08d28f15ee..a53688d966f 100644
--- a/website/i18n/ja/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/ja/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## コントリビューター
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## スペシャルサンクス
diff --git a/website/i18n/ko/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/ko/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/ko/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/ko/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/pt/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/pt/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/pt/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/pt/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/ru/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/ru/docusaurus-plugin-content-pages/credits.mdx
index de666a0ea0d..9eba37e891e 100644
--- a/website/i18n/ru/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/ru/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/tr/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/tr/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/tr/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/tr/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/uk/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/uk/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/uk/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/uk/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/vi/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/vi/docusaurus-plugin-content-pages/credits.mdx
index 39f261a881a..c0f85fb565b 100644
--- a/website/i18n/vi/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/vi/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-pages/credits.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-pages/credits.mdx
index ca721d1dc3d..ae3c73295aa 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-pages/credits.mdx
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-pages/credits.mdx
@@ -15,249 +15,15 @@
## 贡献者
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## 特别提及
diff --git a/website/src/pages/credits.mdx b/website/src/pages/credits.mdx
index a359308a467..88131f2870c 100644
--- a/website/src/pages/credits.mdx
+++ b/website/src/pages/credits.mdx
@@ -15,249 +15,15 @@
## Contributors
-
-
-
-
+{/* object (not img) so the per-contributor links and hover effects inside the SVG work */}
+
## Special Mentions
diff --git a/website/static/img/contributors.svg b/website/static/img/contributors.svg
new file mode 100644
index 00000000000..3bcfa5221e6
--- /dev/null
+++ b/website/static/img/contributors.svg
@@ -0,0 +1,108 @@
+
diff --git a/website/static/img/sponsors.svg b/website/static/img/sponsors.svg
index e9e947a0c53..1a64496b8f1 100644
--- a/website/static/img/sponsors.svg
+++ b/website/static/img/sponsors.svg
@@ -92,4 +92,4 @@ text {
.sp:hover .pulse { animation: none; }
}
-Wails SponsorsThank you to everyone who supports the projectCHAMPIONAstarte Industries (@AstarteIndustries)Astarte IndustriesGOLD SPONSORSCosine (@CosineAI)CosineSILVER SPONSORSJohno Scott (@johnoscott)Johno ScottMichael J. Barton (@mbartisan)Michael J. Bart…Orb (@orbforge)OrbBRONZE SPONSORSArgus Labs (@Argus-Labs)Argus LabsCodeRabbit (@coderabbitai)CodeRabbitKazuya Gokita (@kazoo04)Kazuya GokitaKeygen (@keygen-sh)KeygenCody Bentley (@sircodemane)Cody BentleyCOVERING COSTSKevin Brisson (@kbrisso)Michael Stiles (@mstiles-grs)Sander Steffann (@steffann)BUYING BREAKFASTJohn Wilson (@johnwilson)lewlazarus (@lewlazarus)Michael Belokon (@mibeon)Zach Botterman (@popaprozac)Tai Groot (@taigrr)Tom Wu (@tomyjwu)BUYING COFFEE5aaee9 (@5aaee9)Amolith (@Amolith)SlawomirBuszko (@SlawomirBuszko)Alex Viscreanu (@aexvir)Chris Keenan (@christolo)Eric (@ericfernance)Hari Bhaskaran (@harikb)Iain Collins (@iaincollins)Ian M. Jones (@ianmjones)Sascha Loishandl (@loissascha)Gianluca Onorato (@lucaono13)Masaya Nasu (@nasum)Per Tillisch (@per1234)QuEST Rail LLC (@questrail)Will Cory (@roninjin10)Trea Hauet (@trea)Tyler (@tylertravisty)HELPERS大井さかな (@01397)7csc (@7csc)Hestia (@Hestia-23)Andrew Schreiber (@andrewschreiber)Fabien Brocklesby (@fabienbrocklesby)Glenn Lewis (@gmlewis)JL Griffin (@griffinwebnet)Ian Sinnott (@iansinnott)Johannes Lübke (@johannes-luebke)Joe Constant (@lazyguru)robertg (@rgehrsitz)Xenon007 (@xenon007)Jens Roggenfelder (@JRpersonal)Sami (@gnomeria)Become a Sponsor
+Wails SponsorsThank you to everyone who supports the projectCHAMPIONAstarte Industries (@AstarteIndustries)Astarte IndustriesGOLD SPONSORSCosine (@CosineAI)CosineSILVER SPONSORSJohno Scott (@johnoscott)Johno ScottMichael J. Barton (@mbartisan)Michael J. Bart…Orb (@orbforge)OrbBRONZE SPONSORSArgus Labs (@Argus-Labs)Argus LabsCodeRabbit (@coderabbitai)CodeRabbitKazuya Gokita (@kazoo04)Kazuya GokitaKeygen (@keygen-sh)KeygenCody Bentley (@sircodemane)Cody BentleyCOVERING COSTSKevin Brisson (@kbrisso)Michael Stiles (@mstiles-grs)Sander Steffann (@steffann)BUYING BREAKFASTJohn Wilson (@johnwilson)lewlazarus (@lewlazarus)Michael Belokon (@mibeon)Zach Botterman (@popaprozac)Tai Groot (@taigrr)Tom Wu (@tomyjwu)BUYING COFFEE5aaee9 (@5aaee9)Amolith (@Amolith)SlawomirBuszko (@SlawomirBuszko)Alex Viscreanu (@aexvir)Chris Keenan (@christolo)Eric (@ericfernance)Hari Bhaskaran (@harikb)Iain Collins (@iaincollins)Ian M. Jones (@ianmjones)Sascha Loishandl (@loissascha)Gianluca Onorato (@lucaono13)Masaya Nasu (@nasum)Per Tillisch (@per1234)QuEST Rail LLC (@questrail)Will Cory (@roninjin10)Trea Hauet (@trea)Tyler (@tylertravisty)HELPERS大井さかな (@01397)7csc (@7csc)Hestia (@Hestia-23)Andrew Schreiber (@andrewschreiber)Fabien Brocklesby (@fabienbrocklesby)Glenn Lewis (@gmlewis)JL Griffin (@griffinwebnet)Ian Sinnott (@iansinnott)Johannes Lübke (@johannes-luebke)Joe Constant (@lazyguru)robertg (@rgehrsitz)Xenon007 (@xenon007)Jens Roggenfelder (@JRpersonal)Sami (@gnomeria)Become a Sponsor