├── src ├── testing │ ├── style-mock.js │ ├── index.ts │ ├── in-memory-storage.ts │ ├── cache-mock.ts │ ├── test-utils.ts │ ├── test-setup.ts │ ├── fetch-mock.ts │ └── mock-github-data.ts ├── interface │ ├── storage.ts │ ├── IWidget.ts │ └── IGitHubApi.ts ├── css │ ├── base.scss │ ├── shared.scss │ ├── repositories-list.scss │ └── profile-header.scss ├── gh-widget-init.ts ├── gh-dom.utils.ts ├── gh-cache-storage.ts ├── gh-dom-operator.ts ├── gh-data-loader.ts ├── gh-profile-card.ts ├── gh-widget-init.spec.ts ├── gh-cache-storage.spec.ts ├── gh-dom.utils.spec.ts ├── gh-data-loader.spec.ts ├── gh-dom-operator.spec.ts └── gh-profile-card.spec.ts ├── _config.yml ├── .prettierrc ├── .prettierignore ├── demo ├── screenshot.png ├── widget-generator.js ├── demo.js ├── style.css └── index.html ├── tsconfig.json ├── .github ├── workflows │ └── ci.yml └── copilot-instructions.md ├── .gitignore ├── eslint.config.js ├── jest.config.ts ├── LICENSE ├── package.json ├── README.md └── dist └── gh-profile-card.min.js /src/testing/style-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-dinky 2 | gems: 3 | - jemoji 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "htmlWhitespaceSensitivity": "ignore", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.yml 3 | yarn.lock 4 | *.log 5 | LICENSE 6 | CNAME 7 | *.iml 8 | *.js 9 | -------------------------------------------------------------------------------- /demo/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piotrl/github-profile-card/HEAD/demo/screenshot.png -------------------------------------------------------------------------------- /src/interface/storage.ts: -------------------------------------------------------------------------------- 1 | export interface BrowserStorage { 2 | getItem(key: string): string | null; 3 | setItem(key: string, data: string): void; 4 | removeItem(key: string): void; 5 | } 6 | -------------------------------------------------------------------------------- /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/interface/IWidget.ts: -------------------------------------------------------------------------------- 1 | import { ApiProfile, ApiRepository } from './IGitHubApi'; 2 | 3 | export interface WidgetConfig { 4 | username?: string; 5 | template?: string; 6 | sortBy?: string; 7 | headerText?: string; 8 | maxRepos?: number; 9 | hideTopLanguages?: boolean; 10 | } 11 | 12 | export interface ApiUserData { 13 | profile: ApiProfile; 14 | repositories: ApiRepository[]; 15 | } 16 | -------------------------------------------------------------------------------- /src/testing/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Testing utilities index file 3 | * Exports all testing utilities for easy importing 4 | */ 5 | 6 | // Mock data 7 | export * from './mock-github-data'; 8 | 9 | // Mock utilities 10 | export * from './fetch-mock'; 11 | export * from './cache-mock'; 12 | 13 | // Test utilities 14 | export * from './test-utils'; 15 | 16 | // Storage mocks 17 | export * from './in-memory-storage'; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": false, 5 | "module": "es2022", 6 | "target": "es2017", 7 | "sourceMap": true, 8 | "allowJs": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "moduleResolution": "nodenext", 12 | "strict": false, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "types": ["jest", "node"] 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist"] 19 | } 20 | -------------------------------------------------------------------------------- /src/testing/in-memory-storage.ts: -------------------------------------------------------------------------------- 1 | import { BrowserStorage } from '../interface/storage'; 2 | 3 | export class InMemoryStorage implements BrowserStorage { 4 | private readonly storage = new Map(); 5 | 6 | public getItem(key: string): string | null { 7 | const item = this.storage.get(key); 8 | 9 | return item !== undefined ? item : null; // tslint:disable-line:no-null-keyword 10 | } 11 | 12 | public setItem(key: string, data: string): void { 13 | this.storage.set(key, data); 14 | } 15 | 16 | public removeItem(key: string): void { 17 | this.storage.delete(key); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/gh-widget-init.ts: -------------------------------------------------------------------------------- 1 | import { GitHubCardWidget } from './gh-profile-card'; 2 | 3 | import './css/base.scss'; 4 | 5 | declare global { 6 | interface Window { 7 | GitHubCard: typeof GitHubCardWidget; 8 | } 9 | } 10 | 11 | window.GitHubCard = GitHubCardWidget; 12 | 13 | document.addEventListener('DOMContentLoaded', () => { 14 | const $defaultTemplate = document.querySelector('#github-card'); 15 | if ($defaultTemplate) { 16 | try { 17 | const widget = new GitHubCardWidget(); 18 | widget.init().catch((error) => { 19 | console.error('Failed to initialize GitHub Card widget:', error); 20 | }); 21 | } catch (error) { 22 | console.error('Failed to construct GitHub Card widget:', error); 23 | } 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/ -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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/testing/cache-mock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache mock utilities for testing 3 | */ 4 | import { CacheStorage } from '../gh-cache-storage'; 5 | 6 | /** 7 | * Creates a mocked CacheStorage instance 8 | */ 9 | export function createCacheMock(): jest.Mocked { 10 | return { 11 | get: jest.fn(), 12 | add: jest.fn(), 13 | clearExpiredEntries: jest.fn(), 14 | } as any; 15 | } 16 | 17 | /** 18 | * Sets up cache mock with no cached data 19 | */ 20 | export function setupEmptyCache(mockCache: jest.Mocked): void { 21 | mockCache.get.mockReturnValue(undefined); 22 | } 23 | 24 | /** 25 | * Sets up cache mock with cached data 26 | */ 27 | export function setupCacheWithData( 28 | mockCache: jest.Mocked, 29 | url: string, 30 | data: any, 31 | lastModified: string = 'Mon, 18 Mar 2019 20:40:35 GMT', 32 | ): void { 33 | mockCache.get.mockImplementation((requestedUrl: string) => { 34 | if (requestedUrl === url) { 35 | return { 36 | lastModified, 37 | data, 38 | }; 39 | } 40 | return undefined; 41 | }); 42 | } 43 | 44 | /** 45 | * Resets all cache mock calls 46 | */ 47 | export function resetCacheMock(mockCache: jest.Mocked): void { 48 | mockCache.get.mockClear(); 49 | mockCache.add.mockClear(); 50 | mockCache.clearExpiredEntries.mockClear(); 51 | } 52 | -------------------------------------------------------------------------------- /src/testing/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { CacheStorage } from '../gh-cache-storage'; 2 | import { GitHubApiLoader } from '../gh-data-loader'; 3 | import { createCacheMock, setupEmptyCache } from './cache-mock'; 4 | import { setupFetchMock, resetFetchMock } from './fetch-mock'; 5 | 6 | jest.mock('../gh-cache-storage'); 7 | const MockCacheStorage = CacheStorage as jest.MockedClass; 8 | 9 | export function setupTestEnvironment(): { 10 | loader: GitHubApiLoader; 11 | mockCache: jest.Mocked; 12 | } { 13 | setupFetchMock(); 14 | 15 | const mockCache = createCacheMock(); 16 | MockCacheStorage.mockImplementation(() => mockCache); 17 | setupEmptyCache(mockCache); 18 | 19 | const loader = new GitHubApiLoader(); 20 | 21 | return { loader, mockCache }; 22 | } 23 | 24 | export function cleanupTestEnvironment(): void { 25 | jest.clearAllMocks(); 26 | resetFetchMock(); 27 | } 28 | 29 | export function setupTestDOM(html: string): void { 30 | document.body.innerHTML = html; 31 | } 32 | 33 | export function setupCommonTestHooks(): { 34 | getLoader: () => GitHubApiLoader; 35 | getMockCache: () => jest.Mocked; 36 | } { 37 | let loader: GitHubApiLoader; 38 | let mockCache: jest.Mocked; 39 | 40 | beforeEach(() => { 41 | const env = setupTestEnvironment(); 42 | loader = env.loader; 43 | mockCache = env.mockCache; 44 | }); 45 | 46 | afterEach(() => { 47 | cleanupTestEnvironment(); 48 | }); 49 | 50 | return { 51 | getLoader: () => loader, 52 | getMockCache: () => mockCache, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /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/gh-dom.utils.ts: -------------------------------------------------------------------------------- 1 | function appendChildren($parent: HTMLElement, nodes: HTMLElement[]): void { 2 | nodes.forEach((node) => $parent.appendChild(node)); 3 | } 4 | 5 | export function createProfile(children: HTMLElement[]): HTMLDivElement { 6 | const $profile = document.createElement('div'); 7 | $profile.classList.add('profile'); 8 | appendChildren($profile, children); 9 | 10 | return $profile; 11 | } 12 | 13 | export function createName( 14 | profileUrl: string, 15 | name: string, 16 | ): HTMLAnchorElement { 17 | const $name = document.createElement('a'); 18 | $name.href = profileUrl; 19 | $name.className = 'name'; 20 | $name.appendChild(document.createTextNode(name || '')); 21 | 22 | return $name; 23 | } 24 | 25 | export function createAvatar(avatarUrl: string): HTMLImageElement { 26 | const $avatar = document.createElement('img'); 27 | $avatar.src = avatarUrl; 28 | $avatar.className = 'avatar'; 29 | $avatar.alt = 'GitHub avatar'; 30 | 31 | return $avatar; 32 | } 33 | 34 | export function createFollowButton( 35 | username: string, 36 | followUrl: string, 37 | ): HTMLAnchorElement { 38 | const $followButton = document.createElement('a'); 39 | $followButton.href = followUrl; 40 | $followButton.className = 'follow-button'; 41 | $followButton.textContent = `Follow @${username}`; 42 | 43 | return $followButton; 44 | } 45 | 46 | export function createFollowers(followersAmount: number): HTMLSpanElement { 47 | const $followers = document.createElement('span'); 48 | $followers.className = 'followers'; 49 | $followers.textContent = String(followersAmount); 50 | 51 | return $followers; 52 | } 53 | 54 | export function createFollowContainer(children: HTMLElement[]): HTMLDivElement { 55 | const $followContainer = document.createElement('div'); 56 | $followContainer.className = 'followMe'; 57 | appendChildren($followContainer, children); 58 | 59 | return $followContainer; 60 | } 61 | -------------------------------------------------------------------------------- /src/testing/test-setup.ts: -------------------------------------------------------------------------------- 1 | import { jest, beforeEach, expect, afterEach } from '@jest/globals'; 2 | 3 | // Mock console methods during tests to reduce noise 4 | const originalError = console.error; 5 | const originalWarn = console.warn; 6 | 7 | // Mock localStorage globally 8 | const localStorageMock = (() => { 9 | let store: { [key: string]: string } = {}; 10 | 11 | return { 12 | getItem: jest.fn((key: string) => store[key] || null), 13 | setItem: jest.fn((key: string, value: string) => { 14 | store[key] = value.toString(); 15 | }), 16 | removeItem: jest.fn((key: string) => { 17 | delete store[key]; 18 | }), 19 | clear: jest.fn(() => { 20 | store = {}; 21 | }), 22 | length: 0, 23 | key: jest.fn(), 24 | }; 25 | })(); 26 | 27 | // Safely define localStorage only if window exists 28 | if (typeof window !== 'undefined') { 29 | Object.defineProperty(window, 'localStorage', { 30 | value: localStorageMock, 31 | writable: true, 32 | }); 33 | } else { 34 | // For node environment, create a global localStorage 35 | (global as any).localStorage = localStorageMock; 36 | } 37 | 38 | beforeEach(() => { 39 | console.error = jest.fn(); 40 | console.warn = jest.fn(); 41 | 42 | // Reset all mocks before each test 43 | jest.clearAllMocks(); 44 | localStorageMock.clear(); 45 | 46 | // Global DOM setup for tests that need it 47 | // Only reset DOM if it exists (for jsdom environment tests) 48 | if (typeof document !== 'undefined') { 49 | document.head.innerHTML = ''; 50 | document.body.innerHTML = ''; 51 | } 52 | }); 53 | 54 | afterEach(() => { 55 | console.error = originalError; 56 | console.warn = originalWarn; 57 | }); 58 | 59 | // Add custom matchers 60 | expect.extend({ 61 | toBeInDOM() { 62 | return { 63 | pass: true, 64 | message: () => 'Expected element to be in the DOM', 65 | }; 66 | }, 67 | }); 68 | 69 | // Export for use in tests 70 | export { localStorageMock }; 71 | -------------------------------------------------------------------------------- /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/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/testing/fetch-mock.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { ApiProfile, ApiRepository } from '../interface/IGitHubApi'; 3 | 4 | export interface MockResponse { 5 | status: number; 6 | headers?: { 7 | get: jest.Mock; 8 | }; 9 | json?: jest.Mock; 10 | } 11 | 12 | /** 13 | * Global fetch mock function 14 | */ 15 | export const mockFetch = jest.fn(); 16 | 17 | /** 18 | * Sets up the global fetch mock 19 | */ 20 | export function setupFetchMock(): void { 21 | (global as any).fetch = mockFetch; 22 | } 23 | 24 | /** 25 | * Creates a successful HTTP response mock 26 | */ 27 | export function createSuccessResponse( 28 | data: T, 29 | headers: Record = {}, 30 | ): MockResponse { 31 | return { 32 | status: 200, 33 | headers: { 34 | get: jest.fn((header: string) => headers[header] || null), 35 | }, 36 | json: jest.fn().mockResolvedValue(data), 37 | }; 38 | } 39 | 40 | /** 41 | * Creates an error HTTP response mock 42 | */ 43 | export function createErrorResponse( 44 | status: number, 45 | message: string, 46 | headers: Record = {}, 47 | ): MockResponse { 48 | return { 49 | status, 50 | headers: { 51 | get: jest.fn((header: string) => headers[header] || null), 52 | }, 53 | json: jest.fn().mockResolvedValue({ message }), 54 | }; 55 | } 56 | 57 | /** 58 | * Creates a 304 Not Modified response mock 59 | */ 60 | export function createNotModifiedResponse(): MockResponse { 61 | return { 62 | status: 304, 63 | }; 64 | } 65 | 66 | /** 67 | * Creates a network error for fetch 68 | */ 69 | export function createNetworkError(message: string = 'Network failure'): Error { 70 | return new Error(message); 71 | } 72 | 73 | /** 74 | * Creates a JSON parsing error for fetch 75 | */ 76 | export function createJsonError(message: string = 'Invalid JSON'): Error { 77 | return new Error(message); 78 | } 79 | 80 | /** 81 | * Resets the fetch mock 82 | */ 83 | export function resetFetchMock(): void { 84 | mockFetch.mockReset(); 85 | } 86 | 87 | /** 88 | * Sets up common fetch mock responses for user data loading 89 | */ 90 | export function setupUserDataMocks( 91 | profile: ApiProfile, 92 | repositories: ApiRepository[], 93 | ): void { 94 | mockFetch 95 | .mockResolvedValueOnce( 96 | createSuccessResponse(profile, { 97 | 'Last-Modified': 'Mon, 18 Mar 2019 20:40:35 GMT', 98 | }), 99 | ) 100 | .mockResolvedValueOnce( 101 | createSuccessResponse(repositories, { 102 | 'Last-Modified': 'Mon, 18 Mar 2019 20:40:35 GMT', 103 | }), 104 | ); 105 | } 106 | 107 | /** 108 | * Sets up language loading mocks for repositories 109 | */ 110 | export function setupLanguageMocks( 111 | languageStats: Record[], 112 | ): void { 113 | languageStats.forEach((stats) => { 114 | mockFetch.mockResolvedValueOnce(createSuccessResponse(stats)); 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/interface/IGitHubApi.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GitHub API interfaces based on documentation 3 | * 4 | * @see https://developer.github.com/v3/ 5 | */ 6 | 7 | export interface ApiError { 8 | message: string; 9 | status?: number; 10 | username?: string; 11 | isWrongUser?: boolean; 12 | resetDate?: Date; 13 | } 14 | 15 | export interface ApiProfile { 16 | name: string; 17 | avatar_url: string; 18 | followers: number; 19 | followers_url: string; 20 | html_url: string; 21 | login: string; 22 | 23 | id: number; 24 | gravatar_id: string; 25 | url: string; 26 | following_url: string; 27 | gists_url: string; 28 | starred_url: string; 29 | subscriptions_url: string; 30 | organizations_url: string; 31 | repos_url: string; 32 | events_url: string; 33 | received_events_url: string; 34 | type: string; 35 | site_admin: boolean; 36 | company: string; 37 | blog: string; 38 | location: string; 39 | email: string; 40 | hireable: boolean; 41 | bio: string; 42 | public_repos: number; 43 | public_gists: number; 44 | following: number; 45 | created_at: string; 46 | updated_at: string; 47 | } 48 | 49 | export interface RepositoryOwner { 50 | login: string; 51 | id: number; 52 | avatar_url: string; 53 | gravatar_id: string; 54 | url: string; 55 | html_url: string; 56 | followers_url: string; 57 | following_url: string; 58 | gists_url: string; 59 | starred_url: string; 60 | subscriptions_url: string; 61 | organizations_url: string; 62 | repos_url: string; 63 | events_url: string; 64 | received_events_url: string; 65 | type: string; 66 | site_admin: boolean; 67 | } 68 | 69 | export interface ApiRepository { 70 | id: number; 71 | name: string; 72 | full_name: string; 73 | owner: RepositoryOwner; 74 | private: boolean; 75 | html_url: string; 76 | description: string; 77 | fork: boolean; 78 | url: string; 79 | forks_url: string; 80 | keys_url: string; 81 | collaborators_url: string; 82 | teams_url: string; 83 | hooks_url: string; 84 | issue_events_url: string; 85 | events_url: string; 86 | assignees_url: string; 87 | branches_url: string; 88 | tags_url: string; 89 | blobs_url: string; 90 | git_tags_url: string; 91 | git_refs_url: string; 92 | trees_url: string; 93 | statuses_url: string; 94 | languages_url: string; 95 | stargazers_url: string; 96 | contributors_url: string; 97 | subscribers_url: string; 98 | subscription_url: string; 99 | commits_url: string; 100 | git_commits_url: string; 101 | comments_url: string; 102 | issue_comment_url: string; 103 | contents_url: string; 104 | compare_url: string; 105 | merges_url: string; 106 | archive_url: string; 107 | downloads_url: string; 108 | issues_url: string; 109 | pulls_url: string; 110 | milestones_url: string; 111 | notifications_url: string; 112 | labels_url: string; 113 | releases_url: string; 114 | deployments_url: string; 115 | created_at: string; 116 | updated_at: string; 117 | pushed_at: string; 118 | git_url: string; 119 | ssh_url: string; 120 | clone_url: string; 121 | svn_url: string; 122 | homepage: string; 123 | size: number; 124 | stargazers_count: number; 125 | watchers_count: number; 126 | language: unknown; 127 | has_issues: boolean; 128 | has_downloads: boolean; 129 | has_wiki: boolean; 130 | has_pages: boolean; 131 | forks_count: number; 132 | mirror_url?: unknown; 133 | open_issues_count: number; 134 | forks: number; 135 | open_issues: number; 136 | watchers: number; 137 | default_branch: string; 138 | } 139 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/gh-dom-operator.ts: -------------------------------------------------------------------------------- 1 | import { ApiError, ApiProfile, ApiRepository } from './interface/IGitHubApi'; 2 | import { 3 | createAvatar, 4 | createFollowButton, 5 | createFollowContainer, 6 | createFollowers, 7 | createName, 8 | createProfile, 9 | } from './gh-dom.utils'; 10 | 11 | export class DOMOperator { 12 | public static clearChildren($parent: HTMLElement): void { 13 | // More efficient way to clear children 14 | $parent.textContent = ''; 15 | } 16 | 17 | public static createError(error: ApiError, username: string): HTMLDivElement { 18 | const $error = document.createElement('div'); 19 | $error.className = 'error'; 20 | 21 | const $message = document.createElement('span'); 22 | $message.textContent = error.message; 23 | $error.appendChild($message); 24 | 25 | if (error.isWrongUser) { 26 | $message.textContent = `Not found user: ${username}`; 27 | } 28 | 29 | if (error.resetDate) { 30 | const currentTime = new Date().getTime(); 31 | const resetTime = error.resetDate.getTime(); 32 | const remainingMinutes = Math.ceil( 33 | (resetTime - currentTime) / (1000 * 60), 34 | ); 35 | 36 | const $remainingTime = document.createElement('span'); 37 | $remainingTime.className = 'remain'; 38 | $remainingTime.textContent = `Come back after ${remainingMinutes} minutes`; 39 | $error.appendChild($remainingTime); 40 | } 41 | 42 | return $error; 43 | } 44 | 45 | public static createProfile(data: ApiProfile): HTMLDivElement { 46 | const $followButton = createFollowButton(data.login, data.html_url); 47 | const $followers = createFollowers(data.followers); 48 | const $followContainer = createFollowContainer([$followButton, $followers]); 49 | 50 | const $avatar = createAvatar(data.avatar_url); 51 | const $name = createName(data.html_url, data.name); 52 | 53 | return createProfile([$avatar, $name, $followContainer]); 54 | } 55 | 56 | public static createTopLanguagesSection(): HTMLUListElement { 57 | const $langsList = document.createElement('ul'); 58 | $langsList.className = 'languages'; 59 | 60 | return $langsList; 61 | } 62 | 63 | public static createTopLanguagesList(langs: Record): string { 64 | const sortedLanguages = Object.keys(langs) 65 | .map((language) => ({ 66 | name: language, 67 | stat: langs[language], 68 | })) 69 | .sort((a, b) => b.stat - a.stat) 70 | .slice(0, 3); 71 | 72 | return sortedLanguages 73 | .map((lang) => { 74 | // Escape HTML to prevent XSS 75 | const escapedName = this.escapeHtml(lang.name); 76 | return `
  • ${escapedName}
  • `; 77 | }) 78 | .join(''); 79 | } 80 | 81 | private static escapeHtml(text: string): string { 82 | const div = document.createElement('div'); 83 | div.textContent = text; 84 | return div.innerHTML; 85 | } 86 | 87 | public static createRepositoriesHeader(headerText: string): HTMLSpanElement { 88 | const $repositoriesHeader = document.createElement('span'); 89 | $repositoriesHeader.className = 'header'; 90 | $repositoriesHeader.appendChild(document.createTextNode(`${headerText}`)); 91 | 92 | return $repositoriesHeader; 93 | } 94 | 95 | public static createRepositoriesList( 96 | repositories: ApiRepository[], 97 | maxRepos: number, 98 | ): HTMLDivElement { 99 | const $reposList = document.createElement('div'); 100 | $reposList.className = 'repos'; 101 | 102 | repositories 103 | .slice(0, maxRepos) 104 | .map(this.createRepositoryElement) 105 | .forEach((el) => $reposList.appendChild(el)); 106 | 107 | return $reposList; 108 | } 109 | 110 | private static createRepositoryElement( 111 | repository: ApiRepository, 112 | ): HTMLAnchorElement { 113 | const updated = new Date(repository.updated_at); 114 | const $repoLink = document.createElement('a'); 115 | 116 | $repoLink.href = repository.html_url; 117 | $repoLink.title = repository.description || ''; 118 | 119 | // Create elements safely to prevent XSS 120 | const $repoName = document.createElement('span'); 121 | $repoName.className = 'repo-name'; 122 | $repoName.textContent = repository.name; 123 | 124 | const $updated = document.createElement('span'); 125 | $updated.className = 'updated'; 126 | $updated.textContent = `Updated: ${updated.toLocaleDateString()}`; 127 | 128 | const $star = document.createElement('span'); 129 | $star.className = 'star'; 130 | $star.textContent = String(repository.stargazers_count); 131 | 132 | $repoLink.appendChild($repoName); 133 | $repoLink.appendChild($updated); 134 | $repoLink.appendChild($star); 135 | 136 | return $repoLink; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/testing/mock-github-data.ts: -------------------------------------------------------------------------------- 1 | import { ApiProfile, ApiRepository } from '../interface/IGitHubApi'; 2 | 3 | export const mockProfile: ApiProfile = { 4 | name: 'Test User', 5 | avatar_url: 'https://avatars.githubusercontent.com/u/123456', 6 | followers: 100, 7 | followers_url: 'https://api.github.com/users/testuser/followers', 8 | html_url: 'https://github.com/testuser', 9 | login: 'testuser', 10 | repos_url: 'https://api.github.com/users/testuser/repos', 11 | id: 123456, 12 | gravatar_id: '', 13 | url: 'https://api.github.com/users/testuser', 14 | following_url: '', 15 | gists_url: '', 16 | starred_url: '', 17 | subscriptions_url: '', 18 | organizations_url: '', 19 | events_url: '', 20 | received_events_url: '', 21 | type: 'User', 22 | site_admin: false, 23 | company: '', 24 | blog: '', 25 | location: '', 26 | email: '', 27 | hireable: true, 28 | bio: '', 29 | public_repos: 10, 30 | public_gists: 5, 31 | following: 50, 32 | created_at: '2020-01-01T00:00:00Z', 33 | updated_at: '2023-01-01T00:00:00Z', 34 | }; 35 | 36 | export const mockRepositories: ApiRepository[] = [ 37 | { 38 | id: 1, 39 | name: 'test-repo-1', 40 | full_name: 'testuser/test-repo-1', 41 | html_url: 'https://github.com/testuser/test-repo-1', 42 | description: 'A test repository', 43 | languages_url: 44 | 'https://api.github.com/repos/testuser/test-repo-1/languages', 45 | stargazers_count: 20, 46 | updated_at: '2023-02-01T00:00:00Z', 47 | owner: {} as any, 48 | private: false, 49 | fork: false, 50 | url: '', 51 | forks_url: '', 52 | keys_url: '', 53 | collaborators_url: '', 54 | teams_url: '', 55 | hooks_url: '', 56 | issue_events_url: '', 57 | events_url: '', 58 | assignees_url: '', 59 | branches_url: '', 60 | tags_url: '', 61 | blobs_url: '', 62 | git_tags_url: '', 63 | git_refs_url: '', 64 | trees_url: '', 65 | statuses_url: '', 66 | stargazers_url: '', 67 | contributors_url: '', 68 | subscribers_url: '', 69 | subscription_url: '', 70 | commits_url: '', 71 | git_commits_url: '', 72 | comments_url: '', 73 | issue_comment_url: '', 74 | contents_url: '', 75 | compare_url: '', 76 | merges_url: '', 77 | archive_url: '', 78 | downloads_url: '', 79 | issues_url: '', 80 | pulls_url: '', 81 | milestones_url: '', 82 | notifications_url: '', 83 | labels_url: '', 84 | releases_url: '', 85 | deployments_url: '', 86 | created_at: '2020-01-01T00:00:00Z', 87 | pushed_at: '2023-02-01T00:00:00Z', 88 | git_url: '', 89 | ssh_url: '', 90 | clone_url: '', 91 | svn_url: '', 92 | homepage: '', 93 | size: 100, 94 | watchers_count: 5, 95 | language: 'TypeScript', 96 | has_issues: true, 97 | has_downloads: true, 98 | has_wiki: true, 99 | has_pages: false, 100 | forks_count: 2, 101 | open_issues_count: 1, 102 | forks: 2, 103 | open_issues: 1, 104 | watchers: 5, 105 | default_branch: 'main', 106 | }, 107 | { 108 | id: 2, 109 | name: 'test-repo-2', 110 | full_name: 'testuser/test-repo-2', 111 | html_url: 'https://github.com/testuser/test-repo-2', 112 | description: 'Another test repository', 113 | languages_url: 114 | 'https://api.github.com/repos/testuser/test-repo-2/languages', 115 | stargazers_count: 10, 116 | updated_at: '2023-01-01T00:00:00Z', 117 | owner: {} as any, 118 | private: false, 119 | fork: false, 120 | url: '', 121 | forks_url: '', 122 | keys_url: '', 123 | collaborators_url: '', 124 | teams_url: '', 125 | hooks_url: '', 126 | issue_events_url: '', 127 | events_url: '', 128 | assignees_url: '', 129 | branches_url: '', 130 | tags_url: '', 131 | blobs_url: '', 132 | git_tags_url: '', 133 | git_refs_url: '', 134 | trees_url: '', 135 | statuses_url: '', 136 | stargazers_url: '', 137 | contributors_url: '', 138 | subscribers_url: '', 139 | subscription_url: '', 140 | commits_url: '', 141 | git_commits_url: '', 142 | comments_url: '', 143 | issue_comment_url: '', 144 | contents_url: '', 145 | compare_url: '', 146 | merges_url: '', 147 | archive_url: '', 148 | downloads_url: '', 149 | issues_url: '', 150 | pulls_url: '', 151 | milestones_url: '', 152 | notifications_url: '', 153 | labels_url: '', 154 | releases_url: '', 155 | deployments_url: '', 156 | created_at: '2020-01-01T00:00:00Z', 157 | pushed_at: '2023-01-01T00:00:00Z', 158 | git_url: '', 159 | ssh_url: '', 160 | clone_url: '', 161 | svn_url: '', 162 | homepage: '', 163 | size: 100, 164 | watchers_count: 5, 165 | language: 'JavaScript', 166 | has_issues: true, 167 | has_downloads: true, 168 | has_wiki: true, 169 | has_pages: false, 170 | forks_count: 2, 171 | open_issues_count: 1, 172 | forks: 2, 173 | open_issues: 1, 174 | watchers: 5, 175 | default_branch: 'main', 176 | }, 177 | ]; 178 | 179 | export const mockLanguageStats = [ 180 | { TypeScript: 1000, JavaScript: 500 }, 181 | { Python: 800, TypeScript: 200 }, 182 | ]; 183 | 184 | /** 185 | * Creates a mock repository with custom properties 186 | */ 187 | export function createMockRepository( 188 | overrides: Partial = {}, 189 | ): ApiRepository { 190 | return { 191 | ...mockRepositories[0], 192 | ...overrides, 193 | }; 194 | } 195 | 196 | /** 197 | * Creates a mock profile with custom properties 198 | */ 199 | export function createMockProfile( 200 | overrides: Partial = {}, 201 | ): ApiProfile { 202 | return { 203 | ...mockProfile, 204 | ...overrides, 205 | }; 206 | } 207 | -------------------------------------------------------------------------------- /src/gh-profile-card.ts: -------------------------------------------------------------------------------- 1 | import { GitHubApiLoader } from './gh-data-loader'; 2 | import { DOMOperator } from './gh-dom-operator'; 3 | import { ApiUserData, WidgetConfig } from './interface/IWidget'; 4 | import { ApiError, ApiRepository } from './interface/IGitHubApi'; 5 | 6 | export class GitHubCardWidget { 7 | private apiLoader: GitHubApiLoader = new GitHubApiLoader(); 8 | private $template: HTMLElement; 9 | private userData: ApiUserData | null = null; 10 | private options: WidgetConfig; 11 | 12 | constructor(options: WidgetConfig = {}) { 13 | this.$template = this.findTemplate(options.template); 14 | this.extractHtmlConfig(options, this.$template); 15 | this.options = this.completeConfiguration(options); 16 | } 17 | 18 | public async init(): Promise { 19 | try { 20 | this.userData = await this.apiLoader.loadUserData(this.options.username!); 21 | this.render(this.options); 22 | } catch (err) { 23 | this.render(this.options, err as ApiError); 24 | } 25 | } 26 | 27 | public refresh(options: WidgetConfig): void { 28 | this.options = this.completeConfiguration(options); 29 | this.render(this.options); 30 | } 31 | 32 | private completeConfiguration(options: WidgetConfig): WidgetConfig { 33 | const defaultConfig: Required = { 34 | username: '', 35 | template: '#github-card', 36 | sortBy: 'stars', // possible: 'stars', 'updateTime' 37 | headerText: 'Most starred repositories', 38 | maxRepos: 5, 39 | hideTopLanguages: false, 40 | }; 41 | 42 | return { 43 | ...defaultConfig, 44 | ...options, 45 | }; 46 | } 47 | 48 | private findTemplate(templateCssSelector = '#github-card'): HTMLElement { 49 | const $template = document.querySelector( 50 | templateCssSelector, 51 | ) as HTMLElement; 52 | if (!$template) { 53 | throw new Error(`No template found for selector: ${templateCssSelector}`); 54 | } 55 | $template.className = 'gh-profile-card'; 56 | return $template; 57 | } 58 | 59 | private extractHtmlConfig( 60 | widgetConfig: WidgetConfig, 61 | $template: HTMLElement, 62 | ): void { 63 | const dataset = $template.dataset; 64 | 65 | widgetConfig.username = widgetConfig.username || dataset.username; 66 | widgetConfig.sortBy = widgetConfig.sortBy || dataset.sortBy; 67 | widgetConfig.headerText = widgetConfig.headerText || dataset.headerText; 68 | 69 | if (dataset.maxRepos) { 70 | const parsedMaxRepos = parseInt(dataset.maxRepos, 10); 71 | if (!isNaN(parsedMaxRepos)) { 72 | widgetConfig.maxRepos = widgetConfig.maxRepos || parsedMaxRepos; 73 | } 74 | } 75 | 76 | widgetConfig.hideTopLanguages = 77 | widgetConfig.hideTopLanguages || dataset.hideTopLanguages === 'true'; 78 | 79 | if (!widgetConfig.username) { 80 | throw new Error('Username is required but not provided'); 81 | } 82 | } 83 | 84 | private render(options: WidgetConfig, error?: ApiError): void { 85 | const $root = this.$template; 86 | 87 | // clear root template element to prepare space for widget 88 | DOMOperator.clearChildren($root); 89 | 90 | if (error) { 91 | const $errorSection = DOMOperator.createError( 92 | error, 93 | options.username || '', 94 | ); 95 | $root.appendChild($errorSection); 96 | return; 97 | } 98 | 99 | // API doesn't return errors, try to build widget 100 | const repositories = this.userData!.repositories; 101 | this.sortRepositories(repositories, options.sortBy || 'stars'); 102 | 103 | const $profile = DOMOperator.createProfile(this.userData!.profile); 104 | if (!options.hideTopLanguages) { 105 | $profile.appendChild(this.createTopLanguagesSection(repositories)); 106 | } 107 | $root.appendChild($profile); 108 | 109 | if ((options.maxRepos || 0) > 0) { 110 | const $reposHeader = DOMOperator.createRepositoriesHeader( 111 | options.headerText || 'Repositories', 112 | ); 113 | const $reposList = DOMOperator.createRepositoriesList( 114 | repositories, 115 | options.maxRepos || 5, 116 | ); 117 | $reposList.insertBefore($reposHeader, $reposList.firstChild); 118 | 119 | $root.appendChild($reposList); 120 | } 121 | } 122 | 123 | private createTopLanguagesSection( 124 | repositories: ApiRepository[], 125 | ): HTMLUListElement { 126 | const $topLanguages = DOMOperator.createTopLanguagesSection(); 127 | 128 | if (!repositories || repositories.length === 0) { 129 | return $topLanguages; 130 | } 131 | 132 | this.apiLoader.loadRepositoriesLanguages( 133 | repositories.slice(0, 10), 134 | (langStats) => { 135 | if (langStats.length > 0) { 136 | const languagesRank = this.groupLanguagesUsage(langStats); 137 | $topLanguages.innerHTML = 138 | DOMOperator.createTopLanguagesList(languagesRank); 139 | } 140 | }, 141 | ); 142 | return $topLanguages; 143 | } 144 | 145 | private groupLanguagesUsage( 146 | langStats: Record[], 147 | ): Record { 148 | const languagesRank: Record = {}; 149 | 150 | langStats.forEach((repoLangs) => { 151 | if (repoLangs && typeof repoLangs === 'object') { 152 | Object.entries(repoLangs).forEach(([language, bytes]) => { 153 | if (typeof bytes === 'number' && bytes > 0) { 154 | languagesRank[language] = (languagesRank[language] || 0) + bytes; 155 | } 156 | }); 157 | } 158 | }); 159 | 160 | return languagesRank; 161 | } 162 | 163 | private sortRepositories(repos: ApiRepository[], sortBy: string): void { 164 | if (!repos || repos.length === 0) { 165 | return; 166 | } 167 | 168 | repos.sort((firstRepo, secondRepo) => { 169 | if (sortBy === 'stars') { 170 | const starDifference = 171 | secondRepo.stargazers_count - firstRepo.stargazers_count; 172 | if (starDifference !== 0) { 173 | return starDifference; 174 | } 175 | } 176 | return this.dateDifference(secondRepo.updated_at, firstRepo.updated_at); 177 | }); 178 | } 179 | 180 | private dateDifference(first: string, second: string): number { 181 | const firstDate = new Date(first); 182 | const secondDate = new Date(second); 183 | 184 | if (isNaN(firstDate.getTime()) || isNaN(secondDate.getTime())) { 185 | return 0; 186 | } 187 | 188 | return firstDate.getTime() - secondDate.getTime(); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/gh-widget-init.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, jest } from '@jest/globals'; 2 | 3 | jest.mock('./gh-profile-card'); 4 | 5 | import { GitHubCardWidget } from './gh-profile-card'; 6 | const MockGitHubCardWidget = GitHubCardWidget as jest.MockedClass< 7 | typeof GitHubCardWidget 8 | >; 9 | 10 | // Import after mocking 11 | import './gh-widget-init'; 12 | 13 | describe('Widget Initialization', () => { 14 | let mockWidget: jest.Mocked; 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks(); 18 | 19 | // Clear any existing DOM 20 | document.body.innerHTML = ''; 21 | 22 | // Mock widget instance 23 | mockWidget = { 24 | init: jest.fn(), 25 | refresh: jest.fn(), 26 | } as any; 27 | 28 | MockGitHubCardWidget.mockImplementation(() => mockWidget); 29 | }); 30 | 31 | describe('Global GitHubCard', () => { 32 | it('should expose GitHubCard class globally', () => { 33 | expect(window.GitHubCard).toBe(GitHubCardWidget); 34 | }); 35 | 36 | it('should allow creating new instances via global', () => { 37 | new window.GitHubCard(); 38 | expect(MockGitHubCardWidget).toHaveBeenCalled(); 39 | }); 40 | }); 41 | 42 | describe('Automatic initialization', () => { 43 | it('should auto-initialize when #github-card element exists', () => { 44 | // Setup DOM with default template 45 | document.body.innerHTML = 46 | '
    '; 47 | 48 | // Trigger DOMContentLoaded event 49 | const event = new Event('DOMContentLoaded'); 50 | document.dispatchEvent(event); 51 | 52 | expect(MockGitHubCardWidget).toHaveBeenCalled(); 53 | expect(mockWidget.init).toHaveBeenCalled(); 54 | }); 55 | 56 | it('should not auto-initialize when #github-card element does not exist', () => { 57 | // Setup DOM without default template 58 | document.body.innerHTML = '
    '; 59 | 60 | // Trigger DOMContentLoaded event 61 | const event = new Event('DOMContentLoaded'); 62 | document.dispatchEvent(event); 63 | 64 | expect(MockGitHubCardWidget).not.toHaveBeenCalled(); 65 | expect(mockWidget.init).not.toHaveBeenCalled(); 66 | }); 67 | 68 | it('should work when DOM is already loaded', () => { 69 | // Setup DOM 70 | document.body.innerHTML = 71 | '
    '; 72 | 73 | // Simulate DOMContentLoaded already fired by directly calling the handler 74 | const event = new Event('DOMContentLoaded'); 75 | document.dispatchEvent(event); 76 | 77 | expect(MockGitHubCardWidget).toHaveBeenCalled(); 78 | expect(mockWidget.init).toHaveBeenCalled(); 79 | }); 80 | 81 | it('should handle multiple #github-card elements gracefully', () => { 82 | // Setup DOM with multiple elements (though selector should only find first) 83 | document.body.innerHTML = ` 84 |
    85 |
    86 | `; 87 | 88 | // Trigger DOMContentLoaded event 89 | const event = new Event('DOMContentLoaded'); 90 | document.dispatchEvent(event); 91 | 92 | // Should only initialize once for the first #github-card 93 | expect(MockGitHubCardWidget).toHaveBeenCalledTimes(1); 94 | expect(mockWidget.init).toHaveBeenCalledTimes(1); 95 | }); 96 | }); 97 | 98 | describe('Manual initialization', () => { 99 | it('should allow manual initialization with custom options', () => { 100 | document.body.innerHTML = 101 | '
    '; 102 | 103 | new window.GitHubCard({ 104 | template: '#custom-template', 105 | sortBy: 'updateTime', 106 | maxRepos: 3, 107 | }); 108 | 109 | expect(MockGitHubCardWidget).toHaveBeenCalledWith({ 110 | template: '#custom-template', 111 | sortBy: 'updateTime', 112 | maxRepos: 3, 113 | }); 114 | }); 115 | 116 | it('should allow multiple manual widget instances', () => { 117 | document.body.innerHTML = ` 118 |
    119 |
    120 | `; 121 | 122 | new window.GitHubCard({ template: '#widget-1' }); 123 | new window.GitHubCard({ template: '#widget-2' }); 124 | 125 | expect(MockGitHubCardWidget).toHaveBeenCalledTimes(2); 126 | expect(MockGitHubCardWidget).toHaveBeenNthCalledWith(1, { 127 | template: '#widget-1', 128 | }); 129 | expect(MockGitHubCardWidget).toHaveBeenNthCalledWith(2, { 130 | template: '#widget-2', 131 | }); 132 | }); 133 | }); 134 | 135 | describe('Error scenarios', () => { 136 | it('should handle widget initialization errors gracefully', () => { 137 | MockGitHubCardWidget.mockImplementation(() => { 138 | throw new Error('Template not found'); 139 | }); 140 | 141 | document.body.innerHTML = 142 | '
    '; 143 | 144 | // Should not throw when auto-initializing 145 | expect(() => { 146 | const event = new Event('DOMContentLoaded'); 147 | document.dispatchEvent(event); 148 | }).not.toThrow(); 149 | }); 150 | 151 | it('should handle widget init() method errors gracefully', () => { 152 | mockWidget.init.mockImplementation(() => { 153 | throw new Error('API error'); 154 | }); 155 | 156 | document.body.innerHTML = 157 | '
    '; 158 | 159 | // Should not throw when auto-initializing 160 | expect(() => { 161 | const event = new Event('DOMContentLoaded'); 162 | document.dispatchEvent(event); 163 | }).not.toThrow(); 164 | }); 165 | }); 166 | 167 | describe('Browser compatibility', () => { 168 | it('should work with querySelector', () => { 169 | document.body.innerHTML = 170 | '
    '; 171 | 172 | const element = document.querySelector('#github-card'); 173 | expect(element).not.toBeNull(); 174 | expect(element?.id).toBe('github-card'); 175 | }); 176 | 177 | it('should work with addEventListener', () => { 178 | const handler = jest.fn(); 179 | document.addEventListener('DOMContentLoaded', handler); 180 | 181 | const event = new Event('DOMContentLoaded'); 182 | document.dispatchEvent(event); 183 | 184 | expect(handler).toHaveBeenCalled(); 185 | }); 186 | }); 187 | 188 | describe('CSS loading', () => { 189 | it('should import CSS styles', () => { 190 | // The actual CSS import is handled by the bundler 191 | // We just verify the import statement exists in the module 192 | // This is more of a integration test that would be caught during build 193 | expect(true).toBe(true); // Placeholder - CSS loading is tested in build process 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/gh-dom.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it } from '@jest/globals'; 2 | import { 3 | createProfile, 4 | createName, 5 | createAvatar, 6 | createFollowButton, 7 | createFollowers, 8 | createFollowContainer, 9 | } from './gh-dom.utils'; 10 | 11 | describe('DOM Utils', () => { 12 | beforeEach(() => { 13 | document.body.innerHTML = ''; 14 | }); 15 | 16 | describe('createProfile', () => { 17 | it('should create a profile div with children', () => { 18 | const child1 = document.createElement('div'); 19 | child1.textContent = 'Child 1'; 20 | const child2 = document.createElement('span'); 21 | child2.textContent = 'Child 2'; 22 | 23 | const profile = createProfile([child1, child2]); 24 | 25 | expect(profile.tagName).toBe('DIV'); 26 | expect(profile.classList.contains('profile')).toBe(true); 27 | expect(profile.children).toHaveLength(2); 28 | expect(profile.children[0]).toBe(child1); 29 | expect(profile.children[1]).toBe(child2); 30 | }); 31 | 32 | it('should create a profile div with no children', () => { 33 | const profile = createProfile([]); 34 | 35 | expect(profile.tagName).toBe('DIV'); 36 | expect(profile.classList.contains('profile')).toBe(true); 37 | expect(profile.children).toHaveLength(0); 38 | }); 39 | }); 40 | 41 | describe('createName', () => { 42 | it('should create a name link with valid inputs', () => { 43 | const profileUrl = 'https://github.com/testuser'; 44 | const name = 'Test User'; 45 | 46 | const nameElement = createName(profileUrl, name); 47 | 48 | expect(nameElement.tagName).toBe('A'); 49 | expect(nameElement.href).toBe(profileUrl); 50 | expect(nameElement.className).toBe('name'); 51 | expect(nameElement.textContent).toBe(name); 52 | }); 53 | 54 | it('should handle empty name gracefully', () => { 55 | const profileUrl = 'https://github.com/testuser'; 56 | const name = ''; 57 | 58 | const nameElement = createName(profileUrl, name); 59 | 60 | expect(nameElement.textContent).toBe(''); 61 | }); 62 | 63 | it('should handle null name gracefully', () => { 64 | const profileUrl = 'https://github.com/testuser'; 65 | const name = null as any; 66 | 67 | const nameElement = createName(profileUrl, name); 68 | 69 | expect(nameElement.textContent).toBe(''); 70 | }); 71 | 72 | it('should handle undefined name gracefully', () => { 73 | const profileUrl = 'https://github.com/testuser'; 74 | const name = undefined as any; 75 | 76 | const nameElement = createName(profileUrl, name); 77 | 78 | expect(nameElement.textContent).toBe(''); 79 | }); 80 | }); 81 | 82 | describe('createAvatar', () => { 83 | it('should create an avatar image with correct attributes', () => { 84 | const avatarUrl = 'https://avatars.githubusercontent.com/u/123456'; 85 | 86 | const avatar = createAvatar(avatarUrl); 87 | 88 | expect(avatar.tagName).toBe('IMG'); 89 | expect(avatar.src).toBe(avatarUrl); 90 | expect(avatar.className).toBe('avatar'); 91 | expect(avatar.alt).toBe('GitHub avatar'); 92 | }); 93 | 94 | it('should handle empty avatar URL', () => { 95 | const avatarUrl = ''; 96 | 97 | const avatar = createAvatar(avatarUrl); 98 | 99 | expect(avatar.tagName).toBe('IMG'); 100 | expect(avatar.src).toBe('https://piotrl.github.io/github-profile-card'); 101 | expect(avatar.className).toBe('avatar'); 102 | expect(avatar.alt).toBe('GitHub avatar'); 103 | }); 104 | }); 105 | 106 | describe('createFollowButton', () => { 107 | it('should create a follow button with correct attributes', () => { 108 | const username = 'testuser'; 109 | const followUrl = 'https://github.com/testuser'; 110 | 111 | const button = createFollowButton(username, followUrl); 112 | 113 | expect(button.tagName).toBe('A'); 114 | expect(button.href).toBe(followUrl); 115 | expect(button.className).toBe('follow-button'); 116 | expect(button.textContent).toBe('Follow @testuser'); 117 | }); 118 | 119 | it('should handle empty username', () => { 120 | const username = ''; 121 | const followUrl = 'https://github.com/testuser'; 122 | 123 | const button = createFollowButton(username, followUrl); 124 | 125 | expect(button.textContent).toBe('Follow @'); 126 | }); 127 | 128 | it('should handle special characters in username', () => { 129 | const username = 'test-user_123'; 130 | const followUrl = 'https://github.com/test-user_123'; 131 | 132 | const button = createFollowButton(username, followUrl); 133 | 134 | expect(button.textContent).toBe('Follow @test-user_123'); 135 | }); 136 | }); 137 | 138 | describe('createFollowers', () => { 139 | it('should create a followers span with count', () => { 140 | const followersAmount = 1234; 141 | 142 | const followers = createFollowers(followersAmount); 143 | 144 | expect(followers.tagName).toBe('SPAN'); 145 | expect(followers.className).toBe('followers'); 146 | expect(followers.textContent).toBe('1234'); 147 | }); 148 | 149 | it('should handle zero followers', () => { 150 | const followersAmount = 0; 151 | 152 | const followers = createFollowers(followersAmount); 153 | 154 | expect(followers.textContent).toBe('0'); 155 | }); 156 | 157 | it('should handle large follower counts', () => { 158 | const followersAmount = 999999; 159 | 160 | const followers = createFollowers(followersAmount); 161 | 162 | expect(followers.textContent).toBe('999999'); 163 | }); 164 | 165 | it('should handle negative numbers', () => { 166 | const followersAmount = -5; 167 | 168 | const followers = createFollowers(followersAmount); 169 | 170 | expect(followers.textContent).toBe('-5'); 171 | }); 172 | }); 173 | 174 | describe('createFollowContainer', () => { 175 | it('should create a follow container with children', () => { 176 | const followButton = createFollowButton( 177 | 'testuser', 178 | 'https://github.com/testuser', 179 | ); 180 | const followers = createFollowers(100); 181 | 182 | const container = createFollowContainer([followButton, followers]); 183 | 184 | expect(container.tagName).toBe('DIV'); 185 | expect(container.className).toBe('followMe'); 186 | expect(container.children).toHaveLength(2); 187 | expect(container.children[0]).toBe(followButton); 188 | expect(container.children[1]).toBe(followers); 189 | }); 190 | 191 | it('should create an empty follow container', () => { 192 | const container = createFollowContainer([]); 193 | 194 | expect(container.tagName).toBe('DIV'); 195 | expect(container.className).toBe('followMe'); 196 | expect(container.children).toHaveLength(0); 197 | }); 198 | 199 | it('should handle mixed child elements', () => { 200 | const div = document.createElement('div'); 201 | const span = document.createElement('span'); 202 | const anchor = document.createElement('a'); 203 | 204 | const container = createFollowContainer([div, span, anchor]); 205 | 206 | expect(container.children).toHaveLength(3); 207 | expect(container.children[0]).toBe(div); 208 | expect(container.children[1]).toBe(span); 209 | expect(container.children[2]).toBe(anchor); 210 | }); 211 | }); 212 | 213 | describe('integration tests', () => { 214 | it('should create a complete profile structure', () => { 215 | const avatar = createAvatar( 216 | 'https://avatars.githubusercontent.com/u/123456', 217 | ); 218 | const name = createName('https://github.com/testuser', 'Test User'); 219 | const followButton = createFollowButton( 220 | 'testuser', 221 | 'https://github.com/testuser', 222 | ); 223 | const followers = createFollowers(1234); 224 | const followContainer = createFollowContainer([followButton, followers]); 225 | const profile = createProfile([avatar, name, followContainer]); 226 | 227 | expect(profile.classList.contains('profile')).toBe(true); 228 | expect(profile.children).toHaveLength(3); 229 | 230 | // Check avatar 231 | const avatarElement = profile.children[0] as HTMLImageElement; 232 | expect(avatarElement.tagName).toBe('IMG'); 233 | expect(avatarElement.className).toBe('avatar'); 234 | 235 | // Check name 236 | const nameElement = profile.children[1] as HTMLAnchorElement; 237 | expect(nameElement.tagName).toBe('A'); 238 | expect(nameElement.className).toBe('name'); 239 | expect(nameElement.textContent).toBe('Test User'); 240 | 241 | // Check follow container 242 | const followContainerElement = profile.children[2] as HTMLDivElement; 243 | expect(followContainerElement.tagName).toBe('DIV'); 244 | expect(followContainerElement.className).toBe('followMe'); 245 | expect(followContainerElement.children).toHaveLength(2); 246 | }); 247 | 248 | it('should handle XSS attempts in user data', () => { 249 | const maliciousName = ''; 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('': 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('