-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwiki-runtime.js
More file actions
500 lines (449 loc) · 21.6 KB
/
Copy pathwiki-runtime.js
File metadata and controls
500 lines (449 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
// === WIKI RUNTIME ===
// Three jobs, all of which must happen before an entry is rendered:
//
// 1. merge the generated, source-derived prose into wikiData;
// 2. build the entity index used to cross-link entries;
// 3. own navigation, so that entry + map view live in the History API
// rather than in a variable that a page reload throws away.
//
// Load order matters: this file needs wiki-data.js and generated/wiki-overrides.js
// and must run before main.js opens anything.
(function () {
'use strict';
// ---------- 1. generated content ----------
// Overrides are MERGED, never substituted: wikiData owns coordinates,
// images, and region geometry, and the content build must not be able to
// erase them. See CONTENT_PIPELINE.md.
var overrides = window.WIKI_ENTRY_OVERRIDES || {};
var unknown = [];
Object.keys(overrides).forEach(function (id) {
// Map entries already live in wikiData and retain their coordinates
// and imagery. Book-only entries (history, races, factions) have no
// marker, so the generated entry itself is their complete data.
if (!wikiData[id]) wikiData[id] = {};
var entry = wikiData[id];
var override = overrides[id];
Object.keys(override).forEach(function (key) { entry[key] = override[key]; });
entry.generated = true;
});
// Kept for defensive compatibility if a future data loader explicitly
// rejects an override. Book-only entries are valid and are created above.
if (unknown.length) {
// Console only: a reader cannot act on this, and every entry they can
// actually reach still works.
reportBuildError(null, 'Generated content refers to ' + unknown.length +
' unknown entry ID(s): ' + unknown.join(', ') +
'. Fix content/entry-meta.json or add the entry to wiki-data.js.');
}
/**
* Say a thing went wrong.
*
* `detail` is the build instruction and goes to the console only. The
* banner is the reader's, so it carries `message` alone -- a visitor who
* is handed "Run: node scripts/build-book.mjs" learns nothing from it and
* concludes the site is broken.
*
* With no `message`, nothing is shown at all: some failures are worth
* recording for whoever maintains the site and not worth interrupting a
* reader who may never touch the affected part.
*/
function reportBuildError(message, detail) {
console.error('[wiki] ' + (detail || message));
if (!message) return;
var banner = document.getElementById('build-error');
if (!banner) {
banner = document.createElement('div');
banner.id = 'build-error';
banner.setAttribute('role', 'status');
document.body.appendChild(banner);
}
// The same missing artifact can be reported by several callers; one
// line per distinct message, not one per attempt.
if (banner.querySelector('[data-message="' + CSS.escape(message) + '"]')) return;
var line = document.createElement('div');
line.dataset.message = message;
line.textContent = message;
banner.appendChild(line);
}
// ---------- 2. entity index ----------
// Titles plus the explicit aliases from content/entry-meta.json. No
// stemmer: invented names ("Remosa" / "Remosan" / "Remathis") make an
// automatic adjectival rule produce confident nonsense, so alternate forms
// are authored by hand.
var byName = {}; // lowercased name -> [entry id, ...]
Object.keys(wikiData).forEach(function (id) {
var entry = wikiData[id];
var names = [entry.title].concat(entry.aliases || []);
names.forEach(function (name) {
if (!name) return;
var key = name.toLowerCase();
if (!byName[key]) byName[key] = [];
if (byName[key].indexOf(id) < 0) byName[key].push(id);
});
});
var ambiguous = Object.keys(byName).filter(function (key) { return byName[key].length > 1; });
// Ambiguity is reported, never guessed at: linking "Remosa" to one of two
// entries at random is worse than not linking it. scripts/audit.mjs fails the
// build on this; here it is only surfaced for whoever is looking.
if (ambiguous.length) {
console.warn('[wiki] ambiguous aliases, not linked:', ambiguous.map(function (key) {
return key + ' -> ' + byName[key].join('/');
}));
}
var linkable = Object.keys(byName)
.filter(function (key) { return byName[key].length === 1; })
.sort(function (a, b) { return b.length - a.length; }); // longest match wins
// Word boundaries that also refuse to fire inside a longer name: "Remosa"
// must not match the first six letters of "Remosan", which is its own
// alias and would otherwise be shadowed by the shorter one. A trailing
// apostrophe is deliberately allowed through, so "Remosa's wonders" links
// the name and leaves the possessive outside the link.
var linkPattern = linkable.length ? new RegExp(
'(?<![\\w\'’])(' +
linkable.map(function (key) { return key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }).join('|') +
')(?!\\w)', 'gi') : null;
var SKIP_TAGS = { A: 1, CODE: 1, PRE: 1, SCRIPT: 1, STYLE: 1, TEXTAREA: 1, BUTTON: 1 };
/**
* Link unambiguous entity mentions inside `root`, in text nodes only.
*
* Existing anchors, attributes, image URLs and markup are never touched,
* because nothing but character data is ever examined or rewritten.
*/
// How often one entry may link to the same other entry. Vanguard mentions
// Brenmath more than a dozen times; linking all of them turned a paragraph
// into a field of underlines and told the reader nothing after the first.
// Two, not one, so a long entry that returns to a subject much later can
// offer the way across again -- and only from a different block, or the
// second link lands in the same breath as the first.
var MAX_LINKS_PER_TARGET = 2;
// `.seg` first because it is what a paragraph actually is here: the
// generated prose has no <p> at all, only narration segments separated by
// <br>, so selecting on block tags alone found nothing and collapsed a
// whole entry into one "block". The tags still matter for the older
// hand-written entries in wiki-data.js, which do use them.
var BLOCKS = '.seg, p, li, td, th, dd, dt, figcaption, blockquote, h1, h2, h3, h4, h5, h6';
/** The paragraph-ish element a text node sits in, for the one-per-block rule. */
function blockOf(node, root) {
var el = node.parentElement;
var block = el && el.closest && el.closest(BLOCKS);
return (block && root.contains(block)) ? block : root;
}
function linkify(root, currentId) {
if (!linkPattern) return;
// Shared across the whole entry: the budget is per entry, not per
// paragraph, and the walker visits in document order so the mentions
// that survive are the earliest ones.
var budget = { count: {}, block: {} };
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: function (node) {
for (var el = node.parentNode; el && el !== root; el = el.parentNode) {
if (SKIP_TAGS[el.nodeName]) return NodeFilter.FILTER_REJECT;
}
// linkPattern is global, so test() advances lastIndex and the
// next call would start mid-string. Reset before every use.
linkPattern.lastIndex = 0;
return linkPattern.test(node.nodeValue)
? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
var targets = [];
while (walker.nextNode()) targets.push(walker.currentNode);
targets.forEach(function (node) { linkTextNode(node, currentId, root, budget); });
}
function linkTextNode(node, currentId, root, budget) {
var text = node.nodeValue;
var fragment = document.createDocumentFragment();
var block = blockOf(node, root);
var cursor = 0;
var match;
linkPattern.lastIndex = 0;
while ((match = linkPattern.exec(text)) !== null) {
var id = byName[match[1].toLowerCase()][0];
if (id === currentId) continue; // never link an entry to itself
// Spent, or already offered in this very paragraph. The mention
// still reads as written; it simply is not a link.
var spent = budget.count[id] || 0;
if (spent >= MAX_LINKS_PER_TARGET) continue;
if (spent > 0 && budget.block[id] === block) continue;
budget.count[id] = spent + 1;
budget.block[id] = block;
if (match.index > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, match.index)));
}
var link = document.createElement('a');
link.className = 'wiki-link';
link.href = '#' + id;
link.dataset.entry = id;
link.textContent = match[1]; // keep the author's capitalization
link.title = wikiData[id].title;
fragment.appendChild(link);
cursor = match.index + match[1].length;
}
if (!cursor) return;
if (cursor < text.length) fragment.appendChild(document.createTextNode(text.slice(cursor)));
node.parentNode.replaceChild(fragment, node);
}
// ---------- 3. navigation ----------
// The trail exists only to label the Back control ("< Haldrith"). The
// restore itself goes through history.back(), so browser Back/Forward and
// the sidebar control are the same operation and a reload cannot desync
// them.
var trail = [];
// A #haldrith link is shareable and bookmarkable, so the tab it opens
// should say what it is rather than repeating the site's name. Captured
// once, before anything has changed it.
var SITE_TITLE = document.title;
// "poi" is a filing code, not a description. It is what the map data calls
// the type and what the editor picks from, so it stays the stored value --
// but nothing shown to a reader should say it.
var TYPE_LABELS = { poi: 'place of interest' };
function typeLabel(type) {
var key = String(type || '').toLowerCase();
return TYPE_LABELS[key] || type;
}
function setTitle(entry) {
document.title = entry ? entry.title + ' - Virelia' : SITE_TITLE;
}
function currentView() {
if (typeof map === 'undefined') return null;
var c = map.getCenter();
return { lat: c.lat, lng: c.lng, zoom: map.getZoom() };
}
function depth() {
return (history.state && typeof history.state.depth === 'number') ? history.state.depth : -1;
}
/**
* Open an entry. This is the only path into the sidebar.
*
* `push` false is used when replaying history, so that restoring a state
* does not append a new one.
*/
function open(id, options) {
options = options || {};
var entry = wikiData[id];
if (!entry) { console.warn('[wiki] no such entry:', id); return; }
if (options.push !== false) {
var next = depth() + 1;
trail[next] = id;
trail.length = next + 1;
history.pushState({ entry: id, depth: next, view: currentView() }, '', '#' + id);
}
render(entry, id);
if (window.MapFocus) window.MapFocus.focusEntry(id, options.view);
// The focus animation moves the map; record where it landed so Back
// returns to the view the reader actually had.
window.setTimeout(syncView, 500);
}
function render(entry, id) {
var sidebar = document.getElementById('sidebar');
var contentDiv = document.getElementById('sidebar-content');
contentDiv.innerHTML = '';
contentDiv.scrollTop = 0;
if (window.Narration) window.Narration.stop();
if (entry.image) {
var img = document.createElement('img');
img.className = 'ribbon';
img.src = entry.image;
img.alt = entry.title;
img.onclick = function () { showLightbox(entry.image, entry.title); };
img.onerror = function () { this.style.display = 'none'; };
contentDiv.appendChild(img);
}
// The title and the way across to the wiki share a line: the title
// rarely fills it, and a full-width button under the heading pushed
// the prose down the panel for something read once.
var head = document.createElement('div');
head.className = 'entry-head';
var title = document.createElement('h2');
title.textContent = entry.title;
// Segment 1 of the narration is the title, so the read-along can
// highlight it before the body starts. The button is deliberately
// outside it -- inside, the highlight would sweep over the control.
title.dataset.seg = 's-0001';
var kind = document.createElement('small');
kind.textContent = '(' + typeLabel(entry.type) + ')';
title.appendChild(document.createTextNode(' '));
title.appendChild(kind);
head.appendChild(title);
var toWiki = document.createElement('button');
toWiki.type = 'button';
toWiki.className = 'entry-wiki-link';
toWiki.title = 'Read ' + entry.title + ' in the Wiki';
toWiki.setAttribute('aria-label', toWiki.title);
// The same mark as the Wiki button on the map, so it needs no words.
toWiki.innerHTML = '<svg aria-hidden="true"><use href="#icon-wiki"></use></svg>';
toWiki.addEventListener('click', function () {
if (window.Reader) window.Reader.open(id);
});
head.appendChild(toWiki);
contentDiv.appendChild(head);
var body = document.createElement('div');
body.className = 'entry-body';
body.innerHTML = entry.content;
if (entry.manualNarration && !body.querySelector('.seg') && window.Narration) {
window.Narration.segmentManual(body, entry.narration.segments);
}
linkify(body, id);
contentDiv.appendChild(body);
// Pick the side before the panel slides in, and before the map moves:
// an entry the map cannot pan out from under a right-hand panel gets a
// left-hand one instead. MapFocus then frames the map around whichever
// side we chose.
if (window.MapFocus) {
var wantLeft = window.MapFocus.preferredSide(id) === 'left';
if (wantLeft !== sidebar.classList.contains('side-left')) {
// Which edge the panel docks to also decides which property
// the slide animates: `right` for the default side, `left` for
// the other. Switching edges in the same style pass that adds
// `active` leaves the new axis with no resolved value to start
// from -- it was `auto` an instant earlier, and `auto` cannot
// be interpolated -- so the panel appears instead of sliding.
// That is why an entry near the edge snapped while a central
// one opened gradually.
//
// Settle it at the new edge's off-screen resting place and
// make the browser resolve that, so the slide has somewhere to
// start from.
sidebar.classList.remove('active');
sidebar.classList.toggle('side-left', wantLeft);
void sidebar.offsetWidth; // forces the pending style/layout pass
}
}
// Swapping one entry for another used to be a hard cut: the panel is
// already open and in place, so the only thing that changes is all of
// its content, at once. A short fade gives the eye something to
// follow. Retriggered by hand because the element is not replaced.
contentDiv.classList.remove('is-entering');
void contentDiv.offsetWidth;
contentDiv.classList.add('is-entering');
sidebar.classList.add('active');
sidebar.dataset.entry = id;
setTitle(entry);
updateBackControl();
if (window.Narration) window.Narration.attach(id, contentDiv);
}
function close() {
var sidebar = document.getElementById('sidebar');
sidebar.classList.remove('active');
delete sidebar.dataset.entry;
if (window.Narration) window.Narration.stop();
if (window.MapFocus) window.MapFocus.clear();
setTitle(null);
// Closing starts a new reading session. Replace the active entry
// state instead of pushing an empty history item, then clear the
// sidebar's Back label so it can never advertise a closed entry.
trail = [];
history.replaceState({ entry: null, depth: -1, view: currentView() }, '',
window.location.pathname + window.location.search);
resetBackControl();
var bar = document.getElementById('entry-bar');
if (bar) bar.hidden = true;
}
/**
* Show Back only when it goes somewhere.
*
* Absent rather than greyed: it sits in the corner of the artwork, and a
* permanently dead control there is clutter that has to be looked past
* every time. The arrow is the whole visible label -- naming the previous
* entry made the control as wide as the name and pushed the player onto a
* second row -- so the destination lives in the tooltip and the accessible
* name, where it costs no room and is still announced.
*/
function updateBackControl() {
var button = document.getElementById('entry-back');
if (!button) return;
var previous = trail[depth() - 1];
button.hidden = !previous;
if (!previous) return;
var label = 'Back to ' + wikiData[previous].title;
button.title = label;
button.setAttribute('aria-label', label);
}
function resetBackControl() {
var button = document.getElementById('entry-back');
if (!button) return;
button.hidden = true;
}
function syncView() {
if (!history.state) return;
var state = {
entry: history.state.entry,
depth: history.state.depth,
view: currentView()
};
history.replaceState(state, '', window.location.hash);
}
window.addEventListener('popstate', function (event) {
var state = event.state;
if (state && state.entry && wikiData[state.entry]) {
render(wikiData[state.entry], state.entry);
if (window.MapFocus) window.MapFocus.focusEntry(state.entry, state.view);
} else {
document.getElementById('sidebar').classList.remove('active');
setTitle(null);
if (window.Narration) window.Narration.stop();
if (window.MapFocus) window.MapFocus.clear();
if (state && state.view && typeof map !== 'undefined') {
map.setView([state.view.lat, state.view.lng], state.view.zoom);
}
var bar = document.getElementById('entry-bar');
if (bar) bar.hidden = true;
resetBackControl();
return;
}
updateBackControl();
});
// A click on a generated link is a navigation, not a page load.
document.addEventListener('click', function (event) {
var link = event.target.closest && event.target.closest('a.wiki-link');
if (!link) return;
event.preventDefault();
open(link.dataset.entry);
});
// Imported book images open in the lightbox. Delegated because the prose
// is injected as HTML and carries no handlers of its own -- and because
// the same markup is used in the sidebar and in the wiki overlay.
document.addEventListener('click', function (event) {
var image = event.target.closest && event.target.closest('.entry-figure img');
if (!image || typeof showLightbox !== 'function') return;
var figure = image.closest('.entry-figure');
var caption = figure && figure.querySelector('figcaption');
showLightbox(image.getAttribute('src'),
image.getAttribute('alt') || (caption && caption.textContent) || '');
});
/**
* Seed history and honour a deep link. Called once from main.js, after the
* map exists, so that the entry a shared #haldrith URL names is opened with
* its map focus rather than silently ignored.
*/
function start() {
var id = decodeURIComponent(window.location.hash.replace(/^#/, ''));
if (id && wikiData[id]) {
trail[0] = id;
history.replaceState({ entry: id, depth: 0, view: currentView() }, '', '#' + id);
open(id, { push: false });
} else {
history.replaceState({ entry: null, depth: -1, view: currentView() }, '',
window.location.pathname + window.location.search);
}
map.on('moveend zoomend', debounce(syncView, 250));
}
function debounce(fn, ms) {
var timer = null;
return function () {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(fn, ms);
};
}
window.WikiRuntime = {
start: start,
typeLabel: typeLabel,
open: open,
close: close,
linkify: linkify,
reportBuildError: reportBuildError
};
// Every other module is a plain <script> in the same document, so by
// DOMContentLoaded the map, the narrator and the region overlays all exist.
document.addEventListener('DOMContentLoaded', start);
})();