// @ts-nocheck const cleanTerm = (text) => text.replace(/\s+/g, " ").replace(/\.$/, "").trim(); const slugify = (text) => text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); const entrySpans = Array.from(document.querySelectorAll("span.none2")); const entryMap = new Map(); entrySpans.forEach((span, index) => { const term = cleanTerm(span.textContent); if (!term) { return; } const id = `entry-${slugify(term)}`; span.setAttribute("id", id); span.classList.add("entry-title"); span.style.setProperty("--entry-delay", `${Math.min(index * 0.01, 0.4)}s`); entryMap.set(term, id); }); const tocList = document.getElementById("toc-list"); const tocLetters = document.getElementById("toc-letters"); const termEntries = Array.from(entryMap.entries()).sort(([a], [b]) => a.localeCompare(b), ); const firstByLetter = new Map(); termEntries.forEach(([term, id]) => { const letter = term[0] ? term[0].toUpperCase() : "#"; if (!firstByLetter.has(letter)) { firstByLetter.set(letter, id); } const link = document.createElement("a"); link.href = `#${id}`; link.textContent = term; const item = document.createElement("li"); item.appendChild(link); tocList?.appendChild(item); }); Array.from(firstByLetter.entries()) .sort(([a], [b]) => a.localeCompare(b)) .forEach(([letter, id]) => { const link = document.createElement("a"); link.href = `#${id}`; link.textContent = letter; tocLetters?.appendChild(link); }); const searchInput = document.getElementById("toc-search"); let searchTimeout; searchInput?.addEventListener("input", (event) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { const query = event.target.value.trim().toLowerCase(); Array.from(tocList?.querySelectorAll("li") ?? []).forEach((item) => { const text = item.textContent.toLowerCase(); item.style.display = text.includes(query) ? "" : "none"; }); }, 50); }); // OCR/formatting errors from epub conversion const typoFixes = { "GAY MA G E": "GAY MAGE", "MORE ON RIVERS RIVERS": "RIVERS", "REEK OF WRONG NESS": "REEK OF WRONGNESS", "WITCH LIGHT": "WITCHLIGHT", }; // truncated possessives and hyphenated terms const partialMatches = { "A KING'S": "KINGS", "KING'": "KINGS", "MERCHANT'": "MERCHANTS", "POWER-": "POWER", "WIZARD'": "WIZARDS", "WIZARD'S": "WIZARDS", "WIZARD'S BREEDING PROGRAMME": "BREEDING PROGRAMMES", "WIZARD'S STAFF": "STAFFS", }; // British/American spelling and verb forms const spellingVariants = { AMBUSHED: "AMBUSHES", BACKLASHES: "BACKLASH OF MAGIC", CURSED: "CURSES", "FELLOW TRAVELLER": "FELLOW TRAVELERS", "FELLOW TRAVELLERS": "FELLOW TRAVELERS", }; // different terms mapping to canonical entries const synonyms = { "A BARD": "BARDS", "A COURTIER": "COURTIERS", "A DEMON": "DEMONS", "A GODDESS OR GOD": "GODDESSES AND GODS", "A HERBWOMAN": "HERBWOMAN", "A MAGIC USER": "MAGIC USERS", "A SEER": "SEER", "A WIZARD": "WIZARDS", ASSASSIN: "ASSASSINS, GUILD OF", ASSASSINS: "ASSASSINS, GUILD OF", BALLAD: "BALLAD, How to Compose a", BALLADS: "BALLAD, How to Compose a", BLOCKED: "MOUNTAIN PASS, BLOCKED", CHILD: "CHILDREN", ETERNAL: "ETERNAL QUEST", "EVIL SPELLS": "SPELLS", FEMALE: "SLAVES, FEMALE,", "GIFT OF SIGHT": "SIGHT", GOD: "GODDESSES AND GODS", GODDESS: "GODDESSES AND GODS", "GODDESS OR GOD": "GODDESSES AND GODS", GODS: "GODDESSES AND GODS", "GOOD KING": "KINGS", "GOOD SPY": "SPIES", "GOOD WIZARDS": "WIZARDS", "GUILD OF": "ASSASSINS, GUILD OF", HERBWOMEN: "HERBWOMAN", HERO: "HEROES", "HIDDEN CITY": "HIDDEN KINGDOM", "HOMESPUN ROBES": "ROBES", "HOW TO INTERACT WITH WIZARDS": "WIZARDS", IMPORT: "IMPORT/EXPORT", "JOURNEY CAKE": "WAYBREAD OR JOURNEY CAKE", "LOST LAND": "LOST LANDS,", "LOST LANDS": "LOST LANDS,", MAGELIGHT: "MAGELIGHT OR MAGEFIRE", MALE: "SLAVES, MALE,", MINION: "MINIONS OF THE DARK LORD", "MINION OF THE DARK LORD": "MINIONS OF THE DARK LORD", MINIONS: "MINIONS OF THE DARK LORD", MOON: "MOON(S)", "MORE ON RIVERS": "RIVERS", "MOUNTAIN PASS": "MOUNTAIN PASS, BLOCKED", "NORTHERN BARBARIANS DWELL": "NORTHERN BARBARIANS", PANCELTIC: "PANCELTIC TOURS", "PANCELTIC TOUR": "PANCELTIC TOURS", PENTAGRAM: "PENTAGRAM OR PENTACLE", PENTAGRAMS: "PENTAGRAM OR PENTACLE", "PRACTICE RING": "PRACTICE RING OR COMBAT RING", PRIESTESSES: "HIGH PRIESTESSES", RIVERBOAT: "RIVERS", "SECRET VALLEY": "VALLEYS", SLAVE: "GALLEY SLAVE", SLAVES: "SLAVES, MALE,", "STANDING STONES": "STONE CIRCLES", THIEVES: "THIEVES' GUILD", "TOUR COMPANIONS": "COMPANIONS", "TOWN COUNCIL": "COUNCIL", TROTS: "TROTS, THE", WAYBREAD: "WAYBREAD OR JOURNEY CAKE", }; const specialCaseMap = { ...typoFixes, ...partialMatches, ...spellingVariants, ...synonyms, }; const resolveTerm = (term) => { const normalized = term.replace(/'/g, "'"); const special = specialCaseMap[normalized]; if (special && entryMap.has(special)) { return special; } if (entryMap.has(normalized)) { return normalized; } const lower = normalized.toLowerCase(); const candidates = []; // Singularize if (lower.endsWith("ies")) { candidates.push(normalized.slice(0, -3) + "Y"); } if (lower.endsWith("ves")) { candidates.push(normalized.slice(0, -3) + "F"); candidates.push(normalized.slice(0, -3) + "FE"); } if (lower.endsWith("es")) { candidates.push(normalized.slice(0, -2)); } if (lower.endsWith("s")) { candidates.push(normalized.slice(0, -1)); } // Pluralize if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) { candidates.push(normalized.slice(0, -1) + "IES"); } if (lower.endsWith("f")) { candidates.push(normalized.slice(0, -1) + "VES"); } if (lower.endsWith("fe")) { candidates.push(normalized.slice(0, -2) + "VES"); } candidates.push(`${normalized}S`); if (/(s|x|z|ch|sh)$/i.test(lower)) { candidates.push(`${normalized}ES`); } return candidates.find((candidate) => entryMap.has(candidate)) || null; }; const CAPS_TERM_REGEX = /\b[A-Z][A-Z0-9''\-]*(?:\s+[A-Z][A-Z0-9''\-]*)*\b/g; const linkifyTextNode = (node) => { const text = node.nodeValue; if (!text || !/[A-Z]{2}/.test(text)) { return; } const matches = []; CAPS_TERM_REGEX.lastIndex = 0; let match; while ((match = CAPS_TERM_REGEX.exec(text)) !== null) { const raw = match[0]; const cleaned = cleanTerm(raw); const resolved = resolveTerm(cleaned); if (resolved) { matches.push({ start: match.index, end: match.index + raw.length, term: resolved, raw, }); } } if (!matches.length) { return; } // Prefer longer matches to avoid linking a short term inside a longer one. const byLength = matches .slice() .sort((a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start); const selected = []; byLength.forEach((candidate) => { const overlaps = selected.some( (picked) => candidate.start < picked.end && candidate.end > picked.start, ); if (!overlaps) { selected.push(candidate); } }); selected.sort((a, b) => a.start - b.start); const fragment = document.createDocumentFragment(); let lastIndex = 0; selected.forEach(({ start, end, term }) => { if (start > lastIndex) { fragment.appendChild( document.createTextNode(text.slice(lastIndex, start)), ); } const link = document.createElement("a"); link.href = `#${entryMap.get(term)}`; link.textContent = text.slice(start, end); link.className = "entry-link"; fragment.appendChild(link); lastIndex = end; }); if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } node.parentNode.replaceChild(fragment, node); }; const contentEl = document.getElementById("content"); if (contentEl) { const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { const parent = node.parentElement; if (!parent) { return NodeFilter.FILTER_REJECT; } if (parent.closest("a, script, style")) { return NodeFilter.FILTER_REJECT; } if (parent.closest("span.none2")) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }); const nodesToLinkify = []; while (walker.nextNode()) { nodesToLinkify.push(walker.currentNode); } nodesToLinkify.forEach((node) => linkifyTextNode(node)); } // TOC collapse/expand toggle const tocToggle = document.createElement("button"); tocToggle.className = "toc-toggle"; tocToggle.setAttribute("aria-label", "Toggle sidebar"); tocToggle.setAttribute("title", "Toggle sidebar"); document.body.appendChild(tocToggle); const toc = document.querySelector(".toc"); const layout = document.querySelector(".layout"); const isMobile = () => window.matchMedia("(max-width: 700px)").matches; tocToggle.addEventListener("click", () => { toc?.classList.toggle("collapsed"); layout?.classList.toggle("toc-hidden"); if (isMobile()) { document.body.classList.toggle("toc-open"); } else { document.body.classList.toggle("toc-collapsed"); } }); // auto-collapse on mobile (CSS defaults to collapsed, just ensure classes match) if (isMobile()) { toc?.classList.add("collapsed"); layout?.classList.add("toc-hidden"); document.body.classList.remove("toc-open"); } // close drawer when clicking a toc link on mobile tocList?.addEventListener("click", (e) => { if (e.target.tagName === "A" && isMobile()) { toc?.classList.add("collapsed"); layout?.classList.add("toc-hidden"); document.body.classList.remove("toc-open"); } }); // highlight active TOC entry based on hash const updateActiveTocEntry = () => { const id = location.hash.slice(1); tocList?.querySelector("a.active")?.classList.remove("active"); if (id) { const link = tocList?.querySelector(`a[href="#${id}"]`); link?.classList.add("active"); } }; window.addEventListener("hashchange", updateActiveTocEntry); if (location.hash) { updateActiveTocEntry(); } // Entry popup preview on hover const popup = document.createElement("div"); popup.className = "entry-popup"; popup.hidden = true; document.body.appendChild(popup); let hoverTimer = null; let closeTimer = null; const HOVER_DELAY = 500; const CLOSE_DELAY = 500; const getEntryContent = (id) => { const titleEl = document.getElementById(id); if (!titleEl) return null; const allTitles = Array.from(document.querySelectorAll(".entry-title")); const titleIndex = allTitles.indexOf(titleEl); const nextTitle = allTitles[titleIndex + 1] || null; const range = document.createRange(); range.setStartBefore(titleEl); if (nextTitle) { range.setEndBefore(nextTitle); } else { range.setEndAfter(contentEl.lastChild); } const content = range.cloneContents(); const clonedTitle = content.querySelector(".entry-title"); if (clonedTitle) { clonedTitle.removeAttribute("id"); } return content; }; const positionPopup = (linkRect) => { const pad = 8; const popupRect = popup.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let top = linkRect.top - popupRect.height - pad; let left = linkRect.left; if (top < pad) { top = linkRect.bottom + pad; } if (left + popupRect.width > vw - pad) { left = vw - popupRect.width - pad; } if (left < pad) { left = pad; } popup.style.top = `${top}px`; popup.style.left = `${left}px`; }; const showPopup = (link) => { const href = link.getAttribute("href"); if (!href?.startsWith("#entry-")) return; const id = href.slice(1); const content = getEntryContent(id); if (!content) return; popup.innerHTML = ""; popup.appendChild(content); popup.hidden = false; popup.style.top = "0"; popup.style.left = "0"; popup.classList.remove("visible"); requestAnimationFrame(() => { popup.scrollTop = 0; const linkRect = link.getBoundingClientRect(); positionPopup(linkRect); popup.classList.add("visible"); }); }; const hidePopup = () => { popup.classList.remove("visible"); setTimeout(() => { if (!popup.classList.contains("visible")) { popup.hidden = true; } }, 200); }; const clearTimers = () => { clearTimeout(hoverTimer); clearTimeout(closeTimer); hoverTimer = null; closeTimer = null; }; const isEntryLink = (el) => el?.tagName === "A" && el.classList.contains("entry-link") && el.getAttribute("href")?.startsWith("#entry-"); contentEl?.addEventListener("mouseenter", (e) => { if (isMobile()) return; if (!isEntryLink(e.target)) return; clearTimers(); hoverTimer = setTimeout(() => showPopup(e.target), HOVER_DELAY); }, true); contentEl?.addEventListener("mouseleave", (e) => { if (isMobile()) return; if (!isEntryLink(e.target)) return; clearTimers(); closeTimer = setTimeout(hidePopup, CLOSE_DELAY); }, true); popup.addEventListener("mouseenter", () => { clearTimers(); }); popup.addEventListener("mouseleave", () => { clearTimers(); closeTimer = setTimeout(hidePopup, CLOSE_DELAY); }); popup.addEventListener("click", (e) => { if (e.target.tagName === "A") { clearTimers(); hidePopup(); } });