Add themed board game library at /mygames/

Embed the bggpipe "Game Shelves" export (static/games/) into a themed
Hugo page. A small loader (embed.js) fetches each export page, lifts out
its <main>, rewrites relative URLs, and rewires game links to ?g=<slug>
so the blog chrome stays on every page; embed.css scopes the export's
component styling to the embed. The export itself is untouched and stays
re-exportable. Adds the menu entry and excludes badges/ from deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-06 19:15:26 -04:00
co-authored by Claude Opus 4.8
parent e0e08ba67d
commit f6611257b3
400 changed files with 7127 additions and 2 deletions
+129
View File
@@ -0,0 +1,129 @@
"use strict";
/*
* Themed embed for the bggpipe "Game Shelves" export.
*
* The export under /games/ is a self-contained static site regenerated by
* bggpipe; nothing here edits it. This loader fetches an export page, lifts
* out its <main>, rewrites the export's relative URLs to absolute /games/…
* paths, and drops the result into the themed /mygames/ page. Individual game
* links are rewired to ?g=<slug> so the blog chrome stays on every page.
*/
(function () {
const BASE = "/games/"; // where the export is served
const mount = document.getElementById("games-embed");
if (!mount) return;
// Which export page to show: ?g=<slug> for a game, otherwise the index.
function targetFor(slug) {
return slug ? `${BASE}${slug}/` : BASE;
}
// Resolve a relative href/src from the export page against its real URL,
// returning an absolute path so it works from /mygames/.
function absolutize(value, pageUrl) {
if (!value) return value;
// Leave absolute URLs, anchors, and protocol-relative links alone.
if (/^([a-z]+:|\/\/|#|\/)/i.test(value)) return value;
return new URL(value, new URL(pageUrl, location.origin)).pathname +
(new URL(value, new URL(pageUrl, location.origin)).search || "");
}
// Turn an export game link (e.g. "agricola/" or "../agricola/") into the
// themed equivalent (?g=agricola). Returns null if it isn't a game link.
function slugFromGameHref(href, pageUrl) {
const abs = new URL(href, new URL(pageUrl, location.origin)).pathname;
if (!abs.startsWith(BASE)) return null;
const rest = abs.slice(BASE.length).replace(/\/$/, "");
// A game link is a single path segment that isn't an asset folder.
if (!rest || rest.includes("/") || rest === "art") return null;
return rest;
}
function render(slug) {
// no-cache: revalidate against the server so a re-export shows up on
// the next visit instead of whenever the heuristic cache expires
fetch(targetFor(slug), { cache: "no-cache" })
.then((r) => {
if (!r.ok) throw new Error(`${r.status} for ${targetFor(slug)}`);
return r.text();
})
.then((html) => {
const pageUrl = targetFor(slug);
const doc = new DOMParser().parseFromString(html, "text/html");
const main = doc.querySelector("main");
if (!main) throw new Error("no <main> in export page");
// Rewrite asset URLs (images, etc.) to absolute /games/ paths.
main.querySelectorAll("[src]").forEach((el) => {
el.setAttribute("src", absolutize(el.getAttribute("src"), pageUrl));
});
// Rewrite links: game links become ?g=<slug>; everything else
// (BGG, back-to-library, etc.) gets absolutized.
main.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href");
const gameSlug = slugFromGameHref(href, pageUrl);
const backToLibrary =
new URL(href, new URL(pageUrl, location.origin)).pathname
.replace(/\/$/, "") === BASE.replace(/\/$/, "");
if (backToLibrary) {
a.setAttribute("href", "?");
} else if (gameSlug) {
a.setAttribute("href", `?g=${encodeURIComponent(gameSlug)}`);
} else {
a.setAttribute("href", absolutize(href, pageUrl));
}
});
mount.innerHTML = "";
mount.append(...main.childNodes);
wireLinks();
wireSearch();
document.title = doc.title.replace(/ · The Game Shelves$/, "") +
" · Eric's Game Shelves";
window.scrollTo(0, 0);
})
.catch((err) => {
mount.innerHTML =
`<p>Sorry, the game library couldn't load. ` +
`<a href="${BASE}">Open it directly</a>.</p>`;
console.error("games embed:", err);
});
}
// Intercept in-page (?g=…) links so navigation stays client-side and themed.
function wireLinks() {
mount.querySelectorAll('a[href^="?"]').forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const url = new URL(a.getAttribute("href"), location.href);
const slug = url.searchParams.get("g");
history.pushState({ slug }, "", url);
render(slug);
});
});
}
// The export's index carries a search box; its inline <script> doesn't run
// when injected via innerHTML, so re-bind the same behavior here.
function wireSearch() {
const q = mount.querySelector("#q");
if (!q) return;
const cards = Array.from(mount.querySelectorAll("a.game")).map((c) => ({
el: c,
text: ((c.dataset.name || "") + " " + c.textContent).toLowerCase(),
}));
q.addEventListener("input", () => {
const needle = q.value.trim().toLowerCase();
cards.forEach((r) => {
r.el.style.display = !needle || r.text.includes(needle) ? "" : "none";
});
});
}
window.addEventListener("popstate", (e) => {
render((e.state && e.state.slug) || new URLSearchParams(location.search).get("g"));
});
render(new URLSearchParams(location.search).get("g"));
})();