GitHub Show Stars

Show star counts after every GitHub repo link on all webpages

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
})();