Rendered preview for HTML files shown in GitHub/GitLab repos (JS + UTF-8) with auto-refresh
נכון ליום
// ==UserScript==
// @name HTML Preview for GitHub/GitLab
// @namespace http://tampermonkey.net/
// @version 1.5
// @description Rendered preview for HTML files shown in GitHub/GitLab repos (JS + UTF-8) with auto-refresh
// @author sanchomuzax
// @match *://github.com/*/blob/*
// @match *://gitlab.com/*/-/blob/*
// @match *://glab.p24.hu/*/-/blob/*
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @connect *
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const REFRESH_MINUTES = 5;
function isHtmlFile() {
const cleanPath = location.pathname.split('?')[0];
return /\.(html?|xhtml)$/i.test(cleanPath);
}
function getRawUrl() {
const host = location.hostname;
// GitHub -> use github.com /raw/ (auth via cookies, works for private repos too)
if (host === 'github.com') {
const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/);
if (m) return `${location.origin}/${m[1]}/${m[2]}/raw/${m[3]}`;
return null;
}
// GitLab (gitlab.com and self-hosted) -> /-/raw/ path
if (location.pathname.includes('/-/blob/')) {
return location.origin +
location.pathname.replace('/-/blob/', '/-/raw/') +
location.search;
}
return null;
}
function openPreview(rawUrl) {
GM_xmlhttpRequest({
method: 'GET',
url: rawUrl,
overrideMimeType: 'text/html; charset=utf-8',
onload: function (res) {
if (res.status >= 200 && res.status < 300) {
showPreview(res.responseText);
} else {
alert('Failed to load HTML. HTTP ' + res.status);
}
},
onerror: () => alert('Error while loading HTML.')
});
}
// Open the fetched HTML in a NEW top-level tab via a blob: URL.
// A new tab has its own (empty) CSP, so GitHub's CSP can't block it,
// and the page's own JavaScript runs normally (needed for dashboards).
function showPreview(rawHtml) {
let html = rawHtml;
if (!/<meta[^>]+charset/i.test(html)) {
const metaTag = '<meta charset="utf-8">';
if (/<head[^>]*>/i.test(html)) {
html = html.replace(/<head[^>]*>/i, m => m + metaTag);
} else if (/<html[^>]*>/i.test(html)) {
html = html.replace(/<html[^>]*>/i, m => m + '<head>' + metaTag + '</head>');
} else {
html = metaTag + html;
}
}
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const blobUrl = URL.createObjectURL(blob);
GM_openInTab(blobUrl, { active: true, setParent: true });
setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
}
let countdownTimer = null;
function addButton() {
if (document.getElementById('tm-html-preview-btn')) return;
if (!isHtmlFile()) return;
const rawUrl = getRawUrl();
if (!rawUrl) return;
if (!document.body) return;
// Container for the button + countdown
const container = document.createElement('div');
container.id = 'tm-html-preview-btn';
Object.assign(container.style, {
position: 'fixed', bottom: '20px', right: '20px', zIndex: '2147483646',
display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '6px',
fontFamily: 'sans-serif'
});
const btn = document.createElement('button');
btn.textContent = '👁 Preview HTML';
Object.assign(btn.style, {
background: '#1f75cb', color: '#fff', border: 'none',
padding: '10px 16px', borderRadius: '6px', cursor: 'pointer',
fontSize: '14px',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
});
btn.onclick = () => openPreview(rawUrl);
const counter = document.createElement('div');
counter.id = 'tm-html-preview-counter';
Object.assign(counter.style, {
background: 'rgba(0,0,0,0.65)', color: '#fff',
padding: '4px 10px', borderRadius: '6px', fontSize: '12px'
});
container.appendChild(counter);
container.appendChild(btn);
document.body.appendChild(container);
startCountdown(counter);
}
function startCountdown(counterEl) {
if (countdownTimer) clearInterval(countdownTimer);
let remaining = REFRESH_MINUTES * 60; // seconds
function render() {
const m = Math.floor(remaining / 60);
const s = remaining % 60;
counterEl.textContent = `⟳ refresh: ${m}:${String(s).padStart(2, '0')}`;
}
render();
countdownTimer = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(countdownTimer);
location.reload();
return;
}
render();
}, 1000);
}
// --- Robust initialization for GitHub's SPA navigation ---
function ensureButton() {
const exists = document.getElementById('tm-html-preview-btn');
if (isHtmlFile()) {
if (!exists) addButton();
} else {
if (exists) {
exists.remove();
if (countdownTimer) clearInterval(countdownTimer);
}
}
}
// Initial attempts (document-idle can still fire before GitHub finishes
// rendering the file view, so we retry a few times).
ensureButton();
let tries = 0;
const initInterval = setInterval(() => {
ensureButton();
tries++;
if (document.getElementById('tm-html-preview-btn') || tries > 20) {
clearInterval(initInterval);
}
}, 300);
// GitHub fires a 'pjax:end' / 'turbo:load' event on SPA navigation.
['pjax:end', 'turbo:load', 'turbo:render', 'pageshow'].forEach(evt => {
document.addEventListener(evt, () => setTimeout(ensureButton, 300));
window.addEventListener(evt, () => setTimeout(ensureButton, 300));
});
// Fallback: watch for URL changes (SPA) and re-check.
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
const old = document.getElementById('tm-html-preview-btn');
if (old) old.remove();
if (countdownTimer) clearInterval(countdownTimer);
setTimeout(ensureButton, 500);
}
}).observe(document.documentElement, { childList: true, subtree: true });
})();