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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# GitHub Personal Access Token (optional, but recommended for higher rate limits)
# Generate one at: https://github.com/settings/tokens
# No scopes are needed for public data
GITHUB_TOKEN=your_github_token_here
28 changes: 28 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Description

[//]: # "Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context."

## Type of Change

[//]: # "Please select one"

- [ ] Feature
- [ ] Bugfix
- [ ] Refactor
- [ ] Documentation
- [ ] Other

## Checklist

[//]: # "Please check all that apply"

- [ ] Manually tested by running `pnpm dev` and viewing all pages
- [ ] It can build a prod build with `pnpm build`
- [ ] Documentation and comments added
- [ ] No console warnings or errors
- [ ] No ESLint warnings or errors
- [ ] No prettier warnings or errors

## Supplementary Information

[//]: # "Any other information that is important to this PR, such as screenshots of a UI change"
37 changes: 37 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Code Quality

on:
pull_request:
branches: [ main ]
push:
branches: [ main ]

jobs:
lint-and-format:
name: Lint & Format
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # actions/checkout@v6

- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # pnpm/action-setup@v4
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"

- name: Install dependencies
run: pnpm install

# Takes less than eslint, so we run it first
- name: Run Prettier check
run: pnpm format:check

- name: Run ESLint
run: pnpm lint:check
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,7 @@ dist
# Vite logs files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

# Sandbox test outputs
sandbox/output-*.svg
sandbox/output-*-languages-*.svg
10 changes: 10 additions & 0 deletions .prettierc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
5 changes: 5 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package.json
package-lock.json
pnpm-lock.yaml
README.md
.github/**
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,74 @@
# github-flex
Flex your GitHub stats with customizable SVG badges for your profile. Because your GitHub profile deserves to look as good as your code...right?!
# GitHub Flex

Flex your GitHub stats with customizable SVG badges for your profile. Because your GitHub profile deserves to look as
good as your code...right?!

## 🚀 Quick Start

Use the following Markdown snippets to add your GitHub stats, languages, or specific repo info to your profile README:

```markdown
![Stats](https://github-flex.vercel.app/api/stats?username=YOUR_USERNAME&theme=dark)
![Languages](https://github-flex.vercel.app/api/languages?username=YOUR_USERNAME)
![Repo](https://github-flex.vercel.app/api/repo?username=OWNER&repo=REPO_NAME)
```

## 🔌 Available Endpoints

### `/api/stats`

**Parameters:**

- `username` (required)
- `theme` - `default`, `dark`, `radical`, `merko`, `gruvbox`, `tokyonight`
- `hide_border` - `true` or `false`
- `hide_title` - `true` or `false`

### `/api/languages`

**Parameters:**

- `username` (required)
- `theme` - same as above
- `langs_count` - number (default: 5)
- `exclude` - comma-separated languages to exclude (e.g., `HTML,CSS`)
- `hide_border`, `hide_title`

### `/api/repo`

**Parameters:**

- `username` (required)
- `repo` (required)
- `theme`, `hide_border`

## 💻 Local Development

```bash
# Install
pnpm install
# or: npm install

# Build
pnpm build
# or: npm run build

# Run with Vercel CLI
vercel dev

# Test without Vercel CLI
node sandbox/test-stats.js torvalds
node sandbox/test-languages.js torvalds 8
node sandbox/test-svg.js torvalds dark
node sandbox/test-languages-svg.js torvalds dark 8
```

## ☁️ Deploy to Vercel

1. Fork this repo
2. Connect to [Vercel](https://vercel.com)
3. (Optional) Add `GITHUB_TOKEN` env variable for higher rate limits

## 📜 License

This project is licensed under the [MIT License](LICENSE).
108 changes: 108 additions & 0 deletions api/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { VercelRequest, VercelResponse } from "@vercel/node";
import { fetchLanguageStats, getTopLanguages } from "../lib/languages.js";
import { generateCard, generateLanguageItem } from "../lib/card.js";
import { ThemeName } from "../lib/types.js";
import { themes } from "../lib/themes.js";

/**
* API handler to generate a GitHub languages badge.
* Accepts query parameters:
* - username (required): GitHub username
* - theme (optional): Card theme (default: "default")
* - hide_border (optional): Whether to hide the card border (true/false)
* - hide_title (optional): Whether to hide the card title (true/false)
* - langs_count (optional): Number of top languages to display (default: 5)
* - exclude (optional): Comma-separated list of languages to exclude (e.g., "HTML,CSS")
*/
export default async function handler(req: VercelRequest, res: VercelResponse) {
const {
username,
theme = ThemeName.Default,
hide_border,
hide_title,
langs_count = "5",
exclude,
} = req.query;

// Validate username
if (!username || typeof username !== "string") {
return res.status(400).json({ error: "Username is required" });
}

// Validate theme
const selectedTheme =
typeof theme === "string" &&
Object.values(ThemeName).includes(theme as ThemeName)
? (theme as ThemeName)
: ThemeName.Default;
const langsCount = parseInt(
typeof langs_count === "string" ? langs_count : "5",
10,
);

try {
// Get GitHub token from environment variable
const githubToken = process.env.GITHUB_TOKEN;

// Get excluded languages
const excludeParam = typeof exclude === "string" ? exclude : undefined;

// Fetch language stats
const stats = await fetchLanguageStats(username, githubToken, excludeParam);
const topLanguages = getTopLanguages(stats, langsCount);

if (topLanguages.length === 0) {
throw new Error("No languages found");
}

// Get theme colors
const colors = themes[selectedTheme];

// Generate language items with playful card styling
const languageItems = topLanguages
.map((lang, index) =>
generateLanguageItem(
lang.name,
lang.percent,
index,
colors.icon,
colors.border,
),
)
.join("");

// Calculate card height based on number of languages (adjusted for new layout)
const cardHeight = 90 + topLanguages.length * 50;

// Generate the card
const svg = generateCard(languageItems, {
title: hide_title === "true" ? "" : "Most Used Languages",
width: 450,
height: cardHeight,
theme: selectedTheme,
hideBorder: hide_border === "true",
});

// Set cache headers (cache for 4 hours)
res.setHeader("Content-Type", "image/svg+xml");
res.setHeader("Cache-Control", "public, max-age=14400");

return res.status(200).send(svg);
} catch (error) {
console.error("Error generating language badge:", error);

// Return error SVG
const errorSvg = generateCard(
`<g transform="translate(25, 60)"><text class="stat">Failed to fetch language stats</text></g>`,
{
title: "Most Used Languages",
width: 450,
height: 120,
theme: selectedTheme,
},
);

res.setHeader("Content-Type", "image/svg+xml");
return res.status(500).send(errorSvg);
}
}
Loading