GitHub Local Standalone HTML Preview

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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