├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md └── workflows │ └── run.yml ├── .gitignore ├── LICENSE ├── README.md ├── example.png ├── package-lock.json ├── package.json └── src ├── api.js ├── index.js ├── linguist.js └── text.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/run.yml: -------------------------------------------------------------------------------- 1 | name: update gist 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | run: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Setup Node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: "lts/*" 18 | cache: "npm" 19 | 20 | - run: sudo apt-get update 21 | - run: sudo apt-get install cmake pkg-config libicu-dev zlib1g-dev libcurl4-openssl-dev libssl-dev ruby-dev 22 | - run: sudo gem install github-linguist 23 | - run: npm install 24 | - run: npm start 25 | env: 26 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 27 | GIST_ID: 64dacee1c6c93cdbcf48548f6598f823 28 | USERNAME: ${{ github.actor }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 inokawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

lang-box

4 |

💻 Update a pinned gist to contain languages of your recent commits in GitHub

5 |

6 | 7 | --- 8 | 9 | > This project is inspired by [waka-box](https://github.com/matchai/waka-box), [productive-box](https://github.com/maxam2017/productive-box) and [metrics](https://github.com/lowlighter/metrics).
10 | > 📌✨ For more pinned-gist projects like this one, check out: https://github.com/matchai/awesome-pinned-gists 11 | 12 | This project gets your recent commits from your activities fetched from GitHub API, and process them with [linguist](https://github.com/github/linguist) to show the percentage of each languages used. This project also calculate how many lines of codes were added/removed per language. 13 | 14 | ## Setup 15 | 16 | ### Prep work 17 | 18 | 1. Create a new public GitHub Gist (https://gist.github.com/) 19 | 1. Create a token with the `gist` scope and copy it. (https://github.com/settings/tokens/new) 20 | 21 | - And if you would like to include commits in private repos, also add `repo` scope. 22 | 23 | > Enable `repo` scope seems **DANGEROUS**, but secrets are not passed to workflows that are triggered by a pull request from a fork (https://docs.github.com/en/actions/reference/encrypted-secrets) 24 | 25 | ### Project setup 26 | 27 | 1. Fork this repo, or [create a repository from template](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-from-a-template) by clicking [here](https://github.com/inokawa/lang-box/generate) or the **Use this template** button on this project. 28 | - If you added `repo` scope above, it's recommended to create private repository. 29 | 1. Open the "Actions" tab of your fork and click the "enable" button. 30 | 1. Edit the [environment variable](https://github.com/inokawa/lang-box/blob/master/.github/workflows/run.yml#L32-L33) in `.github/workflows/run.yml`: 31 | 32 | - **GIST_ID:** The ID portion from your gist url: `https://gist.github.com/inokawa/`**`64dacee1c6c93cdbcf48548f6598f823`**. 33 | 34 | 1. Go to the repo **Settings > Secrets** 35 | 1. Add the following environment variables: 36 | - **GH_TOKEN:** The GitHub token generated above. 37 | 1. [Pin the newly created Gist](https://help.github.com/en/github/setting-up-and-managing-your-github-profile/pinning-items-to-your-profile) 38 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inokawa/lang-box/4b4c614691e212213e3aa9dd22fe3fbd41ad210d/example.png -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lang-box", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "lang-box", 9 | "version": "1.0.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "node-fetch": "^3.2.10" 13 | } 14 | }, 15 | "node_modules/data-uri-to-buffer": { 16 | "version": "4.0.0", 17 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", 18 | "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==", 19 | "engines": { 20 | "node": ">= 12" 21 | } 22 | }, 23 | "node_modules/fetch-blob": { 24 | "version": "3.2.0", 25 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", 26 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 27 | "funding": [ 28 | { 29 | "type": "github", 30 | "url": "https://github.com/sponsors/jimmywarting" 31 | }, 32 | { 33 | "type": "paypal", 34 | "url": "https://paypal.me/jimmywarting" 35 | } 36 | ], 37 | "dependencies": { 38 | "node-domexception": "^1.0.0", 39 | "web-streams-polyfill": "^3.0.3" 40 | }, 41 | "engines": { 42 | "node": "^12.20 || >= 14.13" 43 | } 44 | }, 45 | "node_modules/formdata-polyfill": { 46 | "version": "4.0.10", 47 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", 48 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", 49 | "dependencies": { 50 | "fetch-blob": "^3.1.2" 51 | }, 52 | "engines": { 53 | "node": ">=12.20.0" 54 | } 55 | }, 56 | "node_modules/node-domexception": { 57 | "version": "1.0.0", 58 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 59 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", 60 | "funding": [ 61 | { 62 | "type": "github", 63 | "url": "https://github.com/sponsors/jimmywarting" 64 | }, 65 | { 66 | "type": "github", 67 | "url": "https://paypal.me/jimmywarting" 68 | } 69 | ], 70 | "engines": { 71 | "node": ">=10.5.0" 72 | } 73 | }, 74 | "node_modules/node-fetch": { 75 | "version": "3.2.10", 76 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", 77 | "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", 78 | "dependencies": { 79 | "data-uri-to-buffer": "^4.0.0", 80 | "fetch-blob": "^3.1.4", 81 | "formdata-polyfill": "^4.0.10" 82 | }, 83 | "engines": { 84 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 85 | }, 86 | "funding": { 87 | "type": "opencollective", 88 | "url": "https://opencollective.com/node-fetch" 89 | } 90 | }, 91 | "node_modules/web-streams-polyfill": { 92 | "version": "3.2.1", 93 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", 94 | "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", 95 | "engines": { 96 | "node": ">= 8" 97 | } 98 | } 99 | }, 100 | "dependencies": { 101 | "data-uri-to-buffer": { 102 | "version": "4.0.0", 103 | "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz", 104 | "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==" 105 | }, 106 | "fetch-blob": { 107 | "version": "3.2.0", 108 | "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", 109 | "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", 110 | "requires": { 111 | "node-domexception": "^1.0.0", 112 | "web-streams-polyfill": "^3.0.3" 113 | } 114 | }, 115 | "formdata-polyfill": { 116 | "version": "4.0.10", 117 | "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", 118 | "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", 119 | "requires": { 120 | "fetch-blob": "^3.1.2" 121 | } 122 | }, 123 | "node-domexception": { 124 | "version": "1.0.0", 125 | "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", 126 | "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==" 127 | }, 128 | "node-fetch": { 129 | "version": "3.2.10", 130 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", 131 | "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", 132 | "requires": { 133 | "data-uri-to-buffer": "^4.0.0", 134 | "fetch-blob": "^3.1.4", 135 | "formdata-polyfill": "^4.0.10" 136 | } 137 | }, 138 | "web-streams-polyfill": { 139 | "version": "3.2.1", 140 | "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", 141 | "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lang-box", 3 | "version": "1.0.0", 4 | "description": "💻 Update a pinned gist to contain languages of your recent commits in GitHub", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node --experimental-modules --es-module-specifier-resolution=node src/index" 9 | }, 10 | "dependencies": { 11 | "node-fetch": "^3.2.10" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/inokawa/lang-box.git" 16 | }, 17 | "keywords": [], 18 | "author": "inokawa (https://github.com/inokawa/)", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/inokawa/lang-box/issues" 22 | }, 23 | "homepage": "https://github.com/inokawa/lang-box#readme" 24 | } 25 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | export class ApiClient { 4 | constructor(token) { 5 | this.token = token; 6 | } 7 | 8 | fetch = async (path, method = "GET", body) => { 9 | const res = await fetch(`https://api.github.com${path}`, { 10 | method, 11 | headers: { 12 | Authorization: `bearer ${this.token}`, 13 | "Content-Type": "application/json", 14 | Accept: "application/vnd.github.v3+json", 15 | }, 16 | body: JSON.stringify(body), 17 | }); 18 | const json = await res.json(); 19 | if (!res.ok) { 20 | throw new Error(json.message); 21 | } 22 | return json; 23 | }; 24 | 25 | fetchGq = async (query) => { 26 | const res = await fetch("https://api.github.com/graphql", { 27 | method: "POST", 28 | headers: { 29 | Authorization: `bearer ${this.token}`, 30 | "Content-Type": "application/json", 31 | }, 32 | body: JSON.stringify({ query }).replace(/\\n/g, ""), 33 | }); 34 | const json = await res.json(); 35 | if (!res.ok) { 36 | throw new Error(json.message); 37 | } 38 | return json; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { ApiClient } from "./api.js"; 2 | import { createContent } from "./text.js"; 3 | import { runLinguist } from "./linguist.js"; 4 | 5 | const { GH_TOKEN, GIST_ID, USERNAME, DAYS } = process.env; 6 | 7 | (async () => { 8 | try { 9 | if (!GH_TOKEN) { 10 | throw new Error("GH_TOKEN is not provided."); 11 | } 12 | if (!GIST_ID) { 13 | throw new Error("GIST_ID is not provided."); 14 | } 15 | if (!USERNAME) { 16 | throw new Error("USERNAME is not provided."); 17 | } 18 | 19 | const api = new ApiClient(GH_TOKEN); 20 | const username = USERNAME; 21 | const days = Math.max(1, Math.min(30, Number(DAYS || 14))); 22 | 23 | console.log(`username is ${username}.`); 24 | console.log(`\n`); 25 | 26 | // https://docs.github.com/en/rest/reference/activity 27 | // GitHub API supports 300 events at max and events older than 90 days will not be fetched. 28 | const maxEvents = 300; 29 | const perPage = 100; 30 | const pages = Math.ceil(maxEvents / perPage); 31 | const fromDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); 32 | 33 | const commits = []; 34 | try { 35 | for (let page = 0; page < pages; page++) { 36 | // https://docs.github.com/en/developers/webhooks-and-events/github-event-types#pushevent 37 | const pushEvents = ( 38 | await api.fetch( 39 | `/users/${username}/events?per_page=${perPage}&page=${page}` 40 | ) 41 | ).filter( 42 | ({ type, actor }) => type === "PushEvent" && actor.login === username 43 | ); 44 | 45 | const recentPushEvents = pushEvents.filter( 46 | ({ created_at }) => new Date(created_at) > fromDate 47 | ); 48 | const isEnd = recentPushEvents.length < pushEvents.length; 49 | console.log(`${recentPushEvents.length} events fetched.`); 50 | 51 | commits.push( 52 | ...( 53 | await Promise.allSettled( 54 | recentPushEvents.flatMap(({ repo, payload }) => 55 | payload.commits 56 | // Ignore duplicated commits 57 | .filter((c) => c.distinct === true) 58 | .map((c) => api.fetch(`/repos/${repo.name}/commits/${c.sha}`)) 59 | ) 60 | ) 61 | ) 62 | .filter(({ status }) => status === "fulfilled") 63 | .map(({ value }) => value) 64 | ); 65 | 66 | if (isEnd) { 67 | break; 68 | } 69 | } 70 | } catch (e) { 71 | console.log("no more page to load"); 72 | } 73 | 74 | console.log(`${commits.length} commits fetched.`); 75 | console.log(`\n`); 76 | 77 | // https://docs.github.com/en/rest/reference/repos#compare-two-commits 78 | const files = commits 79 | // Ignore merge commits 80 | .filter((c) => c.parents.length <= 1) 81 | .flatMap((c) => 82 | c.files.map( 83 | ({ 84 | filename, 85 | additions, 86 | deletions, 87 | changes, 88 | status, // added, removed, modified, renamed 89 | patch, 90 | }) => ({ 91 | path: filename, 92 | additions, 93 | deletions, 94 | changes, 95 | status, 96 | patch, 97 | }) 98 | ) 99 | ); 100 | 101 | const langs = await runLinguist(files); 102 | console.log(`\n`); 103 | langs.forEach((l) => 104 | console.log( 105 | `${l.name}: ${l.count} files, ${l.additions + l.deletions} changes` 106 | ) 107 | ); 108 | 109 | const content = createContent(langs); 110 | console.log(`\n`); 111 | console.log(content); 112 | console.log(`\n`); 113 | 114 | const gist = await api.fetch(`/gists/${GIST_ID}`); 115 | const filename = Object.keys(gist.files)[0]; 116 | await api.fetch(`/gists/${GIST_ID}`, "PATCH", { 117 | files: { 118 | [filename]: { 119 | filename: `💻 Recent coding in languages`, 120 | content, 121 | }, 122 | }, 123 | }); 124 | 125 | console.log(`Update succeeded.`); 126 | } catch (e) { 127 | console.error(e); 128 | process.exitCode = 1; 129 | } 130 | })(); 131 | -------------------------------------------------------------------------------- /src/linguist.js: -------------------------------------------------------------------------------- 1 | import processes from "child_process"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | 5 | const run = (command, options) => 6 | new Promise((resolve, reject) => { 7 | console.debug(`run > ${command}`); 8 | const child = processes.exec(command, options); 9 | let [stdout, stderr] = ["", ""]; 10 | child.stdout.on("data", (data) => (stdout += data)); 11 | child.stderr.on("data", (data) => (stderr += data)); 12 | child.on("close", (code) => { 13 | console.debug(`exited with code ${code}`); 14 | return code === 0 ? resolve(stdout) : reject(stderr); 15 | }); 16 | }); 17 | 18 | const createDummyText = (count) => { 19 | let text = ""; 20 | for (let i = 0; i < count; i++) { 21 | text += "\n"; 22 | } 23 | return text; 24 | }; 25 | 26 | export const runLinguist = async (files) => { 27 | await run("git checkout --orphan temp && git rm -rf . && rm -rf *"); 28 | const datas = files.map((d, i) => ({ 29 | ...d, 30 | path: `${i}${path.extname(d.path)}`, 31 | })); 32 | const pathFileMap = datas.reduce((acc, d) => { 33 | acc[d.path] = d; 34 | return acc; 35 | }, {}); 36 | await Promise.all([ 37 | ...datas.map((d) => 38 | fs.writeFile( 39 | d.path, 40 | d.patch 41 | ? d.patch 42 | .split("\n") 43 | .filter((line) => /^[-+]/.test(line)) 44 | .map((line) => line.substring(1)) 45 | .join("\n") 46 | : d.changes 47 | ? // If the diff is too large, GitHub API do not return patch so calc from changed lines but it's not precise 48 | createDummyText(d.changes) 49 | : "" 50 | ) 51 | ), 52 | run(`echo "*.* linguist-detectable" > .gitattributes`), 53 | run( 54 | `git config user.name "dummy" && git config user.email "dummy@github.com"` 55 | ), 56 | ]); 57 | await run(`git add . && git commit -m "dummy"`); 58 | 59 | const stdout = await run("github-linguist --breakdown --json"); 60 | const res = JSON.parse(stdout); 61 | 62 | const langs = Object.entries({ ...res }) 63 | .reduce((acc, [name, v]) => { 64 | acc.push({ 65 | name, 66 | percent: +v.percentage, 67 | additions: v.files.reduce( 68 | (acc, p) => acc + (pathFileMap[p]?.additions ?? 0), 69 | 0 70 | ), 71 | deletions: v.files.reduce( 72 | (acc, p) => acc + (pathFileMap[p]?.deletions ?? 0), 73 | 0 74 | ), 75 | count: v.files.length, 76 | }); 77 | return acc; 78 | }, []) 79 | .sort((a, b) => b.percent - a.percent); 80 | 81 | return langs; 82 | }; 83 | -------------------------------------------------------------------------------- /src/text.js: -------------------------------------------------------------------------------- 1 | const trimRightStr = (str, len) => { 2 | return str.length > len ? str.substring(0, len - 1) + "…" : str; 3 | }; 4 | 5 | const formatNum = (n) => { 6 | for (const { u, v } of [ 7 | { u: "E", v: 10 ** 18 }, 8 | { u: "P", v: 10 ** 15 }, 9 | { u: "T", v: 10 ** 12 }, 10 | { u: "G", v: 10 ** 9 }, 11 | { u: "M", v: 10 ** 6 }, 12 | { u: "k", v: 10 ** 3 }, 13 | ]) { 14 | const top = n / v; 15 | if (top >= 1) { 16 | return `${top.toFixed(1)}${u}`; 17 | } 18 | } 19 | return `${n}`; 20 | }; 21 | 22 | const generateBarChart = (percent, size) => { 23 | const syms = "░▏▎▍▌▋▊▉█"; 24 | 25 | const frac = Math.floor((size * 8 * percent) / 100); 26 | const barsFull = Math.floor(frac / 8); 27 | if (barsFull >= size) { 28 | return syms.substring(8, 9).repeat(size); 29 | } 30 | const semi = frac % 8; 31 | 32 | return [syms.substring(8, 9).repeat(barsFull), syms.substring(semi, semi + 1)] 33 | .join("") 34 | .padEnd(size, syms.substring(0, 1)); 35 | }; 36 | 37 | export const createContent = (languages) => { 38 | const lines = []; 39 | for (let i = 0; i < languages.length; i++) { 40 | const data = languages[i]; 41 | const { name, percent, additions, deletions } = data; 42 | 43 | lines.push( 44 | [ 45 | trimRightStr(name, 10).padEnd(10), 46 | ("+" + formatNum(additions)).padStart(7) + 47 | "/" + 48 | ("-" + formatNum(deletions)).padStart(7), 49 | generateBarChart(percent, 21), 50 | ].join(" ") + 51 | percent.toFixed(1).padStart(5) + 52 | "%" 53 | ); 54 | } 55 | return lines.join("\n"); 56 | }; 57 | --------------------------------------------------------------------------------