Add fantasyland book

This commit is contained in:
Jared Miller 2025-12-25 09:26:10 -05:00
parent 3d11026429
commit 4f735bac3c
No known key found for this signature in database
3 changed files with 18198 additions and 0 deletions

17605
index.html Normal file

File diff suppressed because it is too large Load diff

296
script.js Normal file
View file

@ -0,0 +1,296 @@
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");
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";
});
});
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 walker = document.createTreeWalker(
document.getElementById("content"),
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");
});

297
style.css Normal file
View file

@ -0,0 +1,297 @@
:root {
--ink: #251f1a;
--ink-soft: #4b3f34;
--paper: #f5efe6;
--paper-deep: #e9ddcc;
--accent: #b6522f;
--accent-deep: #7a2e1a;
--nav-bg: #efe4d6;
--highlight: #f3c98b;
--shadow: rgba(45, 34, 26, 0.18);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(
circle at 20% 20%,
rgba(255, 255, 255, 0.55),
transparent 45%
),
radial-gradient(
circle at 80% 0%,
rgba(236, 196, 140, 0.35),
transparent 50%
),
linear-gradient(135deg, #fbf7f2 0%, #efe0cb 50%, #f8f1e7 100%);
font-family: "Avenir Next", "Gill Sans", "Trebuchet MS", sans-serif;
line-height: 1.6;
}
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.hero {
padding: 48px 6vw 32px;
background:
linear-gradient(120deg, rgba(182, 82, 47, 0.12), transparent 55%),
linear-gradient(220deg, rgba(122, 46, 26, 0.18), transparent 45%);
border-bottom: 1px solid rgba(63, 44, 31, 0.2);
animation: fade-up 0.8s ease both;
}
.hero h1 {
font-family:
"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Garamond, serif;
font-size: clamp(2.4rem, 4vw, 3.6rem);
letter-spacing: 0.02em;
margin: 0 0 8px;
}
.hero p {
margin: 0;
max-width: 54ch;
color: var(--ink-soft);
font-size: 1.05rem;
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 28px;
padding: 32px 6vw 64px;
}
.toc {
background: var(--nav-bg);
border-radius: 18px;
padding: 24px;
box-shadow: 0 18px 40px var(--shadow);
position: sticky;
top: 24px;
max-height: calc(100vh - 48px);
overflow: auto;
animation: fade-up 0.8s ease 0.1s both;
}
.toc h2 {
margin-top: 0;
font-size: 1.1rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-deep);
}
.toc input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(70, 50, 36, 0.2);
font-size: 0.95rem;
margin-bottom: 14px;
background: #fffaf3;
}
.toc-letters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.toc-letters a {
font-size: 0.75rem;
text-decoration: none;
color: var(--accent-deep);
padding: 4px 6px;
border-radius: 6px;
background: rgba(182, 82, 47, 0.12);
}
.toc-letters a:hover {
background: rgba(182, 82, 47, 0.25);
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.toc-list a {
text-decoration: none;
color: var(--ink);
padding: 6px 8px;
border-radius: 10px;
display: block;
transition:
background 0.2s ease,
color 0.2s ease;
}
.toc-list a:hover,
.toc-list a:focus {
background: rgba(182, 82, 47, 0.15);
color: var(--accent-deep);
}
.content {
background: rgba(255, 255, 255, 0.6);
border-radius: 24px;
padding: 32px 36px;
box-shadow: 0 20px 55px var(--shadow);
animation: fade-up 0.8s ease 0.2s both;
}
.content h2,
.content h3 {
font-family:
"Iowan Old Style", "Palatino Linotype", "Book Antiqua", Garamond, serif;
letter-spacing: 0.02em;
color: var(--accent-deep);
}
.entry-title {
font-weight: 700;
color: var(--accent-deep);
letter-spacing: 0.04em;
animation: fade-up 0.6s ease both;
animation-delay: var(--entry-delay, 0s);
}
.entry-title:target {
background: var(--highlight);
padding: 2px 6px;
border-radius: 6px;
}
.entry-link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dashed rgba(122, 46, 26, 0.4);
}
.entry-link:hover {
color: var(--accent-deep);
border-bottom-style: solid;
}
.note {
font-style: italic;
color: var(--ink-soft);
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Collapse/expand toggle */
.toc-toggle {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 100;
background: var(--accent);
color: white;
border: none;
border-radius: 0 8px 8px 0;
padding: 12px 8px;
cursor: pointer;
font-size: 1.2rem;
box-shadow: 2px 2px 8px var(--shadow);
transition:
background 0.2s ease,
left 0.3s ease;
}
.toc-toggle:hover {
background: var(--accent-deep);
}
.toc-toggle::before {
content: "◀";
display: block;
}
.toc.collapsed + .content ~ .toc-toggle::before,
body.toc-collapsed .toc-toggle::before {
content: "▶";
}
.toc {
transition:
margin-left 0.3s ease,
opacity 0.3s ease,
width 0.3s ease;
}
.toc.collapsed {
margin-left: -340px;
opacity: 0;
pointer-events: none;
}
.layout {
transition: grid-template-columns 0.3s ease;
}
.layout.toc-hidden {
grid-template-columns: 0 minmax(0, 1fr);
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.toc {
position: relative;
max-height: none;
}
.toc.collapsed {
margin-left: 0;
margin-top: -100%;
height: 0;
padding: 0;
overflow: hidden;
}
.layout.toc-hidden {
grid-template-columns: 1fr;
}
.toc-toggle {
top: auto;
bottom: 20px;
left: 20px;
transform: none;
border-radius: 8px;
}
.toc-toggle::before {
content: "▲";
}
body.toc-collapsed .toc-toggle::before {
content: "▼";
}
}