├── .gitignore ├── README.md ├── chart.js ├── fonts ├── JetBrainsMono-Bold.ttf └── JetBrainsMono-Regular.ttf └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Package-lock 46 | package-lock.json 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Optional stylelint cache 61 | .stylelintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variable files 79 | .env 80 | .env.development.local 81 | .env.test.local 82 | .env.production.local 83 | .env.local 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![Cover](https://i.ibb.co/C5dzx2K/IMG-9867-1.jpg) 3 | 4 | # Github Star History 5 | - [Ready to use](#ready-to-use) 6 | - [Examples](#examples) 7 | - // TODO 8 | 9 | ## Ready to use 10 | 11 | > \[!NOTE] 12 | > 13 | > This project is self-hosted since 2023. Please, show some :hearts: and put a :star:! 14 | 15 | Include this line in your README.md file and change `USERNAME`, `REPOSITORY` and `COLOR`: 16 | 17 | ```md 18 | [![Star History](https://api.lucabubi.me/chart?username=USERNAME&repository=REPOSITORY&color=COLOR)](https://github.com/lucabubi/star-history) 19 | ``` 20 | 21 | #### Color Reference 22 | 23 | | Color | Palette | 24 | | ----------------- | ------------------------------------------------------------------ | 25 | | red | ![#c91900](https://via.placeholder.com/10/c91900?text=+) rgba(201, 25, 0, x) | 26 | | orange | ![#ff8900](https://via.placeholder.com/10/ff8900?text=+) rgba(255, 137, 0, x) | 27 | | yellow | ![#ffd700](https://via.placeholder.com/10/ffd700?text=+) rgba(255, 215, 0, x) | 28 | | green | ![#20d420](https://via.placeholder.com/10/20d420?text=+) rgba(32, 212, 32, x) | 29 | | blue | ![#1e4eff](https://via.placeholder.com/10/1e4eff?text=+) rgba(30, 78, 255, x) | 30 | | violet | ![#9600d7](https://via.placeholder.com/10/9600d7?text=+) rgba(150, 0, 215, x) | 31 | 32 | #### Examples 33 | 34 | ```md 35 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=red)](https://github.com/lucabubi/star-history) 36 | ``` 37 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=red)](https://github.com/lucabubi/star-history) 38 | 39 | ```md 40 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=orange)](https://github.com/lucabubi/star-history) 41 | ``` 42 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=orange)](https://github.com/lucabubi/star-history) 43 | 44 | ```md 45 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=yellow)](https://github.com/lucabubi/star-history) 46 | ``` 47 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=yellow)](https://github.com/lucabubi/star-history) 48 | 49 | ```md 50 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=green)](https://github.com/lucabubi/star-history) 51 | ``` 52 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=green)](https://github.com/lucabubi/star-history) 53 | 54 | ```md 55 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=blue)](https://github.com/lucabubi/star-history) 56 | ``` 57 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples&color=blue)](https://github.com/lucabubi/star-history) 58 | 59 | ```md 60 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples)](https://github.com/lucabubi/star-history) 61 | ``` 62 | [![Star History](https://api.lucabubi.me/chart?username=mdn&repository=js-examples)](https://github.com/lucabubi/star-history) 63 | 64 | ## // TODO 65 | -------------------------------------------------------------------------------- /chart.js: -------------------------------------------------------------------------------- 1 | // Strict Mode 2 | 'use strict'; 3 | 4 | // Imports 5 | import express, { json } from 'express'; 6 | import helmet from "helmet"; 7 | import NodeCache from 'node-cache'; 8 | import { registerFont, createCanvas } from 'canvas'; 9 | import Chart from 'chart.js/auto'; 10 | import axios from 'axios'; 11 | import dayjs from 'dayjs'; 12 | import * as emoji from 'node-emoji' 13 | import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm.js'; 14 | 15 | registerFont('./fonts/JetBrainsMono-Regular.ttf', { family: 'JetBrains Mono Regular' }) 16 | registerFont('./fonts/JetBrainsMono-Bold.ttf', { family: 'JetBrains Mono Bold' }) 17 | 18 | // Start express plus a security mesure 19 | const app = express().disable("x-powered-by"); 20 | app.use(json()); 21 | app.use(helmet()); 22 | 23 | // URL of the server we're creating 24 | const API_URL = `http://localhost:${process.env.PORT}` 25 | 26 | // Width of canvas in px 27 | const WIDTH = 495; 28 | 29 | // Height of canvas in px 30 | const HEIGHT = 195; 31 | 32 | // Color Palette 33 | const colorPalette = { 34 | red: { 35 | line: 'rgba(201, 25, 0, 1)', 36 | area: 'rgba(201, 25, 0, 0.25)', 37 | title: 'rgba(201, 25, 0, 0.80)', 38 | xlabel: 'rgba(201, 25, 0, 0.80)' 39 | }, 40 | orange: { 41 | line: 'rgba(255, 137, 0, 1)', 42 | area: 'rgba(255, 137, 0, 0.25)', 43 | title: 'rgba(255, 137, 0, 0.80)', 44 | xlabel: 'rgba(255, 137, 0, 0.80)' 45 | }, 46 | yellow: { 47 | line: 'rgba(255, 215, 0, 1)', 48 | area: 'rgba(255, 215, 0, 0.25)', 49 | title: 'rgba(255, 215, 0, 0.80)', 50 | xlabel: 'rgba(255, 215, 0, 0.80)' 51 | }, 52 | green: { 53 | line: 'rgba(32, 212, 32, 1)', 54 | area: 'rgba(32, 212, 32, 0.25)', 55 | title: 'rgba(32, 212, 32, 0.80)', 56 | xlabel: 'rgba(32, 212, 32, 0.80)' 57 | }, 58 | blue: { 59 | line: 'rgba(30, 78, 255, 1)', 60 | area: 'rgba(30, 78, 255, 0.25)', 61 | title: 'rgba(30, 78, 255, 0.80)', 62 | xlabel: 'rgba(30, 78, 255, 0.80)' 63 | }, 64 | violet: { 65 | line: 'rgba(150, 0, 215, 1)', 66 | area: 'rgba(150, 0, 215, 0.25)', 67 | title: 'rgba(150, 0, 215, 0.80)', 68 | xlabel: 'rgba(150, 0, 215, 0.80)' 69 | }, 70 | }; 71 | 72 | // Redirect HTTP to HTTPS (Made for Heroku Deployment) -- COMMENT THIS IF YOU'RE NOT USING HTTPS 73 | app.use((req, res, next) => { 74 | if (req.header('x-forwarded-proto') !== 'https') { 75 | res.redirect(`https://${req.hostname}${req.url}`); 76 | } else { 77 | next(); 78 | } 79 | }); 80 | 81 | // Initialize cache (set default TTL to 24 hours = 86400 seconds) 82 | const cache = new NodeCache({ stdTTL: 86400, checkperiod: 600 }); // Check for expired keys every 10 minutes 83 | 84 | 85 | // Define the GET call to /chart 86 | app.get('/chart', async (req, res) => { 87 | 88 | // Logging 89 | console.log("GET " + req.hostname + req.url); 90 | 91 | // Get these parameters from the url 92 | // Format (optional) -> localhost:3000/chart?username=username&repository=repository (&color=color) 93 | const { username, repository, color } = req.query; 94 | 95 | if (!username || !repository) { 96 | return res.status(400).send('Username and repository are required'); 97 | } 98 | 99 | // Unique cache key based on query parameters 100 | const cacheKey = `${username}-${repository}-${color}`; 101 | // Github API endpoint 102 | const GITHUB_API_URL = `https://api.github.com/repos/${username}/${repository}`; 103 | 104 | // Check if the chart is already cached 105 | const cachedImage = cache.get(cacheKey); 106 | if (cachedImage) { 107 | // Cache hit 108 | res.header({ 109 | 'Content-Type': 'image/png', 110 | 'Cache-Control': 'public, max-age=86400' 111 | }); 112 | return res.send(Buffer.from(cachedImage.split(',')[1], 'base64')); 113 | } 114 | 115 | // If not cached, generate the chart image (cache miss) 116 | try { 117 | const chartImage = await createChartImage(`https://api.github.com/repos/${username}/${repository}`, color); 118 | 119 | // Cache the generated image 120 | cache.set(cacheKey, chartImage); 121 | 122 | // Send the response with caching headers 123 | res.header({ 124 | 'Content-Type': 'image/png', 125 | 'Cache-Control': 'public, max-age=86400' 126 | }); 127 | res.send(Buffer.from(chartImage.split(',')[1], 'base64')); 128 | } catch (error) { 129 | console.error(error); 130 | res.status(500).send('Error creating chart image'); 131 | } 132 | }); 133 | 134 | // Main function to create the Chart using Chartjs 135 | const createChartImage = async (GITHUB_API_URL, color = "violet") => { 136 | 137 | // Fetch Repository info 138 | const repoInfo = await axios.get(GITHUB_API_URL); 139 | 140 | // Check if the repository exists 141 | if (repoInfo.status !== 200) { 142 | throw new Error(`Unable to fetch repository information. Status code: ${repoInfo.status}`); 143 | } 144 | 145 | const totalStars = repoInfo.data.stargazers_count; 146 | const url = `${GITHUB_API_URL}/stargazers?per_page=100`; 147 | const pageCount = Math.ceil(totalStars / 100); 148 | 149 | // Fetch user-starred history 150 | const starsHistory = await getStarsHistory(url, pageCount); 151 | 152 | // Map for storing dates and stars count 153 | const dateMap = new Map(); 154 | let cumulativeCount = 0; 155 | 156 | // Sort by date 157 | starsHistory.sort((a, b) => dayjs(a.starred_at).isBefore(dayjs(b.starred_at), "day")); 158 | 159 | // Store dates and stars count 160 | starsHistory.forEach(entry => { 161 | const key = dayjs(entry.starred_at).format('YYYY-MM-DD').toString(); 162 | cumulativeCount += 1; 163 | dateMap.set(key, cumulativeCount); 164 | }); 165 | 166 | // X Chart axes 167 | const labels = []; 168 | 169 | // Y Chart axes 170 | const cumulativeStars = []; 171 | 172 | // Fill X-axes and Y-axes 173 | const dataArray = Array.from(dateMap.entries()) 174 | dataArray.forEach(([key, count]) => { 175 | labels.push(key); 176 | cumulativeStars.push(count); 177 | }); 178 | 179 | // Comparing by day, if the repo has been created in a date before the day the first user starred it 180 | if (dayjs(repoInfo.data.created_at).isBefore(dayjs(dataArray[0][0]).format("YYYY-MM-DD"), "day")) { 181 | 182 | let date = dayjs(repoInfo.data.created_at); 183 | const endDate = dayjs(dataArray[0][0]).format("YYYY-MM-DD"); 184 | 185 | while (date.isBefore(endDate)) { 186 | // Then We show as first x-label the day of the creation of the repo and all the dates between repo creation date 187 | // and the date in which the first user starred the repo 188 | labels.unshift(date.format('YYYY-MM-DD')); 189 | cumulativeStars.unshift(0); 190 | date = date.add(1, 'day'); 191 | } 192 | } 193 | 194 | // Comparing by day, if the repo has not been starred today 195 | if (dayjs(dataArray[dataArray.length - 1][0]).isBefore(dayjs().format("YYYY-MM-DD"), "day")) { 196 | // Then We add the last label with today-date 197 | labels.push(dayjs().format('YYYY-MM-DD')); 198 | // And as value we use the last amount of stars known 199 | cumulativeStars.push(dataArray[dataArray.length - 1][1]); 200 | } 201 | 202 | // In order to create rounded corner around the canvas and fill it with black 203 | const colorArea = { 204 | id: 'colorArea', 205 | beforeDraw(chart) { 206 | const { ctx } = chart; 207 | ctx.save(); 208 | ctx.beginPath(); 209 | ctx.roundRect(0, 0, 495, 195, 15); 210 | ctx.fillStyle = 'black'; 211 | ctx.fill(); 212 | } 213 | } 214 | 215 | // Count how many different months there are in the array provided 216 | function countUniqueMonths(x) { 217 | const months = x.map(value => dayjs(value).format('YYYY-MM')); 218 | return new Set(months).size; 219 | } 220 | 221 | // Exact number of X labels to show 222 | let uniqueMonths = countUniqueMonths(labels); 223 | let xLabelsToShow = 6; 224 | if (uniqueMonths <= 2) { 225 | xLabelsToShow = 2; 226 | } else if (uniqueMonths === 3) { 227 | xLabelsToShow = 3; 228 | } else if (uniqueMonths === 4) { 229 | xLabelsToShow = 4; 230 | } else if (uniqueMonths === 5) { 231 | xLabelsToShow = 5; 232 | } 233 | 234 | // Maximum number of Y labels to show 235 | let maxYLabelsToShow = 4; 236 | if (cumulativeStars.length <= 2) 237 | maxYLabelsToShow = 2; 238 | else if (cumulativeStars.length === 3) 239 | maxYLabelsToShow = 3; 240 | 241 | // Chart configuration 242 | const configuration = { 243 | type: 'line', 244 | data: { 245 | labels: labels, 246 | datasets: [{ 247 | data: cumulativeStars, 248 | fill: true, 249 | borderColor: colorPalette[color].line, 250 | backgroundColor: colorPalette[color].area, 251 | tension: 0.4, 252 | borderWidth: 4, 253 | pointRadius: 0 254 | }] 255 | }, 256 | options: { 257 | plugins: { 258 | legend: { 259 | display: false 260 | }, 261 | title: { 262 | display: true, 263 | text: "Star History - " + repoInfo.data.full_name, 264 | color: colorPalette[color].title, 265 | font: { 266 | family: 'JetBrains Mono Bold', 267 | size: 16, 268 | }, 269 | } 270 | }, 271 | scales: { 272 | x: { 273 | type: 'time', 274 | time: { 275 | unit: 'day', 276 | }, 277 | position: 'bottom', 278 | grid: { 279 | display: false 280 | }, 281 | ticks: { 282 | autoSkip: true, 283 | align: 'inner', 284 | color: colorPalette[color].xlabel, 285 | font: { 286 | family: 'JetBrains Mono Bold', 287 | size: 24, 288 | }, 289 | callback: function (value, index, values) { 290 | // Determine the step between each label 291 | let step = Math.floor(values.length / (xLabelsToShow - 1)); 292 | 293 | // Always show the first and last label (last label will show a zap icon) 294 | if (index === 0 || index === values.length - 1) { 295 | let formattedDate = dayjs(value).format('MMM').charAt(0) + dayjs(value).format('YY'); 296 | return index === values.length - 1 ? emoji.get("zap") : formattedDate; 297 | } 298 | 299 | // For other labels, return null (do not show them) if they're not a multiple of the step size 300 | else if (index % step !== 0 || index > values.length - 1 - step / 2) { 301 | return null; 302 | } 303 | 304 | // Calculate the formatted date only when necessary 305 | let formattedDate = dayjs(value).format('MMM').charAt(0) + dayjs(value).format('YY'); 306 | return formattedDate; 307 | } 308 | } 309 | }, 310 | y: { 311 | min: 0, 312 | grid: { 313 | display: false 314 | }, 315 | ticks: { 316 | beginAtZero: true, 317 | autoSkip: true, 318 | maxTicksLimit: maxYLabelsToShow, 319 | align: 'center', 320 | color: 'rgba(255, 255, 255, 0.95)', 321 | font: { 322 | family: 'JetBrains Mono Regular', 323 | size: 18, 324 | }, 325 | } 326 | } 327 | }, 328 | layout: { 329 | padding: { 330 | left: 10, 331 | bottom: 2, 332 | right: 4, 333 | } 334 | } 335 | }, 336 | plugins: [colorArea] 337 | }; 338 | 339 | // Create a canvas with the desidered dimensions 340 | const canvas = createCanvas(WIDTH, HEIGHT); 341 | const ctx = canvas.getContext('2d'); 342 | 343 | // Draw the chart 344 | new Chart(ctx, configuration); 345 | 346 | // Convert canvas to data URL 347 | const dataUrl = canvas.toDataURL(); 348 | return dataUrl; 349 | }; 350 | 351 | // Recursive function to fetch stars history being aware of github pagination limits 352 | const getStarsHistory = async (url, pageCount, page = 1, starsHistory = []) => { 353 | const headers = { 354 | 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, 355 | 'Accept': 'application/vnd.github.v3.star+json' 356 | }; 357 | const response = await axios.get(`${url}&page=${page}`, { headers }); 358 | starsHistory = starsHistory.concat(response.data); 359 | 360 | if (page < pageCount) { 361 | return getStarsHistory(url, pageCount, page + 1, starsHistory); 362 | } else { 363 | return starsHistory; 364 | } 365 | }; 366 | 367 | app.listen(process.env.PORT, () => { 368 | console.log(`Server is running at ${API_URL}/chart`); 369 | }); -------------------------------------------------------------------------------- /fonts/JetBrainsMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabubi/star-history/57f618f0b99aabfae63e7bfa92bfa2a905c72bb5/fonts/JetBrainsMono-Bold.ttf -------------------------------------------------------------------------------- /fonts/JetBrainsMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucabubi/star-history/57f618f0b99aabfae63e7bfa92bfa2a905c72bb5/fonts/JetBrainsMono-Regular.ttf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stars-history", 3 | "version": "1.1.0", 4 | "type": "module", 5 | "description": "A stunning star history widget chart generator for Github Repositories", 6 | "main": "chart.js", 7 | "scripts": { 8 | "start": "node chart.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/lucabubi/star-history.git" 13 | }, 14 | "keywords": [ 15 | "star-history" 16 | ], 17 | "author": "lucabubi", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/lucabubi/star-history/issues" 21 | }, 22 | "homepage": "https://github.com/lucabubi/star-history#readme", 23 | "dependencies": { 24 | "axios": "^1.7.9", 25 | "canvas": "^3.0.1", 26 | "chart.js": "^4.4.7", 27 | "chartjs-adapter-dayjs-4": "^1.0.4", 28 | "dayjs": "^1.11.13", 29 | "express": "^4.21.2", 30 | "helmet": "^8.0.0", 31 | "node-cache": "^5.1.2", 32 | "node-emoji": "^2.1.3" 33 | } 34 | } 35 | --------------------------------------------------------------------------------