GitHub Local Standalone HTML Preview

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

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