HTML Preview for GitHub/GitLab

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

2026/08/06のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 });

})();