|
| 1 | +/** |
| 2 | + * GitHub Widget for Great-Docs |
| 3 | + * |
| 4 | + * A souped-up GitHub icon link with: |
| 5 | + * - Live star and fork counts |
| 6 | + * - Dropdown menu linking to repo, issues, PRs |
| 7 | + * - Caching to avoid API rate limits |
| 8 | + */ |
| 9 | + |
| 10 | +(function() { |
| 11 | + 'use strict'; |
| 12 | + |
| 13 | + // Configuration - will be set by data attributes |
| 14 | + let config = { |
| 15 | + owner: null, |
| 16 | + repo: null, |
| 17 | + cacheKey: 'great-docs-github-stats', |
| 18 | + cacheDuration: 5 * 60 * 1000 // 5 minutes |
| 19 | + }; |
| 20 | + |
| 21 | + /** |
| 22 | + * Initialize the GitHub widget |
| 23 | + */ |
| 24 | + function initGitHubWidget() { |
| 25 | + const widget = document.getElementById('github-widget'); |
| 26 | + if (!widget) return; |
| 27 | + |
| 28 | + config.owner = widget.dataset.owner; |
| 29 | + config.repo = widget.dataset.repo; |
| 30 | + |
| 31 | + if (!config.owner || !config.repo) { |
| 32 | + console.warn('GitHub widget: Missing owner or repo data attributes'); |
| 33 | + return; |
| 34 | + } |
| 35 | + |
| 36 | + // Create the widget structure |
| 37 | + createWidgetStructure(widget); |
| 38 | + |
| 39 | + // Fetch and display stats |
| 40 | + fetchGitHubStats(); |
| 41 | + |
| 42 | + // Setup dropdown behavior |
| 43 | + setupDropdown(widget); |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Create the HTML structure for the widget |
| 48 | + */ |
| 49 | + function createWidgetStructure(container) { |
| 50 | + const repoUrl = `https://github.com/${config.owner}/${config.repo}`; |
| 51 | + |
| 52 | + container.innerHTML = ` |
| 53 | + <div class="gh-widget-trigger" role="button" aria-haspopup="true" aria-expanded="false" tabindex="0"> |
| 54 | + <svg class="gh-icon" viewBox="0 0 16 16" width="20" height="20" fill="currentColor" aria-hidden="true"> |
| 55 | + <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/> |
| 56 | + </svg> |
| 57 | + <span class="gh-stats"> |
| 58 | + <span class="gh-stat gh-stars" title="Stars"> |
| 59 | + <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"> |
| 60 | + <path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/> |
| 61 | + </svg> |
| 62 | + <span class="gh-count" data-stat="stars">-</span> |
| 63 | + </span> |
| 64 | + <span class="gh-stat gh-forks" title="Forks"> |
| 65 | + <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true"> |
| 66 | + <path d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/> |
| 67 | + </svg> |
| 68 | + <span class="gh-count" data-stat="forks">-</span> |
| 69 | + </span> |
| 70 | + </span> |
| 71 | + <svg class="gh-dropdown-arrow" viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true"> |
| 72 | + <path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/> |
| 73 | + </svg> |
| 74 | + </div> |
| 75 | + <div class="gh-dropdown" role="menu" aria-hidden="true"> |
| 76 | + <a href="${repoUrl}" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener"> |
| 77 | + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"> |
| 78 | + <path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/> |
| 79 | + </svg> |
| 80 | + Repository |
| 81 | + </a> |
| 82 | + <a href="${repoUrl}/issues" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener"> |
| 83 | + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"> |
| 84 | + <path d="M8 9.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"/> |
| 85 | + <path fill-rule="evenodd" d="M8 0a8 8 0 100 16A8 8 0 008 0zM1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"/> |
| 86 | + </svg> |
| 87 | + Issues |
| 88 | + <span class="gh-badge gh-issues-count" style="display: none;"></span> |
| 89 | + </a> |
| 90 | + <a href="${repoUrl}/pulls" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener"> |
| 91 | + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"> |
| 92 | + <path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/> |
| 93 | + </svg> |
| 94 | + Pull Requests |
| 95 | + <span class="gh-badge gh-prs-count" style="display: none;"></span> |
| 96 | + </a> |
| 97 | + <div class="gh-dropdown-divider"></div> |
| 98 | + <a href="${repoUrl}/stargazers" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener"> |
| 99 | + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"> |
| 100 | + <path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/> |
| 101 | + </svg> |
| 102 | + Stargazers |
| 103 | + </a> |
| 104 | + <a href="${repoUrl}/network/members" class="gh-dropdown-item" role="menuitem" target="_blank" rel="noopener"> |
| 105 | + <svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true"> |
| 106 | + <path d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/> |
| 107 | + </svg> |
| 108 | + Forks |
| 109 | + </a> |
| 110 | + </div> |
| 111 | + `; |
| 112 | + } |
| 113 | + |
| 114 | + /** |
| 115 | + * Fetch GitHub stats from API with caching |
| 116 | + */ |
| 117 | + async function fetchGitHubStats() { |
| 118 | + const cacheKey = `${config.cacheKey}-${config.owner}-${config.repo}`; |
| 119 | + |
| 120 | + // Check cache first |
| 121 | + const cached = getFromCache(cacheKey); |
| 122 | + if (cached) { |
| 123 | + updateStatsDisplay(cached); |
| 124 | + return; |
| 125 | + } |
| 126 | + |
| 127 | + try { |
| 128 | + // Fetch repo stats |
| 129 | + const repoResponse = await fetch( |
| 130 | + `https://api.github.com/repos/${config.owner}/${config.repo}`, |
| 131 | + { headers: { 'Accept': 'application/vnd.github.v3+json' } } |
| 132 | + ); |
| 133 | + |
| 134 | + if (!repoResponse.ok) { |
| 135 | + throw new Error(`GitHub API error: ${repoResponse.status}`); |
| 136 | + } |
| 137 | + |
| 138 | + const repoData = await repoResponse.json(); |
| 139 | + |
| 140 | + const stats = { |
| 141 | + stars: repoData.stargazers_count, |
| 142 | + forks: repoData.forks_count, |
| 143 | + openIssues: repoData.open_issues_count, // This includes PRs |
| 144 | + timestamp: Date.now() |
| 145 | + }; |
| 146 | + |
| 147 | + // Try to get separate issue and PR counts (optional, may hit rate limits) |
| 148 | + try { |
| 149 | + const [issuesResponse, prsResponse] = await Promise.all([ |
| 150 | + fetch(`https://api.github.com/search/issues?q=repo:${config.owner}/${config.repo}+type:issue+state:open`, { |
| 151 | + headers: { 'Accept': 'application/vnd.github.v3+json' } |
| 152 | + }), |
| 153 | + fetch(`https://api.github.com/search/issues?q=repo:${config.owner}/${config.repo}+type:pr+state:open`, { |
| 154 | + headers: { 'Accept': 'application/vnd.github.v3+json' } |
| 155 | + }) |
| 156 | + ]); |
| 157 | + |
| 158 | + if (issuesResponse.ok && prsResponse.ok) { |
| 159 | + const issuesData = await issuesResponse.json(); |
| 160 | + const prsData = await prsResponse.json(); |
| 161 | + stats.issues = issuesData.total_count; |
| 162 | + stats.prs = prsData.total_count; |
| 163 | + } |
| 164 | + } catch (e) { |
| 165 | + // Silently fail - we'll just not show separate counts |
| 166 | + } |
| 167 | + |
| 168 | + // Cache the results |
| 169 | + saveToCache(cacheKey, stats); |
| 170 | + |
| 171 | + // Update display |
| 172 | + updateStatsDisplay(stats); |
| 173 | + |
| 174 | + } catch (error) { |
| 175 | + console.warn('GitHub widget: Failed to fetch stats', error); |
| 176 | + // Show fallback |
| 177 | + updateStatsDisplay({ stars: '?', forks: '?' }); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + /** |
| 182 | + * Update the display with fetched stats |
| 183 | + */ |
| 184 | + function updateStatsDisplay(stats) { |
| 185 | + const starsEl = document.querySelector('.gh-count[data-stat="stars"]'); |
| 186 | + const forksEl = document.querySelector('.gh-count[data-stat="forks"]'); |
| 187 | + const issuesBadge = document.querySelector('.gh-issues-count'); |
| 188 | + const prsBadge = document.querySelector('.gh-prs-count'); |
| 189 | + |
| 190 | + if (starsEl) starsEl.textContent = formatCount(stats.stars); |
| 191 | + if (forksEl) forksEl.textContent = formatCount(stats.forks); |
| 192 | + |
| 193 | + if (issuesBadge && stats.issues !== undefined) { |
| 194 | + issuesBadge.textContent = formatCount(stats.issues); |
| 195 | + issuesBadge.style.display = 'inline-flex'; |
| 196 | + } |
| 197 | + |
| 198 | + if (prsBadge && stats.prs !== undefined) { |
| 199 | + prsBadge.textContent = formatCount(stats.prs); |
| 200 | + prsBadge.style.display = 'inline-flex'; |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + /** |
| 205 | + * Format large numbers (e.g., 1500 -> 1.5k) |
| 206 | + */ |
| 207 | + function formatCount(num) { |
| 208 | + if (num === undefined || num === null || num === '?') return '?'; |
| 209 | + if (num >= 1000000) { |
| 210 | + return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; |
| 211 | + } |
| 212 | + if (num >= 1000) { |
| 213 | + return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; |
| 214 | + } |
| 215 | + return num.toString(); |
| 216 | + } |
| 217 | + |
| 218 | + /** |
| 219 | + * Get cached data if not expired |
| 220 | + */ |
| 221 | + function getFromCache(key) { |
| 222 | + try { |
| 223 | + const data = localStorage.getItem(key); |
| 224 | + if (!data) return null; |
| 225 | + |
| 226 | + const parsed = JSON.parse(data); |
| 227 | + if (Date.now() - parsed.timestamp > config.cacheDuration) { |
| 228 | + localStorage.removeItem(key); |
| 229 | + return null; |
| 230 | + } |
| 231 | + return parsed; |
| 232 | + } catch (e) { |
| 233 | + return null; |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + /** |
| 238 | + * Save data to cache |
| 239 | + */ |
| 240 | + function saveToCache(key, data) { |
| 241 | + try { |
| 242 | + localStorage.setItem(key, JSON.stringify(data)); |
| 243 | + } catch (e) { |
| 244 | + // Silently fail if localStorage is not available |
| 245 | + } |
| 246 | + } |
| 247 | + |
| 248 | + /** |
| 249 | + * Setup dropdown toggle behavior |
| 250 | + */ |
| 251 | + function setupDropdown(widget) { |
| 252 | + const trigger = widget.querySelector('.gh-widget-trigger'); |
| 253 | + const dropdown = widget.querySelector('.gh-dropdown'); |
| 254 | + |
| 255 | + if (!trigger || !dropdown) return; |
| 256 | + |
| 257 | + // Toggle on click |
| 258 | + trigger.addEventListener('click', (e) => { |
| 259 | + e.stopPropagation(); |
| 260 | + const isExpanded = trigger.getAttribute('aria-expanded') === 'true'; |
| 261 | + trigger.setAttribute('aria-expanded', !isExpanded); |
| 262 | + dropdown.setAttribute('aria-hidden', isExpanded); |
| 263 | + widget.classList.toggle('gh-widget-open', !isExpanded); |
| 264 | + }); |
| 265 | + |
| 266 | + // Toggle on Enter/Space key |
| 267 | + trigger.addEventListener('keydown', (e) => { |
| 268 | + if (e.key === 'Enter' || e.key === ' ') { |
| 269 | + e.preventDefault(); |
| 270 | + trigger.click(); |
| 271 | + } |
| 272 | + }); |
| 273 | + |
| 274 | + // Close on click outside |
| 275 | + document.addEventListener('click', (e) => { |
| 276 | + if (!widget.contains(e.target)) { |
| 277 | + trigger.setAttribute('aria-expanded', 'false'); |
| 278 | + dropdown.setAttribute('aria-hidden', 'true'); |
| 279 | + widget.classList.remove('gh-widget-open'); |
| 280 | + } |
| 281 | + }); |
| 282 | + |
| 283 | + // Close on Escape key |
| 284 | + document.addEventListener('keydown', (e) => { |
| 285 | + if (e.key === 'Escape') { |
| 286 | + trigger.setAttribute('aria-expanded', 'false'); |
| 287 | + dropdown.setAttribute('aria-hidden', 'true'); |
| 288 | + widget.classList.remove('gh-widget-open'); |
| 289 | + } |
| 290 | + }); |
| 291 | + } |
| 292 | + |
| 293 | + // Initialize when DOM is ready |
| 294 | + if (document.readyState === 'loading') { |
| 295 | + document.addEventListener('DOMContentLoaded', initGitHubWidget); |
| 296 | + } else { |
| 297 | + initGitHubWidget(); |
| 298 | + } |
| 299 | +})(); |
0 commit comments