├── .github ├── copilot-instructions.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── _config.yml ├── build.js ├── demo ├── demo.js ├── index.html ├── screenshot.png ├── style.css └── widget-generator.js ├── dist └── gh-profile-card.min.js ├── eslint.config.js ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── css │ ├── base.scss │ ├── profile-header.scss │ ├── repositories-list.scss │ └── shared.scss ├── gh-cache-storage.spec.ts ├── gh-cache-storage.ts ├── gh-data-loader.spec.ts ├── gh-data-loader.ts ├── gh-dom-operator.spec.ts ├── gh-dom-operator.ts ├── gh-dom.utils.spec.ts ├── gh-dom.utils.ts ├── gh-profile-card.spec.ts ├── gh-profile-card.ts ├── gh-widget-init.spec.ts ├── gh-widget-init.ts ├── interface │ ├── IGitHubApi.ts │ ├── IWidget.ts │ └── storage.ts └── testing │ ├── cache-mock.ts │ ├── fetch-mock.ts │ ├── in-memory-storage.ts │ ├── index.ts │ ├── mock-github-data.ts │ ├── style-mock.js │ ├── test-setup.ts │ └── test-utils.ts └── tsconfig.json /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions 2 | 3 | ## Project Overview 4 | 5 | This is a **GitHub Profile Card Widget** - a vanilla JavaScript/TypeScript library that displays GitHub user profiles and repositories on websites. The widget is built with modern TypeScript, uses no external dependencies, and follows a modular architecture. 6 | 7 | ### Key Technologies 8 | - **TypeScript** (v3.7.5) - Primary language 9 | - **Jest** (v24.9.0) - Testing framework 10 | - **ESBuild** - Build tool (can evolve as needed) 11 | - **SCSS** - Styling 12 | - **Vanilla JavaScript** - No frameworks, pure browser compatibility 13 | 14 | ## Architecture & Code Organization 15 | 16 | ### Core Components 17 | - `gh-profile-card.ts` - Main widget class and entry point 18 | - `gh-data-loader.ts` - GitHub API interaction and data fetching 19 | - `gh-dom-operator.ts` - DOM manipulation and rendering 20 | - `gh-cache-storage.ts` - Caching mechanism for API responses 21 | - `gh-widget-init.ts` - Widget initialization and configuration 22 | 23 | ### Directory Structure 24 | ``` 25 | src/ 26 | ├── interface/ # TypeScript interfaces and types 27 | ├── css/ # SCSS stylesheets 28 | └── testing/ # Test utilities and mocks 29 | ``` 30 | 31 | ### Key Interfaces - You cannot modify these as they come from GitHub API 32 | - `WidgetConfig` - Widget configuration options 33 | - `ApiProfile` - GitHub user profile data structure 34 | - `ApiRepository` - GitHub repository data structure 35 | - `ApiUserData` - Combined user data (profile + repositories) 36 | 37 | ## Core Principles 38 | 39 | ### Type Safety 40 | - Use **TypeScript strict mode** for all code 41 | - Define clear interfaces for data structures 42 | - Leverage type checking to prevent runtime errors 43 | 44 | ### Error Handling 45 | - Use custom `ApiError` interface for API-related errors 46 | - Distinguish between network errors and user-not-found errors 47 | - Provide meaningful error messages to users 48 | - Implement graceful degradation 49 | 50 | ### Performance 51 | - **Prefer** caching API responses to reduce GitHub rate limiting 52 | - **Consider** lazy loading for non-critical features like language statistics 53 | - **Minimize** DOM manipulations when performance is a concern 54 | - **Evaluate** debouncing for user-triggered API calls 55 | 56 | ### Accessibility 57 | - Ensure proper **semantic HTML** structure 58 | - Use appropriate **ARIA labels** where needed 59 | - Support **keyboard navigation** patterns 60 | - Maintain **color contrast** standards 61 | 62 | ## Development Guidelines 63 | 64 | ### Testing Strategy 65 | - **Prefer** using `src/testing/` utilities for consistent test setup 66 | - Extract results to separate variables for better debugging 67 | - Mock external dependencies (API, DOM, storage) unless integration testing 68 | - Test both success and error scenarios 69 | - Include edge cases and invalid inputs 70 | - Use Given-When-Then style for test cases 71 | - Avoid overcomplexity in tests; keep them focused on single behaviors 72 | 73 | ### Key Testing Utilities 74 | - `mock-github-data.ts` - Standardized GitHub API mock data 75 | - `fetch-mock.ts` - HTTP request mocking utilities 76 | - `cache-mock.ts` - Cache storage mocking 77 | - `test-utils.ts` - Common test helper functions 78 | 79 | ### API Integration 80 | - **Use** GitHub REST API v3 for consistency with existing implementation 81 | - **Implement** caching to reduce API calls and respect rate limits 82 | - **Handle** rate limiting gracefully with proper error messages 83 | - **Support** error states (network, 404, invalid JSON) 84 | - **Consider** retry logic for transient failures 85 | 86 | ### Widget Configuration 87 | Support both **programmatic** and **HTML data-attribute** configuration: 88 | ```html 89 |
94 |
95 | ``` 96 | 97 | ## Flexible Development Patterns 98 | 99 | ### Adding New Features (Adapt as Needed) 100 | **Typical workflow:** 101 | 1. **Consider** defining TypeScript interfaces in `src/interface/` for type safety 102 | 2. Implement core logic with appropriate error handling 103 | 3. **Prefer** comprehensive unit tests for maintainability 104 | 4. Update DOM operator for rendering if UI changes needed 105 | 5. **Consider** integration tests for complex workflows 106 | 6. Update documentation for public API changes 107 | 108 | **Escape hatches:** For simple features or prototypes, feel free to iterate and refactor the structure as the feature evolves. 109 | 110 | ### Modifying GitHub API Integration 111 | **Typical workflow:** 112 | 1. Update interfaces in `IGitHubApi.ts` if data structures change 113 | 2. Modify `gh-data-loader.ts` implementation 114 | 3. **Prefer** updating mock data in `testing/mock-github-data.ts` for consistency 115 | 4. Add/update tests for new API behavior 116 | 5. **Consider** backwards compatibility impact 117 | 118 | **Escape hatches:** For experimental API features, prototype first and formalize interfaces later. 119 | 120 | ### Styling Changes 121 | **Preferences:** 122 | 1. **Prefer** modifying SCSS files in `src/css/` for consistency 123 | 2. **Consider** BEM methodology for CSS classes (but not required) 124 | 3. **Use** CSS custom properties for theming when appropriate 125 | 4. **Test** responsive design across devices 126 | 5. **Validate** accessibility impact 127 | 128 | **Flexibility:** Use whatever CSS methodology makes sense for the specific change. 129 | 130 | ### Testing New Components 131 | **Strong preferences:** 132 | 1. **Use** existing test utilities from `src/testing/` unless specific requirements dictate otherwise 133 | 2. Mock external dependencies (API, DOM, storage) for unit tests 134 | 3. Test both success and error scenarios 135 | 4. Include edge cases and invalid inputs 136 | 5. Verify TypeScript type safety 137 | 138 | **Adaptations:** For complex integration scenarios, feel free to create specialized test setups. 139 | 140 | ## Build & Development Environment 141 | 142 | ### Development Commands 143 | - `npm run build` - Production build 144 | - `npm run test` - Run all tests 145 | - `npm run test:watch` - Watch mode testing 146 | - `npm run lint` - ESLint validation 147 | - `npm run format` - Prettier formatting 148 | 149 | ### Build Process (Current Setup) 150 | - **ESBuild** compiles TypeScript to optimized JavaScript 151 | - **SCSS** compiled to minified CSS 152 | - **Bundle** created for browser distribution 153 | - **Source maps** generated for debugging 154 | 155 | *Note: Build tooling can evolve as project needs change* 156 | 157 | ## Browser Compatibility & Standards 158 | 159 | ### Target Environment 160 | - **Modern browsers** (ES2017+) - current target 161 | - **TypeScript compilation** provides compatibility layer 162 | - **Avoid** experimental browser features unless polyfilled 163 | - **Test** in multiple browsers for production releases 164 | 165 | ## Contributing Guidelines 166 | 167 | ### Requirements (Non-negotiable) 168 | - All tests must pass (`npm run test`) 169 | - Code must be linted (`npm run lint`) 170 | - Code must be formatted (`npm run format`) 171 | - Handle GitHub API rate limiting appropriately 172 | - Maintain accessibility standards 173 | 174 | ### Strong Preferences 175 | - Add tests for new functionality 176 | - Update documentation for API changes 177 | - Use TypeScript interfaces for new data structures 178 | - Follow established error handling patterns 179 | 180 | ### Code Review Focus Areas 181 | - **Type safety** - Proper TypeScript usage 182 | - **Test coverage** - Adequate test scenarios 183 | - **Performance** - Consider impact on widget load time 184 | - **Accessibility** - Semantic markup and ARIA when needed 185 | - **API compatibility** - Don't break existing integrations 186 | 187 | ## Debugging & Troubleshooting 188 | 189 | ### Common Issues & Solutions 190 | - **CORS errors** - Ensure proper GitHub API usage 191 | - **Rate limiting** - Verify caching implementation 192 | - **DOM not ready** - Check initialization timing 193 | - **TypeScript errors** - Validate interface implementations 194 | 195 | ### Debugging Tools 196 | - Browser DevTools for DOM inspection and network analysis 197 | - Jest test runner for isolated component testing 198 | - TypeScript compiler for type checking 199 | - ESLint for code quality validation 200 | 201 | ## Security & Data Handling 202 | 203 | ### Security Practices 204 | - **Sanitize** all user-provided data 205 | - **Validate** GitHub API responses 206 | - **Avoid** direct DOM innerHTML injection when possible 207 | - **Use** Content Security Policy compatible code 208 | - **Handle** potentially malicious repository data gracefully 209 | 210 | ### Performance Monitoring 211 | - **Monitor** bundle size impact of changes 212 | - **Consider** API response times for user experience 213 | - **Measure** DOM rendering performance for large datasets 214 | - **Validate** memory usage patterns 215 | - **Test** cache effectiveness 216 | 217 | ## Flexibility & Evolution 218 | 219 | ### When to Deviate from Guidelines 220 | - **Performance requirements** dictate different approaches 221 | - **Specific feature needs** require specialized patterns 222 | - **External constraints** (APIs, libraries) require adaptations 223 | - **Prototyping phase** needs faster iteration 224 | 225 | ### Documentation for Deviations 226 | When deviating from established patterns: 227 | 1. Document the reason for deviation 228 | 2. Consider long-term maintenance impact 229 | 3. Update guidelines if pattern proves beneficial 230 | 4. Ensure team awareness of new patterns 231 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main, master ] 6 | push: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [22.x] 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'npm' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Run linter 31 | run: npm run lint 32 | 33 | - name: Run tests 34 | run: npm test 35 | 36 | - name: Run build 37 | run: npm run build 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | .idea/ 3 | *.iml 4 | node_modules/ 5 | 6 | # SASS 7 | .sass-cache 8 | src/*.css 9 | 10 | # Linux 11 | *~ 12 | 13 | # KDE directory preferences 14 | .directory 15 | 16 | #Sublime Text 17 | 18 | # workspace files are user-specific 19 | *.sublime-workspace 20 | 21 | # project files should be checked into the repository, unless a significant 22 | # proportion of contributors will probably not be using SublimeText 23 | # *.sublime-project 24 | 25 | #sftp configuration file 26 | sftp-config.json 27 | 28 | # Created by http://www.gitignore.io 29 | 30 | ### Node ### 31 | # Logs 32 | logs 33 | *.log 34 | 35 | # Runtime data 36 | pids 37 | *.pid 38 | *.seed 39 | 40 | # Compiled binary addons (http://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Users Environment Variables 44 | .lock-wscript 45 | 46 | 47 | /nbproject/private/ 48 | coverage/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.yml 3 | yarn.lock 4 | *.log 5 | LICENSE 6 | CNAME 7 | *.iml 8 | *.js 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "htmlWhitespaceSensitivity": "ignore", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Piotr Lewandowski 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Profile Card 2 | 3 | > Widget shows your GitHub profile directly on your website. 4 | > Show your current projects — always up to date. 5 | 6 | ![Screenshot](./demo/screenshot.png) 7 | 8 | ## Live [demo and configuration](https://piotrl.github.io/github-profile-card/demo?username=piotrl) 9 | 10 | ## Contents 11 | 12 | - [GitHub Profile Card](#github-profile-card) 13 | - [Main features](#main-features) 14 | - [Live demo and configuration](#live-demo-and-configuration) 15 | - [Changelog](#changelog) 16 | - [Quick install](#quick-install) 17 | - [Download](#download) 18 | - [Advanced configuration](#advanced-configuration) 19 | - [Configuration options](#configuration-options) 20 | - [FAQ](#faq) 21 | - [Feedback](#feedback) 22 | 23 | ### Main features 24 | 25 | - Top languages statistics 26 | - Last updated repositories 27 | - Configurable in HTML 28 | - Copy-Paste installation 29 | - No jQuery and any other libraries required 30 | 31 | ### [Changelog](https://github.com/piotrl/github-profile-card/releases) 32 | 33 | ## Quick install 34 | 35 | Include script and style just before `` tag: 36 | 37 | ``` 38 | 39 | ``` 40 | 41 | Include HTML code anywhere you would like to place widget: 42 | 43 | ``` 44 |
46 |
47 | ``` 48 | 49 | Great! Widget will autoload. We're done here. 50 | 51 | ## Download 52 | 53 | With [_npm_](https://www.npmjs.com/package/github-profile-card) 54 | 55 | ``` 56 | npm install github-profile-card --save 57 | ``` 58 | 59 | ## Advanced configuration 60 | 61 | Configure widget in HTML: 62 | 63 | ``` 64 |
69 |
70 | ``` 71 | 72 | For special usages, it is possible to configure widget(s) in JavaScript. 73 | You have to use different template than `#github-card`. 74 | 75 | ``` 76 | var widget = new GitHubCard({ 77 | username: 'YOUR_GITHUB_USERNAME' 78 | template: '#github-card-demo', 79 | sortBy: 'stars', 80 | reposHeaderText: 'Most starred', 81 | maxRepos: 5, 82 | hideTopLanguages: false, 83 | }); 84 | 85 | widget.init(); 86 | ``` 87 | 88 | ## Configuration options 89 | 90 | | HTML option (`data-` prefix) | JavaScript option | Default | Details | 91 | | ---------------------------- | ------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | 92 | | `username` | `username` | None | GitHub profile username | 93 | | `—` | `template` | `#github-card` | DOM selector of your widget in HTML | 94 | | `sort-by` | `sortBy` | `stars` | Repositories sorting method (`stars` or `updateTime`) | 95 | | `max-repos` | `maxRepos` | `5` | Amount of listed repositories. `0` disables section | 96 | | `header-text` | `headerText` | `Most starred repositories` | Text label above repositories list | 97 | | `hide-top-languages` | `hideTopLanguages` | `false` | Avoids heavy network traffic for calculating `Top Languages` section. Recommended for profiles with huge amount of repositories. | 98 | 99 | ## FAQ 100 | 101 | - **My language statistic is affected by libraries and dependencies** 102 | 103 | Consider ignoring them with .gitattributes: [My repository is detected as the wrong language](https://github.com/github/linguist#overrides) 104 | 105 | - **How language statistic is build?** 106 | 107 | It is sum of all characters written in language you use. 108 | One big repository in `C#` will be ranked higher than many small `JavaScript` repositories. 109 | 110 | It is based on 10 last updated repositories, to represent your current interests. 111 | 112 | - **How to show two or more profiles on page?** 113 | 114 | You have to create two widgets with different ID, then initialize each manually in JS. 115 | 116 | e.g. 117 | 118 | ``` 119 |
120 |
121 | 122 | 126 | ``` 127 | 128 | ## Feedback 129 | 130 | I love feedback, send me one! 131 | 132 | - show me website on which you're using this widget: [leave comment](https://github.com/piotrl/github-profile-card/issues/15) 133 | - ping me on twitter: [@constjs](https://twitter.com/constjs) 134 | - create [new issue](https://github.com/piotrl/github-profile-card/issues/new) 135 | 136 | Remember no other libraries required. It's like gluten free 😉 137 | 138 | ![gluten-free](http://forthebadge.com/images/badges/gluten-free.svg) 139 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-dinky 2 | gems: 3 | - jemoji 4 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import { sassPlugin } from 'esbuild-sass-plugin'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import PACKAGE from './package.json' with { type: 'json' }; 6 | 7 | const ENV = process.env.WEBPACK_ENV; 8 | const libraryName = 'gh-profile-card'; 9 | const banner = `/** 10 | * ${PACKAGE.name} - ${PACKAGE.version} | ${PACKAGE.license} 11 | * (c) 2014 - ${new Date().getFullYear()} ${PACKAGE.author} | ${PACKAGE.homepage} 12 | */ 13 | `; 14 | 15 | const outfilePath = path.resolve('dist', `${libraryName}.min.js`); 16 | 17 | esbuild 18 | .build({ 19 | entryPoints: ['./src/gh-widget-init.ts'], 20 | outfile: outfilePath, 21 | bundle: true, 22 | minify: false, 23 | sourcemap: ENV === 'dev', // Inline source map in development 24 | format: 'iife', 25 | target: ['es2022'], 26 | plugins: [ 27 | sassPlugin({ 28 | type: 'style', 29 | }), // Load SCSS using esbuild-sass-plugin 30 | { 31 | name: 'banner-plugin', 32 | setup(build) { 33 | build.onEnd((result) => { 34 | const content = fs.readFileSync(outfilePath, 'utf8'); 35 | fs.writeFileSync(outfilePath, `${banner}\n${content}`); 36 | }); 37 | }, 38 | }, 39 | ], 40 | }) 41 | .catch(() => process.exit(1)); 42 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | (function (GitHubCard, widgetGenerator) { 2 | 'use strict'; 3 | 4 | // Generating new widget from user input 5 | document.addEventListener('DOMContentLoaded', () => { 6 | const options = { 7 | template: '#github-card-demo', 8 | sortBy: 'stars', // possible: 'stars', 'updateTime' 9 | headerText: 'Most starred repositories', 10 | maxRepos: 5, 11 | }; 12 | overrideOptionsByUrlParams(options); 13 | 14 | let widget = new GitHubCard(options); 15 | widget.init(); 16 | refreshConfigTextarea(options); 17 | 18 | initSortingControl(options, refreshWidget); 19 | initRepositoriesControl(options, refreshWidget); 20 | initUserControl(options, initWidget); 21 | 22 | function initWidget(options) { 23 | widget = new GitHubCard(options); 24 | widget.init(); 25 | refreshConfigTextarea(options); 26 | } 27 | 28 | function refreshWidget(updatedOptions) { 29 | widget.refresh(updatedOptions); 30 | refreshConfigTextarea(updatedOptions); 31 | } 32 | }); 33 | 34 | function refreshConfigTextarea(updatedOptions) { 35 | const textarea = document.getElementById('install-code'); 36 | textarea.value = widgetGenerator.regenerate(updatedOptions); 37 | } 38 | 39 | // Sort repository acording to 40 | // radio inputs on website 41 | function initSortingControl(options, refreshWidget) { 42 | var $sortingRadios = document.querySelectorAll( 43 | '.choose-repo-sorting label', 44 | ); 45 | 46 | // sort by update time 47 | $sortingRadios[0].addEventListener('click', (event) => { 48 | event.target.classList.add('active'); 49 | $sortingRadios[1].classList.remove('active'); 50 | 51 | options.sortBy = 'updateTime'; 52 | options.headerText = event.target.textContent + ' repositories'; 53 | 54 | refreshWidget(options); 55 | }); 56 | 57 | // sort by starrgazers 58 | $sortingRadios[1].addEventListener('click', (event) => { 59 | event.target.classList.add('active'); 60 | $sortingRadios[0].classList.remove('active'); 61 | 62 | options.sortBy = 'stars'; 63 | options.headerText = event.target.textContent + ' repositories'; 64 | 65 | refreshWidget(options); 66 | }); 67 | } 68 | 69 | // Manipulating the number of repositories 70 | function initRepositoriesControl(options, refreshWidget) { 71 | const $inputNumber = document.getElementById('gh-reposNum'); 72 | 73 | $inputNumber.onchange = () => { 74 | options.maxRepos = $inputNumber.value; 75 | refreshWidget(options); 76 | }; 77 | } 78 | 79 | // Creating brand new widget instance 80 | // for user that we type in input 81 | function initUserControl(options, cb) { 82 | const $input = document.getElementById('gh-uname'); 83 | const $submit = document.getElementById('gh-uname-submit'); 84 | 85 | $submit.addEventListener('click', (event) => { 86 | options.username = $input.value; 87 | cb(options); 88 | 89 | event.preventDefault(); 90 | }); 91 | } 92 | 93 | function overrideOptionsByUrlParams(options) { 94 | const queryParameters = new URL(document.location).searchParams; 95 | for (const [key, value] of queryParameters) { 96 | options[key] = value; 97 | } 98 | } 99 | })(window.GitHubCard, window.widgetGenerator); 100 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GitHub Profile Card demo 6 | 7 | 8 | 9 |
10 |

11 | 12 | GitHub Profile Card 13 | 14 |

15 | 16 |
17 | 18 |
19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 30 | 31 |
32 | 39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 |

48 | Use it on your website! - 49 | 50 | Guide 51 | 52 |

53 |
54 |
55 | 62 |
63 |
64 | 65 | 69 | 70 | 71 | 76 | 103 | 104 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /demo/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piotrl/github-profile-card/81289550e9cba351b4df68483685f53cb905db78/demo/screenshot.png -------------------------------------------------------------------------------- /demo/style.css: -------------------------------------------------------------------------------- 1 | /* Demo styles */ 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | max-width: 500px; 9 | margin: 10px auto; 10 | font-size: 18px; 11 | 12 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; 13 | color: #232323; 14 | background-color: #fbfaf7; 15 | -webkit-font-smoothing: antialiased; 16 | } 17 | 18 | h1 { 19 | font-size: 30px; 20 | } 21 | 22 | h1, 23 | h2, 24 | h3 { 25 | display: block; 26 | font-family: Arvo, Monaco, serif; 27 | line-height: 1.3; 28 | font-weight: normal; 29 | color: #232323; 30 | margin: 36px 0 10px; 31 | 32 | border-bottom: 1px solid #ccc; 33 | padding-bottom: 5px; 34 | } 35 | 36 | a { 37 | color: #c30000; 38 | font-weight: 200; 39 | text-decoration: none; 40 | } 41 | 42 | .row { 43 | padding: 5px; 44 | margin-top: 20px; 45 | overflow: hidden; 46 | } 47 | 48 | .config-section-left, 49 | .config-section-right { 50 | float: left; 51 | max-width: 200px; 52 | } 53 | 54 | .content-section { 55 | float: left; 56 | } 57 | 58 | .content-section .choose-user { 59 | margin-bottom: 10px; 60 | } 61 | 62 | .content-section .tooltip { 63 | margin: 15px 0; 64 | position: relative; 65 | } 66 | 67 | .content-section .tooltip::before { 68 | display: none; 69 | } 70 | 71 | .content-section input[type='text'] { 72 | max-width: 220px; 73 | width: 220px; 74 | } 75 | 76 | .pre { 77 | border: 1px solid #dddfe2; 78 | border-radius: 3px; 79 | background-color: #f6f7f9; 80 | } 81 | 82 | .pre textarea { 83 | background: none; 84 | border: none; 85 | box-sizing: border-box; 86 | color: #4b4f56; 87 | font-family: 88 | Menlo, 89 | Monaco, 90 | Andale Mono, 91 | Courier New, 92 | monospace; 93 | font-size: 14px; 94 | height: auto; 95 | line-height: 20px; 96 | padding: 12px; 97 | min-width: 100%; 98 | } 99 | 100 | .tooltip { 101 | position: relative; 102 | display: block; 103 | box-shadow: 0 0 1px 1px #fff; 104 | background: #fff; 105 | border: 1px solid #ddd; 106 | padding: 2px 7px; 107 | margin-top: 15px; 108 | margin-right: 15px; 109 | } 110 | 111 | .tooltip::before { 112 | content: ''; 113 | position: absolute; 114 | display: block; 115 | width: 10px; 116 | height: 10px; 117 | right: -6px; 118 | top: 6px; 119 | background: inherit; 120 | border-right: 1px solid; 121 | border-bottom: 1px solid; 122 | border-color: inherit; 123 | transform: rotate(-45deg); 124 | -webkit-transform: rotate(-45deg); 125 | -ms-transform: rotate(-45deg); 126 | } 127 | 128 | .config-section-right { 129 | position: relative; 130 | top: 180px; 131 | } 132 | 133 | .config-section-right .tooltip { 134 | margin-left: 15px; 135 | } 136 | 137 | .config-section-right .tooltip::before { 138 | left: -6px; 139 | right: auto; 140 | 141 | border: none; 142 | border-top: 1px solid; 143 | border-left: 1px solid; 144 | border-color: inherit; 145 | } 146 | 147 | input { 148 | outline: none; 149 | } 150 | 151 | input[type='text'], 152 | input[type='number'], 153 | textarea { 154 | border: none; 155 | max-width: 125px; 156 | padding: 5px; 157 | font-size: 0.7em; 158 | } 159 | 160 | input[type='number'] { 161 | width: 85px; 162 | } 163 | 164 | input[type='submit'] { 165 | background: #d14836; 166 | color: #fff; 167 | border: none; 168 | border-radius: 3px; 169 | padding: 3px 5px; 170 | font-size: 0.7em; 171 | } 172 | 173 | label { 174 | font-size: 0.7em; 175 | } 176 | 177 | .choose-repo-sorting [type='radio'] { 178 | display: none; 179 | } 180 | 181 | .active { 182 | font-weight: bold; 183 | } 184 | -------------------------------------------------------------------------------- /demo/widget-generator.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | 'use strict'; 3 | 4 | const attributes = { 5 | username: 'data-username', 6 | maxRepos: 'data-max-repos', 7 | sortBy: 'data-sort-by', 8 | headerText: 'data-header-text', 9 | }; 10 | 11 | window.widgetGenerator = { 12 | regenerate: regenerate, 13 | }; 14 | 15 | function regenerate(options) { 16 | const attributesTemplate = Object.keys(options) 17 | .map((option) => { 18 | const attribute = attributes[option]; 19 | const value = options[option]; 20 | if (!attribute) { 21 | return ''; 22 | } 23 | return `\n\t${attribute}="${value}"`; 24 | }) 25 | .join(''); 26 | 27 | return `
\n
`; 28 | } 29 | })(); 30 | -------------------------------------------------------------------------------- /dist/gh-profile-card.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * github-profile-card - 3.2.0 | MIT 3 | * (c) 2014 - 2025 Piotr Lewandowski | https://piotrl.github.io/github-profile-card/ 4 | */ 5 | 6 | (() => { 7 | // src/gh-cache-storage.ts 8 | var CacheStorage = class { 9 | constructor(storage) { 10 | this.storage = storage; 11 | this.cacheName = "github-request-cache"; 12 | this.requestCache = this.getCache() || {}; 13 | } 14 | get(key) { 15 | return this.requestCache[key]; 16 | } 17 | add(url, entry) { 18 | this.requestCache[url] = entry; 19 | this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); 20 | } 21 | getCache() { 22 | return JSON.parse(this.storage.getItem(this.cacheName)); 23 | } 24 | }; 25 | 26 | // src/gh-data-loader.ts 27 | var GitHubApiLoader = class { 28 | constructor() { 29 | this.apiBase = "https://api.github.com"; 30 | this.cache = new CacheStorage(window.localStorage); 31 | } 32 | loadUserData(username, callback) { 33 | const request = this.apiGet(`${this.apiBase}/users/${username}`); 34 | request.success((profile) => { 35 | this.apiGet(profile.repos_url).success((repositories) => { 36 | callback({ profile, repositories }, null); 37 | }); 38 | }); 39 | request.error((result, request2) => { 40 | const error = this.identifyError(result, request2); 41 | callback(null, error); 42 | }); 43 | } 44 | loadRepositoriesLanguages(repositories, callback) { 45 | const languagesUrls = this.extractLangURLs(repositories); 46 | const langStats = []; 47 | let requestsAmount = languagesUrls.length; 48 | languagesUrls.forEach((repoLangUrl) => { 49 | const request = this.apiGet(repoLangUrl); 50 | request.error(() => requestsAmount--); 51 | request.success((repoLangs) => { 52 | langStats.push(repoLangs); 53 | if (langStats.length === requestsAmount) { 54 | callback(langStats); 55 | } 56 | }); 57 | }); 58 | } 59 | identifyError(result, request) { 60 | const error = { 61 | message: result.message 62 | }; 63 | if (request.status === 404) { 64 | error.isWrongUser = true; 65 | } 66 | const limitRequests = request.getResponseHeader("X-RateLimit-Remaining"); 67 | if (Number(limitRequests) === 0) { 68 | const resetTime = request.getResponseHeader("X-RateLimit-Reset"); 69 | error.resetDate = new Date(Number(resetTime) * 1e3); 70 | error.message = error.message.split("(")[0]; 71 | } 72 | return error; 73 | } 74 | extractLangURLs(profileRepositories) { 75 | return profileRepositories.map((repository) => repository.languages_url); 76 | } 77 | apiGet(url) { 78 | const request = this.buildRequest(url); 79 | return { 80 | success: (callback) => { 81 | request.addEventListener("load", () => { 82 | if (request.status === 304) { 83 | callback(this.cache.get(url).data, request); 84 | } 85 | if (request.status === 200) { 86 | const response = JSON.parse(request.responseText); 87 | this.cache.add(url, { 88 | lastModified: request.getResponseHeader("Last-Modified"), 89 | data: response 90 | }); 91 | callback(response, request); 92 | } 93 | }); 94 | }, 95 | error: (callback) => { 96 | request.addEventListener("load", () => { 97 | if (request.status !== 200 && request.status !== 304) { 98 | callback(JSON.parse(request.responseText), request); 99 | } 100 | }); 101 | } 102 | }; 103 | } 104 | buildRequest(url) { 105 | const request = new XMLHttpRequest(); 106 | request.open("GET", url); 107 | this.buildApiHeaders(request, url); 108 | request.send(); 109 | return request; 110 | } 111 | buildApiHeaders(request, url) { 112 | request.setRequestHeader("Accept", "application/vnd.github.v3+json"); 113 | const urlCache = this.cache.get(url); 114 | if (urlCache) { 115 | request.setRequestHeader("If-Modified-Since", urlCache.lastModified); 116 | } 117 | } 118 | }; 119 | 120 | // src/gh-dom.utils.ts 121 | function appendChildren($parent, nodes) { 122 | nodes.forEach((node) => $parent.appendChild(node)); 123 | } 124 | function createProfile(children) { 125 | const $profile = document.createElement("div"); 126 | $profile.classList.add("profile"); 127 | appendChildren($profile, children); 128 | return $profile; 129 | } 130 | function createName(profileUrl, name) { 131 | const $name = document.createElement("a"); 132 | $name.href = profileUrl; 133 | $name.className = "name"; 134 | $name.appendChild(document.createTextNode(name)); 135 | return $name; 136 | } 137 | function createAvatar(avatarUrl) { 138 | const $avatar = document.createElement("img"); 139 | $avatar.src = avatarUrl; 140 | $avatar.className = "avatar"; 141 | return $avatar; 142 | } 143 | function createFollowButton(username, followUrl) { 144 | const $followButton = document.createElement("a"); 145 | $followButton.href = followUrl; 146 | $followButton.className = "follow-button"; 147 | $followButton.innerHTML = "Follow @" + username; 148 | return $followButton; 149 | } 150 | function createFollowers(followersAmount) { 151 | const $followers = document.createElement("span"); 152 | $followers.className = "followers"; 153 | $followers.innerHTML = "" + followersAmount; 154 | return $followers; 155 | } 156 | function createFollowContainer(children) { 157 | const $followContainer = document.createElement("div"); 158 | $followContainer.className = "followMe"; 159 | appendChildren($followContainer, children); 160 | return $followContainer; 161 | } 162 | 163 | // src/gh-dom-operator.ts 164 | var DOMOperator = class { 165 | static clearChildren($parent) { 166 | while ($parent.hasChildNodes()) { 167 | $parent.removeChild($parent.firstChild); 168 | } 169 | } 170 | static createError(error, username) { 171 | const $error = document.createElement("div"); 172 | $error.className = "error"; 173 | $error.innerHTML = `${error.message}`; 174 | if (error.isWrongUser) { 175 | $error.innerHTML = `Not found user: ${username}`; 176 | } 177 | if (error.resetDate) { 178 | let remainingTime = error.resetDate.getMinutes() - (/* @__PURE__ */ new Date()).getMinutes(); 179 | remainingTime = remainingTime < 0 ? 60 + remainingTime : remainingTime; 180 | $error.innerHTML += `Come back after ${remainingTime} minutes`; 181 | } 182 | return $error; 183 | } 184 | static createProfile(data) { 185 | const $followButton = createFollowButton(data.login, data.html_url); 186 | const $followers = createFollowers(data.followers); 187 | const $followContainer = createFollowContainer([$followButton, $followers]); 188 | const $avatar = createAvatar(data.avatar_url); 189 | const $name = createName(data.html_url, data.name); 190 | return createProfile([$avatar, $name, $followContainer]); 191 | } 192 | static createTopLanguagesSection() { 193 | const $langsList = document.createElement("ul"); 194 | $langsList.className = "languages"; 195 | return $langsList; 196 | } 197 | static createTopLanguagesList(langs) { 198 | return Object.keys(langs).map((language) => ({ 199 | name: language, 200 | stat: langs[language] 201 | })).sort((a, b) => b.stat - a.stat).slice(0, 3).map((lang) => `
  • ${lang.name}
  • `).reduce((list, nextElement) => list + nextElement); 202 | } 203 | static createRepositoriesHeader(headerText) { 204 | const $repositoriesHeader = document.createElement("span"); 205 | $repositoriesHeader.className = "header"; 206 | $repositoriesHeader.appendChild(document.createTextNode(`${headerText}`)); 207 | return $repositoriesHeader; 208 | } 209 | static createRepositoriesList(repositories, maxRepos) { 210 | const $reposList = document.createElement("div"); 211 | $reposList.className = "repos"; 212 | repositories.slice(0, maxRepos).map(this.createRepositoryElement).forEach((el) => $reposList.appendChild(el)); 213 | return $reposList; 214 | } 215 | static createRepositoryElement(repository) { 216 | const updated = new Date(repository.updated_at); 217 | const $repoLink = document.createElement("a"); 218 | $repoLink.href = repository.html_url; 219 | $repoLink.title = repository.description; 220 | $repoLink.innerHTML = ` 221 | ${repository.name} 222 | Updated: ${updated.toLocaleDateString()} 223 | ${repository.stargazers_count} 224 | `; 225 | return $repoLink; 226 | } 227 | }; 228 | 229 | // src/gh-profile-card.ts 230 | var GitHubCardWidget = class { 231 | constructor(options = {}) { 232 | this.apiLoader = new GitHubApiLoader(); 233 | this.$template = this.findTemplate(options.template); 234 | this.extractHtmlConfig(options, this.$template); 235 | this.options = this.completeConfiguration(options); 236 | } 237 | init() { 238 | this.apiLoader.loadUserData(this.options.username, (data, err) => { 239 | this.userData = data; 240 | this.render(this.options, err); 241 | }); 242 | } 243 | refresh(options) { 244 | this.options = this.completeConfiguration(options); 245 | this.render(this.options); 246 | } 247 | completeConfiguration(options) { 248 | const defaultConfig = { 249 | username: null, 250 | template: "#github-card", 251 | sortBy: "stars", 252 | // possible: 'stars', 'updateTime' 253 | headerText: "Most starred repositories", 254 | maxRepos: 5, 255 | hideTopLanguages: false 256 | }; 257 | for (const key in defaultConfig) { 258 | defaultConfig[key] = options[key] || defaultConfig[key]; 259 | } 260 | return defaultConfig; 261 | } 262 | findTemplate(templateCssSelector = "#github-card") { 263 | const $template = document.querySelector( 264 | templateCssSelector 265 | ); 266 | if (!$template) { 267 | throw `No template found for selector: ${templateCssSelector}`; 268 | } 269 | $template.className = "gh-profile-card"; 270 | return $template; 271 | } 272 | extractHtmlConfig(widgetConfig, $template) { 273 | widgetConfig.username = widgetConfig.username || $template.dataset["username"]; 274 | widgetConfig.sortBy = widgetConfig.sortBy || $template.dataset["sortBy"]; 275 | widgetConfig.headerText = widgetConfig.headerText || $template.dataset["headerText"]; 276 | widgetConfig.maxRepos = widgetConfig.maxRepos || parseInt($template.dataset["maxRepos"], 10); 277 | widgetConfig.hideTopLanguages = widgetConfig.hideTopLanguages || $template.dataset["hideTopLanguages"] === "true"; 278 | if (!widgetConfig.username) { 279 | throw "Not provided username"; 280 | } 281 | } 282 | render(options, error) { 283 | const $root = this.$template; 284 | DOMOperator.clearChildren($root); 285 | if (error) { 286 | const $errorSection = DOMOperator.createError(error, options.username); 287 | $root.appendChild($errorSection); 288 | return; 289 | } 290 | const repositories = this.userData.repositories; 291 | this.sortRepositories(repositories, options.sortBy); 292 | const $profile = DOMOperator.createProfile(this.userData.profile); 293 | if (!options.hideTopLanguages) { 294 | $profile.appendChild(this.createTopLanguagesSection(repositories)); 295 | } 296 | $root.appendChild($profile); 297 | if (options.maxRepos > 0) { 298 | const $reposHeader = DOMOperator.createRepositoriesHeader( 299 | options.headerText 300 | ); 301 | const $reposList = DOMOperator.createRepositoriesList( 302 | repositories, 303 | options.maxRepos 304 | ); 305 | $reposList.insertBefore($reposHeader, $reposList.firstChild); 306 | $root.appendChild($reposList); 307 | } 308 | } 309 | createTopLanguagesSection(repositories) { 310 | const $topLanguages = DOMOperator.createTopLanguagesSection(); 311 | this.apiLoader.loadRepositoriesLanguages( 312 | repositories.slice(0, 10), 313 | (langStats) => { 314 | const languagesRank = this.groupLanguagesUsage(langStats); 315 | $topLanguages.innerHTML = DOMOperator.createTopLanguagesList(languagesRank); 316 | } 317 | ); 318 | return $topLanguages; 319 | } 320 | groupLanguagesUsage(langStats) { 321 | const languagesRank = {}; 322 | langStats.forEach((repoLangs) => { 323 | for (const language in repoLangs) { 324 | languagesRank[language] = languagesRank[language] || 0; 325 | languagesRank[language] += repoLangs[language]; 326 | } 327 | }); 328 | return languagesRank; 329 | } 330 | sortRepositories(repos, sortyBy) { 331 | repos.sort((firstRepo, secondRepo) => { 332 | if (sortyBy === "stars") { 333 | const starDifference = secondRepo.stargazers_count - firstRepo.stargazers_count; 334 | if (starDifference !== 0) { 335 | return starDifference; 336 | } 337 | } 338 | return this.dateDifference(secondRepo.updated_at, firstRepo.updated_at); 339 | }); 340 | } 341 | dateDifference(first, second) { 342 | return new Date(first).getTime() - new Date(second).getTime(); 343 | } 344 | }; 345 | 346 | // src/css/base.scss 347 | var css = `@charset "UTF-8"; 348 | /** 349 | * Github widget styles 350 | * ------------------------------------------------------------------ 351 | */ 352 | .gh-profile-card { 353 | /* followers number */ 354 | /* List of repositories */ 355 | } 356 | .gh-profile-card { 357 | width: 280px; 358 | border-radius: 5px; 359 | font-size: 16px; 360 | font-family: Helvetica; 361 | background: #fafafa; 362 | border-width: 1px 1px 2px; 363 | border-style: solid; 364 | border-color: #ddd; 365 | overflow: hidden; 366 | } 367 | .gh-profile-card a { 368 | text-decoration: none; 369 | color: #444; 370 | } 371 | .gh-profile-card a:hover { 372 | color: #4183c4; 373 | } 374 | .gh-profile-card .name { 375 | display: block; 376 | font-size: 1.2em; 377 | font-weight: bold; 378 | color: #222; 379 | } 380 | .gh-profile-card .error { 381 | font-size: 0.8em; 382 | color: #444; 383 | padding: 10px; 384 | } 385 | .gh-profile-card .error span { 386 | display: block; 387 | border-bottom: 1px solid #ddd; 388 | padding-bottom: 5px; 389 | margin-bottom: 5px; 390 | } 391 | .gh-profile-card .error span.remain { 392 | text-align: center; 393 | font-weight: bold; 394 | } 395 | .gh-profile-card .profile { 396 | background: #fff; 397 | overflow: hidden; 398 | padding: 15px 10px; 399 | padding-bottom: 0; 400 | } 401 | .gh-profile-card .stats { 402 | padding: 5px; 403 | } 404 | .gh-profile-card .languages { 405 | position: relative; 406 | clear: both; 407 | margin: 0 -10px; 408 | padding: 10px; 409 | min-height: 36px; 410 | border-top: 1px solid #dedede; 411 | font-size: 0.8em; 412 | } 413 | .gh-profile-card .languages::before { 414 | position: absolute; 415 | top: -0.7em; 416 | background: #fff; 417 | padding-right: 5px; 418 | content: "Top languages"; 419 | font-style: italic; 420 | color: #555; 421 | } 422 | .gh-profile-card .languages li { 423 | display: inline-block; 424 | color: #444; 425 | font-weight: bold; 426 | margin-left: 10px; 427 | } 428 | .gh-profile-card .languages li::after { 429 | content: "\u2022"; 430 | margin-left: 10px; 431 | color: #999; 432 | } 433 | .gh-profile-card .languages li:last-child::after { 434 | content: ""; 435 | } 436 | .gh-profile-card .followMe { 437 | margin-top: 3px; 438 | } 439 | .gh-profile-card .follow-button { 440 | font-size: 0.8em; 441 | color: #333; 442 | float: left; 443 | padding: 0 10px; 444 | line-height: 1.5em; 445 | border: 1px solid #d5d5d5; 446 | border-radius: 3px; 447 | font-weight: bold; 448 | background: #eaeaea; 449 | background-image: linear-gradient(#fafafa, #eaeaea); 450 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.9); 451 | -moz-user-select: none; 452 | -webkit-user-select: none; 453 | -ms-user-select: none; 454 | user-select: none; 455 | } 456 | .gh-profile-card .follow-button:hover { 457 | color: inherit; 458 | background: #ddd; 459 | background-image: linear-gradient(#eee, #ddd); 460 | } 461 | .gh-profile-card .followMe span { 462 | position: relative; 463 | background: #fff; 464 | margin-left: 8px; 465 | padding: 0 5px; 466 | color: #444; 467 | font-size: 0.8em; 468 | border: 1px solid; 469 | border-color: #bbb; 470 | } 471 | .gh-profile-card .followMe span::before { 472 | content: ""; 473 | position: absolute; 474 | display: block; 475 | width: 5px; 476 | height: 5px; 477 | left: -4px; 478 | top: 30%; 479 | background: inherit; 480 | border-left: 1px solid; 481 | border-top: 1px solid; 482 | border-color: inherit; 483 | -moz-transform: rotate(-45deg); 484 | -webkit-transform: rotate(-45deg); 485 | -ms-transform: rotate(-45deg); 486 | transform: rotate(-45deg); 487 | } 488 | .gh-profile-card .avatar { 489 | width: 64px; 490 | height: 64px; 491 | float: left; 492 | margin: 0 10px 15px 0; 493 | margin-left: 0; 494 | border-radius: 5px; 495 | box-shadow: 0 0 2px 0 #ddd; 496 | } 497 | .gh-profile-card .repos { 498 | clear: both; 499 | } 500 | .gh-profile-card .repos .header { 501 | display: block; 502 | width: 100%; 503 | font-weight: bold; 504 | background: #eaeaea; 505 | background-image: linear-gradient(#fafafa, #eaeaea); 506 | border: solid #d5d5d5; 507 | border-width: 1px 0; 508 | color: #555; 509 | font-size: 0.8em; 510 | padding: 5px 10px; 511 | } 512 | .gh-profile-card .repos a { 513 | position: relative; 514 | display: block; 515 | padding: 7px 10px; 516 | font-size: 0.9em; 517 | border-top: 1px solid #ddd; 518 | } 519 | .gh-profile-card .repos a:first-of-type { 520 | border: none; 521 | } 522 | .gh-profile-card .repos .repo-name { 523 | max-width: 280px; 524 | font-weight: bold; 525 | text-overflow: ellipsis; 526 | } 527 | .gh-profile-card .repos .updated { 528 | display: block; 529 | font-size: 0.75em; 530 | font-style: italic; 531 | color: #777; 532 | } 533 | .gh-profile-card .repos .star { 534 | position: absolute; 535 | font-size: 0.9em; 536 | right: 0.5em; 537 | top: 1.1em; 538 | color: #888; 539 | } 540 | .gh-profile-card .repos .star::after { 541 | content: "\xA0\u2605"; 542 | font-size: 1.1em; 543 | font-weight: bold; 544 | }`; 545 | document.head.appendChild(document.createElement("style")).appendChild(document.createTextNode(css)); 546 | 547 | // src/gh-widget-init.ts 548 | window.GitHubCard = GitHubCardWidget; 549 | document.addEventListener("DOMContentLoaded", () => { 550 | const $defaultTemplate = document.querySelector("#github-card"); 551 | if ($defaultTemplate) { 552 | const widget = new GitHubCardWidget(); 553 | widget.init(); 554 | } 555 | }); 556 | })(); 557 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import jestPlugin from 'eslint-plugin-jest'; 6 | import prettierPlugin from 'eslint-plugin-prettier'; 7 | import prettierConfig from 'eslint-config-prettier'; 8 | 9 | export default tseslint.config( 10 | eslint.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | prettierConfig, 13 | { 14 | plugins: { 15 | prettier: prettierPlugin, 16 | }, 17 | rules: { 18 | 'prettier/prettier': 'error', 19 | }, 20 | }, 21 | { 22 | files: ['**/*.spec.ts', '**/testing/*.ts'], 23 | plugins: { 24 | jest: jestPlugin, 25 | }, 26 | rules: { 27 | ...jestPlugin.configs.recommended.rules, 28 | '@typescript-eslint/no-explicit-any': 'off', 29 | 'jest/no-done-callback': 'warn' 30 | }, 31 | languageOptions: { 32 | globals: { 33 | ...jestPlugin.environments.globals.globals, 34 | }, 35 | }, 36 | }, 37 | { 38 | ignores: ['dist', 'node_modules', 'coverage', '**/*.js'], 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'jest'; 2 | import { createDefaultPreset } from 'ts-jest'; 3 | 4 | const presetConfig = createDefaultPreset({ 5 | useESM: true, 6 | }); 7 | 8 | const config: Config = { 9 | ...presetConfig, 10 | testEnvironment: 'jsdom', 11 | testEnvironmentOptions: { 12 | url: 'https://piotrl.github.io/github-profile-card', 13 | }, 14 | moduleNameMapper: { 15 | '\\.(css|scss)$': '/src/testing/style-mock.js', 16 | }, 17 | // Coverage configuration 18 | collectCoverage: true, 19 | coverageDirectory: 'coverage', 20 | coverageReporters: ['text', 'lcov', 'html'], 21 | collectCoverageFrom: [ 22 | 'src/**/*.ts', 23 | '!src/**/*.spec.ts', 24 | '!src/testing/**', 25 | '!src/css/**', 26 | ], 27 | // Coverage thresholds 28 | coverageThreshold: { 29 | global: { 30 | branches: 85, 31 | functions: 90, 32 | lines: 90, 33 | statements: 90, 34 | }, 35 | }, 36 | testMatch: ['/src/**/*.spec.ts'], 37 | setupFilesAfterEnv: ['/src/testing/test-setup.ts'], 38 | }; 39 | 40 | export default config; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-profile-card", 3 | "version": "3.2.0", 4 | "description": "Widget for presenting your GitHub profile on your website. Pure JavaScript.", 5 | "main": "dist/gh-profile-card.min.js", 6 | "type": "module", 7 | "devDependencies": { 8 | "@eslint/js": "9.31.0", 9 | "@jest/globals": "30.0.4", 10 | "eslint": "9.31.0", 11 | "eslint-plugin-jest": "28.8.3", 12 | "eslint-config-prettier": "10.1.5", 13 | "eslint-plugin-prettier": "5.5.1", 14 | "jest": "30.0.4", 15 | "jest-environment-jsdom": "30.0.4", 16 | "prettier": "3.6.2", 17 | "sass": "1.25.0", 18 | "ts-jest": "29.4.0", 19 | "typescript": "5.8.3", 20 | "typescript-eslint": "8.36.0", 21 | "esbuild": "0.25.1", 22 | "esbuild-sass-plugin": "3.3.1" 23 | }, 24 | "scripts": { 25 | "build": "node build.js", 26 | "build:check": "npm run build && npm run lint && npm run format:check && npm run test", 27 | "lint": "eslint .", 28 | "format": "prettier --write {*,src/*,demo/*}", 29 | "format:check": "prettier --check {*,src/*,demo/*}", 30 | "test": "jest", 31 | "test:watch": "jest --watch" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/piotrl/github-profile-card.git" 36 | }, 37 | "keywords": [ 38 | "github", 39 | "widget", 40 | "vanilla", 41 | "javascript", 42 | "browser" 43 | ], 44 | "author": "Piotr Lewandowski", 45 | "license": "MIT", 46 | "bugs": { 47 | "url": "https://github.com/piotrl/github-profile-card/issues" 48 | }, 49 | "homepage": "https://piotrl.github.io/github-profile-card/" 50 | } 51 | -------------------------------------------------------------------------------- /src/css/base.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Github widget styles 3 | * ------------------------------------------------------------------ 4 | */ 5 | 6 | .gh-profile-card { 7 | @import 'shared'; 8 | @import 'profile-header'; 9 | @import 'repositories-list'; 10 | } 11 | -------------------------------------------------------------------------------- /src/css/profile-header.scss: -------------------------------------------------------------------------------- 1 | .profile { 2 | background: #fff; 3 | overflow: hidden; 4 | padding: 15px 10px; 5 | padding-bottom: 0; 6 | } 7 | 8 | .stats { 9 | padding: 5px; 10 | } 11 | 12 | .languages { 13 | & { 14 | position: relative; 15 | clear: both; 16 | margin: 0 -10px; 17 | padding: 10px; 18 | min-height: 36px; 19 | 20 | border-top: 1px solid #dedede; 21 | font-size: 0.8em; 22 | } 23 | 24 | &::before { 25 | position: absolute; 26 | top: -0.7em; 27 | background: #fff; 28 | padding-right: 5px; 29 | content: 'Top languages'; 30 | font-style: italic; 31 | color: #555; 32 | } 33 | 34 | li { 35 | display: inline-block; 36 | color: #444; 37 | font-weight: bold; 38 | margin-left: 10px; 39 | 40 | &::after { 41 | content: '\2022'; 42 | margin-left: 10px; 43 | color: #999; 44 | } 45 | 46 | &:last-child::after { 47 | content: ''; 48 | } 49 | } 50 | } 51 | 52 | .followMe { 53 | margin-top: 3px; 54 | } 55 | 56 | .follow-button { 57 | font-size: 0.8em; 58 | color: #333; 59 | float: left; 60 | padding: 0 10px; 61 | line-height: 1.5em; 62 | border: 1px solid #d5d5d5; 63 | border-radius: 3px; 64 | font-weight: bold; 65 | background: #eaeaea; 66 | background-image: linear-gradient(#fafafa, #eaeaea); 67 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.9); 68 | -moz-user-select: none; 69 | -webkit-user-select: none; 70 | -ms-user-select: none; 71 | user-select: none; 72 | } 73 | 74 | .follow-button:hover { 75 | color: inherit; 76 | background: #ddd; 77 | background-image: linear-gradient(#eee, #ddd); 78 | } 79 | 80 | /* followers number */ 81 | .followMe span { 82 | position: relative; 83 | background: #fff; 84 | margin-left: 8px; 85 | padding: 0 5px; 86 | color: #444; 87 | font-size: 0.8em; 88 | border: 1px solid; 89 | border-color: #bbb; 90 | } 91 | 92 | .followMe span::before { 93 | content: ''; 94 | position: absolute; 95 | display: block; 96 | width: 5px; 97 | height: 5px; 98 | left: -4px; 99 | top: 30%; 100 | background: inherit; 101 | border-left: 1px solid; 102 | border-top: 1px solid; 103 | border-color: inherit; 104 | -moz-transform: rotate(-45deg); 105 | -webkit-transform: rotate(-45deg); 106 | -ms-transform: rotate(-45deg); 107 | transform: rotate(-45deg); 108 | } 109 | 110 | .avatar { 111 | width: 64px; 112 | height: 64px; 113 | float: left; 114 | margin: 0 10px 15px 0; 115 | margin-left: 0; 116 | border-radius: 5px; 117 | box-shadow: 0 0 2px 0 #ddd; 118 | } 119 | -------------------------------------------------------------------------------- /src/css/repositories-list.scss: -------------------------------------------------------------------------------- 1 | /* List of repositories */ 2 | 3 | .repos { 4 | & { 5 | clear: both; 6 | } 7 | 8 | .header { 9 | display: block; 10 | width: 100%; 11 | font-weight: bold; 12 | background: #eaeaea; 13 | background-image: linear-gradient(#fafafa, #eaeaea); 14 | border: solid #d5d5d5; 15 | border-width: 1px 0; 16 | color: #555; 17 | font-size: 0.8em; 18 | padding: 5px 10px; 19 | } 20 | 21 | a { 22 | position: relative; 23 | display: block; 24 | padding: 7px 10px; 25 | font-size: 0.9em; 26 | border-top: 1px solid #ddd; 27 | 28 | &:first-of-type { 29 | border: none; 30 | } 31 | } 32 | 33 | .repo-name { 34 | max-width: 280px; 35 | font-weight: bold; 36 | text-overflow: ellipsis; 37 | } 38 | 39 | .updated { 40 | display: block; 41 | font-size: 0.75em; 42 | font-style: italic; 43 | color: #777; 44 | } 45 | 46 | .star { 47 | position: absolute; 48 | font-size: 0.9em; 49 | right: 0.5em; 50 | top: 1.1em; 51 | color: #888; 52 | 53 | &::after { 54 | content: '\a0\2605'; 55 | font-size: 1.1em; 56 | font-weight: bold; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/css/shared.scss: -------------------------------------------------------------------------------- 1 | & { 2 | width: 280px; 3 | border-radius: 5px; 4 | font-size: 16px; 5 | font-family: Helvetica; 6 | background: #fafafa; 7 | border-width: 1px 1px 2px; 8 | border-style: solid; 9 | border-color: #ddd; 10 | overflow: hidden; 11 | } 12 | 13 | a { 14 | text-decoration: none; 15 | color: #444; 16 | 17 | &:hover { 18 | color: #4183c4; 19 | } 20 | } 21 | 22 | .name { 23 | display: block; 24 | font-size: 1.2em; 25 | font-weight: bold; 26 | color: #222; 27 | } 28 | 29 | .error { 30 | & { 31 | font-size: 0.8em; 32 | color: #444; 33 | padding: 10px; 34 | } 35 | 36 | span { 37 | display: block; 38 | border-bottom: 1px solid #ddd; 39 | padding-bottom: 5px; 40 | margin-bottom: 5px; 41 | 42 | &.remain { 43 | text-align: center; 44 | font-weight: bold; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/gh-cache-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, jest } from '@jest/globals'; 2 | import { CacheStorage, CacheEntry } from './gh-cache-storage'; 3 | import { InMemoryStorage } from './testing/in-memory-storage'; 4 | import { BrowserStorage } from './interface/storage'; 5 | 6 | describe('CacheStorage', () => { 7 | const url = 8 | 'https://api.github.com/repos/piotrl/github-profile-card/languages'; 9 | const cacheData: CacheEntry = { 10 | lastModified: 'Mon, 18 Mar 2019 20:40:35 GMT', 11 | data: { 12 | TypeScript: 19766, 13 | CSS: 3790, 14 | JavaScript: 1350, 15 | }, 16 | }; 17 | 18 | let storage: BrowserStorage; 19 | 20 | beforeEach(() => { 21 | storage = new InMemoryStorage(); 22 | }); 23 | 24 | describe('basic functionality', () => { 25 | it('should be undefined on empty init', () => { 26 | const cache = new CacheStorage(storage); 27 | 28 | const result = cache.get('not-defined'); 29 | 30 | expect(result).toBeUndefined(); 31 | }); 32 | 33 | it('should return back saved value', () => { 34 | const cache = new CacheStorage(storage); 35 | 36 | cache.add(url, cacheData); 37 | const result = cache.get(url); 38 | 39 | expect(result).toEqual(cacheData); 40 | }); 41 | 42 | it('should initialize with existing entries', () => { 43 | const cacheName = 'github-request-cache'; 44 | storage.setItem( 45 | cacheName, 46 | JSON.stringify({ 47 | [url]: cacheData, 48 | }), 49 | ); 50 | const cache = new CacheStorage(storage); 51 | 52 | const result = cache.get(url); 53 | 54 | expect(result).toEqual(cacheData); 55 | }); 56 | }); 57 | 58 | describe('error handling', () => { 59 | it('should handle corrupted cache data gracefully', () => { 60 | const cacheName = 'github-request-cache'; 61 | storage.setItem(cacheName, 'invalid-json'); 62 | 63 | // Should not throw 64 | const cache = new CacheStorage(storage); 65 | const result = cache.get(url); 66 | 67 | expect(result).toBeUndefined(); 68 | }); 69 | 70 | it('should handle null cache data', () => { 71 | const mockStorage: BrowserStorage = { 72 | getItem: jest.fn().mockReturnValue(null), 73 | setItem: jest.fn(), 74 | removeItem: jest.fn(), 75 | }; 76 | 77 | const cache = new CacheStorage(mockStorage); 78 | const result = cache.get(url); 79 | 80 | expect(result).toBeUndefined(); 81 | }); 82 | 83 | it('should handle non-object cache data', () => { 84 | const cacheName = 'github-request-cache'; 85 | storage.setItem(cacheName, '"string-instead-of-object"'); 86 | 87 | const cache = new CacheStorage(storage); 88 | const result = cache.get(url); 89 | 90 | expect(result).toBeUndefined(); 91 | }); 92 | 93 | it('should clear corrupted cache on initialization error', () => { 94 | const mockStorage: BrowserStorage = { 95 | getItem: jest.fn().mockReturnValue('invalid-json'), 96 | setItem: jest.fn(), 97 | removeItem: jest.fn(), 98 | }; 99 | 100 | new CacheStorage(mockStorage); 101 | 102 | expect(mockStorage.removeItem).toHaveBeenCalledWith( 103 | 'github-request-cache', 104 | ); 105 | }); 106 | 107 | it('should handle storage errors during save', () => { 108 | const mockStorage: BrowserStorage = { 109 | getItem: jest.fn().mockReturnValue(null), 110 | setItem: jest.fn().mockImplementation(() => { 111 | throw new Error('Storage full'); 112 | }), 113 | removeItem: jest.fn(), 114 | }; 115 | 116 | const cache = new CacheStorage(mockStorage); 117 | 118 | // Should not throw 119 | expect(() => cache.add(url, cacheData)).not.toThrow(); 120 | }); 121 | 122 | it('should retry save after clearing expired entries on storage error', () => { 123 | let callCount = 0; 124 | const mockStorage: BrowserStorage = { 125 | getItem: jest.fn().mockReturnValue(null), 126 | setItem: jest.fn().mockImplementation(() => { 127 | callCount++; 128 | if (callCount === 1) { 129 | throw new Error('Storage full'); 130 | } 131 | // Second call succeeds 132 | }), 133 | removeItem: jest.fn(), 134 | }; 135 | 136 | const cache = new CacheStorage(mockStorage); 137 | cache.add(url, cacheData); 138 | 139 | expect(mockStorage.setItem).toHaveBeenCalledTimes(3); // Initial call + retry + potential success 140 | }); 141 | }); 142 | 143 | describe('clearExpiredEntries', () => { 144 | it('should clear expired entries', () => { 145 | const cache = new CacheStorage(storage); 146 | const oldEntry: CacheEntry = { 147 | lastModified: 'Mon, 01 Jan 2020 00:00:00 GMT', 148 | data: { test: 'old' }, 149 | }; 150 | const recentEntry: CacheEntry = { 151 | lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', 152 | data: { test: 'recent' }, 153 | }; 154 | 155 | cache.add('old-url', oldEntry); 156 | cache.add('recent-url', recentEntry); 157 | 158 | const cutoffDate = new Date('2022-01-01'); 159 | cache.clearExpiredEntries(cutoffDate); 160 | 161 | expect(cache.get('old-url')).toBeUndefined(); 162 | expect(cache.get('recent-url')).toEqual(recentEntry); 163 | }); 164 | 165 | it('should not save when no entries are expired', () => { 166 | const mockStorage: BrowserStorage = { 167 | getItem: jest.fn().mockReturnValue(null), 168 | setItem: jest.fn(), 169 | removeItem: jest.fn(), 170 | }; 171 | 172 | const cache = new CacheStorage(mockStorage); 173 | const recentEntry: CacheEntry = { 174 | lastModified: 'Mon, 01 Jan 2024 00:00:00 GMT', 175 | data: { test: 'recent' }, 176 | }; 177 | 178 | cache.add('recent-url', recentEntry); 179 | jest.clearAllMocks(); // Clear the setItem call from add() 180 | 181 | const cutoffDate = new Date('2020-01-01'); 182 | cache.clearExpiredEntries(cutoffDate); 183 | 184 | expect(mockStorage.setItem).not.toHaveBeenCalled(); 185 | }); 186 | 187 | it('should handle entries without lastModified date', () => { 188 | const cache = new CacheStorage(storage); 189 | const entryWithoutDate: CacheEntry = { 190 | lastModified: undefined as string, 191 | data: { test: 'data' }, 192 | }; 193 | 194 | cache.add('test-url', entryWithoutDate); 195 | 196 | const cutoffDate = new Date(); 197 | 198 | // Should not throw 199 | expect(() => cache.clearExpiredEntries(cutoffDate)).not.toThrow(); 200 | }); 201 | 202 | it('should handle invalid date strings in lastModified', () => { 203 | const cache = new CacheStorage(storage); 204 | const entryWithInvalidDate: CacheEntry = { 205 | lastModified: 'invalid-date-string', 206 | data: { test: 'data' }, 207 | }; 208 | 209 | cache.add('test-url', entryWithInvalidDate); 210 | 211 | const cutoffDate = new Date(); 212 | 213 | // Should not throw and should clear invalid entries 214 | expect(() => cache.clearExpiredEntries(cutoffDate)).not.toThrow(); 215 | expect(cache.get('test-url')).toBeUndefined(); 216 | }); 217 | }); 218 | 219 | describe('performance', () => { 220 | it('should handle large cache efficiently', () => { 221 | const cache = new CacheStorage(storage); 222 | const entries = 1000; 223 | 224 | // Add many entries 225 | for (let i = 0; i < entries; i++) { 226 | cache.add(`url-${i}`, { 227 | lastModified: 'Mon, 18 Mar 2019 20:40:35 GMT', 228 | data: { index: i }, 229 | }); 230 | } 231 | 232 | // Should be able to retrieve any entry quickly 233 | const result = cache.get('url-500'); 234 | expect(result?.data).toEqual({ index: 500 }); 235 | }); 236 | }); 237 | 238 | describe('edge cases', () => { 239 | it('should handle empty string as URL', () => { 240 | const cache = new CacheStorage(storage); 241 | 242 | cache.add('', cacheData); 243 | const result = cache.get(''); 244 | 245 | expect(result).toEqual(cacheData); 246 | }); 247 | 248 | it('should handle special characters in URLs', () => { 249 | const cache = new CacheStorage(storage); 250 | const specialUrl = 251 | 'https://api.github.com/repos/user/repo?param=value&other=ção'; 252 | 253 | cache.add(specialUrl, cacheData); 254 | const result = cache.get(specialUrl); 255 | 256 | expect(result).toEqual(cacheData); 257 | }); 258 | 259 | it('should handle data with circular references', () => { 260 | const cache = new CacheStorage(storage); 261 | const circularData: any = { test: 'data' }; 262 | circularData.self = circularData; 263 | 264 | const entry: CacheEntry = { 265 | lastModified: 'Mon, 18 Mar 2019 20:40:35 GMT', 266 | data: circularData, 267 | }; 268 | 269 | // Should handle JSON.stringify error gracefully 270 | expect(() => cache.add(url, entry)).not.toThrow(); 271 | }); 272 | }); 273 | }); 274 | -------------------------------------------------------------------------------- /src/gh-cache-storage.ts: -------------------------------------------------------------------------------- 1 | import { BrowserStorage } from './interface/storage'; 2 | 3 | interface Cache { 4 | [url: string]: CacheEntry; 5 | } 6 | 7 | export interface CacheEntry { 8 | lastModified: string; 9 | data: unknown; 10 | } 11 | 12 | export class CacheStorage { 13 | private cacheName = 'github-request-cache'; 14 | private requestCache: Cache = this.initializeCache(); 15 | 16 | constructor(private readonly storage: BrowserStorage) {} 17 | 18 | public get(key: string): CacheEntry | undefined { 19 | return this.requestCache[key]; 20 | } 21 | 22 | public add(url: string, entry: CacheEntry): void { 23 | this.requestCache[url] = entry; 24 | this.saveCache(); 25 | } 26 | 27 | private initializeCache(): Cache { 28 | try { 29 | const cacheData = this.storage.getItem(this.cacheName); 30 | if (!cacheData) { 31 | return {}; 32 | } 33 | 34 | const cache = JSON.parse(cacheData); 35 | return cache && typeof cache === 'object' ? cache : {}; 36 | } catch (error) { 37 | console.error('Failed to parse cache:', error); 38 | // Clear corrupted cache data 39 | try { 40 | this.storage.removeItem(this.cacheName); 41 | } catch (cleanupError) { 42 | console.error('Failed to clear corrupted cache:', cleanupError); 43 | } 44 | return {}; 45 | } 46 | } 47 | 48 | private saveCache(): void { 49 | try { 50 | this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); 51 | } catch (error) { 52 | console.error('Failed to save cache:', error); 53 | // If storage is full, try to clear expired entries and retry 54 | this.clearExpiredEntries(new Date()); 55 | try { 56 | this.storage.setItem(this.cacheName, JSON.stringify(this.requestCache)); 57 | } catch (retryError) { 58 | console.error('Failed to save cache after cleanup:', retryError); 59 | } 60 | } 61 | } 62 | 63 | public clearExpiredEntries(currentDate: Date): void { 64 | let hasChanges = false; 65 | 66 | for (const [url, entry] of Object.entries(this.requestCache)) { 67 | if (entry.lastModified) { 68 | const entryDate = new Date(entry.lastModified); 69 | // Clear entries with invalid dates or expired entries 70 | if (isNaN(entryDate.getTime()) || entryDate < currentDate) { 71 | delete this.requestCache[url]; 72 | hasChanges = true; 73 | } 74 | } 75 | } 76 | 77 | if (hasChanges) { 78 | this.saveCache(); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/gh-data-loader.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | beforeEach, 3 | describe, 4 | expect, 5 | it, 6 | afterEach, 7 | jest, 8 | } from '@jest/globals'; 9 | import { GitHubApiLoader } from './gh-data-loader'; 10 | import { CacheStorage } from './gh-cache-storage'; 11 | 12 | import { 13 | cleanupTestEnvironment, 14 | createErrorResponse, 15 | createJsonError, 16 | createNetworkError, 17 | createSuccessResponse, 18 | mockFetch, 19 | mockLanguageStats, 20 | mockProfile, 21 | mockRepositories, 22 | setupEmptyCache, 23 | setupLanguageMocks, 24 | setupTestEnvironment, 25 | setupUserDataMocks, 26 | } from './testing/index'; 27 | 28 | describe('GitHubApiLoader', () => { 29 | let loader: GitHubApiLoader; 30 | let mockCache: jest.Mocked; 31 | 32 | beforeEach(() => { 33 | const setup = setupTestEnvironment(); 34 | loader = setup.loader; 35 | mockCache = setup.mockCache; 36 | }); 37 | 38 | afterEach(() => { 39 | cleanupTestEnvironment(); 40 | }); 41 | 42 | describe('loadUserData', () => { 43 | it('should load user profile and repositories successfully', async () => { 44 | // Given 45 | setupUserDataMocks(mockProfile, mockRepositories); 46 | setupEmptyCache(mockCache); 47 | 48 | // When 49 | const result = await loader.loadUserData('testuser'); 50 | 51 | // Then 52 | expect(result).toEqual({ 53 | profile: mockProfile, 54 | repositories: mockRepositories, 55 | }); 56 | }); 57 | 58 | it('should make correct number of API calls', async () => { 59 | // Given 60 | setupUserDataMocks(mockProfile, mockRepositories); 61 | setupEmptyCache(mockCache); 62 | 63 | // When 64 | await loader.loadUserData('testuser'); 65 | 66 | // Then 67 | const callCount = mockFetch.mock.calls.length; 68 | expect(callCount).toBe(2); 69 | }); 70 | 71 | it('should call profile API with correct URL', async () => { 72 | // Given 73 | setupUserDataMocks(mockProfile, mockRepositories); 74 | setupEmptyCache(mockCache); 75 | 76 | // When 77 | await loader.loadUserData('testuser'); 78 | 79 | // Then 80 | expect(mockFetch).toHaveBeenCalledWith( 81 | 'https://api.github.com/users/testuser', 82 | expect.any(Object), 83 | ); 84 | }); 85 | 86 | it('should cache API responses after successful load', async () => { 87 | // Given 88 | setupUserDataMocks(mockProfile, mockRepositories); 89 | setupEmptyCache(mockCache); 90 | 91 | // When 92 | await loader.loadUserData('testuser'); 93 | 94 | // Then 95 | const cacheAddCallCount = mockCache.add.mock.calls.length; 96 | expect(cacheAddCallCount).toBe(2); 97 | }); 98 | 99 | it('should throw error for empty username', async () => { 100 | // Given 101 | const emptyUsername = ''; 102 | 103 | // When & Then 104 | await expect(loader.loadUserData(emptyUsername)).rejects.toThrow(); 105 | }); 106 | 107 | it('should throw error for whitespace username', async () => { 108 | // Given 109 | const whitespaceUsername = ' '; 110 | 111 | // When & Then 112 | await expect(loader.loadUserData(whitespaceUsername)).rejects.toThrow(); 113 | }); 114 | 115 | it('should throw error for null username', async () => { 116 | // Given 117 | const nullUsername = null as any; 118 | 119 | // When & Then 120 | await expect(loader.loadUserData(nullUsername)).rejects.toThrow(); 121 | }); 122 | 123 | it('should handle 404 user not found error', async () => { 124 | // Given 125 | mockFetch.mockResolvedValueOnce(createErrorResponse(404, 'Not Found')); 126 | setupEmptyCache(mockCache); 127 | 128 | // When 129 | const result = loader.loadUserData('nonexistentuser'); 130 | 131 | // Then 132 | await expect(result).rejects.toMatchObject({ 133 | message: 'Not Found', 134 | isWrongUser: true, 135 | }); 136 | }); 137 | 138 | it('should handle rate limit error', async () => { 139 | // Given 140 | const resetDate = new Date(Date.now() + 3600000); // 1 hour from now 141 | 142 | mockFetch.mockResolvedValueOnce( 143 | createErrorResponse(403, 'API rate limit exceeded (test details)', { 144 | 'X-RateLimit-Remaining': '0', 145 | 'X-RateLimit-Reset': String(Math.floor(resetDate.getTime() / 1000)), 146 | }), 147 | ); 148 | setupEmptyCache(mockCache); 149 | 150 | // When 151 | const result = loader.loadUserData('testuser'); 152 | 153 | // Then 154 | await expect(result).rejects.toMatchObject({ 155 | message: 'API rate limit exceeded ', 156 | resetDate: expect.any(Date), 157 | }); 158 | }); 159 | 160 | it('should use cached data when available with 304 response', async () => { 161 | // Given 162 | mockCache.get.mockReturnValue({ 163 | lastModified: 'Mon, 18 Mar 2019 20:40:35 GMT', 164 | data: mockProfile, 165 | }); 166 | 167 | mockFetch.mockResolvedValueOnce({ 168 | status: 304, 169 | }); 170 | 171 | // We need to test the internal fetch method indirectly 172 | // Since we can't test 304 directly with loadUserData, let's test the behavior 173 | mockFetch 174 | .mockResolvedValueOnce({ 175 | status: 200, 176 | headers: { 177 | get: jest.fn().mockReturnValue('Mon, 18 Mar 2019 20:40:35 GMT'), 178 | }, 179 | json: jest.fn(() => Promise.resolve(mockProfile)), 180 | } as any) 181 | .mockResolvedValueOnce({ 182 | status: 200, 183 | headers: { 184 | get: jest.fn().mockReturnValue('Mon, 18 Mar 2019 20:40:35 GMT'), 185 | }, 186 | json: jest.fn(() => Promise.resolve(mockRepositories)), 187 | } as any); 188 | 189 | // When 190 | const result = await loader.loadUserData('testuser'); 191 | 192 | // Then 193 | expect(result).toBeDefined(); 194 | }); 195 | 196 | it('should handle network errors', async () => { 197 | // Given 198 | // First setup valid profile response, then fail on repos 199 | mockFetch 200 | .mockResolvedValueOnce(createSuccessResponse(mockProfile)) 201 | .mockRejectedValueOnce(createNetworkError('Network failure')); 202 | setupEmptyCache(mockCache); 203 | 204 | // When 205 | const result = loader.loadUserData('testuser'); 206 | 207 | // Then 208 | await expect(result).rejects.toThrow('Network error: Network failure'); 209 | }); 210 | 211 | it('should handle JSON parsing errors', async () => { 212 | // Given 213 | // First setup valid profile response, then fail parsing on repos 214 | mockFetch 215 | .mockResolvedValueOnce(createSuccessResponse(mockProfile)) 216 | .mockResolvedValueOnce({ 217 | status: 200, 218 | headers: { get: jest.fn().mockReturnValue(null) }, 219 | json: jest.fn().mockRejectedValue(createJsonError('Invalid JSON')), 220 | }); 221 | setupEmptyCache(mockCache); 222 | 223 | // When 224 | const result = loader.loadUserData('testuser'); 225 | 226 | // Then 227 | await expect(result).rejects.toThrow( 228 | 'Failed to parse API response as JSON', 229 | ); 230 | }); 231 | }); 232 | 233 | describe('loadRepositoriesLanguages', () => { 234 | it('should load language statistics for repositories', (done) => { 235 | // Setup language mocks using utility 236 | setupLanguageMocks(mockLanguageStats); 237 | setupEmptyCache(mockCache); 238 | 239 | loader.loadRepositoriesLanguages( 240 | mockRepositories.slice(0, 2), 241 | (langStats) => { 242 | try { 243 | expect(langStats).toHaveLength(2); 244 | // Since completion order is not guaranteed, just check that we got the expected data 245 | expect(langStats).toEqual( 246 | expect.arrayContaining([ 247 | mockLanguageStats[0], 248 | mockLanguageStats[1], 249 | ]), 250 | ); 251 | done(); 252 | } catch (error) { 253 | done(error as Error); 254 | } 255 | }, 256 | ); 257 | }); 258 | 259 | it('should handle empty repositories array', (done) => { 260 | loader.loadRepositoriesLanguages([], (langStats) => { 261 | expect(langStats).toEqual([]); 262 | done(); 263 | }); 264 | }); 265 | 266 | it('should handle null repositories', (done) => { 267 | loader.loadRepositoriesLanguages(null as any, (langStats) => { 268 | expect(langStats).toEqual([]); 269 | done(); 270 | }); 271 | }); 272 | 273 | it('should handle failed language requests gracefully', (done) => { 274 | mockFetch 275 | .mockResolvedValueOnce(createSuccessResponse(mockLanguageStats[0])) 276 | .mockRejectedValueOnce(createNetworkError('Network error')); 277 | setupEmptyCache(mockCache); 278 | 279 | loader.loadRepositoriesLanguages( 280 | mockRepositories.slice(0, 2), 281 | (langStats) => { 282 | try { 283 | expect(langStats).toHaveLength(2); 284 | // One should succeed with the mock data, one should fail with empty object 285 | expect(langStats).toEqual( 286 | expect.arrayContaining([mockLanguageStats[0], {}]), 287 | ); 288 | done(); 289 | } catch (error) { 290 | done(error as Error); 291 | } 292 | }, 293 | ); 294 | }); 295 | 296 | it('should handle repositories without language URLs', (done) => { 297 | const reposWithoutLangUrl = mockRepositories.map((repo) => ({ 298 | ...repo, 299 | languages_url: undefined as any, 300 | })); 301 | 302 | loader.loadRepositoriesLanguages(reposWithoutLangUrl, (langStats) => { 303 | expect(langStats).toEqual([]); 304 | done(); 305 | }); 306 | }); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /src/gh-data-loader.ts: -------------------------------------------------------------------------------- 1 | import { CacheStorage, CacheEntry } from './gh-cache-storage'; 2 | import { ApiUserData } from './interface/IWidget'; 3 | import { ApiError, ApiProfile, ApiRepository } from './interface/IGitHubApi'; 4 | 5 | const API_HOST = 'https://api.github.com'; 6 | 7 | export class GitHubApiLoader { 8 | private cache = new CacheStorage(window.localStorage); 9 | 10 | public async loadUserData(username: string): Promise { 11 | if (typeof username !== 'string') { 12 | throw new Error('Invalid username provided'); 13 | } 14 | 15 | const sanitizedUsername = username.trim(); 16 | if (!sanitizedUsername) { 17 | throw new Error('Username cannot be empty'); 18 | } 19 | 20 | const profile = await this.fetch( 21 | `${API_HOST}/users/${sanitizedUsername}`, 22 | ); 23 | const repositories = await this.fetch(profile.repos_url); 24 | 25 | return { profile, repositories }; 26 | } 27 | 28 | public loadRepositoriesLanguages( 29 | repositories: ApiRepository[], 30 | callback: (rank: Record[]) => void, 31 | ): void { 32 | if (!repositories || repositories.length === 0) { 33 | callback([]); 34 | return; 35 | } 36 | 37 | const languagesUrls = this.extractLangURLs(repositories); 38 | const langStats: Record[] = []; 39 | const requestsAmount = languagesUrls.length; 40 | let completedRequests = 0; 41 | 42 | if (requestsAmount === 0) { 43 | callback([]); 44 | return; 45 | } 46 | 47 | const handleCompletion = () => { 48 | completedRequests++; 49 | if (completedRequests === requestsAmount) { 50 | callback(langStats); 51 | } 52 | }; 53 | 54 | languagesUrls.forEach((repoLangUrl) => { 55 | this.fetch>(repoLangUrl) 56 | .then((repoLangs) => { 57 | langStats.push(repoLangs || {}); 58 | handleCompletion(); 59 | }) 60 | .catch((error) => { 61 | console.warn('Failed to load languages for repository:', error); 62 | langStats.push({}); 63 | handleCompletion(); 64 | }); 65 | }); 66 | } 67 | 68 | private async identifyError(response: Response): Promise { 69 | let result: { message?: string }; 70 | try { 71 | result = await response.json(); 72 | } catch { 73 | result = { message: 'Failed to parse error response' }; 74 | } 75 | 76 | const error: ApiError = { 77 | message: 78 | result.message || `HTTP ${response.status}: ${response.statusText}`, 79 | }; 80 | 81 | if (response.status === 404) { 82 | error.isWrongUser = true; 83 | } 84 | 85 | const limitRequests = response.headers.get('X-RateLimit-Remaining'); 86 | if (Number(limitRequests) === 0) { 87 | const resetTime = response.headers.get('X-RateLimit-Reset'); 88 | if (resetTime) { 89 | error.resetDate = new Date(Number(resetTime) * 1000); 90 | // full message is too long, leave only general message 91 | error.message = error.message.split('(')[0]; 92 | } 93 | } 94 | 95 | return error; 96 | } 97 | 98 | private extractLangURLs(profileRepositories: ApiRepository[]): string[] { 99 | return profileRepositories 100 | .filter((repo) => repo && repo.languages_url) 101 | .map((repository) => repository.languages_url); 102 | } 103 | 104 | private async fetch(url: string): Promise { 105 | if (typeof url !== 'string') { 106 | throw new Error('Invalid URL provided for fetch'); 107 | } 108 | 109 | const cache = this.cache.get(url); 110 | 111 | let response: Response; 112 | try { 113 | response = await fetch(url, { 114 | headers: this.buildHeaders(cache), 115 | }); 116 | } catch (networkError) { 117 | throw new Error(`Network error: ${networkError.message}`); 118 | } 119 | 120 | if (response.status === 304 && cache) { 121 | return cache.data as T; 122 | } 123 | 124 | if (response.status !== 200) { 125 | throw await this.identifyError(response); 126 | } 127 | 128 | let jsonResponse: T; 129 | try { 130 | jsonResponse = await response.json(); 131 | } catch { 132 | throw new Error('Failed to parse API response as JSON'); 133 | } 134 | 135 | const lastModified = response.headers.get('Last-Modified'); 136 | if (lastModified) { 137 | this.cache.add(url, { 138 | lastModified, 139 | data: jsonResponse, 140 | }); 141 | } 142 | 143 | return jsonResponse; 144 | } 145 | 146 | private buildHeaders(cache?: CacheEntry): HeadersInit { 147 | const headers: HeadersInit = { 148 | Accept: 'application/vnd.github.v3+json', 149 | }; 150 | 151 | if (cache?.lastModified) { 152 | headers['If-Modified-Since'] = cache.lastModified; 153 | } 154 | 155 | return headers; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/gh-dom-operator.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from '@jest/globals'; 2 | import { DOMOperator } from './gh-dom-operator'; 3 | import { ApiError, ApiProfile, ApiRepository } from './interface/IGitHubApi'; 4 | 5 | describe('DOMOperator', () => { 6 | const initTemplate = ` 7 |
    8 | `; 9 | 10 | let $template: HTMLElement; 11 | 12 | beforeEach(() => { 13 | document.body.innerHTML = initTemplate; 14 | $template = document.querySelector('#github-card')!; 15 | }); 16 | 17 | describe('basic functionality', () => { 18 | it('should compile', () => { 19 | expect($template).toBeDefined(); 20 | }); 21 | 22 | it('should clear children efficiently', () => { 23 | $template.innerHTML = ` 24 |
    25 |
    26 | `; 27 | 28 | DOMOperator.clearChildren($template); 29 | const result = $template.innerHTML; 30 | 31 | expect(result).toBe(''); 32 | }); 33 | 34 | it('should clear children with text content', () => { 35 | $template.textContent = 'Some text content'; 36 | $template.appendChild(document.createElement('div')); 37 | 38 | DOMOperator.clearChildren($template); 39 | 40 | expect($template.textContent).toBe(''); 41 | expect($template.children).toHaveLength(0); 42 | }); 43 | }); 44 | 45 | describe('error handling', () => { 46 | it('should create API error', () => { 47 | const error: ApiError = { 48 | message: 'Service not reachable', 49 | }; 50 | 51 | const $error = DOMOperator.createError(error, ''); 52 | const message = $error.textContent; 53 | 54 | expect(message).toBe('Service not reachable'); 55 | expect($error.className).toBe('error'); 56 | }); 57 | 58 | it('should create 404 error', () => { 59 | const username = 'piotrl-not-defined'; 60 | const error: ApiError = { 61 | isWrongUser: true, 62 | message: 'Username not found', 63 | }; 64 | 65 | const $error = DOMOperator.createError(error, username); 66 | const message = $error.textContent; 67 | 68 | expect(message).toBe(`Not found user: ${username}`); 69 | }); 70 | 71 | it('should create rate limit error with remaining time', () => { 72 | const username = 'testuser'; 73 | const resetDate = new Date(Date.now() + 3600000); // 1 hour from now 74 | const error: ApiError = { 75 | message: 'Rate limit exceeded', 76 | resetDate, 77 | }; 78 | 79 | const $error = DOMOperator.createError(error, username); 80 | 81 | expect($error.children).toHaveLength(2); 82 | expect($error.children[0].textContent).toBe('Rate limit exceeded'); 83 | expect($error.children[1].textContent).toContain('Come back after'); 84 | expect($error.children[1].className).toBe('remain'); 85 | }); 86 | 87 | it('should handle past reset date', () => { 88 | const username = 'testuser'; 89 | const resetDate = new Date(Date.now() - 3600000); // 1 hour ago 90 | const error: ApiError = { 91 | message: 'Rate limit exceeded', 92 | resetDate, 93 | }; 94 | 95 | const $error = DOMOperator.createError(error, username); 96 | 97 | // Should still show the remaining time (will be negative, but handled) 98 | expect($error.children).toHaveLength(2); 99 | expect($error.children[1].textContent).toContain('Come back after'); 100 | }); 101 | 102 | it('should handle empty error message', () => { 103 | const error: ApiError = { 104 | message: '', 105 | }; 106 | 107 | const $error = DOMOperator.createError(error, 'testuser'); 108 | 109 | expect($error.children[0].textContent).toBe(''); 110 | }); 111 | }); 112 | 113 | describe('profile creation', () => { 114 | const mockProfile: ApiProfile = { 115 | name: 'Test User', 116 | avatar_url: 'https://avatars.githubusercontent.com/u/123456', 117 | followers: 1234, 118 | html_url: 'https://github.com/testuser', 119 | login: 'testuser', 120 | // Required fields 121 | followers_url: '', 122 | repos_url: '', 123 | id: 123456, 124 | gravatar_id: '', 125 | url: '', 126 | following_url: '', 127 | gists_url: '', 128 | starred_url: '', 129 | subscriptions_url: '', 130 | organizations_url: '', 131 | events_url: '', 132 | received_events_url: '', 133 | type: 'User', 134 | site_admin: false, 135 | company: '', 136 | blog: '', 137 | location: '', 138 | email: '', 139 | hireable: true, 140 | bio: '', 141 | public_repos: 10, 142 | public_gists: 5, 143 | following: 50, 144 | created_at: '2020-01-01T00:00:00Z', 145 | updated_at: '2023-01-01T00:00:00Z', 146 | }; 147 | 148 | it('should create profile element', () => { 149 | const $profile = DOMOperator.createProfile(mockProfile); 150 | 151 | expect($profile.tagName).toBe('DIV'); 152 | expect($profile.classList.contains('profile')).toBe(true); 153 | }); 154 | 155 | it('should handle profile with missing name', () => { 156 | const profileWithoutName = { ...mockProfile, name: null as any }; 157 | 158 | const $profile = DOMOperator.createProfile(profileWithoutName); 159 | 160 | expect($profile).toBeDefined(); 161 | }); 162 | 163 | it('should handle profile with empty avatar URL', () => { 164 | const profileWithoutAvatar = { ...mockProfile, avatar_url: '' }; 165 | 166 | const $profile = DOMOperator.createProfile(profileWithoutAvatar); 167 | 168 | expect($profile).toBeDefined(); 169 | }); 170 | }); 171 | 172 | describe('top languages', () => { 173 | it('should create top languages section', () => { 174 | const $langsList = DOMOperator.createTopLanguagesSection(); 175 | 176 | expect($langsList.tagName).toBe('UL'); 177 | expect($langsList.className).toBe('languages'); 178 | }); 179 | 180 | it('should create top languages list with multiple languages', () => { 181 | const langs = { 182 | TypeScript: 10000, 183 | JavaScript: 5000, 184 | CSS: 2000, 185 | HTML: 1000, 186 | Python: 500, 187 | }; 188 | 189 | const result = DOMOperator.createTopLanguagesList(langs); 190 | 191 | expect(result).toContain('
  • TypeScript
  • '); 192 | expect(result).toContain('
  • JavaScript
  • '); 193 | expect(result).toContain('
  • CSS
  • '); 194 | // Should only include top 3 195 | expect(result).not.toContain('
  • HTML
  • '); 196 | expect(result).not.toContain('
  • Python
  • '); 197 | }); 198 | 199 | it('should create top languages list with fewer than 3 languages', () => { 200 | const langs = { 201 | TypeScript: 10000, 202 | JavaScript: 5000, 203 | }; 204 | 205 | const result = DOMOperator.createTopLanguagesList(langs); 206 | 207 | expect(result).toContain('
  • TypeScript
  • '); 208 | expect(result).toContain('
  • JavaScript
  • '); 209 | expect((result.match(/
  • /g) || []).length).toBe(2); 210 | }); 211 | 212 | it('should handle empty languages object', () => { 213 | const langs = {}; 214 | 215 | const result = DOMOperator.createTopLanguagesList(langs); 216 | 217 | expect(result).toBe(''); 218 | }); 219 | 220 | it('should escape HTML in language names', () => { 221 | const langs = { 222 | 'C++': 10000, 223 | '': 5000, 224 | 'C#': 2000, 225 | }; 226 | 227 | const result = DOMOperator.createTopLanguagesList(langs); 228 | 229 | expect(result).toContain('
  • C++
  • '); 230 | expect(result).toContain('
  • C#
  • '); 231 | expect(result).not.toContain('', 379 | description: '', 380 | }; 381 | 382 | const $reposList = DOMOperator.createRepositoriesList([maliciousRepo], 1); 383 | const $repoElement = $reposList.children[0] as HTMLAnchorElement; 384 | const $repoName = $repoElement.querySelector('.repo-name'); 385 | 386 | expect($repoName?.textContent).toBe(''); 387 | expect($repoName?.innerHTML).not.toContain(''; 250 | const maliciousUsername = ''; 251 | 252 | const nameElement = createName( 253 | 'https://github.com/testuser', 254 | maliciousName, 255 | ); 256 | const followButton = createFollowButton( 257 | maliciousUsername, 258 | 'https://github.com/testuser', 259 | ); 260 | 261 | // Text content should be escaped 262 | expect(nameElement.textContent).toBe(maliciousName); 263 | expect(nameElement.innerHTML).not.toContain('