301 lines
8.4 KiB
JavaScript
301 lines
8.4 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");
|
||
/** @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");
|
||
});
|