HTML Preview for GitHub/GitLab

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

As of 2026-08-06. See the latest version.

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

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

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

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         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 });

})();