GitHub Local Standalone HTML Preview

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
})();