GitHub Local Standalone HTML Preview

私有仓库纯本地单文件 HTML 预览实现,绕过 GitHub CSP 限制,完美支持 ES Module

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         GitHub Local Standalone HTML Preview
// @namespace    github
// @version      0.1
// @description  私有仓库纯本地单文件 HTML 预览实现,绕过 GitHub CSP 限制,完美支持 ES Module
// @match        https://github.com/*
// @license MIT
// @grant        GM_openInTab
// ==/UserScript==

(function() {
    'use strict';

    const eyeIconSvg = `<svg aria-hidden="true" focusable="false" class="octicon octicon-eye" viewBox="0 0 16 16" width="16" height="16" fill="currentColor" style="display:inline-block;vertical-align:text-bottom;"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.622-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"></path></svg>`;

    // 防抖
    let timeout = null;
    function debounceInject() {
        if (timeout) clearTimeout(timeout);
        timeout = setTimeout(injectPreviewButtons, 300);
    }

    // 字符串转 Base64(解决原生 btoa 对中文字符串报错的问题)
    function utf8ToBase64(str) {
        const bytes = new TextEncoder().encode(str);
        let binary = '';
        for (let i = 0; i < bytes.byteLength; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        return btoa(binary);
    }

    function injectPreviewButtons() {
        const links = document.querySelectorAll('a.Link--primary[href$=".html"]:not(.has-preview-btn)');

        links.forEach(link => {
            link.classList.add('has-preview-btn');

            const btn = document.createElement('span');
            btn.innerHTML = eyeIconSvg;
            btn.title = "Local Preview (Bypass CSP)";
            btn.className = "color-fg-muted";
            btn.style.cssText = 'margin-left: 8px; cursor: pointer; transition: color 0.2s;';
            btn.onmouseenter = () => btn.classList.replace('color-fg-muted', 'color-fg-default');
            btn.onmouseleave = () => btn.classList.replace('color-fg-default', 'color-fg-muted');

            btn.addEventListener('click', async (e) => {
                e.stopPropagation();
                e.preventDefault();

                if (btn.style.opacity === '0.5') return;
                btn.style.opacity = '0.5';

                const rawUrl = location.origin + link.getAttribute('href').replace('/blob/', '/raw/');
                const baseDir = rawUrl.substring(0, rawUrl.lastIndexOf('/') + 1);

                try {
                    const res = await fetch(rawUrl);
                    if (!res.ok) throw new Error('Failed to fetch HTML');
                    const htmlContent = await res.text();

                    const parser = new DOMParser();
                    const doc = parser.parseFromString(htmlContent, 'text/html');

                    // 注入 base 处理图片等相对路径
                    let baseTag = doc.querySelector('base');
                    if (!baseTag) {
                        baseTag = doc.createElement('base');
                        doc.head.insertBefore(baseTag, doc.head.firstChild);
                    }
                    baseTag.href = baseDir;

                    const tasks =[];

                    // 预处理相对路径的 CSS
                    doc.querySelectorAll('link[rel="stylesheet"]').forEach(linkNode => {
                        const href = linkNode.getAttribute('href');
                        if (href && !href.match(/^(http|https|data):/i)) {
                            const assetUrl = new URL(href, baseDir).href;
                            tasks.push(
                                fetch(assetUrl).then(r => r.text()).then(css => {
                                    const style = doc.createElement('style');
                                    style.textContent = css;
                                    linkNode.replaceWith(style);
                                }).catch(() => {})
                            );
                        }
                    });

                    // 预处理相对路径的 JS (针对绕过 GitHub MIME 限制)
                    doc.querySelectorAll('script[src]').forEach(scriptNode => {
                        const src = scriptNode.getAttribute('src');
                        if (src && !src.match(/^(http|https|data):/i)) {
                            const assetUrl = new URL(src, baseDir).href;
                            tasks.push(
                                fetch(assetUrl).then(r => r.text()).then(js => {
                                    const newScript = doc.createElement('script');
                                    newScript.textContent = js;
                                    Array.from(scriptNode.attributes).forEach(attr => {
                                        if (attr.name !== 'src') newScript.setAttribute(attr.name, attr.value);
                                    });
                                    scriptNode.replaceWith(newScript);
                                }).catch(() => {})
                            );
                        }
                    });

                    await Promise.all(tasks);

                    const finalHtml = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;

                    // 【核心改动】:利用 Data URL + GM_openInTab,彻底抛弃父窗口继承,摆脱 GitHub CSP 限制
                    const dataUrl = 'data:text/html;charset=utf-8;base64,' + utf8ToBase64(finalHtml);
                    GM_openInTab(dataUrl, { active: true });

                } catch (error) {
                    console.error(error);
                    alert("Preview initialization failed: " + error.message);
                } finally {
                    btn.style.opacity = '1';
                }
            });

            link.parentNode.appendChild(btn);
        });
    }

    const observer = new MutationObserver(debounceInject);
    observer.observe(document.body, { childList: true, subtree: true });
    debounceInject();
})();