fantasyland/script.js

301 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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");
/** @type {HTMLElement} */ (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");
searchInput?.addEventListener("input", (event) => {
const target = /** @type {HTMLInputElement} */ (event.target);
const query = target.value.trim().toLowerCase();
Array.from(tocList?.querySelectorAll("li") ?? []).forEach((item) => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(query) ? "" : "none";
});
});
const specialCaseMap = {
"A BARD": "BARDS",
"A COURTIER": "COURTIERS",
"A DEMON": "DEMONS",
"A GODDESS OR GOD": "GODDESSES AND GODS",
"A HERBWOMAN": "HERBWOMAN",
"A KING'S": "KINGS",
"A MAGIC USER": "MAGIC USERS",
"A SEER": "SEER",
"A WIZARD": "WIZARDS",
AMBUSHED: "AMBUSHES",
ASSASSIN: "ASSASSINS, GUILD OF",
ASSASSINS: "ASSASSINS, GUILD OF",
BACKLASHES: "BACKLASH OF MAGIC",
BALLAD: "BALLAD, How to Compose a",
BALLADS: "BALLAD, How to Compose a",
BLOCKED: "MOUNTAIN PASS, BLOCKED",
CHILD: "CHILDREN",
CURSED: "CURSES",
ETERNAL: "ETERNAL QUEST",
"EVIL SPELLS": "SPELLS",
FEMALE: "SLAVES, FEMALE,",
"FELLOW TRAVELLER": "FELLOW TRAVELERS",
"FELLOW TRAVELLERS": "FELLOW TRAVELERS",
"GAY MA G E": "GAY MAGE",
"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",
"KING'": "KINGS",
"LOST LAND": "LOST LANDS,",
"LOST LANDS": "LOST LANDS,",
MAGELIGHT: "MAGELIGHT OR MAGEFIRE",
MALE: "SLAVES, MALE,",
"MERCHANT'": "MERCHANTS",
MINION: "MINIONS OF THE DARK LORD",
"MINION OF THE DARK LORD": "MINIONS OF THE DARK LORD",
MINIONS: "MINIONS OF THE DARK LORD",
"MORE ON RIVERS": "RIVERS",
"MORE ON RIVERS RIVERS": "RIVERS",
MOON: "MOON(S)",
"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",
"POWER-": "POWER",
"PRACTICE RING": "PRACTICE RING OR COMBAT RING",
PRIESTESSES: "HIGH PRIESTESSES",
"SECRET VALLEY": "VALLEYS",
RIVERBOAT: "RIVERS",
"REEK OF WRONG NESS": "REEK OF WRONGNESS",
SLAVE: "GALLEY SLAVE",
SLAVES: "SLAVES, MALE,",
"STANDING STONES": "STONE CIRCLES",
THIEVES: "THIEVES GUILD",
"TOWN COUNCIL": "COUNCIL",
"TOUR COMPANIONS": "COMPANIONS",
TROTS: "TROTS, THE",
WAYBREAD: "WAYBREAD OR JOURNEY CAKE",
"WITCH LIGHT": "WITCHLIGHT",
"WIZARD'": "WIZARDS",
"WIZARD'S": "WIZARDS",
"WIZARD'S BREEDING PROGRAMME": "BREEDING PROGRAMMES",
"WIZARD'S STAFF": "STAFFS",
};
const resolveTerm = (term) => {
const normalized = term.replace(//g, "'");
const special = specialCaseMap[normalized];
if (special && entryMap.has(special)) {
return special;
}
if (entryMap.has(term)) {
return term;
}
const lower = term.toLowerCase();
const candidates = [];
// Singularize
if (lower.endsWith("ies")) {
candidates.push(term.slice(0, -3) + "Y");
}
if (lower.endsWith("ves")) {
candidates.push(term.slice(0, -3) + "F");
candidates.push(term.slice(0, -3) + "FE");
}
if (lower.endsWith("es")) {
candidates.push(term.slice(0, -2));
}
if (lower.endsWith("s")) {
candidates.push(term.slice(0, -1));
}
// Pluralize
if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) {
candidates.push(term.slice(0, -1) + "IES");
}
if (lower.endsWith("f")) {
candidates.push(term.slice(0, -1) + "VES");
}
if (lower.endsWith("fe")) {
candidates.push(term.slice(0, -2) + "VES");
}
candidates.push(`${term}S`);
if (/(s|x|z|ch|sh)$/i.test(lower)) {
candidates.push(`${term}ES`);
}
return candidates.find((candidate) => entryMap.has(candidate)) || null;
};
const linkifyTextNode = (node) => {
const text = node.nodeValue;
if (!text || !/[A-Z]{2}/.test(text)) {
return;
}
const matches = [];
const regex = /\b[A-Z][A-Z0-9'\-]*(?:\s+[A-Z][A-Z0-9'\-]*)*\b/g;
let match;
while ((match = 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");
tocToggle.addEventListener("click", () => {
toc?.classList.toggle("collapsed");
layout?.classList.toggle("toc-hidden");
document.body.classList.toggle("toc-collapsed");
});