Skip to content

Commit cc0c4bf

Browse files
Codestzclaude
andcommitted
feat: redesign profile README with RSS feed, self-hosted stats, and GitHub Actions
- Rewrite README as a GitHub profile README with typing SVG, focus cards, tech icons, project list, and blog post links - Add RSS feed route at /feed serving all blog posts as RSS 2.0 XML - Add RSS autodiscovery link in root layout metadata - Add GitHub Action to auto-update README blog posts from RSS feed - Add generate-stats script that fetches GitHub API data and generates static SVG cards (stats, languages, streak, contribution graph) for both dark and light themes — zero external service dependencies - Add GitHub Action to regenerate stats SVGs every 6 hours Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5e98645 commit cc0c4bf

File tree

14 files changed

+883
-501
lines changed

14 files changed

+883
-501
lines changed

.github/scripts/generate-stats.js

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
// Generates static SVG stats cards from GitHub API data
2+
// Run via: node .github/scripts/generate-stats.js
3+
4+
/* eslint-disable @typescript-eslint/no-require-imports */
5+
const fs = require('fs');
6+
const path = require('path');
7+
8+
const USERNAME = 'Codestz';
9+
const PURPLE = '#7c3aed';
10+
const TOKEN = process.env.GITHUB_TOKEN;
11+
12+
if (!TOKEN) {
13+
console.error('GITHUB_TOKEN is required');
14+
process.exit(1);
15+
}
16+
17+
async function githubAPI(query) {
18+
const res = await fetch('https://api.github.com/graphql', {
19+
method: 'POST',
20+
headers: {
21+
Authorization: `bearer ${TOKEN}`,
22+
'Content-Type': 'application/json',
23+
},
24+
body: JSON.stringify({ query }),
25+
});
26+
if (!res.ok) {
27+
const text = await res.text();
28+
throw new Error(`GitHub API error: ${res.status} - ${text}`);
29+
}
30+
const json = await res.json();
31+
if (json.errors) {
32+
throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
33+
}
34+
return json;
35+
}
36+
37+
async function fetchStats() {
38+
const { data } = await githubAPI(`{
39+
user(login: "${USERNAME}") {
40+
contributionsCollection {
41+
totalCommitContributions
42+
totalPullRequestContributions
43+
totalPullRequestReviewContributions
44+
totalIssueContributions
45+
contributionCalendar {
46+
totalContributions
47+
weeks {
48+
contributionDays {
49+
contributionCount
50+
date
51+
}
52+
}
53+
}
54+
}
55+
repositories(first: 100, ownerAffiliations: OWNER, orderBy: {field: STARGAZERS, direction: DESC}) {
56+
totalCount
57+
nodes {
58+
stargazerCount
59+
primaryLanguage { name }
60+
languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
61+
edges { size node { name color } }
62+
}
63+
}
64+
}
65+
mergedPRs: pullRequests(states: MERGED) { totalCount }
66+
allPRs: pullRequests(states: [OPEN, CLOSED, MERGED]) { totalCount }
67+
}
68+
}`);
69+
return data.user;
70+
}
71+
72+
function calculateStreak(weeks) {
73+
const days = weeks
74+
.flatMap((w) => w.contributionDays)
75+
.sort((a, b) => b.date.localeCompare(a.date));
76+
77+
let currentStreak = 0;
78+
let longestStreak = 0;
79+
let tempStreak = 0;
80+
let todayIsToday = true;
81+
82+
for (const day of days) {
83+
if (day.contributionCount > 0) {
84+
tempStreak++;
85+
if (todayIsToday || currentStreak > 0) currentStreak = tempStreak;
86+
} else {
87+
if (todayIsToday) {
88+
todayIsToday = false;
89+
continue;
90+
}
91+
longestStreak = Math.max(longestStreak, tempStreak);
92+
tempStreak = 0;
93+
if (currentStreak > 0) break;
94+
}
95+
todayIsToday = false;
96+
}
97+
longestStreak = Math.max(longestStreak, tempStreak);
98+
return { currentStreak, longestStreak };
99+
}
100+
101+
function getLanguageStats(repos) {
102+
const langMap = {};
103+
for (const repo of repos) {
104+
for (const edge of repo.languages.edges) {
105+
const name = edge.node.name;
106+
if (!langMap[name]) langMap[name] = { size: 0, color: edge.node.color || '#888' };
107+
langMap[name].size += edge.size;
108+
}
109+
}
110+
const sorted = Object.entries(langMap)
111+
.sort((a, b) => b[1].size - a[1].size)
112+
.slice(0, 8);
113+
const total = sorted.reduce((sum, [, v]) => sum + v.size, 0);
114+
return sorted.map(([name, v]) => ({
115+
name,
116+
color: v.color,
117+
percentage: ((v.size / total) * 100).toFixed(1),
118+
}));
119+
}
120+
121+
function generateStatsSVG(stats, theme) {
122+
const isDark = theme === 'dark';
123+
const textColor = isDark ? '#c9d1d9' : '#333333';
124+
const totalStars = stats.repositories.nodes.reduce((sum, r) => sum + r.stargazerCount, 0);
125+
const mergedPRs = stats.mergedPRs.totalCount;
126+
const cc = stats.contributionsCollection;
127+
128+
const items = [
129+
{
130+
icon: 'M12 .587l3.668 7.568L24 9.306l-6 5.862 1.416 8.245L12 19.446l-7.416 3.967L6 15.168 0 9.306l8.332-1.151z',
131+
label: 'Total Stars',
132+
value: totalStars,
133+
},
134+
{
135+
icon: 'M16 1.25a14.75 14.75 0 0 1 0 29.5H2V16C2 7.85 8.6 1.25 16 1.25zM8 18a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2H8zm0-4a1 1 0 1 0 0 2h12a1 1 0 1 0 0-2H8z',
136+
label: 'Commits',
137+
value: cc.totalCommitContributions,
138+
viewBox: '0 0 32 32',
139+
},
140+
{
141+
icon: 'M18 13v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h7l2 2h5a2 2 0 0 1 2 2v1H9l-2 4v5h11l2-6z',
142+
label: 'PRs Merged',
143+
value: mergedPRs,
144+
},
145+
{
146+
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
147+
label: 'Reviews',
148+
value: cc.totalPullRequestReviewContributions,
149+
},
150+
{
151+
icon: 'M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 5a1 1 0 011 1v4a1 1 0 01-1 1H9a1 1 0 110-2h2V8a1 1 0 011-1z',
152+
label: 'Issues',
153+
value: cc.totalIssueContributions,
154+
},
155+
{
156+
icon: 'M3 3h18v18H3V3zm2 2v14h14V5H5z',
157+
label: 'Repos',
158+
value: stats.repositories.totalCount,
159+
},
160+
];
161+
162+
const rows = items
163+
.map((item, i) => {
164+
const y = 48 + i * 28;
165+
return `
166+
<g transform="translate(25, ${y})">
167+
<svg width="16" height="16" viewBox="0 0 ${item.viewBox || '24 24'}" fill="none" stroke="${PURPLE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
168+
<path d="${item.icon}"/>
169+
</svg>
170+
<text x="28" y="12" fill="${textColor}" font-size="13" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">${item.label}:</text>
171+
<text x="290" y="12" fill="${textColor}" font-size="13" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="end">${item.value.toLocaleString()}</text>
172+
</g>`;
173+
})
174+
.join('');
175+
176+
return `<svg xmlns="http://www.w3.org/2000/svg" width="340" height="230" viewBox="0 0 340 230">
177+
<text x="25" y="28" fill="${PURPLE}" font-size="16" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">Codestz's GitHub Stats</text>
178+
${rows}
179+
</svg>`;
180+
}
181+
182+
function generateLanguagesSVG(languages, theme) {
183+
const isDark = theme === 'dark';
184+
const textColor = isDark ? '#c9d1d9' : '#333333';
185+
186+
let barX = 25;
187+
const barWidth = 290;
188+
const bars = languages.map((lang) => {
189+
const width = (parseFloat(lang.percentage) / 100) * barWidth;
190+
const x = barX;
191+
barX += width;
192+
return `<rect x="${x}" y="42" width="${width}" height="8" fill="${lang.color}" rx="0"/>`;
193+
});
194+
195+
const langLabels = languages.map((lang, i) => {
196+
const col = i % 2;
197+
const row = Math.floor(i / 2);
198+
const x = 25 + col * 150;
199+
const y = 70 + row * 22;
200+
return `
201+
<g transform="translate(${x}, ${y})">
202+
<circle cx="5" cy="5" r="5" fill="${lang.color}"/>
203+
<text x="16" y="9" fill="${textColor}" font-size="12" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">${lang.name} ${lang.percentage}%</text>
204+
</g>`;
205+
});
206+
207+
const height = 70 + Math.ceil(languages.length / 2) * 22 + 10;
208+
209+
return `<svg xmlns="http://www.w3.org/2000/svg" width="340" height="${height}" viewBox="0 0 340 ${height}">
210+
<text x="25" y="28" fill="${PURPLE}" font-size="16" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">Top Languages</text>
211+
${bars.join('')}
212+
${langLabels.join('')}
213+
</svg>`;
214+
}
215+
216+
function generateStreakSVG(streak, totalContributions, theme) {
217+
const isDark = theme === 'dark';
218+
const textColor = isDark ? '#c9d1d9' : '#333333';
219+
const subColor = isDark ? '#888888' : '#666666';
220+
221+
return `<svg xmlns="http://www.w3.org/2000/svg" width="540" height="160" viewBox="0 0 540 160">
222+
<g transform="translate(25, 30)">
223+
<text x="70" y="20" fill="${textColor}" font-size="28" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">${totalContributions}</text>
224+
<text x="70" y="42" fill="${subColor}" font-size="12" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">Total Contributions</text>
225+
</g>
226+
<line x1="180" y1="15" x2="180" y2="130" stroke="${isDark ? '#333' : '#e0e0e0'}" stroke-width="1"/>
227+
<g transform="translate(190, 20)">
228+
<text x="80" y="16" fill="${PURPLE}" font-size="36" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">${streak.currentStreak}</text>
229+
<text x="80" y="42" fill="${subColor}" font-size="12" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">Current Streak</text>
230+
<text x="80" y="62" fill="${subColor}" font-size="10" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">days</text>
231+
</g>
232+
<line x1="360" y1="15" x2="360" y2="130" stroke="${isDark ? '#333' : '#e0e0e0'}" stroke-width="1"/>
233+
<g transform="translate(365, 30)">
234+
<text x="70" y="20" fill="${textColor}" font-size="28" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">${streak.longestStreak}</text>
235+
<text x="70" y="42" fill="${subColor}" font-size="12" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif" text-anchor="middle">Longest Streak</text>
236+
</g>
237+
<g transform="translate(25, 85)">
238+
${generateMiniGraph(streak.recentDays)}
239+
</g>
240+
</svg>`;
241+
}
242+
243+
function generateMiniGraph(recentDays) {
244+
if (!recentDays || recentDays.length === 0) return '';
245+
246+
const width = 490;
247+
const height = 50;
248+
const max = Math.max(...recentDays.map((d) => d.count), 1);
249+
const barWidth = Math.max(1, width / recentDays.length - 1);
250+
251+
const bars = recentDays.map((day, i) => {
252+
const barHeight = Math.max(2, (day.count / max) * height);
253+
const x = i * (barWidth + 1);
254+
const y = height - barHeight;
255+
const opacity = day.count === 0 ? 0.1 : 0.3 + (day.count / max) * 0.7;
256+
return `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${PURPLE}" opacity="${opacity.toFixed(2)}" rx="1"/>`;
257+
});
258+
259+
return `<g transform="translate(0, 0)">${bars.join('')}</g>`;
260+
}
261+
262+
function generateContributionGraphSVG(weeks, theme) {
263+
const isDark = theme === 'dark';
264+
const subColor = isDark ? '#888888' : '#666666';
265+
const emptyColor = isDark ? '#161b22' : '#ebedf0';
266+
267+
// Take last 52 weeks
268+
const recentWeeks = weeks.slice(-52);
269+
const cellSize = 11;
270+
const gap = 2;
271+
const marginLeft = 30;
272+
const marginTop = 40;
273+
const width = marginLeft + recentWeeks.length * (cellSize + gap) + 20;
274+
const height = marginTop + 7 * (cellSize + gap) + 30;
275+
const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
276+
277+
function getColor(count) {
278+
if (count === 0) return emptyColor;
279+
if (count <= 2) return `${PURPLE}44`;
280+
if (count <= 5) return `${PURPLE}77`;
281+
if (count <= 10) return `${PURPLE}aa`;
282+
return PURPLE;
283+
}
284+
285+
const cells = recentWeeks
286+
.map((week, wi) =>
287+
week.contributionDays
288+
.map((day, di) => {
289+
const x = marginLeft + wi * (cellSize + gap);
290+
const y = marginTop + di * (cellSize + gap);
291+
return `<rect x="${x}" y="${y}" width="${cellSize}" height="${cellSize}" fill="${getColor(day.contributionCount)}" rx="2"><title>${day.date}: ${day.contributionCount} contributions</title></rect>`;
292+
})
293+
.join('')
294+
)
295+
.join('');
296+
297+
const labels = dayLabels
298+
.map((label, i) => {
299+
if (!label) return '';
300+
const y = marginTop + i * (cellSize + gap) + 9;
301+
return `<text x="0" y="${y}" fill="${subColor}" font-size="10" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">${label}</text>`;
302+
})
303+
.join('');
304+
305+
// Month labels
306+
const months = [
307+
'Jan',
308+
'Feb',
309+
'Mar',
310+
'Apr',
311+
'May',
312+
'Jun',
313+
'Jul',
314+
'Aug',
315+
'Sep',
316+
'Oct',
317+
'Nov',
318+
'Dec',
319+
];
320+
let lastMonth = -1;
321+
const monthLabels = recentWeeks
322+
.map((week, wi) => {
323+
const firstDay = week.contributionDays[0];
324+
if (!firstDay) return '';
325+
const month = new Date(firstDay.date).getMonth();
326+
if (month !== lastMonth) {
327+
lastMonth = month;
328+
const x = marginLeft + wi * (cellSize + gap);
329+
return `<text x="${x}" y="${marginTop - 8}" fill="${subColor}" font-size="10" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">${months[month]}</text>`;
330+
}
331+
return '';
332+
})
333+
.join('');
334+
335+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
336+
<text x="${marginLeft}" y="20" fill="${PURPLE}" font-size="16" font-weight="bold" font-family="'Segoe UI', Ubuntu, 'Helvetica Neue', sans-serif">Contribution Graph</text>
337+
${monthLabels}
338+
${labels}
339+
${cells}
340+
</svg>`;
341+
}
342+
343+
async function main() {
344+
console.log('Fetching GitHub stats...');
345+
const stats = await fetchStats();
346+
const weeks = stats.contributionsCollection.contributionCalendar.weeks;
347+
const totalContributions = stats.contributionsCollection.contributionCalendar.totalContributions;
348+
const languages = getLanguageStats(stats.repositories.nodes);
349+
const streak = calculateStreak(weeks);
350+
351+
// Get recent 90 days for mini bar graph
352+
const allDays = weeks
353+
.flatMap((w) => w.contributionDays)
354+
.sort((a, b) => a.date.localeCompare(b.date));
355+
streak.recentDays = allDays.slice(-90).map((d) => ({ count: d.contributionCount }));
356+
357+
const outDir = path.join(process.cwd(), 'public', 'stats');
358+
fs.mkdirSync(outDir, { recursive: true });
359+
360+
for (const theme of ['dark', 'light']) {
361+
fs.writeFileSync(path.join(outDir, `stats-${theme}.svg`), generateStatsSVG(stats, theme));
362+
fs.writeFileSync(
363+
path.join(outDir, `langs-${theme}.svg`),
364+
generateLanguagesSVG(languages, theme)
365+
);
366+
fs.writeFileSync(
367+
path.join(outDir, `streak-${theme}.svg`),
368+
generateStreakSVG(streak, totalContributions, theme)
369+
);
370+
fs.writeFileSync(
371+
path.join(outDir, `contributions-${theme}.svg`),
372+
generateContributionGraphSVG(weeks, theme)
373+
);
374+
console.log(`Generated ${theme} theme SVGs`);
375+
}
376+
377+
console.log('Done! SVGs written to public/stats/');
378+
}
379+
380+
main().catch((err) => {
381+
console.error(err);
382+
process.exit(1);
383+
});

0 commit comments

Comments
 (0)