GitHub Local Standalone HTML Preview

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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