HTML Preview for GitHub/GitLab

Rendered preview for HTML files shown in GitHub/GitLab repos (JS + UTF-8) with auto-refresh

От 06.08.2026. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

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

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         HTML Preview for GitHub/GitLab
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Rendered preview for HTML files shown in GitHub/GitLab repos (JS + UTF-8) with auto-refresh
// @author       sanchomuzax
// @match        *://github.com/*/blob/*
// @match        *://gitlab.com/*/-/blob/*
// @match        *://glab.p24.hu/*/-/blob/*
// @grant        GM_xmlhttpRequest
// @grant        GM_openInTab
// @connect      *
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    const REFRESH_MINUTES = 5;

    function isHtmlFile() {
        const cleanPath = location.pathname.split('?')[0];
        return /\.(html?|xhtml)$/i.test(cleanPath);
    }

    function getRawUrl() {
        const host = location.hostname;
        // GitHub -> use github.com /raw/ (auth via cookies, works for private repos too)
        if (host === 'github.com') {
            const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/);
            if (m) return `${location.origin}/${m[1]}/${m[2]}/raw/${m[3]}`;
            return null;
        }
        // GitLab (gitlab.com and self-hosted) -> /-/raw/ path
        if (location.pathname.includes('/-/blob/')) {
            return location.origin +
                   location.pathname.replace('/-/blob/', '/-/raw/') +
                   location.search;
        }
        return null;
    }

    function openPreview(rawUrl) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: rawUrl,
            overrideMimeType: 'text/html; charset=utf-8',
            onload: function (res) {
                if (res.status >= 200 && res.status < 300) {
                    showPreview(res.responseText);
                } else {
                    alert('Failed to load HTML. HTTP ' + res.status);
                }
            },
            onerror: () => alert('Error while loading HTML.')
        });
    }

    // Open the fetched HTML in a NEW top-level tab via a blob: URL.
    // A new tab has its own (empty) CSP, so GitHub's CSP can't block it,
    // and the page's own JavaScript runs normally (needed for dashboards).
    function showPreview(rawHtml) {
        let html = rawHtml;
        if (!/<meta[^>]+charset/i.test(html)) {
            const metaTag = '<meta charset="utf-8">';
            if (/<head[^>]*>/i.test(html)) {
                html = html.replace(/<head[^>]*>/i, m => m + metaTag);
            } else if (/<html[^>]*>/i.test(html)) {
                html = html.replace(/<html[^>]*>/i, m => m + '<head>' + metaTag + '</head>');
            } else {
                html = metaTag + html;
            }
        }
        const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
        const blobUrl = URL.createObjectURL(blob);
        GM_openInTab(blobUrl, { active: true, setParent: true });
        setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
    }

    let countdownTimer = null;

    function addButton() {
        if (document.getElementById('tm-html-preview-btn')) return;
        if (!isHtmlFile()) return;
        const rawUrl = getRawUrl();
        if (!rawUrl) return;
        if (!document.body) return;

        // Container for the button + countdown
        const container = document.createElement('div');
        container.id = 'tm-html-preview-btn';
        Object.assign(container.style, {
            position: 'fixed', bottom: '20px', right: '20px', zIndex: '2147483646',
            display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '6px',
            fontFamily: 'sans-serif'
        });

        const btn = document.createElement('button');
        btn.textContent = '👁 Preview HTML';
        Object.assign(btn.style, {
            background: '#1f75cb', color: '#fff', border: 'none',
            padding: '10px 16px', borderRadius: '6px', cursor: 'pointer',
            fontSize: '14px',
            boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
        });
        btn.onclick = () => openPreview(rawUrl);

        const counter = document.createElement('div');
        counter.id = 'tm-html-preview-counter';
        Object.assign(counter.style, {
            background: 'rgba(0,0,0,0.65)', color: '#fff',
            padding: '4px 10px', borderRadius: '6px', fontSize: '12px'
        });

        container.appendChild(counter);
        container.appendChild(btn);
        document.body.appendChild(container);

        startCountdown(counter);
    }

    function startCountdown(counterEl) {
        if (countdownTimer) clearInterval(countdownTimer);
        let remaining = REFRESH_MINUTES * 60; // seconds

        function render() {
            const m = Math.floor(remaining / 60);
            const s = remaining % 60;
            counterEl.textContent = `⟳ refresh: ${m}:${String(s).padStart(2, '0')}`;
        }

        render();
        countdownTimer = setInterval(() => {
            remaining--;
            if (remaining <= 0) {
                clearInterval(countdownTimer);
                location.reload();
                return;
            }
            render();
        }, 1000);
    }

    // --- Robust initialization for GitHub's SPA navigation ---

    function ensureButton() {
        const exists = document.getElementById('tm-html-preview-btn');
        if (isHtmlFile()) {
            if (!exists) addButton();
        } else {
            if (exists) {
                exists.remove();
                if (countdownTimer) clearInterval(countdownTimer);
            }
        }
    }

    // Initial attempts (document-idle can still fire before GitHub finishes
    // rendering the file view, so we retry a few times).
    ensureButton();
    let tries = 0;
    const initInterval = setInterval(() => {
        ensureButton();
        tries++;
        if (document.getElementById('tm-html-preview-btn') || tries > 20) {
            clearInterval(initInterval);
        }
    }, 300);

    // GitHub fires a 'pjax:end' / 'turbo:load' event on SPA navigation.
    ['pjax:end', 'turbo:load', 'turbo:render', 'pageshow'].forEach(evt => {
        document.addEventListener(evt, () => setTimeout(ensureButton, 300));
        window.addEventListener(evt, () => setTimeout(ensureButton, 300));
    });

    // Fallback: watch for URL changes (SPA) and re-check.
    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            const old = document.getElementById('tm-html-preview-btn');
            if (old) old.remove();
            if (countdownTimer) clearInterval(countdownTimer);
            setTimeout(ensureButton, 500);
        }
    }).observe(document.documentElement, { childList: true, subtree: true });

})();