Compare commits

...

11 commits

3 changed files with 167 additions and 76 deletions

View file

@ -32,6 +32,7 @@
<input
id="toc-search"
type="search"
autofocus
placeholder="Search the entries..."
/>
<div id="toc-letters" class="toc-letters"></div>

144
script.js
View file

@ -53,39 +53,66 @@ Array.from(firstByLetter.entries())
});
const searchInput = document.getElementById("toc-search");
let searchTimeout;
searchInput?.addEventListener("input", (event) => {
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";
});
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);
});
const specialCaseMap = {
// 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 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",
@ -102,91 +129,90 @@ const specialCaseMap = {
"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)",
"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",
"POWER-": "POWER",
"PRACTICE RING": "PRACTICE RING OR COMBAT RING",
PRIESTESSES: "HIGH PRIESTESSES",
"SECRET VALLEY": "VALLEYS",
RIVERBOAT: "RIVERS",
"REEK OF WRONG NESS": "REEK OF WRONGNESS",
"SECRET VALLEY": "VALLEYS",
SLAVE: "GALLEY SLAVE",
SLAVES: "SLAVES, MALE,",
"STANDING STONES": "STONE CIRCLES",
THIEVES: "THIEVES GUILD",
"TOWN COUNCIL": "COUNCIL",
THIEVES: "THIEVES' GUILD",
"TOUR COMPANIONS": "COMPANIONS",
"TOWN COUNCIL": "COUNCIL",
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 specialCaseMap = {
...typoFixes,
...partialMatches,
...spellingVariants,
...synonyms,
};
const resolveTerm = (term) => {
const normalized = term.replace(//g, "'");
const normalized = term.replace(/'/g, "'");
const special = specialCaseMap[normalized];
if (special && entryMap.has(special)) {
return special;
}
if (entryMap.has(term)) {
return term;
if (entryMap.has(normalized)) {
return normalized;
}
const lower = term.toLowerCase();
const lower = normalized.toLowerCase();
const candidates = [];
// Singularize
if (lower.endsWith("ies")) {
candidates.push(term.slice(0, -3) + "Y");
candidates.push(normalized.slice(0, -3) + "Y");
}
if (lower.endsWith("ves")) {
candidates.push(term.slice(0, -3) + "F");
candidates.push(term.slice(0, -3) + "FE");
candidates.push(normalized.slice(0, -3) + "F");
candidates.push(normalized.slice(0, -3) + "FE");
}
if (lower.endsWith("es")) {
candidates.push(term.slice(0, -2));
candidates.push(normalized.slice(0, -2));
}
if (lower.endsWith("s")) {
candidates.push(term.slice(0, -1));
candidates.push(normalized.slice(0, -1));
}
// Pluralize
if (lower.endsWith("y") && !/[aeiou]y$/.test(lower)) {
candidates.push(term.slice(0, -1) + "IES");
candidates.push(normalized.slice(0, -1) + "IES");
}
if (lower.endsWith("f")) {
candidates.push(term.slice(0, -1) + "VES");
candidates.push(normalized.slice(0, -1) + "VES");
}
if (lower.endsWith("fe")) {
candidates.push(term.slice(0, -2) + "VES");
candidates.push(normalized.slice(0, -2) + "VES");
}
candidates.push(`${term}S`);
candidates.push(`${normalized}S`);
if (/(s|x|z|ch|sh)$/i.test(lower)) {
candidates.push(`${term}ES`);
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)) {
@ -194,9 +220,9 @@ const linkifyTextNode = (node) => {
}
const matches = [];
const regex = /\b[A-Z][A-Z0-9'\-]*(?:\s+[A-Z][A-Z0-9'\-]*)*\b/g;
CAPS_TERM_REGEX.lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
while ((match = CAPS_TERM_REGEX.exec(text)) !== null) {
const raw = match[0];
const cleaned = cleanTerm(raw);
const resolved = resolveTerm(cleaned);
@ -290,8 +316,38 @@ 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");
document.body.classList.toggle("toc-collapsed");
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" });
}
});

View file

@ -14,8 +14,13 @@
box-sizing: border-box;
}
html {
max-width: 100%;
}
body {
margin: 0;
max-width: 100%;
color: var(--ink);
background:
radial-gradient(
@ -40,7 +45,7 @@ body {
}
.hero {
padding: 48px 6vw 32px;
padding: clamp(24px, 6vw, 48px) clamp(16px, 6vw, 6vw) clamp(16px, 4vw, 32px);
background:
linear-gradient(120deg, rgba(182, 82, 47, 0.12), transparent 55%),
linear-gradient(220deg, rgba(122, 46, 26, 0.18), transparent 45%);
@ -51,7 +56,7 @@ body {
.hero h1 {
font-family:
"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Garamond, serif;
font-size: clamp(2.4rem, 4vw, 3.6rem);
font-size: clamp(1.5rem, 6vw, 3.6rem);
letter-spacing: 0.02em;
margin: 0 0 8px;
}
@ -65,15 +70,15 @@ body {
.layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 28px;
padding: 32px 6vw 64px;
grid-template-columns: minmax(200px, 280px) minmax(0, 1fr);
gap: clamp(16px, 4vw, 28px);
padding: clamp(16px, 4vw, 32px) clamp(12px, 6vw, 6vw) clamp(32px, 8vw, 64px);
}
.toc {
background: var(--nav-bg);
border-radius: 18px;
padding: 24px;
padding: clamp(16px, 4vw, 24px);
box-shadow: 0 18px 40px var(--shadow);
position: sticky;
top: 24px;
@ -101,8 +106,8 @@ body {
}
.toc-letters {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(28px, 1fr));
gap: 6px;
margin-bottom: 16px;
}
@ -110,6 +115,7 @@ body {
.toc-letters a {
font-size: 0.75rem;
text-decoration: none;
text-align: center;
color: var(--accent-deep);
padding: 4px 6px;
border-radius: 6px;
@ -147,10 +153,13 @@ body {
.content {
background: rgba(255, 255, 255, 0.6);
border-radius: 24px;
padding: 32px 36px;
border-radius: clamp(12px, 3vw, 24px);
padding: clamp(16px, 4vw, 32px) clamp(16px, 5vw, 36px);
box-shadow: 0 20px 55px var(--shadow);
animation: fade-up 0.8s ease 0.2s both;
overflow-wrap: break-word;
word-wrap: break-word;
min-width: 0;
}
.content h2,
@ -167,6 +176,7 @@ body {
letter-spacing: 0.04em;
animation: fade-up 0.6s ease both;
animation-delay: var(--entry-delay, 0s);
scroll-margin-top: 1rem;
}
.entry-title:target {
@ -257,41 +267,65 @@ body.toc-collapsed .toc-toggle::before {
grid-template-columns: 0 minmax(0, 1fr);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
@media (max-width: 700px) {
.page,
.hero,
.content {
max-width: 100%;
}
.toc {
position: relative;
max-height: none;
}
.toc.collapsed {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(85vw, 320px);
max-height: 100vh;
border-radius: 0 18px 18px 0;
z-index: 200;
margin-left: 0;
margin-top: -100%;
height: 0;
padding: 0;
overflow: hidden;
transform: translateX(-100%);
transition: transform 0.3s ease, opacity 0.3s ease;
animation: none;
}
.layout.toc-hidden {
grid-template-columns: 1fr;
body.toc-open .toc {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.toc-toggle {
top: auto;
bottom: 20px;
left: 20px;
transform: none;
border-radius: 8px;
top: 50%;
bottom: auto;
transform: translateY(-50%);
z-index: 201;
transition: left 0.3s ease;
padding: 10px 6px;
font-size: 1rem;
left: 0;
}
body.toc-open .toc-toggle {
left: min(85vw, 320px);
}
.toc-toggle::before {
content: "▲";
content: "";
}
body.toc-collapsed .toc-toggle::before {
content: "▼";
body.toc-open .toc-toggle::before {
content: "◀";
}
.layout,
.layout.toc-hidden {
display: block;
grid-template-columns: none;
max-width: 100%;
}
.content {
border-radius: clamp(8px, 2vw, 16px);
}
}