Saves any Steam Community guide as a self-contained offline HTML file. Strips Steam's chrome and replaces it with a clean Wikipedia-style reading layout, inlines all images as WebP (GIFs preserved with animation), includes a floating table of contents, and a tap-to-zoom lightbox. YouTube embeds are replaced with titled links.
// ==UserScript==
// @name Steam Guide Saver
// @namespace https://greasyfork.org/users/YOUR_USERNAME
// @version 1.0.0
// @description Saves any Steam Community guide as a self-contained offline HTML file. Strips Steam's chrome and replaces it with a clean Wikipedia-style reading layout, inlines all images as WebP (GIFs preserved with animation), includes a floating table of contents, and a tap-to-zoom lightbox. YouTube embeds are replaced with titled links.
// @author Camarron and Claude
// @license MIT
// @match https://steamcommunity.com/sharedfiles/filedetails/*
// @match https://steamcommunity.com/workshop/filedetails/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect steamuserimages-a.akamaihd.net
// @connect cdn.akamai.steamstatic.com
// @connect steamcdn-a.akamaihd.net
// @connect shared.akamai.steamstatic.com
// @connect community.akamai.steamstatic.com
// @connect images.steamusercontent.com
// @connect youtube.com
// @run-at document-idle
// ==/UserScript==
// MIT License
//
// Copyright (c) 2026 YOUR_NAME
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
(function () {
'use strict';
// ─── Inject the save bar UI into the live page ─────────────────────────────
GM_addStyle(`
#sgs-bar {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 16px;
background: #1b2838;
border: 1px solid #4c6b22;
border-radius: 4px;
margin-bottom: 14px;
font-family: Arial, sans-serif;
flex-wrap: wrap;
}
#sgs-bar span.sgs-label {
color: #c6d4df;
font-size: 13px;
font-weight: bold;
}
#sgs-save-btn {
padding: 7px 18px;
background: #4c7a2e;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: filter 0.15s;
}
#sgs-save-btn:hover { filter: brightness(1.2); }
#sgs-save-btn:active { filter: brightness(0.85); }
#sgs-save-btn:disabled { filter: brightness(0.6); cursor: not-allowed; }
#sgs-progress { flex: 1; min-width: 180px; }
#sgs-progress-bar-wrap {
background: #2a3f4f;
border-radius: 3px;
height: 8px;
width: 100%;
overflow: hidden;
display: none;
}
#sgs-progress-bar-fill {
height: 100%;
width: 0%;
background: #4c7a2e;
border-radius: 3px;
transition: width 0.2s ease;
}
#sgs-status {
color: #a0b8c8;
font-size: 12px;
font-style: italic;
margin-top: 3px;
}
`);
function injectBar() {
const anchor =
document.querySelector('.guideHeaderContent') ||
document.querySelector('#guide_area_main') ||
document.querySelector('.breadcrumbs');
if (!anchor) { setTimeout(injectBar, 800); return; }
const bar = document.createElement('div');
bar.id = 'sgs-bar';
bar.innerHTML = `
<span class="sgs-label">💾 Steam Guide Saver</span>
<button id="sgs-save-btn">Save Offline HTML</button>
<div id="sgs-progress">
<div id="sgs-progress-bar-wrap"><div id="sgs-progress-bar-fill"></div></div>
<div id="sgs-status"></div>
</div>
`;
anchor.parentNode.insertBefore(bar, anchor);
document.getElementById('sgs-save-btn').addEventListener('click', runSave);
}
function setStatus(msg) {
const el = document.getElementById('sgs-status');
if (el) el.textContent = msg;
}
function setProgress(pct) {
const wrap = document.getElementById('sgs-progress-bar-wrap');
const fill = document.getElementById('sgs-progress-bar-fill');
if (!wrap || !fill) return;
wrap.style.display = 'block';
fill.style.width = Math.min(100, Math.round(pct)) + '%';
}
// ─── GM_xmlhttpRequest as a Promise ────────────────────────────────────────
function gmFetch(url, responseType) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: responseType || 'arraybuffer',
onload: (r) => resolve(r),
onerror: (e) => reject(e),
ontimeout: () => reject(new Error('timeout: ' + url)),
timeout: 30000,
});
});
}
// ─── Detect animated GIF from raw bytes ────────────────────────────────────
// Animated GIFs contain more than one Graphic Control Extension (0x21 0xF9)
function isAnimatedGif(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
let count = 0;
for (let i = 0; i < bytes.length - 1; i++) {
if (bytes[i] === 0x21 && bytes[i + 1] === 0xF9) {
if (++count > 1) return true;
}
}
return false;
}
// ─── ArrayBuffer → base64 string ───────────────────────────────────────────
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
// Process in chunks to avoid stack overflow on large images
const chunkSize = 8192;
for (let i = 0; i < bytes.byteLength; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
// ─── Fetch one image and return a data URI ──────────────────────────────────
// GIFs are passed through raw (preserves animation).
// JPG/PNG are re-encoded as WebP at 0.85 quality via canvas.
async function fetchImageAsDataURI(url) {
const isGif = /\.gif($|\?)/i.test(url);
const res = await gmFetch(url, 'arraybuffer');
if (!res || res.status > 299) return null;
const buffer = res.response;
if (isGif) {
const animated = isAnimatedGif(buffer);
const b64 = arrayBufferToBase64(buffer);
return { dataURI: `data:image/gif;base64,${b64}`, isGif: true, isAnimated: animated };
}
// JPG / PNG — convert to WebP via canvas (no CORS issue since we own the bytes)
const b64 = arrayBufferToBase64(buffer);
const mimeGuess = /\.png($|\?)/i.test(url) ? 'image/png' : 'image/jpeg';
const srcDataURI = `data:${mimeGuess};base64,${b64}`;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
// White background ensures no transparency artefacts on JPEG-origin images
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const webp = canvas.toDataURL('image/webp', 0.85);
resolve({ dataURI: webp, isGif: false, isAnimated: false });
};
img.onerror = () => {
// Fall back to the original format if canvas conversion fails
resolve({ dataURI: srcDataURI, isGif: false, isAnimated: false });
};
img.src = srcDataURI;
});
}
// ─── Replace YouTube iframes with linked titles ─────────────────────────────
// Fetches video title via YouTube's oEmbed API (no key required).
// Falls back to "YouTube Video" if the fetch fails.
// Must be called on the clone BEFORE stripUnwanted removes all iframes.
async function replaceYouTubeIframes(clone) {
const ytIframes = Array.from(clone.querySelectorAll('iframe')).filter((f) =>
/youtube\.com\/embed\//i.test(f.getAttribute('src') || '')
);
for (const frame of ytIframes) {
const src = frame.getAttribute('src') || '';
let videoId = '';
try {
videoId = new URL(src).pathname.replace('/embed/', '').split('/')[0].split('?')[0];
} catch { frame.remove(); continue; }
if (!videoId) { frame.remove(); continue; }
const watchURL = `https://www.youtube.com/watch?v=${videoId}`;
let title = 'YouTube Video';
try {
const oembedURL = `https://www.youtube.com/oembed?url=${encodeURIComponent(watchURL)}&format=json`;
const res = await gmFetch(oembedURL, 'text');
if (res && res.status === 200) {
const json = JSON.parse(res.responseText);
if (json.title) title = json.title;
}
} catch { /* fall back to generic title */ }
const link = document.createElement('a');
link.href = watchURL;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = title;
frame.parentNode.replaceChild(link, frame);
}
}
// ─── Strip Steam chrome from the cloned DOM ─────────────────────────────────
function stripUnwanted(clone) {
[
'#global_header', '#footer', '#footer_spacer',
'.responsive_header', '.supernav_container',
'#rightContents',
'.rightSideContent', '.guideRightSidebar', '.guide_sidebar',
'.workshopItemStats', '.workshopItemControls',
'.breadcrumbs', '.workshop_item_awards',
'[id="-1"]',
'.commentsSection', '.commentthread_area',
'.highlight_strip', '.apphub_background',
'.home_viewmore_tabs',
'#sgs-bar', 'script', 'iframe', 'noscript',
].forEach((sel) => {
clone.querySelectorAll(sel).forEach((el) => el.remove());
});
}
// ─── Extract guide content ──────────────────────────────────────────────────
function extractGuideContent() {
// Real container: div.guide.subSections holds all div.subSection.detailBox sections
const contentEl =
document.querySelector('.guide.subSections') ||
document.querySelector('.guide') ||
document.querySelector('#profileBlock');
// Steam page title format: "Steam Community :: Guide :: GUIDE NAME"
let titleText = document.title
.replace(/^Steam Community\s*::\s*Guide\s*::\s*/i, '')
.trim();
if (!titleText) titleText = document.title;
// Author is in the first .friendBlockContent (inside the "Created by" sidebar panel)
// Its text content is "Author Name\nOffline" — take just the first line
const authorEl = document.querySelector('.friendBlockContent');
const authorText = authorEl
? authorEl.textContent.trim().split('\n')[0].trim()
: '';
return { titleText, authorText, contentEl };
}
// ─── Build TOC from section titles in the cloned content ───────────────────
function buildTOC(contentClone) {
// Steam guide sections use div.subSectionTitle for their headings
const headings = Array.from(contentClone.querySelectorAll('.subSectionTitle'))
.filter((h) => h.textContent.trim().length > 0);
if (headings.length < 2) return { inlineTOC: '', floatTOC: '' };
let items = '';
headings.forEach((h, i) => {
const id = `sgs-s${i}`;
const text = h.textContent.trim();
h.setAttribute('id', id);
items += `<li><a href="#${id}">${escapeHTML(text)}</a></li>`;
});
const inlineTOC = `<nav id="sgs-toc" aria-label="Table of contents">
<div class="sgs-toc-title">Contents</div>
<ol>${items}</ol>
</nav>`;
const floatTOC = `<ol>${items}</ol>`;
return { inlineTOC, floatTOC };
}
// ─── Utility ───────────────────────────────────────────────────────────────
function escapeHTML(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
// ─── CSS baked into the output file ────────────────────────────────────────
function buildOutputCSS() {
return `
/* ── Reset ── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
/* ── Base ── */
html{font-size:17px;scroll-behavior:smooth}
body{
background:#f8f9fa;
color:#202122;
font-family:Georgia,'Times New Roman',serif;
line-height:1.7;
}
/* ── Page wrapper ── */
#sgs-page{
max-width:880px;
margin:0 auto;
padding:2rem 1.5rem 5rem;
}
/* ── Guide header ── */
#sgs-header{
border-bottom:1px solid #a2a9b1;
margin-bottom:1.5rem;
padding-bottom:0.75rem;
}
#sgs-header h1{
font-size:1.95rem;
font-weight:normal;
color:#000;
line-height:1.25;
margin-bottom:0.3rem;
}
.sgs-meta{font-size:0.82rem;color:#54595d}
/* ── Table of contents (inline) ── */
#sgs-toc{
background:#f8f9fa;
border:1px solid #a2a9b1;
display:inline-block;
padding:0.7rem 1.2rem 0.8rem 1rem;
margin-bottom:1.4rem;
min-width:180px;
font-size:0.9rem;
clear:both;
}
.sgs-toc-title{
font-weight:bold;
font-size:0.92rem;
margin-bottom:0.35rem;
font-family:Georgia,serif;
}
#sgs-toc ol{margin-left:1.3rem}
#sgs-toc li{margin:0.18rem 0}
#sgs-toc a{color:#0645ad;text-decoration:none}
#sgs-toc a:hover{text-decoration:underline}
/* ── Guide content ── */
#sgs-content{color:#202122}
/* Section title — top level heading, largest */
#sgs-content .subSectionTitle{
font-family:Georgia,serif;
font-weight:normal;
font-size:1.65rem;
border-bottom:1px solid #a2a9b1;
margin:2rem 0 0.6rem;
padding-bottom:0.2rem;
color:#000;
}
/* bb_h1 — first sub-level within a section */
#sgs-content .bb_h1,#sgs-content h1{
font-family:Georgia,serif;
font-weight:normal;
font-size:1.3rem;
border-bottom:1px solid #a2a9b1;
margin:1.5rem 0 0.5rem;
padding-bottom:0.2rem;
color:#000;
}
/* bb_h2 — second sub-level */
#sgs-content .bb_h2,#sgs-content h2{
font-family:Georgia,serif;
font-weight:normal;
font-size:1.1rem;
border-bottom:none;
margin:1.2rem 0 0.3rem;
color:#000;
}
/* bb_h3 — third sub-level, no underline, slightly bold */
#sgs-content .bb_h3,#sgs-content h3,
#sgs-content .guideSection,#sgs-content .sectionTitle{
font-family:Georgia,serif;
font-weight:bold;
font-size:1rem;
border-bottom:none;
margin:1rem 0 0.25rem;
color:#000;
}
#sgs-content p,#sgs-content .bb_p{margin:0.55rem 0}
#sgs-content a{color:#0645ad}
#sgs-content a:visited{color:#0b0080}
#sgs-content ul,#sgs-content ol{margin:0.5rem 0 0.5rem 1.8rem}
#sgs-content li{margin:0.18rem 0}
#sgs-content table{border-collapse:collapse;margin:0.9rem 0;width:100%;font-size:0.91rem}
#sgs-content th,#sgs-content td{border:1px solid #a2a9b1;padding:0.38rem 0.6rem;text-align:left}
#sgs-content th{background:#eaecf0;font-weight:bold}
#sgs-content tr:nth-child(even) td{background:#f4f4f4}
#sgs-content blockquote,#sgs-content .bb_blockquote{
border-left:4px solid #a2a9b1;
margin:0.7rem 0 0.7rem 1rem;
padding:0.3rem 0.8rem;
color:#54595d;
}
#sgs-content code,#sgs-content .bb_code{
font-family:'Courier New',monospace;
background:#eaecf0;
padding:0.1em 0.3em;
border-radius:2px;
font-size:0.87em;
}
#sgs-content pre{
background:#eaecf0;
padding:0.75rem 1rem;
border-radius:3px;
overflow-x:auto;
font-size:0.87em;
margin:0.7rem 0;
}
#sgs-content hr{border:none;border-top:1px solid #a2a9b1;margin:1.1rem 0}
/* ── Images ── */
#sgs-content img{
max-width:100%;
height:auto;
display:block;
cursor:zoom-in;
margin:0.5rem 0;
border-radius:2px;
}
#sgs-content img.sgs-gif-anim{cursor:default}
/* ── Lightbox ── */
#sgs-lightbox{
display:none;
position:fixed;
inset:0;
background:rgba(0,0,0,0.88);
z-index:9999;
align-items:center;
justify-content:center;
cursor:zoom-out;
}
#sgs-lightbox.active{display:flex}
#sgs-lightbox-img{
max-width:95vw;
max-height:92vh;
object-fit:contain;
border-radius:2px;
box-shadow:0 4px 32px rgba(0,0,0,0.6);
cursor:default;
}
#sgs-lb-close{
position:fixed;
top:1rem;right:1.25rem;
color:#fff;font-size:2rem;
cursor:pointer;line-height:1;
opacity:0.8;background:none;border:none;
font-family:Arial,sans-serif;
}
#sgs-lb-close:hover{opacity:1}
/* ── Floating TOC button ── */
#sgs-float-btn{
position:fixed;
bottom:1.5rem;right:1.5rem;
background:#fff;
border:1px solid #a2a9b1;
border-radius:50%;
width:2.8rem;height:2.8rem;
display:flex;align-items:center;justify-content:center;
cursor:pointer;
box-shadow:0 2px 8px rgba(0,0,0,0.18);
font-size:1.1rem;
z-index:1000;
opacity:0;pointer-events:none;
transition:opacity 0.2s ease;
}
#sgs-float-btn.visible{opacity:1;pointer-events:auto}
#sgs-float-btn:hover{background:#eaecf0}
/* ── Floating TOC panel ── */
#sgs-float-panel{
position:fixed;
bottom:5rem;right:1.5rem;
background:#fff;
border:1px solid #a2a9b1;
border-radius:4px;
padding:0.7rem 1rem;
max-height:60vh;overflow-y:auto;
width:260px;
box-shadow:0 4px 16px rgba(0,0,0,0.15);
z-index:1000;
display:none;
font-size:0.87rem;
}
#sgs-float-panel.open{display:block}
#sgs-float-panel ol{margin-left:1.1rem}
#sgs-float-panel li{margin:0.22rem 0}
#sgs-float-panel a{color:#0645ad;text-decoration:none}
#sgs-float-panel a:hover{text-decoration:underline}
/* ── Mobile / touch ── */
@media (pointer:coarse),(max-width:640px){
html{font-size:16px}
#sgs-page{padding:1rem 1rem 4rem}
#sgs-header h1{font-size:1.45rem}
#sgs-toc{width:100%;max-width:100%}
#sgs-float-panel{
right:0.75rem;
bottom:4.5rem;
width:calc(100vw - 1.5rem);
max-width:340px;
}
#sgs-float-btn{
bottom:1rem;right:0.75rem;
width:3.2rem;height:3.2rem;
font-size:1.3rem;
}
#sgs-float-panel a,#sgs-toc a{display:block;padding:0.12rem 0}
}
/* ── Desktop / pointer ── */
@media (pointer:fine) and (min-width:641px){
#sgs-toc{float:right;margin:0 0 1rem 1.5rem;max-width:260px}
}
`;
}
// ─── JS baked into the output file ─────────────────────────────────────────
function buildOutputJS() {
return `
(function(){
// Lightbox
var lb = document.getElementById('sgs-lightbox');
var lbImg = document.getElementById('sgs-lightbox-img');
var lbClose = document.getElementById('sgs-lb-close');
document.querySelectorAll('#sgs-content img:not(.sgs-gif-anim)').forEach(function(img){
img.addEventListener('click', function(){
lbImg.src = img.dataset.full || img.src;
lb.classList.add('active');
document.body.style.overflow='hidden';
});
});
function closeLB(){
lb.classList.remove('active');
lbImg.src='';
document.body.style.overflow='';
}
if(lbClose) lbClose.addEventListener('click', closeLB);
lb.addEventListener('click', function(e){ if(e.target===lb) closeLB(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLB(); });
// Floating TOC
var toc = document.getElementById('sgs-toc');
var floatBtn = document.getElementById('sgs-float-btn');
var floatPanel = document.getElementById('sgs-float-panel');
if(toc && floatBtn){
var obs = new IntersectionObserver(function(entries){
entries.forEach(function(en){
if(en.isIntersecting){
floatBtn.classList.remove('visible');
floatPanel.classList.remove('open');
} else {
floatBtn.classList.add('visible');
}
});
},{threshold:0});
obs.observe(toc);
floatBtn.addEventListener('click', function(e){
e.stopPropagation();
floatPanel.classList.toggle('open');
});
document.addEventListener('click', function(e){
if(!floatPanel.contains(e.target) && e.target!==floatBtn){
floatPanel.classList.remove('open');
}
});
floatPanel.querySelectorAll('a').forEach(function(a){
a.addEventListener('click', function(){
floatPanel.classList.remove('open');
});
});
}
})();
`;
}
// ─── Main save routine ──────────────────────────────────────────────────────
async function runSave() {
const btn = document.getElementById('sgs-save-btn');
btn.disabled = true;
setProgress(0);
try {
setStatus('Reading guide…');
const { titleText, authorText, contentEl } = extractGuideContent();
if (!contentEl) throw new Error('Could not locate guide content on this page.');
const contentClone = contentEl.cloneNode(true);
await replaceYouTubeIframes(contentClone); // must run before stripUnwanted removes all iframes
stripUnwanted(contentClone);
// ── Process images ────────────────────────────────────────────────────
const images = Array.from(contentClone.querySelectorAll('img[src]'));
const total = images.length;
let done = 0;
setStatus(`Processing ${total} image${total !== 1 ? 's' : ''}…`);
setProgress(5);
for (const img of images) {
const rawSrc = img.getAttribute('src');
if (!rawSrc || rawSrc.startsWith('data:')) { done++; continue; }
let displayURL, fullResURL;
try {
displayURL = new URL(rawSrc, location.href).href;
// Steam wraps guide images in <a class="modalContentLink" href="FULL_RES_URL">
// Use that href directly as the full-res URL if available
const parentAnchor = img.closest('a.modalContentLink');
if (parentAnchor && parentAnchor.href) {
fullResURL = new URL(parentAnchor.href, location.href).href;
} else {
// Fallback: strip Steam's image scaling query params
const u = new URL(displayURL);
['imw','imh','impolicy','imscale','im'].forEach(p => u.searchParams.delete(p));
fullResURL = u.href;
}
} catch {
done++;
continue;
}
try {
const scaled = await fetchImageAsDataURI(displayURL);
if (scaled) {
img.setAttribute('src', scaled.dataURI);
if (scaled.isGif && scaled.isAnimated) {
// Animated GIF — no lightbox needed
img.classList.add('sgs-gif-anim');
} else {
// Fetch full-res for lightbox; fall back to scaled if it fails
if (fullResURL !== displayURL) {
try {
const full = await fetchImageAsDataURI(fullResURL);
img.setAttribute('data-full', full ? full.dataURI : scaled.dataURI);
} catch {
img.setAttribute('data-full', scaled.dataURI);
}
} else {
img.setAttribute('data-full', scaled.dataURI);
}
}
}
} catch (err) {
console.warn('[SGS] Image fetch failed:', displayURL, err);
}
done++;
setProgress(5 + (done / total) * 82);
setStatus(`Processing images… (${done} / ${total})`);
}
// ── Build TOC ─────────────────────────────────────────────────────────
setStatus('Building table of contents…');
const { inlineTOC, floatTOC } = buildTOC(contentClone);
// ── Assemble HTML ─────────────────────────────────────────────────────
setStatus('Assembling document…');
setProgress(90);
const savedDate = new Date().toLocaleDateString(undefined, {
year: 'numeric', month: 'long', day: 'numeric'
});
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHTML(titleText)}</title>
<style>${buildOutputCSS()}</style>
</head>
<body>
<div id="sgs-page">
<div id="sgs-header">
<h1>${escapeHTML(titleText)}</h1>
<div class="sgs-meta">${authorText ? escapeHTML(authorText) + ' · ' : ''}Saved from Steam Community · ${escapeHTML(savedDate)}</div>
</div>
${inlineTOC}
<div id="sgs-content">
${contentClone.innerHTML}
</div>
</div>
<div id="sgs-lightbox" role="dialog" aria-modal="true" aria-label="Image viewer">
<button id="sgs-lb-close" aria-label="Close">✕</button>
<img id="sgs-lightbox-img" src="" alt="Full size image">
</div>
<button id="sgs-float-btn" aria-label="Table of contents" title="Table of contents">☰</button>
<div id="sgs-float-panel" role="navigation" aria-label="Table of contents">
${floatTOC}
</div>
<script>${buildOutputJS()}</script>
</body>
</html>`;
// ── Download ──────────────────────────────────────────────────────────
setStatus('Saving…');
setProgress(97);
const safeTitle = titleText.replace(/[\\/:*?"<>|]/g, '_').trim() || 'steam_guide';
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
const blobURL = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobURL;
a.download = safeTitle + '.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobURL), 8000);
setProgress(100);
setStatus('Saved!');
setTimeout(() => {
setStatus('');
setProgress(0);
document.getElementById('sgs-progress-bar-wrap').style.display = 'none';
}, 4000);
} catch (err) {
setStatus('Error: ' + err.message);
console.error('[SGS]', err);
} finally {
btn.disabled = false;
}
}
// ─── Boot ──────────────────────────────────────────────────────────────────
injectBar();
})();