PageSourceSearch

https://www.lukaswojcik.com/blog/wp-content/themes/lw-theme/js/lw-nav.js?v=1790285329

js lukaswojcik.com collected 2026-10-08 19:26:38 UTC 36,841 bytes, 812 lines download raw bytes

1/*
2 * Seitenwechsel ohne Neuaufbau - Blog und statische Seiten, nach dem
3 * Vorbild von Stats4U (stats4u3/js/nav.js, 23.09.2026).
4 *
5 * Ein Klick auf einen Verweis holt dieselbe Adresse, die der Browser sonst
6 * geladen haette, und tauscht einen Teil der Seite. Zwei Arten:
7 *
8 *  - INHALT (Blog zu Blog): nur der Teil zwischen <!--lw:inhalt--> (Ende
9 *    header.php) und <!--/lw:inhalt--> (Anfang footer.php). Kopf, Menue und
10 *    Fuss bleiben stehen; ihre Sprachverweise werden abgeglichen.
11 *  - RAHMEN (sobald eine der statischen Seiten /{en,de,pl}/ oder
12 *    /{en,de,pl}/projects/ beteiligt ist): der ganze .site-container samt
13 *    dem CSS der Zielseite. Die statischen Seiten tragen ihr CSS im <head>,
14 *    der Blog in style.css und in der Kopfzeile selbst - getauscht wird
15 *    deshalb genau das, was die Zielseite beim gewoehnlichen Laden haette.
16 *    Neue Stilblaetter laden vorher mit media="not all" und gelten erst im
17 *    Moment des Tauschs; nichts blitzt ungestaltet auf. Die Skripte der
18 *    Zielseite ausserhalb des Rahmens laufen neu (Burger und Schalter der
19 *    statischen Seiten, Fussskripte von WordPress) - bis auf den Block aus
20 *    inc/footer-tracker.php (Einwilligung, GTM, MousePlayer, Chat): der
21 *    steht auf jeder Seite gleich und laeuft einfach weiter.
22 *
23 * In beiden Faellen ziehen die Angaben im <head> mit (Titel, Beschreibung,
24 * canonical, hreflang, og:, JSON-LD), ebenso <html lang> und die Klassen am
25 * <body>.
26 *
27 * FUER SUCHMASCHINEN AENDERT SICH NICHTS. Der Server liefert jede Adresse
28 * weiter als ganze Seite; es gibt keinen Teil-Endpunkt, der in den
29 * Seitencache geraten koennte. Ohne Klick tut das Skript nichts, und bei
30 * Strg-/Mittelklick, ohne JavaScript oder wenn ein Abruf schiefgeht, sind
31 * die Verweise gewoehnliche Seiten.
32 *
33 * WELCHE SEITEN. Blogseiten mit Marken (lw_weich() in der functions.php)
34 * und die sechs statischen Seiten. Werkzeuge haben keine Marken und laden
35 * immer ganz - ihre Skripte rechnen mit einem frischen Dokument; WERKZEUG
36 * unten erspart dafuer den Umweg ueber den Abruf. Ein Sprachwechsel laedt
37 * ebenfalls ganz. Auch dann laeuft der Balken: beim Klick in der alten
38 * Seite und auf der neuen zu Ende. Auf den Werkzeugseiten laedt WordPress
39 * dieses Skript ebenfalls, dort tut es nichts als das. Angemeldet (WP-
40 * Leiste im Blog, nicht auf den statischen Seiten) wechselt der Rahmen die
41 * Leiste mit.
42 *
43 * MESSUNG. GA4 (Enhanced Measurement, Verlaufsereignisse an), MousePlayer
44 * (umwickelt pushState) und reactive.chat (vergleicht die Adresse im Takt)
45 * erkennen den Wechsel selbst. Deshalb steht der neue Titel VOR pushState -
46 * GA4 liest ihn in dem Moment.
47 *
48 * SKRIPTE. Was neu eingesetzt wird, laeuft neu. Zuhoerer, die es dabei an
49 * window, document oder eine Medienabfrage haengt, und Beobachter, die es
50 * startet, loest dieses Skript beim naechsten Tausch desselben Teils selbst
51 * (mitSignal) - die sieben Kopien von Burger und Hell/Dunkel-Schalter
52 * muessen davon nichts wissen. Wer es ausdruecklich will: window.lwSeiteSignal
53 * (Inhalt) und window.lwRahmenSignal (Rahmen). Ein Skript, das auf
54 * DOMContentLoaded wartet, wartet nach einem Tausch ewig - es fragt vorher
55 * document.readyState. Skripte, die einmal geladen bleiben (Lupe, Suche,
56 * Sprachwahl, Chat-Schutz), hoeren auf lw:seite (jeder Tausch) und
57 * lw:rahmen (neuer Rahmen). Externe Skripte laedt ein Dokument nur einmal,
58 * eigene nach Pfad - lw-suche.js steht mit ?v= in den statischen Seiten und
59 * mit ?ver= im Blog.
60 */
61(function () {
62	'use strict';
63
64	var BLOG = /^\/blog\/(en|de|pl)\//;
65	var STATISCH = /^\/(en|de|pl)\/(?:projects\/)?$/;
66	var WERKZEUG = /^\/blog\/(?:en\/toolbox|de\/werkzeuge|pl\/narzedzia)\/[^\/]+/;
67	var KEINE_SEITE = /\/(?:feed|embed|trackback|wp-json)(?:\/|$)|\.[a-z0-9]{2,5}$/i;
68	// Erstes Element des Blocks aus inc/footer-tracker.php. Was danach kommt,
69	// steht auf jeder Seite gleich und wird nie neu eingesetzt: die
70	// Einwilligung koennte die gesperrten Skripte sonst ein zweites Mal
71	// freischalten.
72	var TRACKER = 'script[src*="consented.eu/sdk/dist/stub.js"]';
73
74	// --- Ladebalken oben am Fenster -----------------------------------------
75	// Sein Aussehen steht hier und nicht in style.css: die statischen Seiten
76	// laden style.css nicht. Farben aus den Marken, die alle sieben Kopien
77	// gleich fuehren.
78	if (!document.getElementById('lw-nav-stil')) {
79		var stil = document.createElement('style');
80		stil.id = 'lw-nav-stil';
81		stil.textContent = '.lw-balken{position:fixed;top:0;left:0;right:0;z-index:100000;height:3px;'
82			+ 'overflow:hidden;pointer-events:none;opacity:0}'
83			+ '.lw-balken i{display:block;height:100%;width:calc(var(--p,0)*100%);border-radius:0 3px 3px 0;'
84			+ 'background:linear-gradient(90deg,var(--accent,#2ba6df),var(--accent-soft,#6fc0e6));'
85			+ 'box-shadow:0 0 10px var(--accent,#2ba6df);transition:width .28s ease-out}'
86			+ '.lw-balken.an{opacity:1;transition:opacity .1s}'
87			+ '.lw-balken.an.fertig{opacity:0;transition:opacity .3s .2s}'
88			+ '.lw-balken.neu,.lw-balken.neu i{transition:none}'
89			+ 'main{transition:opacity .12s}html.lw-laedt main{opacity:.6}main:focus{outline:none}'
90			+ '@media (prefers-reduced-motion:reduce){.lw-balken i,.lw-balken.an,.lw-balken.an.fertig,main{transition:none}}';
91		document.head.appendChild(stil);
92	}
93
94	// Eine Seite aus dem Seitencache ist oft schon da, bevor der Balken
95	// gezeichnet wurde (Abruf beim Druecken, gemessen 48 ms zwischen an und
96	// fertig) - er blitzte dann nur halb auf. Deshalb steht er mindestens
97	// MINDEST ms, waehrend der Inhalt laengst getauscht ist.
98	var MINDEST = 350;
99	var bar = null, bp = 0, btick = null, baus = null, bseit = 0;
100	function setzen(p) { bp = p; if (bar) { bar.style.setProperty('--p', String(p)); } }
101	function balkenStart() {
102		bseit = Date.now();
103		if (!bar) {
104			bar = document.createElement('div');
105			bar.className = 'lw-balken';
106			bar.setAttribute('aria-hidden', 'true');
107			bar.appendChild(document.createElement('i'));
108			document.body.appendChild(bar);
109		}
110		clearInterval(btick);
111		clearTimeout(baus);
112		bar.classList.remove('an', 'fertig');
113		bar.classList.add('neu');
114		setzen(0);
115		void bar.offsetWidth;
116		bar.classList.remove('neu');
117		bar.classList.add('an');
118		setzen(0.15);
119		btick = setInterval(function () { setzen(bp + (0.92 - bp) * 0.14); }, 250);
120	}
121	function balkenEnde() {
122		if (!bar || !bar.classList.contains('an') || bar.classList.contains('fertig')) { return; }
123		var rest = MINDEST - (Date.now() - bseit);
124		clearTimeout(baus);
125		if (rest > 0) { baus = setTimeout(balkenEnde, rest); return; }
126		clearInterval(btick);
127		setzen(1);
128		bar.classList.add('fertig');
129		baus = setTimeout(function () { bar.classList.remove('an', 'fertig'); setzen(0); }, 700);
130	}
131
132	// --- Ganze Seite, trotzdem mit Balken -------------------------------------
133	// Nicht jeder Verweis darf tauschen: Werkzeuge, ein Sprachwechsel und
134	// alles, was von einer Werkzeugseite weggeht, laden ganz. Ohne Zutun sah
135	// das aus wie "geht nicht" - kein Balken, obwohl die Seite kam. Deshalb
136	// laeuft der Balken auch hier: beim Klick in der alten Seite (der Browser
137	// zeigt sie, bis die neue gezeichnet ist) und auf der neuen zu Ende
138	// (sessionStorage, zehn Sekunden gueltig). Dieser Teil laeuft auf jeder
139	// Seite, die lw-nav.js laedt - auch auf den Werkzeugen, die sonst nichts
140	// davon merken: kein Tausch, kein Abfangen, nur der Balken.
141	var notaus = null;
142	function nurBalken() {
143		balkenStart();
144		clearTimeout(notaus);
145		// Bleibt die Seite doch stehen (Abbruch, Download): nach zehn
146		// Sekunden weg.
147		notaus = setTimeout(balkenEnde, 10000);
148		try { sessionStorage.setItem('lw-balken', String(Date.now())); } catch (e) { }
149	}
150	function seitenVerweis(e) {
151		if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return false; }
152		var a = e.target.closest ? e.target.closest('a[href]') : null;
153		if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) { return false; }
154		var u;
155		try { u = new URL(a.getAttribute('href'), location.href); } catch (x) { return false; }
156		if (u.origin !== location.origin || !/^https?:$/.test(u.protocol) || KEINE_SEITE.test(u.pathname)) { return false; }
157		// Nur ein Sprungziel auf derselben Seite: kein Laden.
158		if (u.pathname === location.pathname && u.search === location.search && u.hash) { return false; }
159		return true;
160	}
161	// Am window, also nach allen Zuhoerern am document: hat der Seitenwechsel
162	// den Klick schon uebernommen (preventDefault), passiert hier nichts.
163	window.addEventListener('click', function (e) { if (seitenVerweis(e)) { nurBalken(); } });
164	// Zurueck aus dem Speicher des Browsers: die Seite steht, wie sie beim
165	// Verlassen war - mit laufendem Balken. Also wegraeumen.
166	window.addEventListener('pageshow', function (e) {
167		if (!e.persisted) { return; }
168		clearTimeout(notaus);
169		clearInterval(btick);
170		clearTimeout(baus);
171		if (bar) { bar.classList.remove('an', 'fertig'); setzen(0); }
172		document.documentElement.classList.remove('lw-laedt');
173	});
174	(function () {
175		var seit = 0;
176		try { seit = +sessionStorage.getItem('lw-balken') || 0; sessionStorage.removeItem('lw-balken'); } catch (e) { }
177		if (seit && Date.now() - seit < 10000) {
178			balkenStart();
179			setzen(0.7);
180			balkenEnde();
181		}
182	})();
183
184	if (!window.fetch || !window.DOMParser || !window.AbortController || !window.URL
185	    || !window.Promise || !history.pushState || !document.createTreeWalker) { return; }
186
187	function sprache(u) { var m = BLOG.exec(u.pathname) || STATISCH.exec(u.pathname); return m ? m[1] : null; }
188	var SPRACHE = sprache(location);
189
190	// Eine Adresse, die dieses Skript tauschen darf: gleicher Ursprung, Blog
191	// oder statische Seite in derselben Sprache, keine Abfrage, kein
192	// Werkzeug, keine Datei.
193	function tauschbar(u) {
194		return u.origin === location.origin && u.search === '' && SPRACHE !== null
195		    && sprache(u) === SPRACHE && !WERKZEUG.test(u.pathname) && !KEINE_SEITE.test(u.pathname);
196	}
197
198	// Die Knoten zwischen den beiden Marken - in einem Dokument, das die
199	// Marken nicht (beide, als Geschwister) hat, null.
200	function inhalt(doc) {
201		var anf = null, ende = null;
202		var w = doc.createTreeWalker(doc.body, 128 /* NodeFilter.SHOW_COMMENT */);
203		var c;
204		while ((c = w.nextNode())) {
205			var t = c.data.trim();
206			if (t === 'lw:inhalt' && !anf) { anf = c; }
207			else if (t === '/lw:inhalt' && anf) { ende = c; break; }
208		}
209		if (!anf || !ende || anf.parentNode !== ende.parentNode) { return null; }
210		var liste = [];
211		for (var n = anf.nextSibling; n && n !== ende; n = n.nextSibling) { liste.push(n); }
212		return { anf: anf, ende: ende, knoten: liste };
213	}
214	function imTeil(teil, e) {
215		return teil.knoten.some(function (k) { return k === e || (k.nodeType === 1 && k.contains(e)); });
216	}
217
218	// Der Rahmen: Kopf, Banner, Inhalt, Fuss. Auf allen drei Seitenarten
219	// derselbe aeussere Behaelter.
220	function rahmen(doc) { return doc.querySelector('body > .site-container'); }
221	// Eine Seite, deren Skripte einen Tausch vertragen: Blog mit Marken oder
222	// eine der statischen Seiten.
223	function geeignet(doc, pfad) { return !!inhalt(doc) || STATISCH.test(pfad); }
224
225	// Ab hier der Seitenwechsel selbst - nicht auf Werkzeugseiten und nicht
226	// ohne Marken: dort bleibt es beim Balken oben.
227	if (!tauschbar(new URL(location.href)) || !geeignet(document, location.pathname) || !rahmen(document)) { return; }
228
229	var ctl = new AbortController(), rctl = new AbortController();
230	window.lwSeiteSignal = ctl.signal;
231	window.lwRahmenSignal = rctl.signal;
232
233	// Zurueck und Vor innerhalb der Sitzung rollt dieses Skript selbst -
234	// sonst springt der Browser, bevor der neue Inhalt steht. Beim Neuladen
235	// und beim Verlassen gilt wieder das Verhalten des Browsers: pagehide
236	// stellt den Eintrag, auf dem die Seite gerade steht, auf "auto" zurueck.
237	// Kommt ein Eintrag, den dieses Skript angelegt hat, spaeter als ganze
238	// Seite zurueck (Zurueck aus einem Werkzeug), traegt history.state die
239	// Hoehe.
240	function selbstRollen(ja) {
241		if ('scrollRestoration' in history) { history.scrollRestoration = ja ? 'manual' : 'auto'; }
242	}
243	selbstRollen(true);
244	window.addEventListener('pagehide', function () { selbstRollen(false); });
245	window.addEventListener('pageshow', function (e) { if (e.persisted) { selbstRollen(true); } });
246	try {
247		var nav = performance.getEntriesByType('navigation')[0];
248		if (nav && nav.type === 'back_forward' && history.state && typeof history.state.y === 'number'
249		    && history.state.y > 0 && window.scrollY === 0) {
250			window.scrollTo(0, history.state.y);
251		}
252	} catch (e) { }
253
254	function ohneHash(u) { return u.pathname + u.search; }
255	var gezeigt = ohneHash(new URL(location.href));
256	var lauf = 0;
257
258	// --- Abruf ---------------------------------------------------------------
259	// Ergebnis: { t: Text, u: URL nach Weiterleitungen } - oder ein Fehler,
260	// wenn die Antwort kein HTML ist. Auch 404 ist eine Seite.
261	function abrufen(adr) {
262		return fetch(adr, { credentials: 'same-origin' }).then(function (r) {
263			if (!/text\/html/i.test(r.headers.get('content-type') || '')) { throw new Error('kein html'); }
264			var ziel = r.url ? new URL(r.url) : new URL(adr);
265			return r.text().then(function (t) { return { t: t, u: ziel }; });
266		});
267	}
268
269	// Beim Druecken auf einen Verweis beginnt der Abruf schon, der Klick
270	// kommt 80-150 ms spaeter. Das ersetzt fuer diese Verweise das Vorladen
271	// von WordPress (speculationrules, "conservative" - ebenfalls beim
272	// Druecken); dessen Antwort koennte fetch() nicht verwenden, und jeder
273	// Klick kostete den Pi zwei Abrufe. Deshalb bekommen tauschbare Verweise
274	// die Klasse no-prefetch, die die Regel von WordPress ausnimmt.
275	var vorab = null;
276	function vorabNehmen(u) {
277		var v = vorab;
278		vorab = null;
279		if (v && v.adr === ohneHash(u) && Date.now() - v.zeit < 10000) { return v.p; }
280		return null;
281	}
282	function sperren(a) {
283		try {
284			if (tauschbar(new URL(a.getAttribute('href'), location.href))) { a.classList.add('no-prefetch'); }
285		} catch (x) { }
286	}
287	function vorladenSperren(wurzel) {
288		if (wurzel.nodeType !== 1 && wurzel.nodeType !== 9) { return; }
289		if (wurzel.nodeType === 1 && wurzel.tagName === 'A' && wurzel.hasAttribute('href')) { sperren(wurzel); }
290		Array.prototype.forEach.call(wurzel.querySelectorAll('a[href]'), sperren);
291	}
292	vorladenSperren(document);
293	// Verweise, die erst spaeter entstehen - die Treffer der Suche baut
294	// lw-suche.js bei jedem Tastendruck neu -, bekommen die Klasse ebenso.
295	if (window.MutationObserver) {
296		new MutationObserver(function (aenderungen) {
297			aenderungen.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes, vorladenSperren); });
298		}).observe(document.body, { childList: true, subtree: true });
299	}
300
301	// Zurueck und Vor zeigen Seiten, die schon einmal geholt wurden: aus
302	// dem Speicher, ohne Abruf. Die Beitraege aendern sich innerhalb eines
303	// Besuchs nicht; zehn Seiten sind auch bei der Werkzeuguebersicht
304	// (450 KB) wenige Megabyte.
305	var ablage = [];
306	function ablegen(adr, erg) {
307		ablage = ablage.filter(function (e) { return e.adr !== adr; });
308		ablage.push({ adr: adr, erg: erg });
309		if (ablage.length > 10) { ablage.shift(); }
310	}
311	function ausAblage(adr) {
312		for (var i = 0; i < ablage.length; i++) { if (ablage[i].adr === adr) { return ablage[i].erg; } }
313		return null;
314	}
315
316	// --- Skripte -------------------------------------------------------------
317	function abs(src) { try { return new URL(src, location.href).href; } catch (x) { return src; } }
318	// Eigene Skripte nach Pfad, fremde nach voller Adresse (reCAPTCHA traegt
319	// den Schluessel in der Abfrage).
320	function skriptSchluessel(src) {
321		try {
322			var u = new URL(src, location.href);
323			return u.origin === location.origin ? u.pathname : u.href;
324		} catch (x) { return src; }
325	}
326	var geladen = {};
327	Array.prototype.forEach.call(document.querySelectorAll('script[src]'), function (s) {
328		geladen[skriptSchluessel(s.getAttribute('src'))] = true;
329	});
330
331	function istJs(s) {
332		var t = (s.getAttribute('type') || '').trim().toLowerCase();
333		return t === '' || t === 'module' || /^(?:text|application)\/(?:x-)?(?:java|ecma)script$/.test(t);
334	}
335
336	// Ein neues <script> mit denselben Angaben; das eingesetzte laeuft nicht
337	// von selbst. Externe laufen in ihrer Reihenfolge (async = false), jedes
338	// nur einmal je Dokument; das Versprechen haelt der Ladebalken.
339	function skriptNeu(alt, warten) {
340		var neu = document.createElement('script');
341		for (var i = 0; i < alt.attributes.length; i++) {
342			neu.setAttribute(alt.attributes[i].name, alt.attributes[i].value);
343		}
344		if (alt.hasAttribute('src')) {
345			var schl = skriptSchluessel(alt.getAttribute('src'));
346			if (geladen[schl]) { return null; }
347			geladen[schl] = true;
348			neu.async = false;
349			warten.push(new Promise(function (fertig) {
350				neu.addEventListener('load', fertig);
351				neu.addEventListener('error', fertig);
352			}));
353		} else {
354			neu.textContent = alt.textContent;
355		}
356		return neu;
357	}
358
359	// Waehrend fn laeuft, bekommt jeder Zuhoerer an window, document oder
360	// einer Medienabfrage das Signal, und jeder Beobachter trennt sich, wenn
361	// es abbricht. Eingesetzte Inline-Skripte laufen beim Einsetzen, also
362	// genau in diesem Fenster. So haengen Burger, Schalter und Co. nach dem
363	// naechsten Tausch nicht mehr doppelt - ohne dass eine ihrer Kopien
364	// geaendert werden musste.
365	function mitSignal(signal, fn) {
366		var ET = window.EventTarget && window.EventTarget.prototype;
367		var orig = ET && ET.addEventListener;
368		var beobachter = [];
369		if (orig) {
370			ET.addEventListener = function (typ, h, opt) {
371				if (this === window || this === document || (window.MediaQueryList && this instanceof window.MediaQueryList)) {
372					if (opt === undefined || opt === null || typeof opt === 'boolean') {
373						opt = { capture: !!opt, signal: signal };
374					} else if (!opt.signal) {
375						var o = {};
376						for (var k in opt) { if (Object.prototype.hasOwnProperty.call(opt, k)) { o[k] = opt[k]; } }
377						o.signal = signal;
378						opt = o;
379					}
380				}
381				return orig.call(this, typ, h, opt);
382			};
383		}
384		['MutationObserver', 'IntersectionObserver', 'ResizeObserver'].forEach(function (n) {
385			var P = window[n] && window[n].prototype;
386			if (!P || !P.observe) { return; }
387			var o = P.observe;
388			beobachter.push([P, o]);
389			P.observe = function () {
390				var self = this;
391				if (!self.lwGetrennt) {
392					self.lwGetrennt = true;
393					signal.addEventListener('abort', function () { self.disconnect(); });
394				}
395				return o.apply(this, arguments);
396			};
397		});
398		try { fn(); } finally {
399			if (orig) { ET.addEventListener = orig; }
400			beobachter.forEach(function (b) { b[0].observe = b[1]; });
401		}
402	}
403
404	// signalFuer(altesSkript) -> Signal, an dem seine Zuhoerer haengen.
405	function skripteStarten(knoten, warten, signalFuer) {
406		knoten.forEach(function (k) {
407			if (k.nodeType !== 1) { return; }
408			var alle = k.tagName === 'SCRIPT' ? [k] : Array.prototype.slice.call(k.querySelectorAll('script'));
409			alle.forEach(function (alt) {
410				if (!istJs(alt)) { return; }
411				var neu = skriptNeu(alt, warten);
412				if (!neu) { return; }
413				mitSignal(signalFuer(alt), function () { alt.parentNode.replaceChild(neu, alt); });
414			});
415		});
416	}
417
418	// Skripte ausserhalb des Rahmens bis zum Tracker-Block: Burger und
419	// Schalter der statischen Seiten, Suchtexte, Fussskripte von WordPress,
420	// die Einblend-Beobachter der Projektseite. Sie gehoeren zur Seite und
421	// werden beim Rahmentausch ersetzt.
422	function seitenSkripteVon(doc) {
423		var r = [], rah = rahmen(doc);
424		var alle = doc.body ? doc.body.querySelectorAll('script') : [];
425		for (var i = 0; i < alle.length; i++) {
426			if (alle[i].matches(TRACKER)) { break; }
427			if (rah && rah.contains(alle[i])) { continue; }
428			r.push(alle[i]);
429		}
430		return r;
431	}
432	var seitenSkripte = seitenSkripteVon(document);
433
434	// Skripte, die WordPress nur auf bestimmten Seiten einreiht (die Lupe
435	// nur bei Diagrammen, reCAPTCHA nur bei Beitraegen, Code Block Pro nur
436	// bei dessen Bloecken), stehen im Fuss - ausserhalb des Inhalts. Kommt
437	// die neue Seite mit einem, das hier fehlt: nachladen, samt der Zeilen
438	// davor (-js-extra, -js-before) und danach (-js-after).
439	function fussSkripte(doc, warten) {
440		Array.prototype.forEach.call(doc.querySelectorAll('script[id$="-js"][src]'), function (s) {
441			if (geladen[skriptSchluessel(s.getAttribute('src'))] || document.getElementById(s.id)) { return; }
442			var name = s.id.slice(0, -3);
443			[name + '-js-extra', name + '-js-before', s.id, name + '-js-after'].forEach(function (id) {
444				var alt = doc.getElementById(id);
445				if (!alt || document.getElementById(id) || !istJs(alt)) { return; }
446				var neu = skriptNeu(alt, warten);
447				if (!neu) { return; }
448				mitSignal(ctl.signal, function () { document.body.appendChild(neu); });
449				seitenSkripte.push(neu);
450			});
451		});
452	}
453
454	// Beim Rahmentausch: die Seitenskripte der alten Seite aus dem Dokument
455	// (gelaufen sind sie; ihre Zuhoerer loest das Rahmensignal), die der
456	// neuen vor den Tracker-Block. Externe, die schon geladen sind, bleiben
457	// weg; speculationrules kommen mit, damit WordPress die Werkzeuge
458	// weiter vorlaedt.
459	function rahmenSkripte(doc, warten) {
460		seitenSkripte.forEach(function (s) { if (s.parentNode) { s.parentNode.removeChild(s); } });
461		seitenSkripte = [];
462		var vor = document.querySelector(TRACKER);
463		seitenSkripteVon(doc).forEach(function (alt) {
464			var typ = (alt.getAttribute('type') || '').trim().toLowerCase();
465			if (!istJs(alt) && typ !== 'speculationrules') { return; }
466			var neu = skriptNeu(alt, warten);
467			if (!neu) { return; }
468			mitSignal(rctl.signal, function () {
469				if (vor && vor.parentNode) { vor.parentNode.insertBefore(neu, vor); } else { document.body.appendChild(neu); }
470			});
471			seitenSkripte.push(neu);
472		});
473	}
474
475	// --- Kopf, Stile, Menue ----------------------------------------------------
476	// Was im <head> je Seite verschieden ist. Erst die alten weg, dann die
477	// neuen hinein - so stehen canonical und hreflang nie doppelt.
478	var KOPF = 'title, meta[name="description"], meta[name="robots"], meta[name="author"],'
479	         + ' meta[property^="og:"], meta[property^="article:"], meta[name^="twitter:"],'
480	         + ' link[rel="canonical"], link[rel="alternate"], link[rel="next"], link[rel="prev"],'
481	         + ' link[rel="shortlink"], script[type="application/ld+json"]';
482	function kopfTauschen(doc) {
483		Array.prototype.forEach.call(document.head.querySelectorAll(KOPF), function (e) { e.parentNode.removeChild(e); });
484		Array.prototype.forEach.call(doc.head.querySelectorAll(KOPF), function (e) {
485			document.head.appendChild(document.importNode(e, true));
486		});
487		var lang = doc.documentElement.getAttribute('lang');
488		if (lang) { document.documentElement.setAttribute('lang', lang); }
489	}
490	function grundsprache(doc) { return (doc.documentElement.getAttribute('lang') || '').slice(0, 2).toLowerCase(); }
491
492	// Die Stilblaetter einer Seite, wie der Server sie liefert: im <head> und
493	// direkt im <body> (footer.php des Blogs). Eigene tragen keine id oder
494	// die von WordPress (...-css); was ein fremdes Skript mit eigener id
495	// einhaengt, bleibt unberuehrt, ebenso der Balken hier.
496	function istSeitenstil(e) { return e.id !== 'lw-nav-stil' && (!e.id || /-css$/.test(e.id)); }
497	function stileDerSeite(doc) {
498		return Array.prototype.filter.call(doc.querySelectorAll('head > style, head > link[rel~="stylesheet"],'
499			+ ' body > style, body > link[rel~="stylesheet"]'), istSeitenstil);
500	}
501	function stilSchluessel(e) {
502		return e.tagName === 'LINK' ? 'L ' + abs(e.getAttribute('href'))
503		                            : 'S ' + (e.getAttribute('media') || '') + ' ' + e.textContent;
504	}
505	var seitenStile = stileDerSeite(document);
506
507	// Rahmentausch, Schritt 1: die neuen Stilblaetter laden, aber noch nicht
508	// gelten lassen (media="not all"). Fertig, wenn alle geladen sind -
509	// laengstens nach fuenf Sekunden.
510	function stileVorbereiten(doc) {
511		var liste = stileDerSeite(doc);
512		var da = seitenStile.map(stilSchluessel);
513		var neu = [], warten = [];
514		liste.forEach(function (e) {
515			if (da.indexOf(stilSchluessel(e)) !== -1) { return; }
516			var k = document.importNode(e, true);
517			var media = k.getAttribute('media');
518			k.setAttribute('media', 'not all');
519			if (k.tagName === 'LINK') {
520				warten.push(new Promise(function (fertig) {
521					k.addEventListener('load', fertig);
522					k.addEventListener('error', fertig);
523					setTimeout(fertig, 5000);
524				}));
525			}
526			document.head.appendChild(k);
527			neu.push({ el: k, media: media });
528		});
529		return Promise.all(warten).then(function () { return { liste: liste, neu: neu }; });
530	}
531	// Schritt 2, im selben Zug wie der Tausch: alte weg, neue scharf.
532	function stileUmschalten(v) {
533		var soll = v.liste.map(stilSchluessel);
534		seitenStile = seitenStile.filter(function (e) {
535			if (e.parentNode && soll.indexOf(stilSchluessel(e)) !== -1) { return true; }
536			if (e.parentNode) { e.parentNode.removeChild(e); }
537			return false;
538		});
539		v.neu.forEach(function (n) {
540			if (n.media === null) { n.el.removeAttribute('media'); } else { n.el.setAttribute('media', n.media); }
541			seitenStile.push(n.el);
542		});
543	}
544	// Ein spaeterer Klick kam dazwischen: die vorbereiteten wieder weg.
545	function stileVerwerfen(v) {
546		v.neu.forEach(function (n) { if (n.el.parentNode) { n.el.parentNode.removeChild(n.el); } });
547	}
548
549	// Inhaltstausch: Blockstile laedt WordPress je Seite (Listen, Tabellen,
550	// Code). Fehlt einer, kommt er dazu; entfernt wird keiner - sie gelten
551	// nur fuer ihre eigenen Blockklassen. Vor dem Einsetzen, damit nichts
552	// ungestaltet aufblitzt.
553	function stileNachziehen(doc, neuKnoten) {
554		var hrefs = Array.prototype.map.call(document.querySelectorAll('link[rel="stylesheet"]'), function (l) { return l.href; });
555		Array.prototype.forEach.call(doc.querySelectorAll('style[id], link[rel="stylesheet"]'), function (e) {
556			// Was im getauschten Teil steht, kommt ohnehin mit.
557			if (neuKnoten.some(function (k) { return k === e || (k.nodeType === 1 && k.contains(e)); })) { return; }
558			var da = e.id ? document.getElementById(e.id)
559			              : hrefs.indexOf(abs(e.getAttribute('href'))) !== -1;
560			if (da) { return; }
561			var k = document.importNode(e, true);
562			document.head.appendChild(k);
563			if (istSeitenstil(k)) { seitenStile.push(k); }
564		});
565	}
566
567	// Die Klassen am <body> (single, postid-..., category-...) kommen vom
568	// Server. Klassen, die ein Skript gesetzt hat (lw-suche-offen), bleiben.
569	var serverKlassen = document.body.className.split(/\s+/).filter(
569Boolean);
570	function koerperKlassen(doc) {
571		var neu = doc.body.className.split(/\s+/).filter(Boolean);
572		serverKlassen.forEach(function (k) { if (neu.indexOf(k) === -1) { document.body.classList.remove(k); } });
573		neu.forEach(function (k) { document.body.classList.add(k); });
574		serverKlassen = neu;
575	}
576
577	// Menue und Fuss stehen beim Inhaltstausch ausserhalb, aendern sich aber
578	// je Seite: die Sprachverweise zeigen auf die Uebersetzung DIESER Seite.
579	// Paarweise nach Reihenfolge; weicht die Zahl ab, bleibt es stehen - die
580	// Sprachverweise gleichen sich darueber hinaus immer ueber hreflang ab.
581	var ATTR = ['href', 'class', 'aria-current', 'hreflang', 'lang'];
582	function angleichen(alt, neu) {
583		ATTR.forEach(function (attr) {
584			var w = neu.getAttribute(attr);
585			if (w === null) { alt.removeAttribute(attr); }
586			else if (alt.getAttribute(attr) !== w) { alt.setAttribute(attr, w); }
587		});
588	}
589	function menueAbgleichen(doc) {
590		['#site-nav', '.lw-sprachwahl', '.site-footer'].forEach(function (sel) {
591			var alt = document.querySelector(sel), neu = doc.querySelector(sel);
592			if (!alt || !neu) { return; }
593			var a = alt.querySelectorAll('a, li'), n = neu.querySelectorAll('a, li');
594			if (a.length === n.length) {
595				for (var i = 0; i < a.length; i++) { if (a[i].tagName === n[i].tagName) { angleichen(a[i], n[i]); } }
596			}
597			Array.prototype.forEach.call(neu.querySelectorAll('a[hreflang]'), function (n) {
598				var a = alt.querySelector('a[hreflang="' + n.getAttribute('hreflang') + '"]');
599				if (a) { a.setAttribute('href', n.getAttribute('href')); }
600			});
601		});
602	}
603
604	// Wer angemeldet ist, hat die Werkzeugleiste von WordPress (wp_footer,
605	// ausserhalb des Tauschs). Sie traegt Verweise auf DIESE Seite
606	// ("Beitrag bearbeiten", Anpassen, Yoast) und kommt deshalb ganz aus der
607	// neuen Seite. Ihre Untermenues oeffnet danach CSS: das Markup bringt die
608	// Klasse nojs mit, den Rueckfall von WordPress selbst (li:hover); admin-
609	// bar.js schliesst nur beim Laden an und wuerde die neue Leiste nicht mehr
610	// kennen.
611	//
612	// Beim Rahmentausch zwischen Blog und statischen Seiten kommt sie dazu
613	// oder faellt weg - die statischen Seiten zeigen sie nie. Ihre Stile
614	// (admin-bar-css, admin-bar-inline-css: der Abstand oben) wechseln mit
615	// den uebrigen, die Klasse admin-bar mit den Body-Klassen.
616	function leisteTauschen(doc) {
617		var alt = document.getElementById('wpadminbar'), neu = doc.getElementById('wpadminbar');
618		if (alt && neu) {
619			alt.parentNode.replaceChild(document.importNode(neu, true), alt);
620		} else if (alt) {
621			alt.parentNode.removeChild(alt);
622		} else if (neu) {
623			var kopie = document.importNode(neu, true), vor = document.querySelector(TRACKER);
624			if (vor && vor.parentNode) { vor.parentNode.insertBefore(kopie, vor); } else { document.body.appendChild(kopie); }
625		}
626	}
627
628	// --- Tausch --------------------------------------------------------------
629	// 'inhalt', 'rahmen' oder null (ganze Seite). Den Rahmen nur zwischen
630	// Seiten, deren Skripte das vertragen - ein Werkzeug hat einen Rahmen,
631	// aber keine Marken.
632	function tauschArt(doc, pfad) {
633		if (inhalt(doc) && inhalt(document)) { return 'inhalt'; }
634		if (rahmen(doc) && rahmen(document) && geeignet(doc, pfad)
635		    && geeignet(document, new URL(gezeigt, location.href).pathname)) { return 'rahmen'; }
636		return null;
637	}
638
639	// schieben: neuer Eintrag im Verlauf (Klick) oder nicht (Zurueck/Vor);
640	// y: Hoehe, auf die danach gerollt wird (Zurueck/Vor).
641	function tauschen(u, schieben, y) {
642		// Ein spaeterer Klick macht das Ergebnis dieses Laufs wertlos; der
643		// Abruf selbst laeuft zu Ende (er kann schon beim Druecken begonnen
644		// haben und gehoert dann nicht diesem Lauf allein).
645		var nr = ++lauf;
646		var vorY = window.scrollY;
647		var adr = u.href;
648		var sprung = u.hash;
649		var ausSpeicher = !schieben ? ausAblage(ohneHash(u)) : null;
650		var p = ausSpeicher ? Promise.resolve(ausSpeicher) : (vorabNehmen(u) || abrufen(adr));
651		if (!ausSpeicher) {
652			document.documentElement.classList.add('lw-laedt');
653			balkenStart();
654		}
655		function ganz() {
656			try { sessionStorage.setItem('lw-balken', String(Date.now())); } catch (e) { }
657			if (schieben) { location.href = adr; } else { location.replace(adr); }
658		}
659		p.then(function (erg) {
660			if (nr !== lauf) { return; }
661			// Die Adresse nach Weiterleitungen - fetch kennt kein Sprungziel,
662			// das kommt vom Verweis.
663			u = new URL(erg.u.href);
664			if (sprung && !u.hash) { u.hash = sprung; }
665			adr = u.href;
666			var doc = new DOMParser().parseFromString(erg.t, 'text/html');
667			var art = tauschArt(doc, u.pathname);
668			// Kam oder ging eine Anmeldung, aendern sich Leiste, ihr Abstand
669			// oben und die Stile - das kann nur der Rahmentausch.
670			if (art === 'inhalt' && !document.getElementById('wpadminbar') !== !doc.getElementById('wpadminbar')) {
671				art = 'rahmen';
672			}
673			// Kein passender Teil (Werkzeug, Fehlerseite des Servers), eine
674			// andere Sprache oder eine Weiterleitung ausserhalb: die ganze Seite.
675			if (!art || !tauschbar(u) || grundsprache(doc) !== grundsprache(document)) {
676				ganz();
677				return;
678			}
679			ablegen(ohneHash(u), erg);
680			return (art === 'rahmen' ? stileVorbereiten(doc) : Promise.resolve(null)).then(function (stile) {
681				if (nr !== lauf) { if (stile) { stileVerwerfen(stile); } return; }
682				return einsetzen(doc, art, stile, u, adr, schieben && ohneHash(u) !== gezeigt, vorY, y, nr);
683			});
684		}).catch(function () {
685			if (nr !== lauf) { return; }
686			// Faellt der Abruf aus, tut es der gewoehnliche Weg.
687			ganz();
688		});
689	}
690
691	function einsetzen(doc, art, stile, u, adr, schieben, vorY, y, nr) {
692		// Die alte Seite abmelden: Zuhoerer an window/document loesen - beim
693		// Rahmentausch auch die des Rahmens. Stats4U (s4u.js) schliesst dabei
694		// die Verweildauer der alten Seite ab, solange location noch sie
695		// zeigt - wie auf stats4u.net (js/nav.js). Die neue Adresse zaehlt
696		// danach inc/footer-tracker.php (Abschnitt 6).
697		try { document.dispatchEvent(new Event('s4u:seiteweg')); } catch (e) { }
698		window.s4uSeiteWeich = true;
699		ctl.abort();
700		ctl = new AbortController();
701		window.lwSeiteSignal = ctl.signal;
702		if (art === 'rahmen') {
703			rctl.abort();
704			rctl = new AbortController();
705			window.lwRahmenSignal = rctl.signal;
706		}
707
708		var warten = [], eingesetzt = [], neuerRahmen = null;
709		if (art === 'rahmen') {
710			stileUmschalten(stile);
711			neuerRahmen = document.importNode(rahmen(doc), true);
712			var alterRahmen = rahmen(document);
713			alterRahmen.parentNode.replaceChild(neuerRahmen, alterRahmen);
714		} else {
715			var neu = inhalt(doc), alt = inhalt(document);
716			stileNachziehen(doc, neu.knoten);
717			alt.knoten.forEach(function (k) { k.parentNode.removeChild(k); });
718			neu.knoten.forEach(function (k) {
719				var kopie = document.importNode(k, true);
720				alt.ende.parentNode.insertBefore(kopie, alt.ende);
721				eingesetzt.push(kopie);
722			});
723		}
724		kopfTauschen(doc);
725		koerperKlassen(doc);
726		if (art === 'inhalt') { menueAbgleichen(doc); }
727		leisteTauschen(doc);
728		if (schieben) {
729			var st = history.state && typeof history.state === 'object' ? history.state : {};
730			var mit = {};
731			for (var k in st) { if (Object.prototype.hasOwnProperty.call(st, k)) { mit[k] = st[k]; } }
732			mit.y = vorY;
733			history.replaceState(mit, '');
734			history.pushState({ y: 0 }, '', adr);
735		}
736		gezeigt = ohneHash(u);
737
738		if (art === 'rahmen') {
739			// Skripte im Inhalt des neuen Rahmens haengen am Seitensignal,
740			// die uebrigen (Burger, Schalter, Banner) am Rahmensignal.
741			var teil = inhalt(document);
742			skripteStarten([neuerRahmen], warten, function (s) { return teil && imTeil(teil, s) ? ctl.signal : rctl.signal; });
743			rahmenSkripte(doc, warten);
744		} else {
745			skripteStarten(eingesetzt, warten, function () { return ctl.signal; });
746			fussSkripte(doc, warten);
747		}
748		// Auch Menue und Fuss: der Abgleich oben hat ihre Klassen neu gesetzt.
749		vorladenSperren(document);
750		try { document.dispatchEvent(new Event('lw:seite')); } catch (e) { }
751		if (art === 'rahmen') {
752			mitSignal(rctl.signal, function () {
753				try { document.dispatchEvent(new Event('lw:rahmen')); } catch (e) { }
754			});
755		}
756		if (typeof window.codeBlockProInit === 'function') {
757			try { window.codeBlockProInit(); } catch (e) { }
758		}
759
760		// Hinrollen: zum Sprungziel, sonst nach oben - bei Zurueck auf die
761		// gemerkte Hoehe. Dann den Fokus auf den Inhalt, damit Tastatur und
762		// Vorleser dort weitermachen, wo der Leser hinsieht.
763		var ziel = u.hash && u.hash.length > 1 ? document.getElementById(decodeURIComponent(u.hash.slice(1))) : null;
764		if (ziel) { ziel.scrollIntoView(); }
765		else { window.scrollTo(0, typeof y === 'number' ? y : 0); }
766		var main = document.querySelector('main');
767		if (main) {
768			if (!main.hasAttribute('tabindex')) { main.setAttribute('tabindex', '-1'); }
769			try { main.focus({ preventScroll: true }); } catch (e) { }
770		}
771		return Promise.all(warten).then(function () {
772			if (nr !== lauf) { return; }
773			document.documentElement.classList.remove('lw-laedt');
774			balkenEnde();
775		});
776	}
777
778	function verweis(e) {
779		if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return null; }
780		var a = e.target.closest ? e.target.closest('a[href]') : null;
781		if (!a || a.target || a.hasAttribute('download') || a.hasAttribute('hreflang')
782		    || a.hasAttribute('data-voll') || /(?:^|\s)external(?:\s|$)/.test(a.getAttribute('rel') || '')) { return null; }
783		var u;
784		try { u = new URL(a.getAttribute('href'), location.href); } catch (x) { return null; }
785		if (!tauschbar(u)) { return null; }
786		// Nur ein Sprungziel auf derselben Seite: das kann der Browser selbst.
787		if (ohneHash(u) === ohneHash(new URL(location.href))) { return null; }
788		return u;
789	}
790
791	document.addEventListener('pointerdown', function (e) {
792		var u = verweis(e);
793		if (!u || (vorab && vorab.adr === ohneHash(u))) { return; }
794		var p = abrufen(u.href);
795		p.catch(function () { });
796		vorab = { adr: ohneHash(u), zeit: Date.now(), p: p };
797	}, true);
798
799	document.addEventListener('click', function (e) {
800		var u = verweis(e);
801		if (!u) { return; }
802		e.preventDefault();
803		tauschen(u, true);
804	});
805
806	window.addEventListener('popstate', function (e) {
807		var u = new URL(location.href);
808		if (ohneHash(u) === gezeigt) { return; }   // nur das Sprungziel
809		if (!tauschbar(u)) { location.reload(); return; }
810		tauschen(u, false, e.state && typeof e.state.y === 'number' ? e.state.y : 0);
811	});
812})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.