GitHub Show Stars

Show star counts after every GitHub repo link on all webpages

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub Show Stars
// @namespace    https://github.com/h4rvey-g/github-show-stars
// @version      1.2.4
// @description  Show star counts after every GitHub repo link on all webpages
// @author       h4rvey-g
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    // -------------------------------------------------------------------------
    // Configuration
    // -------------------------------------------------------------------------

    /**
     * Optional GitHub Personal Access Token.
     * Set via Tampermonkey storage to raise the API rate limit from 60 to 5000
     * requests/hour.  Never hard-code your token here.
     *
     * To set the token run the following one-time snippet in the browser console
     * while the userscript is installed:
     *   GM_setValue('github_token', 'ghp_yourTokenHere');
     */
    let GITHUB_TOKEN = GM_getValue('github_token', '');

    // -------------------------------------------------------------------------
    // Menu command – let the user set / clear the GitHub token from the
    // Tampermonkey extension menu without touching the browser console.
    // -------------------------------------------------------------------------
    GM_registerMenuCommand('Set GitHub Token', () => {
        const current = GM_getValue('github_token', '');
        const input = prompt(
            'Enter your GitHub Personal Access Token.\n' +
            'Leave blank and click OK to clear the stored token.\n\n' +
            (current ? 'A token is currently set.' : 'No token is currently set.'),
            current
        );
        // prompt() returns null when the user clicks Cancel – do nothing.
        if (input === null) return;

        const trimmed = input.trim();
        GM_setValue('github_token', trimmed);
        GITHUB_TOKEN = trimmed;
        if (trimmed) {
            alert('GitHub token saved. Reload the page to apply the new token.');
        } else {
            alert('GitHub token cleared. Reload the page to apply the change.');
        }
    });

    /** How long (ms) to keep a cached star-count before re-fetching. */
    const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes

    /** Attribute added to links that have already been processed. */
    const PROCESSED_ATTR = 'data-gss-processed';

    /** Substring that must appear in an href for the CSS pre-filter to match. */
    const GITHUB_HREF_FILTER = '://github.com/';

    /**
     * GitHub top-level routes that are not repository owners.
     * This keeps us from treating pages like /settings/tokens as repos.
     */
    const RESERVED_TOP_LEVEL_PATHS = new Set([
        'about',
        'account',
        'apps',
        'blog',
        'collections',
        'contact',
        'customer-stories',
        'enterprise',
        'events',
        'explore',
        'features',
        'gist',
        'git-guides',
        'github-copilot',
        'issues',
        'login',
        'logout',
        'marketplace',
        'mobile',
        'new',
        'notifications',
        'orgs',
        'organizations',
        'pricing',
        'pulls',
        'readme',
        'search',
        'security',
        'settings',
        'signup',
        'site',
        'sponsors',
        'team',
        'teams',
        'topics',
        'trending',
    ]);

    /** Class name used on injected star badges. */
    const BADGE_CLASS = 'gss-star-badge';

    /** Keyword used to identify awesome repositories. */
    const AWESOME_KEYWORD = 'awesome';

    /**
     * Extract "owner/repo" from a GitHub pathname.
     * When allowSubpaths is true, additional path segments are allowed after the
     * repository name (e.g. /owner/repo/issues/1).
     */
    function extractGitHubRepoPath(pathname, { allowSubpaths = false } = {}) {
        const parts = pathname.split('/').filter(Boolean);
        if (parts.length < 2) return null;
        if (!allowSubpaths && parts.length !== 2) return null;

        const [owner, repo] = parts;
        if (!owner || !repo) return null;
        if (owner.startsWith('.') || repo.startsWith('.')) return null;
        if (RESERVED_TOP_LEVEL_PATHS.has(owner.toLowerCase())) return null;
        if (repo === 'repositories') return null;

        return `${owner}/${repo}`;
    }

    /** Current GitHub repository page, if the current page is inside a repo. */
    const CURRENT_PAGE_REPO = window.location.hostname === 'github.com'
        ? extractGitHubRepoPath(window.location.pathname, { allowSubpaths: true })
        : null;

    /**
     * The floating sorting panel is only enabled on GitHub repository pages
     * whose own repository path contains "awesome".
     */
    const AWESOME_FEATURE_ENABLED = Boolean(
        CURRENT_PAGE_REPO && CURRENT_PAGE_REPO.toLowerCase().includes(AWESOME_KEYWORD)
    );

    // -------------------------------------------------------------------------
    // In-memory cache  { "owner/repo": { stars: Number, pushedAt: String, createdAt: String, ts: Number } }
    // -------------------------------------------------------------------------
    const cache = {};

    // -------------------------------------------------------------------------
    // Inject stylesheet once
    // -------------------------------------------------------------------------
    (function injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
            .${BADGE_CLASS} {
                display: inline-flex;
                align-items: center;
                gap: 2px;
                margin-left: 4px;
                padding: 1px 5px;
                border-radius: 10px;
                font-size: 0.78em;
                font-weight: 600;
                line-height: 1.5;
                vertical-align: middle;
                white-space: nowrap;
                background: #f1f8ff;
                color: #0969da;
                border: 1px solid #d0e4f7;
                text-decoration: none !important;
                cursor: default;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
            }
            .${BADGE_CLASS}:hover {
                background: #dbeafe;
            }
            .${BADGE_CLASS}--loading {
                color: #8b949e;
                border-color: #d0d7de;
                background: #f6f8fa;
            }
            .${BADGE_CLASS}--error {
                color: #cf222e;
                border-color: #f8d7da;
                background: #fff0f0;
            }
            .gss-star-tooltip {
                position: fixed;
                z-index: 2147483647;
                max-width: min(320px, calc(100vw - 16px));
                padding: 6px 8px;
                border-radius: 6px;
                background: #24292f;
                color: #ffffff;
                box-shadow: 0 8px 24px rgba(31, 35, 40, 0.22);
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
                font-size: 12px;
                font-weight: 500;
                line-height: 1.4;
                white-space: pre-line;
                pointer-events: none;
            }
            @media (prefers-color-scheme: dark) {
                .${BADGE_CLASS} {
                    background: #1c2d40;
                    color: #58a6ff;
                    border-color: #1f4068;
                }
                .${BADGE_CLASS}:hover {
                    background: #1f3a5c;
                }
                .${BADGE_CLASS}--loading {
                    color: #8b949e;
                    border-color: #30363d;
                    background: #161b22;
                }
                .${BADGE_CLASS}--error {
                    color: #ff7b72;
                    border-color: #6e2f2f;
                    background: #3d1414;
                }
            }
            .gss-awesome-panel {
                position: fixed;
                width: 320px;
                max-height: min(55vh, 460px);
                overflow: hidden;
                display: flex;
                flex-direction: column;
                border-radius: 12px;
                border: 1px solid #d0d7de;
                background: #ffffff;
                box-shadow: 0 10px 30px rgba(31, 35, 40, 0.15);
                z-index: 2147483647;
                contain: layout paint;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
            }
            .gss-awesome-panel__header {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 8px;
                padding: 10px 12px;
                border-bottom: 1px solid #d8dee4;
                font-size: 13px;
                font-weight: 700;
                color: #24292f;
                background: #f6f8fa;
                cursor: move;
                user-select: none;
            }
            .gss-awesome-panel__count {
                font-size: 12px;
                font-weight: 600;
                color: #57606a;
            }
            .gss-awesome-panel__body {
                overflow: auto;
                padding: 6px 8px;
            }
            .gss-awesome-panel__empty {
                margin: 8px 4px;
                padding: 10px;
                border-radius: 8px;
                font-size: 12px;
                color: #57606a;
                background: #f6f8fa;
                border: 1px dashed #d0d7de;
            }
            .gss-awesome-item {
                display: flex;
                align-items: baseline;
                justify-content: space-between;
                gap: 10px;
                padding: 6px 8px;
                margin: 2px 0;
                border-radius: 8px;
                text-decoration: none;
                color: inherit;
            }
            .gss-awesome-item:hover {
                background: #eaeef2;
            }
            .gss-awesome-item__repo {
                font-size: 12px;
                color: #0969da;
                overflow: hidden;
                text-overflow: ellipsis;
                white-space: nowrap;
            }
            .gss-awesome-item__stars {
                font-size: 12px;
                font-weight: 700;
                color: #24292f;
                white-space: nowrap;
            }
            @media (prefers-color-scheme: dark) {
                .gss-awesome-panel {
                    border-color: #30363d;
                    background: #161b22;
                    box-shadow: 0 10px 30px rgba(1, 4, 9, 0.45);
                }
                .gss-awesome-panel__header {
                    color: #f0f6fc;
                    background: #161b22;
                    border-bottom-color: #30363d;
                }
                .gss-awesome-panel__count,
                .gss-awesome-panel__empty {
                    color: #8b949e;
                }
                .gss-awesome-panel__empty {
                    border-color: #30363d;
                    background: #0d1117;
                }
                .gss-awesome-item:hover {
                    background: #21262d;
                }
                .gss-awesome-item__repo {
                    color: #58a6ff;
                }
                .gss-awesome-item__stars {
                    color: #c9d1d9;
                }
            }
        `;
        document.head.appendChild(style);
    })();

    // -------------------------------------------------------------------------
    // Linked repo floating panel for GitHub awesome repository pages
    // -------------------------------------------------------------------------
    const awesomeRepoMap = new Map();
    let awesomePanelBody = null;
    let awesomePanelCount = null;
    const PANEL_POSITION_KEY = 'awesome_panel_position_v1';
    const AWESOME_PANEL_RENDER_DELAY_MS = 120;
    let awesomePanelRenderTimer = null;

    function ensureAwesomePanel() {
        if (!AWESOME_FEATURE_ENABLED || awesomePanelBody) return;

        const panel = document.createElement('section');
        panel.className = 'gss-awesome-panel';
        panel.setAttribute('aria-label', 'Repositories linked from this awesome GitHub repository, sorted by stars');
        panel.innerHTML = `
            <div class="gss-awesome-panel__header">
                <span>Linked Repos ⭐</span>
                <span class="gss-awesome-panel__count">0</span>
            </div>
            <div class="gss-awesome-panel__body"></div>
        `;

        awesomePanelBody = panel.querySelector('.gss-awesome-panel__body');
        awesomePanelCount = panel.querySelector('.gss-awesome-panel__count');
        applyInitialPanelPosition(panel);
        bindPanelDrag(panel);
        document.body.appendChild(panel);
        renderAwesomePanel();
    }

    function applyPanelPosition(panel, left, top) {
        const maxLeft = Math.max(8, window.innerWidth - panel.offsetWidth - 8);
        const maxTop = Math.max(8, window.innerHeight - panel.offsetHeight - 8);
        const safeLeft = Math.min(Math.max(8, left), maxLeft);
        const safeTop = Math.min(Math.max(8, top), maxTop);
        panel.style.left = `${safeLeft}px`;
        panel.style.top = `${safeTop}px`;
    }

    function getSavedPanelPosition() {
        const raw = GM_getValue(PANEL_POSITION_KEY, '');
        if (!raw) return null;
        try {
            const parsed = JSON.parse(raw);
            if (typeof parsed?.left !== 'number' || typeof parsed?.top !== 'number') return null;
            return parsed;
        } catch {
            return null;
        }
    }

    function savePanelPosition(left, top) {
        GM_setValue(PANEL_POSITION_KEY, JSON.stringify({ left, top }));
    }

    function getSmartDefaultPosition(panel) {
        const margin = 16;
        const panelWidth = panel.offsetWidth;
        const panelHeight = panel.offsetHeight;
        const defaultPos = {
            left: window.innerWidth - panelWidth - margin,
            top: 88,
        };

        const readme = document.querySelector('#readme');
        if (!readme) return defaultPos;

        const rect = readme.getBoundingClientRect();
        const overlapsHorizontally = defaultPos.left < rect.right && defaultPos.left + panelWidth > rect.left;
        const overlapsVertically = defaultPos.top < rect.bottom && defaultPos.top + panelHeight > rect.top;
        if (!overlapsHorizontally || !overlapsVertically) return defaultPos;

        const rightSpace = window.innerWidth - rect.right;
        if (rightSpace >= panelWidth + margin) {
            return { left: rect.right + margin, top: Math.max(88, rect.top) };
        }

        const leftSpace = rect.left;
        if (leftSpace >= panelWidth + margin) {
            return { left: rect.left - panelWidth - margin, top: Math.max(88, rect.top) };
        }

        return defaultPos;
    }

    function applyInitialPanelPosition(panel) {
        panel.style.visibility = 'hidden';
        document.body.appendChild(panel);

        const saved = getSavedPanelPosition();
        if (saved) {
            applyPanelPosition(panel, saved.left, saved.top);
        } else {
            const pos = getSmartDefaultPosition(panel);
            applyPanelPosition(panel, pos.left, pos.top);
            savePanelPosition(pos.left, pos.top);
        }

        panel.remove();
        panel.style.visibility = '';
    }

    function bindPanelDrag(panel) {
        const header = panel.querySelector('.gss-awesome-panel__header');
        if (!header) return;

        let dragging = false;
        let offsetX = 0;
        let offsetY = 0;
        let pointerId = null;

        const onPointerMove = (event) => {
            if (!dragging) return;
            const left = event.clientX - offsetX;
            const top = event.clientY - offsetY;
            applyPanelPosition(panel, left, top);
        };

        const onPointerUp = () => {
            if (!dragging) return;
            dragging = false;
            if (pointerId !== null) {
                header.releasePointerCapture?.(pointerId);
            }
            pointerId = null;
            const left = Number.parseFloat(panel.style.left) || 0;
            const top = Number.parseFloat(panel.style.top) || 0;
            savePanelPosition(left, top);
        };

        header.addEventListener('pointerdown', (event) => {
            if (event.button !== 0) return;
            dragging = true;
            pointerId = event.pointerId;
            header.setPointerCapture?.(pointerId);
            const rect = panel.getBoundingClientRect();
            offsetX = event.clientX - rect.left;
            offsetY = event.clientY - rect.top;
            event.preventDefault();
        });

        header.addEventListener('pointermove', onPointerMove);
        header.addEventListener('pointerup', onPointerUp);
        header.addEventListener('pointercancel', onPointerUp);

        window.addEventListener('resize', () => {
            const left = Number.parseFloat(panel.style.left) || 0;
            const top = Number.parseFloat(panel.style.top) || 0;
            applyPanelPosition(panel, left, top);
            savePanelPosition(
                Number.parseFloat(panel.style.left) || 0,
                Number.parseFloat(panel.style.top) || 0
            );
        });
    }

    function shouldTrackRepoInAwesomePanel(repoPath) {
        if (!AWESOME_FEATURE_ENABLED || !repoPath) return false;
        return repoPath.toLowerCase() !== CURRENT_PAGE_REPO.toLowerCase();
    }

    function scheduleAwesomePanelRender() {
        if (awesomePanelRenderTimer !== null) return;
        awesomePanelRenderTimer = window.setTimeout(() => {
            awesomePanelRenderTimer = null;
            renderAwesomePanel();
        }, AWESOME_PANEL_RENDER_DELAY_MS);
    }

    function upsertAwesomeRepo(repoPath, stars) {
        if (!shouldTrackRepoInAwesomePanel(repoPath)) return;
        if (awesomeRepoMap.get(repoPath) === stars) return;
        awesomeRepoMap.set(repoPath, stars);
        if (!awesomePanelBody || !awesomePanelCount) {
            ensureAwesomePanel();
            return;
        }
        awesomePanelCount.textContent = `${awesomeRepoMap.size}`;
        scheduleAwesomePanelRender();
    }

    function renderAwesomePanel() {
        if (!awesomePanelBody || !awesomePanelCount) return;

        const sortedEntries = Array.from(awesomeRepoMap.entries())
            .sort((a, b) => {
                if (b[1] !== a[1]) return b[1] - a[1];
                return a[0].localeCompare(b[0]);
            });

        awesomePanelCount.textContent = `${sortedEntries.length}`;
        if (sortedEntries.length === 0) {
            const empty = document.createElement('div');
            empty.className = 'gss-awesome-panel__empty';
            empty.textContent = 'No linked GitHub repositories have been loaded yet.';
            awesomePanelBody.replaceChildren(empty);
            return;
        }

        const fragment = document.createDocumentFragment();
        for (const [repoPath, stars] of sortedEntries) {
            const item = document.createElement('a');
            item.className = 'gss-awesome-item';
            item.href = `https://github.com/${repoPath}`;
            item.target = '_blank';
            item.rel = 'noopener noreferrer';
            item.title = `${repoPath} • ${stars.toLocaleString()} stars`;
            item.innerHTML = `
                <span class="gss-awesome-item__repo">${repoPath}</span>
                <span class="gss-awesome-item__stars">⭐ ${formatStars(stars)}</span>
            `;
            fragment.appendChild(item);
        }
        awesomePanelBody.replaceChildren(fragment);
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    /**
     * Format a date string as a human-readable relative time.
     * e.g.  "just now"  |  "5 minutes ago"  |  "3 hours ago"  |  "2 days ago"
     *       "4 months ago"  |  "2 years ago"
     */
    function timeAgo(dateString) {
        const seconds = Math.floor((Date.now() - new Date(dateString)) / 1000);
        if (seconds < 60) return 'just now';
        const minutes = Math.floor(seconds / 60);
        if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
        const hours = Math.floor(minutes / 60);
        if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
        const days = Math.floor(hours / 24);
        if (days <= 30) return `${days} day${days !== 1 ? 's' : ''} ago`;
        const months = Math.floor(days / 30.44); // average days per month (365.25 / 12)
        if (months < 12) return `${months} month${months !== 1 ? 's' : ''} ago`;
        const years = Math.floor(days / 365.25);
        return `${years} year${years !== 1 ? 's' : ''} ago`;
    }

    /**
     * Format a raw star count into a compact human-readable string.
     * e.g.  42 → "42"  |  1234 → "1.2k"  |  23456 → "23.5k"  |  1234567 → "1.2M"
     */
    function formatStars(n) {
        if (n >= 1_000_000) {
            return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
        }
        if (n >= 1_000) {
            return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'k';
        }
        return String(n);
    }

    /**
     * Extract "owner/repo" from a GitHub repository URL.
     * Returns null unless the URL is the repository root itself
     * (e.g. https://github.com/owner/repo or /owner/repo/).
     */
    function extractRepo(href) {
        try {
            const url = new URL(href);
            // Only handle github.com links
            if (url.hostname !== 'github.com') return null;

            return extractGitHubRepoPath(url.pathname);
        } catch {
            return null;
        }
    }

    function isGitHubRepoChromeLink(anchor, repoPath) {
        if (window.location.hostname !== 'github.com') return false;
        if (!CURRENT_PAGE_REPO || !repoPath) return false;
        if (repoPath.toLowerCase() !== CURRENT_PAGE_REPO.toLowerCase()) return false;

        // Repository header / navigation chrome on GitHub repo pages. These
        // links point to the current repo as part of page navigation, not as
        // content references that benefit from a star badge.
        if (anchor.closest('#repository-container-header, nav[aria-label="Repository"], .js-repo-nav, .UnderlineNav')) {
            return true;
        }

        // Repository title breadcrumb/header link.
        if (anchor.closest('strong[itemprop="name"]')) return true;

        // GitHub repository navigation / toolbar links that point back to the
        // current repo root and should not get an extra star badge.
        if (anchor.id === 'code-tab' || anchor.id === 'code-view-repo-link') return true;
        if (anchor.closest('[data-menu-item="i0code-tab"]')) return true;

        return false;
    }

    function shouldSkipBadgeForAnchor(anchor, repoPath) {
        if (!anchor || !repoPath) return true;

        // Avoid recursively decorating our own floating panel items.
        if (anchor.closest('.gss-awesome-panel')) return true;

        // GitHub keeps some repo-root navigation links hidden in the DOM.
        // Adding badges after those invisible anchors makes the badge appear in
        // odd places around the toolbar.
        if (
            window.location.hostname === 'github.com' &&
            CURRENT_PAGE_REPO &&
            repoPath.toLowerCase() === CURRENT_PAGE_REPO.toLowerCase() &&
            anchor.closest('[hidden]')
        ) {
            return true;
        }

        return isGitHubRepoChromeLink(anchor, repoPath);
    }

    /**
     * Fetch repo info for "owner/repo" via the GitHub REST API.
     * Returns a Promise<{ stars, pushedAt, createdAt }>.
     */
    function fetchRepoInfo(repoPath) {
        return new Promise((resolve, reject) => {
            const headers = {
                Accept: 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
            };
            if (GITHUB_TOKEN) {
                headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
            }

            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.github.com/repos/${repoPath}`,
                headers,
                onload(response) {
                    if (response.status === 200) {
                        try {
                            const data = JSON.parse(response.responseText);
                            resolve({
                                stars: data.stargazers_count,
                                pushedAt: data.pushed_at,
                                createdAt: data.created_at,
                            });
                        } catch {
                            reject(new Error('JSON parse error'));
                        }
                    } else {
                        reject(new Error(`HTTP ${response.status}`));
                    }
                },
                onerror() {
                    reject(new Error('Network error'));
                },
                ontimeout() {
                    reject(new Error('Timeout'));
                },
                timeout: 10000,
            });
        });
    }

    /**
     * Return the cached repo info if still fresh, or null otherwise.
     */
    function getCached(repoPath) {
        const entry = cache[repoPath];
        if (!entry) return null;
        if (Date.now() - entry.ts > CACHE_TTL_MS) return null;
        return { stars: entry.stars, pushedAt: entry.pushedAt, createdAt: entry.createdAt };
    }

    /**
     * Store repo info in the cache.
     */
    function setCached(repoPath, info) {
        cache[repoPath] = { stars: info.stars, pushedAt: info.pushedAt, createdAt: info.createdAt, ts: Date.now() };
    }

    // In-flight promise map to deduplicate concurrent requests for the same repo
    const inFlight = {};

    /**
     * Get repo info for a repo, using cache and deduplication.
     */
    function getRepoInfo(repoPath) {
        const cached = getCached(repoPath);
        if (cached !== null) return Promise.resolve(cached);

        if (inFlight[repoPath]) return inFlight[repoPath];

        const promise = fetchRepoInfo(repoPath)
            .then((info) => {
                setCached(repoPath, info);
                delete inFlight[repoPath];
                return info;
            })
            .catch((err) => {
                delete inFlight[repoPath];
                throw err;
            });

        inFlight[repoPath] = promise;
        return promise;
    }

    // -------------------------------------------------------------------------
    // Badge rendering
    // -------------------------------------------------------------------------

    const TOOLTIP_ID = 'gss-star-tooltip';
    // Google Search sometimes flips result blocks with nested scaleY(-1)
    // transforms to change visual order. Badges inserted as siblings can inherit
    // only the outer flip, so compensate when ancestors mirror the coordinate system.
    const TRANSFORM_EPSILON = 0.001;
    let tooltipElement = null;
    let activeTooltipBadge = null;

    function multiply2dMatrix(left, right) {
        return {
            a: (left.a * right.a) + (left.c * right.b),
            b: (left.b * right.a) + (left.d * right.b),
            c: (left.a * right.c) + (left.c * right.d),
            d: (left.b * right.c) + (left.d * right.d),
        };
    }

    function parseTransformMatrix(transform) {
        if (!transform || transform === 'none') return null;

        if (typeof DOMMatrixReadOnly === 'function') {
            try {
                const matrix = new DOMMatrixReadOnly(transform);
                return { a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d };
            } catch {
                // Fall through to the string parser for older/quirky browsers.
            }
        }

        const matrix2d = transform.match(/^matrix\(([^)]+)\)$/);
        if (matrix2d) {
            const values = matrix2d[1].split(/,\s*/).map(Number.parseFloat);
            if (values.length === 6 && values.every(Number.isFinite)) {
                return { a: values[0], b: values[1], c: values[2], d: values[3] };
            }
        }

        const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/);
        if (matrix3d) {
            const values = matrix3d[1].split(/,\s*/).map(Number.parseFloat);
            if (values.length === 16 && values.every(Number.isFinite)) {
                return { a: values[0], b: values[1], c: values[4], d: values[5] };
            }
        }

        return null;
    }

    function getCumulativeAncestorMatrix(element) {
        let cumulative = { a: 1, b: 0, c: 0, d: 1 };
        for (let node = element.parentElement; node; node = node.parentElement) {
            const matrix = parseTransformMatrix(getComputedStyle(node).transform);
            if (matrix) {
                cumulative = multiply2dMatrix(matrix, cumulative);
            }
        }
        return cumulative;
    }

    function getCounterFlipTransform(element) {
        const matrix = getCumulativeAncestorMatrix(element);
        const xAxisIsMostlyHorizontal = Math.abs(matrix.a) >= Math.abs(matrix.b);
        const yAxisIsMostlyVertical = Math.abs(matrix.d) >= Math.abs(matrix.c);
        const flipX = xAxisIsMostlyHorizontal && matrix.a < -TRANSFORM_EPSILON;
        const flipY = yAxisIsMostlyVertical && matrix.d < -TRANSFORM_EPSILON;

        if (flipX && flipY) return 'scale(-1, -1)';
        if (flipX) return 'scaleX(-1)';
        if (flipY) return 'scaleY(-1)';
        return '';
    }

    function keepBadgeUprightInTransformedContext(badge) {
        const counterTransform = getCounterFlipTransform(badge);
        if (counterTransform) {
            badge.style.transform = counterTransform;
            badge.style.transformOrigin = 'center';
        } else {
            badge.style.removeProperty('transform');
            badge.style.removeProperty('transform-origin');
        }
    }

    function ensureTooltipElement() {
        if (tooltipElement) return tooltipElement;

        tooltipElement = document.createElement('div');
        tooltipElement.id = TOOLTIP_ID;
        tooltipElement.className = 'gss-star-tooltip';
        tooltipElement.setAttribute('role', 'tooltip');
        tooltipElement.hidden = true;
        document.body.appendChild(tooltipElement);
        return tooltipElement;
    }

    function positionTooltip(badge) {
        if (!tooltipElement || tooltipElement.hidden || !badge.isConnected) return;

        const margin = 8;
        const gap = 6;
        const badgeRect = badge.getBoundingClientRect();
        const tooltipRect = tooltipElement.getBoundingClientRect();

        let left = badgeRect.left + (badgeRect.width / 2) - (tooltipRect.width / 2);
        left = Math.min(Math.max(margin, left), window.innerWidth - tooltipRect.width - margin);

        let top = badgeRect.top - tooltipRect.height - gap;
        if (top < margin) {
            top = badgeRect.bottom + gap;
        }
        if (top + tooltipRect.height > window.innerHeight - margin) {
            top = Math.max(margin, window.innerHeight - tooltipRect.height - margin);
        }

        tooltipElement.style.left = `${Math.round(left)}px`;
        tooltipElement.style.top = `${Math.round(top)}px`;
    }

    function showTooltip(badge) {
        const text = badge.dataset.gssTooltip;
        if (!text) return;

        const tooltip = ensureTooltipElement();
        activeTooltipBadge = badge;
        tooltip.textContent = text;
        tooltip.hidden = false;
        badge.setAttribute('aria-describedby', TOOLTIP_ID);
        positionTooltip(badge);
    }

    function hideTooltip(badge) {
        if (badge && activeTooltipBadge !== badge) return;
        if (activeTooltipBadge) {
            activeTooltipBadge.removeAttribute('aria-describedby');
        }
        activeTooltipBadge = null;
        if (tooltipElement) {
            tooltipElement.hidden = true;
        }
    }

    function setBadgeTooltip(badge, text) {
        badge.dataset.gssTooltip = text;
        // Native title tooltips have a browser-controlled delay. Use only the
        // custom tooltip so repository metadata appears immediately on hover.
        badge.removeAttribute('title');
        if (activeTooltipBadge === badge) {
            showTooltip(badge);
        }
    }

    function bindBadgeTooltip(badge) {
        badge.addEventListener('pointerenter', () => showTooltip(badge));
        badge.addEventListener('pointerleave', () => hideTooltip(badge));
        badge.addEventListener('pointercancel', () => hideTooltip(badge));
        badge.addEventListener('pointerdown', () => hideTooltip(badge));
    }

    window.addEventListener('scroll', () => {
        if (activeTooltipBadge) positionTooltip(activeTooltipBadge);
    }, { passive: true, capture: true });
    window.addEventListener('resize', () => {
        if (activeTooltipBadge) positionTooltip(activeTooltipBadge);
    }, { passive: true });

    /**
     * Create a loading-state badge element.
     */
    function createBadge() {
        const badge = document.createElement('span');
        badge.className = `${BADGE_CLASS} ${BADGE_CLASS}--loading`;
        badge.setAttribute('aria-label', 'Loading star count');
        setBadgeTooltip(badge, 'Loading star count');
        badge.textContent = '⭐ …';
        bindBadgeTooltip(badge);
        return badge;
    }

    /**
     * Update badge once we have the repo info (or an error).
     */
    function updateBadge(badge, repoPath, info) {
        badge.classList.remove(`${BADGE_CLASS}--loading`, `${BADGE_CLASS}--error`);
        badge.setAttribute('aria-label', `${info.stars} stars on GitHub`);
        let tooltip = `${info.stars.toLocaleString()} stars — ${repoPath}`;
        if (info.pushedAt) tooltip += `\nLast commit: ${timeAgo(info.pushedAt)}`;
        if (info.createdAt) tooltip += `\nCreated: ${timeAgo(info.createdAt)}`;
        setBadgeTooltip(badge, tooltip);
        badge.textContent = `⭐ ${formatStars(info.stars)}`;
        upsertAwesomeRepo(repoPath, info.stars);
    }

    function setBadgeError(badge, repoPath, err) {
        badge.classList.remove(`${BADGE_CLASS}--loading`);
        badge.classList.add(`${BADGE_CLASS}--error`);
        badge.setAttribute('aria-label', 'Could not load star count');
        setBadgeTooltip(badge, `Could not load stars for ${repoPath}: ${err.message}`);
        badge.textContent = '⭐ ?';
    }

    // -------------------------------------------------------------------------
    // Batch-on-scroll loading
    // -------------------------------------------------------------------------

    /** Number of repositories fetched per scroll-triggered batch. */
    const FETCH_BATCH_SIZE = 30;

    /** Ordered list of discovered repo entries. */
    const repoEntries = [];

    /** Batch indexes that are allowed to start fetching. */
    const unlockedBatches = new Set([0]);

    /** Intersection observer used to unlock a batch when user scrolls to it. */
    const batchUnlockObserver = typeof IntersectionObserver === 'function'
        ? new IntersectionObserver((entries) => {
            for (const entry of entries) {
                if (!entry.isIntersecting) continue;
                const batchIndex = Number(entry.target.getAttribute('data-gss-batch-index'));
                if (!Number.isNaN(batchIndex)) {
                    unlockBatch(batchIndex);
                }
            }
        }, { root: null, rootMargin: '0px', threshold: 0.01 })
        : null;

    function unlockBatch(batchIndex) {
        if (unlockedBatches.has(batchIndex)) return;
        unlockedBatches.add(batchIndex);
        processUnlockedEntries();
    }

    function startRepoFetch(entry) {
        if (entry.fetchStarted) return;
        entry.fetchStarted = true;
        if (batchUnlockObserver) {
            batchUnlockObserver.unobserve(entry.anchor);
        }
        setBadgeTooltip(entry.badge, `Loading stars for ${entry.repoPath}`);

        getRepoInfo(entry.repoPath)
            .then((info) => updateBadge(entry.badge, entry.repoPath, info))
            .catch((err) => setBadgeError(entry.badge, entry.repoPath, err));
    }

    function processUnlockedEntries() {
        for (const entry of repoEntries) {
            if (!unlockedBatches.has(entry.batchIndex)) continue;
            startRepoFetch(entry);
        }
    }

    // -------------------------------------------------------------------------
    // Link processing
    // -------------------------------------------------------------------------

    /**
     * Process a single anchor element: extract the repo, create a badge, and
     * start the async fetch.
     */
    function processLink(anchor) {
        if (anchor.hasAttribute(PROCESSED_ATTR)) return;

        const repoPath = extractRepo(anchor.href);
        if (!repoPath || shouldSkipBadgeForAnchor(anchor, repoPath)) {
            anchor.setAttribute(PROCESSED_ATTR, '1');
            return;
        }

        anchor.setAttribute(PROCESSED_ATTR, '1');

        const badge = createBadge();
        anchor.insertAdjacentElement('afterend', badge);
        keepBadgeUprightInTransformedContext(badge);
        const scheduleTransformCheck = typeof requestAnimationFrame === 'function'
            ? requestAnimationFrame
            : (callback) => setTimeout(callback, 0);
        scheduleTransformCheck(() => keepBadgeUprightInTransformedContext(badge));

        const batchIndex = Math.floor(repoEntries.length / FETCH_BATCH_SIZE);
        anchor.setAttribute('data-gss-batch-index', String(batchIndex));
        const entry = { anchor, badge, repoPath, batchIndex, fetchStarted: false };
        repoEntries.push(entry);

        if (AWESOME_FEATURE_ENABLED || unlockedBatches.has(batchIndex)) {
            startRepoFetch(entry);
        } else if (batchUnlockObserver) {
            batchUnlockObserver.observe(anchor);
        } else {
            // Fallback for very old browsers: no viewport signal, unlock all.
            unlockedBatches.add(batchIndex);
            startRepoFetch(entry);
        }
    }

    // -------------------------------------------------------------------------
    // Idle-callback scheduler – process links in small chunks to avoid long
    // synchronous tasks (>50 ms) that trigger GitHub's long-task analytics
    // observer, which then tries to POST to collector.github.com and gets
    // blocked by ad-blockers (ERR_BLOCKED_BY_CLIENT).
    // -------------------------------------------------------------------------

    /** Maximum number of anchors to process per idle slice. */
    const BATCH_SIZE = 20;

    /** Pending anchor queue fed by the MutationObserver and initial scan. */
    const pendingAnchors = [];
    let idleCallbackScheduled = false;

    const scheduleIdle = typeof requestIdleCallback === 'function'
        ? (cb) => requestIdleCallback(cb, { timeout: 2000 })
        : (cb) => setTimeout(cb, 0);

    function flushPending(deadline) {
        const hasTime = () => deadline && typeof deadline.timeRemaining === 'function'
            ? deadline.timeRemaining() > 0
            : true;

        while (pendingAnchors.length > 0 && hasTime()) {
            const batch = pendingAnchors.splice(0, BATCH_SIZE);
            batch.forEach(processLink);
        }

        if (pendingAnchors.length > 0) {
            scheduleIdle(flushPending);
        } else {
            idleCallbackScheduled = false;
        }
    }

    function enqueueLinks(root) {
        const selector = window.location.hostname === 'github.com'
            ? `a[href*="${GITHUB_HREF_FILTER}"]:not([${PROCESSED_ATTR}]), a[href^="/"]:not([${PROCESSED_ATTR}])`
            : `a[href*="${GITHUB_HREF_FILTER}"]:not([${PROCESSED_ATTR}])`;
        const anchors = root.querySelectorAll
            ? root.querySelectorAll(selector)
            : [];
        anchors.forEach((a) => pendingAnchors.push(a));
        if (!idleCallbackScheduled && pendingAnchors.length > 0) {
            idleCallbackScheduled = true;
            scheduleIdle(flushPending);
        }
    }

    // -------------------------------------------------------------------------
    // MutationObserver – handle dynamically added content (SPAs, infinite scroll)
    // -------------------------------------------------------------------------
    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType !== Node.ELEMENT_NODE) continue;
                // Check the node itself
                if (node.tagName === 'A' && extractRepo(node.href)) pendingAnchors.push(node);
                // Check descendants
                enqueueLinks(node);
            }
        }
        if (!idleCallbackScheduled && pendingAnchors.length > 0) {
            idleCallbackScheduled = true;
            scheduleIdle(flushPending);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Initial scan of the already-loaded DOM (batched to avoid long tasks)
    enqueueLinks(document);
})();