HTML Preview for GitHub/GitLab

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

目前為 2026-08-06 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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 });

})();