私有仓库纯本地单文件 HTML 预览实现,绕过 GitHub CSP 限制,完美支持 ES Module
// ==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();
})();