fantasyland/script.js

353 lines
9.8 KiB
JavaScript

// @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");
}
});
// scroll TOC item into view when navigating via hash
window.addEventListener("hashchange", () => {
const id = location.hash.slice(1);
if (id) {
tocList?.querySelector(`a[href="#${id}"]`)?.scrollIntoView({ block: "nearest" });
}
});