MediaWiki:Common.js: Difference between revisions

Current answers. Practical procedures. Reliable reference.
Jump to navigation Jump to search
Clarify cookie banner wording
Integrate GA4 with optional cookie consent
Line 287: Line 287:
}
}
}() );
}() );
/* EDFM cookie preferences banner. First-party only; no build step. */
/* EDFM cookie preferences banner + optional GA4 loader.
* Basic Google Consent Mode only: Google Analytics is never loaded while the
* optional site-improvement category is undecided or denied.
*/
(function () {
(function () {
'use strict';
'use strict';
var STORAGE_KEY = 'edfmCookieConsent.v1';
var STORAGE_KEY = 'edfmCookieConsent.v1';
var ANALYTICS_COOKIE = 'edfmCookieAnalytics';
var ANALYTICS_COOKIE = 'edfmCookieAnalytics';
var MEASUREMENT_ID = 'G-4VJHJ06777';
var loadedMeasurementId = null;
var gaDisabledKey = 'ga-disable-' + MEASUREMENT_ID;
function parseChoice() {
function parseChoice() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
catch (e) { return null; }
catch (e) { return null; }
}
}
function hasAnalyticsConsent() {
var choice = parseChoice();
return !!(choice && choice.analytics === true);
}
function setAnalyticsCookie(enabled) {
function setAnalyticsCookie(enabled) {
var maxAge = enabled ? 60 * 60 * 24 * 180 : 0;
var maxAge = enabled ? 60 * 60 * 24 * 180 : 0;
Line 303: Line 316:
}
}
}
}
function deleteCookieEverywhere(name) {
var host = window.location.hostname;
var domains = [ '', host ];
if (host.indexOf('.') !== -1) {
domains.push('.' + host);
var parts = host.split('.');
if (parts.length > 2) {
domains.push('.' + parts.slice(-2).join('.'));
}
}
var paths = [ '/', window.location.pathname || '/' ];
domains.forEach(function (domain) {
paths.forEach(function (path) {
document.cookie = name + '=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=' + path + (domain ? '; Domain=' + domain : '') + '; Secure; SameSite=Lax';
});
});
}
function deleteGaCookies() {
document.cookie.split(';').forEach(function (part) {
var name = part.split('=')[0].trim();
if (name === '_ga' || name.indexOf('_ga_') === 0) {
deleteCookieEverywhere(name);
}
});
}
function eligibleMeasurementId() {
var marker = document.querySelector('meta[name="edfm-ga4-measurement-id"]');
return marker ? marker.getAttribute('content') : null;
}
function sanitizeReferrerConfig() {
var cfg = {
allow_google_signals: false,
allow_ad_personalization_signals: false
};
try {
var ref = document.referrer ? new URL(document.referrer) : null;
if (ref && ref.origin === location.origin) {
var path = ref.pathname.toLowerCase();
var query = ref.search.toLowerCase();
var sensitivePath = /\/wiki\/(special:(userlogin|createaccount|changecredentials|passwordreset|confirmemail|preferences|pluggableauthlogin|userlogout)|user:|user_talk:|profile:|profiles:|commander:|commanders:|cmdr:)/.test(path);
var sensitiveTitle = /(^|[?&])title=special(?::|%3a)(userlogin|createaccount|changecredentials|passwordreset|confirmemail|preferences|pluggableauthlogin|userlogout)(&|$)/i.test(query);
var sensitiveQuery = /(^|[?&])(code|state|token|access_token|refresh_token|id_token|session|session_id|email|email_address|username|user|user_id|account_id|frontier_id|fdev_id|commander|commander_name|cmdr|wpname|wplogintoken|returnto|returntoquery|wpreturnto|wpreturntoquery|authaction|provider)=/i.test(query);
if (sensitivePath || sensitiveTitle || sensitiveQuery) {
cfg.page_referrer = location.origin + '/wiki/[private-referrer]';
}
}
} catch (e) {}
return cfg;
}
function initDataLayer() {
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
}
function loadAnalyticsAfterConsent() {
var id = eligibleMeasurementId();
if (!id || id !== MEASUREMENT_ID || !hasAnalyticsConsent()) {
return;
}
window[gaDisabledKey] = false;
initDataLayer();
window.gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'granted'
});
if (!loadedMeasurementId && !document.querySelector('script[data-edfm-ga4="' + id + '"]')) {
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id);
s.setAttribute('data-edfm-ga4', id);
document.head.appendChild(s);
window.gtag('js', new Date());
window.gtag('config', id, sanitizeReferrerConfig());
loadedMeasurementId = id;
} else {
window.gtag('consent', 'update', { analytics_storage: 'granted' });
}
}
function disableAnalytics() {
window[gaDisabledKey] = true;
if (window.gtag) {
window.gtag('consent', 'update', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied'
});
}
deleteGaCookies();
}
function saveChoice(analytics) {
function saveChoice(analytics) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ necessary: true, analytics: !!analytics, updated: new Date().toISOString() }));
var enabled = !!analytics;
setAnalyticsCookie(!!analytics);
localStorage.setItem(STORAGE_KEY, JSON.stringify({ necessary: true, analytics: enabled, updated: new Date().toISOString() }));
setAnalyticsCookie(enabled);
if (enabled) {
loadAnalyticsAfterConsent();
} else {
disableAnalytics();
}
}
}
function bannerHtml() {
function bannerHtml() {
return '<div class="edfm-cookie-card" role="dialog" aria-live="polite" aria-label="Cookie preferences">' +
return '<div class="edfm-cookie-card" role="dialog" aria-live="polite" aria-label="Cookie preferences">' +
'<div class="edfm-cookie-title">Cookie preferences</div>' +
'<div class="edfm-cookie-title">Cookie preferences</div>' +
'<div class="edfm-cookie-text">EDFM uses necessary cookies to keep the site secure and support features like logging in. Optional cookies help us understand which searches are popular so we can improve guides. These optional cookies do not store your IP address or username in the trending data.</div>' +
'<div class="edfm-cookie-text">EDFM uses necessary cookies to keep the site secure and support features such as logging in. Optional site-improvement cookies, including Google Analytics, help us understand how visitors use EDFM so we can improve guides and site navigation. Analytics is only enabled if you allow optional cookies. EDFM does not intentionally send Analytics usernames, Commander names, email addresses, or Frontier account identifiers.</div>' +
'<div class="edfm-cookie-options" hidden><label><input type="checkbox" class="edfm-cookie-analytics"> Allow optional site-improvement cookies</label></div>' +
'<div class="edfm-cookie-options" hidden><label><input type="checkbox" class="edfm-cookie-analytics"> Allow optional site-improvement cookies</label></div>' +
'<div class="edfm-cookie-actions">' +
'<div class="edfm-cookie-actions">' +
Line 320: Line 439:
'</div>';
'</div>';
}
}
function showBanner() {
function showBanner() {
if (document.getElementById('edfm-cookie-banner')) { return; }
if (document.getElementById('edfm-cookie-banner')) { return; }
Line 328: Line 448:
var options = wrap.querySelector('.edfm-cookie-options');
var options = wrap.querySelector('.edfm-cookie-options');
var checkbox = wrap.querySelector('.edfm-cookie-analytics');
var checkbox = wrap.querySelector('.edfm-cookie-analytics');
var current = parseChoice();
checkbox.checked = !!(current && current.analytics === true);
wrap.querySelector('.edfm-cookie-accept-all').addEventListener('click', function () { saveChoice(true); wrap.remove(); });
wrap.querySelector('.edfm-cookie-accept-all').addEventListener('click', function () { saveChoice(true); wrap.remove(); });
wrap.querySelector('.edfm-cookie-reject').addEventListener('click', function () { saveChoice(false); wrap.remove(); });
wrap.querySelector('.edfm-cookie-reject').addEventListener('click', function () { saveChoice(false); wrap.remove(); });
Line 335: Line 457:
});
});
}
}
function addPreferencesLink() {
var footer = document.getElementById('footer-places') || document.getElementById('footer') || document.body;
if (!footer || document.getElementById('edfm-cookie-preferences-link')) { return; }
var li = document.createElement(footer.tagName && footer.tagName.toLowerCase() === 'ul' ? 'li' : 'div');
var a = document.createElement('a');
a.href = '#cookie-preferences';
a.id = 'edfm-cookie-preferences-link';
a.textContent = 'Cookie Preferences';
a.addEventListener('click', function (event) {
event.preventDefault();
showBanner();
});
li.appendChild(a);
footer.appendChild(li);
}
function initCookiePrefs() {
function initCookiePrefs() {
addPreferencesLink();
var choice = parseChoice();
var choice = parseChoice();
if (!choice) { showBanner(); return; }
if (!choice) { showBanner(); disableAnalytics(); return; }
setAnalyticsCookie(!!choice.analytics);
setAnalyticsCookie(!!choice.analytics);
if (choice.analytics === true) {
loadAnalyticsAfterConsent();
} else {
disableAnalytics();
}
}
}
if (document.readyState === 'loading') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCookiePrefs);
document.addEventListener('DOMContentLoaded', initCookiePrefs);

Revision as of 16:24, 17 August 2026

/* EDFM: small, purely presentational enhancements. None are required
 * for the site to function -- all degrade gracefully with JS off -- and
 * none are achievable in pure CSS, which is why they exist here instead
 * of in MediaWiki:Common.css. Kept deliberately tiny: no framework, no
 * build step, vanilla DOM APIs only.
 */
( function () {
	'use strict';

	/* 1. Eyebrow placement (see Template:Article header, .edfm-eyebrow in
	 * Common.css). MediaWiki always renders the real #firstHeading (the
	 * page's H1) before the article body in the DOM -- there is no
	 * template or CSS hook that lets wikitext content appear earlier than
	 * that. The eyebrow is authored as ordinary wikitext at the top of the
	 * article; this just moves that already-rendered, already-accessible
	 * element to sit visually above the title instead of below it. If this
	 * script doesn't run for any reason, the eyebrow still renders --
	 * directly under the title rather than above it -- so nothing breaks,
	 * this only affects placement.
	 */
	function placeEyebrow() {
		var eyebrow = document.querySelector( '.edfm-eyebrow' );
		var heading = document.getElementById( 'firstHeading' );
		if ( eyebrow && heading && heading.parentNode ) {
			heading.parentNode.insertBefore( eyebrow, heading );
		}
	}

	/* 2. "You are here" sidebar highlighting. MediaWiki's default sidebar
	 * has no built-in current-page indication -- this compares each
	 * sidebar link's URL to the current page and flags the match so
	 * Common.css can style it (see .edfm-nav-current). Wrapped in try/catch
	 * per-link only so one malformed href can't stop the rest from being
	 * checked.
	 */
	function highlightCurrentNavLink() {
		var links = document.querySelectorAll( '#mw-panel .vector-menu-content-list a[href]' );
		/* Compare pathname *and* query string, not just pathname. Most
		 * content pages use clean /wiki/Page_Name URLs where pathname alone
		 * is unambiguous, but action views (edit, history, diff, page
		 * info, ...) all route through the same /index.php pathname and
		 * are only distinguished by their query string (?action=edit vs
		 * ?action=info etc.) -- comparing pathname only would wrongly
		 * highlight every sidebar link that happens to also point at
		 * /index.php, e.g. "Page information" while actually viewing the
		 * edit-source screen.
		 */
		var here = window.location.pathname + window.location.search;
		for ( var i = 0; i < links.length; i++ ) {
			var a = links[ i ];
			try {
				var target = new URL( a.href, window.location.href );
				if ( target.pathname + target.search === here ) {
					a.parentNode.classList.add( 'edfm-nav-current' );
				}
			} catch ( e ) {
				/* malformed href on this one link -- skip it, not fatal */
			}
		}
	}

	/* 3. Jump-nav label shortening (2026-08-08, Engineer article layout
	 * pass). MediaWiki's native table of contents always mirrors a
	 * section's real ==Heading== text exactly -- there's no template or
	 * markup mechanism for a heading to carry a separate "short nav
	 * label". Article headings stay full and descriptive (e.g. "How to
	 * Receive an Invitation") since that's what reads well in the article
	 * body; this only shortens the *jump-nav tile* text afterwards, so the
	 * dense tile grid doesn't force long headings to wrap across two
	 * lines. Keyed by exact heading text, shared across all Engineer
	 * articles (same section names throughout, see Template:New engineer
	 * page) rather than being specific to any one page. Anything not in
	 * this list is left exactly as MediaWiki rendered it -- this only ever
	 * shortens, never invents nav text for a heading it doesn't recognise.
	 */
	var TOC_SHORT_LABELS = {
		'How to Discover This Engineer': 'Discovery',
		'How to Receive an Invitation': 'Invitation',
		'How to Unlock This Engineer': 'Unlock',
		'Engineer Unlock Chain': 'Unlock Chain',
		'Available Engineering': 'Engineering',
		'Experimental Effects': 'Experimentals',
		'Increasing Engineer Reputation': 'Reputation',
		'Recommended Blueprint to Pin': 'Blueprint Pinning'
	};

	function shortenTocLabels() {
		var toctexts = document.querySelectorAll( '#toc .toctext, .toc .toctext' );
		for ( var i = 0; i < toctexts.length; i++ ) {
			var el = toctexts[ i ];
			var full = el.textContent.trim();
			if ( Object.prototype.hasOwnProperty.call( TOC_SHORT_LABELS, full ) ) {
				el.textContent = TOC_SHORT_LABELS[ full ];
			}
		}
	}


	/* 4. Desktop top-bar clocks. RuneScape.wiki has a compact utility strip
	 * in the personal-toolbar tier; EDFM only needs time readouts there.
	 * UTC is synced once from MediaWiki's API curtimestamp (server-side UTC)
	 * and then advanced locally each second; local time uses the visitor's
	 * browser timezone. If the API request fails, the UTC clock falls back to
	 * the browser clock in UTC rather than leaving a blank control.
	 */
	function initTopBarClocks() {
		var personal = document.getElementById( 'p-personal' );
		if ( !personal || document.querySelector( '.edfm-topbar-clocks' ) ) {
			return;
		}

		var clocks = document.createElement( 'div' );
		clocks.className = 'edfm-topbar-clocks';
		clocks.setAttribute( 'aria-label', 'Current time' );

		var utcClock = document.createElement( 'span' );
		utcClock.className = 'edfm-topbar-clock edfm-topbar-clock-utc';
		utcClock.innerHTML = '<span class="edfm-topbar-clock-label">UTC</span> <time>--:--</time>';

		var localClock = document.createElement( 'span' );
		localClock.className = 'edfm-topbar-clock edfm-topbar-clock-local';
		localClock.innerHTML = '<span class="edfm-topbar-clock-label">Local</span> <time>--:--</time>';

		clocks.appendChild( utcClock );
		clocks.appendChild( localClock );
		personal.insertBefore( clocks, personal.firstChild );

		var utcOffset = 0;
		var utcTime = utcClock.querySelector( 'time' );
		var localTime = localClock.querySelector( 'time' );

		function twoDigits( number ) {
			return String( number ).padStart( 2, '0' );
		}
		function render() {
			var localNow = new Date();
			var utcNow = new Date( Date.now() + utcOffset );
			var utcText = twoDigits( utcNow.getUTCHours() ) + ':' + twoDigits( utcNow.getUTCMinutes() );
			var localText = twoDigits( localNow.getHours() ) + ':' + twoDigits( localNow.getMinutes() );

			utcTime.textContent = utcText;
			utcTime.setAttribute( 'datetime', utcNow.toISOString() );
			utcTime.setAttribute( 'title', 'Server time (UTC): ' + utcNow.toUTCString() );

			localTime.textContent = localText;
			localTime.setAttribute( 'datetime', localNow.toISOString() );
			localTime.setAttribute( 'title', 'Your local time: ' + localNow.toLocaleString() );
		}

		function syncServerTime() {
			if ( !window.fetch || !window.mw || !mw.util ) {
				render();
				return;
			}
			var apiUrl = mw.util.wikiScript( 'api' ) + '?action=query&format=json&curtimestamp=1';
			fetch( apiUrl, { credentials: 'same-origin' } )
				.then( function ( response ) { return response.json(); } )
				.then( function ( data ) {
					if ( data && data.curtimestamp ) {
						utcOffset = Date.parse( data.curtimestamp ) - Date.now();
					}
					render();
				} )
				.catch( render );
		}

		syncServerTime();
		window.setInterval( render, 1000 );
	}


	/* 6. Frontier news links. The Frontier feed uses normal MediaWiki external
	 * link wikitext so it remains valid when regenerated by the updater. Add
	 * target/rel in JS because MediaWiki wikitext does not support per-link
	 * target attributes without unsafe raw HTML.
	 */
	function initFrontierNewsExternalLinks() {
		var links = document.querySelectorAll( '.edfm-frontier-news .edfm-news-title a[href]' );
		for ( var i = 0; i < links.length; i++ ) {
			var link = links[ i ];
			link.target = '_blank';
			link.rel = 'nofollow noopener noreferrer';
		}
	}


	/* 7. Mobile app shell. Vector legacy was built for desktop; this adds a
	 * small progressive-enhancement shell on phones: hamburger drawer using
	 * the real #mw-panel links, a search button that reveals/focuses the
	 * existing MediaWiki search input, ESC/scrim close behavior, and ARIA
	 * state updates. With JS off the CSS fallback remains the existing stacked
	 * Vector mobile layout.
	 */
	function initMobileChrome() {
		var navigation = document.getElementById( 'mw-navigation' );
		var panel = document.getElementById( 'mw-panel' );
		var searchInput = document.getElementById( 'searchInput' );
		if ( !navigation || !panel || document.querySelector( '.edfm-mobile-appbar' ) ) {
			return;
		}

		var appbar = document.createElement( 'div' );
		appbar.className = 'edfm-mobile-appbar';

		var menuButton = document.createElement( 'button' );
		menuButton.type = 'button';
		menuButton.className = 'edfm-mobile-menu-button';
		menuButton.setAttribute( 'aria-label', 'Open navigation menu' );
		menuButton.setAttribute( 'aria-controls', 'mw-panel' );
		menuButton.setAttribute( 'aria-expanded', 'false' );

		var brand = document.createElement( 'a' );
		brand.className = 'edfm-mobile-brand';
		brand.href = mw && mw.util ? mw.util.getUrl( 'Main Page' ) : '/wiki/Main_Page';
		brand.textContent = 'Field Manual';

		var searchButton = document.createElement( 'button' );
		searchButton.type = 'button';
		searchButton.className = 'edfm-mobile-search-button';
		searchButton.setAttribute( 'aria-label', 'Search the Field Manual' );
		searchButton.setAttribute( 'aria-expanded', 'false' );

		appbar.appendChild( menuButton );
		appbar.appendChild( brand );
		appbar.appendChild( searchButton );
		navigation.insertBefore( appbar, navigation.firstChild );

		var closeButton = document.createElement( 'button' );
		closeButton.type = 'button';
		closeButton.className = 'edfm-mobile-close-button';
		closeButton.setAttribute( 'aria-label', 'Close navigation menu' );
		panel.insertBefore( closeButton, panel.firstChild );

		var scrim = document.createElement( 'button' );
		scrim.type = 'button';
		scrim.className = 'edfm-mobile-scrim';
		scrim.setAttribute( 'aria-label', 'Close navigation menu' );
		document.body.appendChild( scrim );

		function setMenuOpen( open ) {
			document.body.classList.toggle( 'edfm-mobile-menu-open', open );
			menuButton.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
		}
		function setSearchOpen( open ) {
			document.body.classList.toggle( 'edfm-mobile-search-open', open );
			searchButton.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
			if ( open && searchInput ) {
				window.setTimeout( function () { searchInput.focus(); }, 0 );
			}
		}

		menuButton.addEventListener( 'click', function () { setMenuOpen( true ); } );
		closeButton.addEventListener( 'click', function () { setMenuOpen( false ); } );
		scrim.addEventListener( 'click', function () { setMenuOpen( false ); } );
		searchButton.addEventListener( 'click', function () {
			setSearchOpen( !document.body.classList.contains( 'edfm-mobile-search-open' ) );
		} );
		panel.addEventListener( 'click', function ( event ) {
			if ( event.target && event.target.closest && event.target.closest( 'a' ) ) {
				setMenuOpen( false );
			}
		} );
		document.addEventListener( 'keydown', function ( event ) {
			if ( event.key === 'Escape' ) {
				setMenuOpen( false );
				setSearchOpen( false );
			}
		} );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', function () {
			placeEyebrow();
			highlightCurrentNavLink();
			shortenTocLabels();
			initTopBarClocks();
			initFrontierNewsExternalLinks();
			initMobileChrome();
		} );
	} else {
		placeEyebrow();
		highlightCurrentNavLink();
		shortenTocLabels();
		initTopBarClocks();
		initFrontierNewsExternalLinks();
		initMobileChrome();
	}
}() );
/* EDFM cookie preferences banner + optional GA4 loader.
 * Basic Google Consent Mode only: Google Analytics is never loaded while the
 * optional site-improvement category is undecided or denied.
 */
(function () {
	'use strict';
	var STORAGE_KEY = 'edfmCookieConsent.v1';
	var ANALYTICS_COOKIE = 'edfmCookieAnalytics';
	var MEASUREMENT_ID = 'G-4VJHJ06777';
	var loadedMeasurementId = null;
	var gaDisabledKey = 'ga-disable-' + MEASUREMENT_ID;

	function parseChoice() {
		try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
		catch (e) { return null; }
	}

	function hasAnalyticsConsent() {
		var choice = parseChoice();
		return !!(choice && choice.analytics === true);
	}

	function setAnalyticsCookie(enabled) {
		var maxAge = enabled ? 60 * 60 * 24 * 180 : 0;
		document.cookie = ANALYTICS_COOKIE + '=' + (enabled ? '1' : '') + '; Max-Age=' + maxAge + '; Path=/; Secure; SameSite=Lax';
		if (!enabled) {
			document.cookie = 'edfmPopularSearch=; Max-Age=0; Path=/; Secure; SameSite=Lax';
		}
	}

	function deleteCookieEverywhere(name) {
		var host = window.location.hostname;
		var domains = [ '', host ];
		if (host.indexOf('.') !== -1) {
			domains.push('.' + host);
			var parts = host.split('.');
			if (parts.length > 2) {
				domains.push('.' + parts.slice(-2).join('.'));
			}
		}
		var paths = [ '/', window.location.pathname || '/' ];
		domains.forEach(function (domain) {
			paths.forEach(function (path) {
				document.cookie = name + '=; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=' + path + (domain ? '; Domain=' + domain : '') + '; Secure; SameSite=Lax';
			});
		});
	}

	function deleteGaCookies() {
		document.cookie.split(';').forEach(function (part) {
			var name = part.split('=')[0].trim();
			if (name === '_ga' || name.indexOf('_ga_') === 0) {
				deleteCookieEverywhere(name);
			}
		});
	}

	function eligibleMeasurementId() {
		var marker = document.querySelector('meta[name="edfm-ga4-measurement-id"]');
		return marker ? marker.getAttribute('content') : null;
	}

	function sanitizeReferrerConfig() {
		var cfg = {
			allow_google_signals: false,
			allow_ad_personalization_signals: false
		};
		try {
			var ref = document.referrer ? new URL(document.referrer) : null;
			if (ref && ref.origin === location.origin) {
				var path = ref.pathname.toLowerCase();
				var query = ref.search.toLowerCase();
				var sensitivePath = /\/wiki\/(special:(userlogin|createaccount|changecredentials|passwordreset|confirmemail|preferences|pluggableauthlogin|userlogout)|user:|user_talk:|profile:|profiles:|commander:|commanders:|cmdr:)/.test(path);
				var sensitiveTitle = /(^|[?&])title=special(?::|%3a)(userlogin|createaccount|changecredentials|passwordreset|confirmemail|preferences|pluggableauthlogin|userlogout)(&|$)/i.test(query);
				var sensitiveQuery = /(^|[?&])(code|state|token|access_token|refresh_token|id_token|session|session_id|email|email_address|username|user|user_id|account_id|frontier_id|fdev_id|commander|commander_name|cmdr|wpname|wplogintoken|returnto|returntoquery|wpreturnto|wpreturntoquery|authaction|provider)=/i.test(query);
				if (sensitivePath || sensitiveTitle || sensitiveQuery) {
					cfg.page_referrer = location.origin + '/wiki/[private-referrer]';
				}
			}
		} catch (e) {}
		return cfg;
	}

	function initDataLayer() {
		window.dataLayer = window.dataLayer || [];
		window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
	}

	function loadAnalyticsAfterConsent() {
		var id = eligibleMeasurementId();
		if (!id || id !== MEASUREMENT_ID || !hasAnalyticsConsent()) {
			return;
		}
		window[gaDisabledKey] = false;
		initDataLayer();
		window.gtag('consent', 'default', {
			ad_storage: 'denied',
			ad_user_data: 'denied',
			ad_personalization: 'denied',
			analytics_storage: 'granted'
		});
		if (!loadedMeasurementId && !document.querySelector('script[data-edfm-ga4="' + id + '"]')) {
			var s = document.createElement('script');
			s.async = true;
			s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(id);
			s.setAttribute('data-edfm-ga4', id);
			document.head.appendChild(s);
			window.gtag('js', new Date());
			window.gtag('config', id, sanitizeReferrerConfig());
			loadedMeasurementId = id;
		} else {
			window.gtag('consent', 'update', { analytics_storage: 'granted' });
		}
	}

	function disableAnalytics() {
		window[gaDisabledKey] = true;
		if (window.gtag) {
			window.gtag('consent', 'update', {
				ad_storage: 'denied',
				ad_user_data: 'denied',
				ad_personalization: 'denied',
				analytics_storage: 'denied'
			});
		}
		deleteGaCookies();
	}

	function saveChoice(analytics) {
		var enabled = !!analytics;
		localStorage.setItem(STORAGE_KEY, JSON.stringify({ necessary: true, analytics: enabled, updated: new Date().toISOString() }));
		setAnalyticsCookie(enabled);
		if (enabled) {
			loadAnalyticsAfterConsent();
		} else {
			disableAnalytics();
		}
	}

	function bannerHtml() {
		return '<div class="edfm-cookie-card" role="dialog" aria-live="polite" aria-label="Cookie preferences">' +
			'<div class="edfm-cookie-title">Cookie preferences</div>' +
			'<div class="edfm-cookie-text">EDFM uses necessary cookies to keep the site secure and support features such as logging in. Optional site-improvement cookies, including Google Analytics, help us understand how visitors use EDFM so we can improve guides and site navigation. Analytics is only enabled if you allow optional cookies. EDFM does not intentionally send Analytics usernames, Commander names, email addresses, or Frontier account identifiers.</div>' +
			'<div class="edfm-cookie-options" hidden><label><input type="checkbox" class="edfm-cookie-analytics"> Allow optional site-improvement cookies</label></div>' +
			'<div class="edfm-cookie-actions">' +
			'<button type="button" class="edfm-cookie-accept-all">Accept all</button>' +
			'<button type="button" class="edfm-cookie-some">Accept some</button>' +
			'<button type="button" class="edfm-cookie-reject">Reject optional</button>' +
			'</div>' +
			'<div class="edfm-cookie-links"><a href="/wiki/Elite_Dangerous_Field_Manual:Cookie_Policy">Cookie Policy</a> · <a href="/wiki/Elite_Dangerous_Field_Manual:Privacy_Policy">Privacy Policy</a></div>' +
			'</div>';
	}

	function showBanner() {
		if (document.getElementById('edfm-cookie-banner')) { return; }
		var wrap = document.createElement('div');
		wrap.id = 'edfm-cookie-banner';
		wrap.innerHTML = bannerHtml();
		document.body.appendChild(wrap);
		var options = wrap.querySelector('.edfm-cookie-options');
		var checkbox = wrap.querySelector('.edfm-cookie-analytics');
		var current = parseChoice();
		checkbox.checked = !!(current && current.analytics === true);
		wrap.querySelector('.edfm-cookie-accept-all').addEventListener('click', function () { saveChoice(true); wrap.remove(); });
		wrap.querySelector('.edfm-cookie-reject').addEventListener('click', function () { saveChoice(false); wrap.remove(); });
		wrap.querySelector('.edfm-cookie-some').addEventListener('click', function () {
			if (options.hidden) { options.hidden = false; checkbox.focus(); return; }
			saveChoice(checkbox.checked); wrap.remove();
		});
	}

	function addPreferencesLink() {
		var footer = document.getElementById('footer-places') || document.getElementById('footer') || document.body;
		if (!footer || document.getElementById('edfm-cookie-preferences-link')) { return; }
		var li = document.createElement(footer.tagName && footer.tagName.toLowerCase() === 'ul' ? 'li' : 'div');
		var a = document.createElement('a');
		a.href = '#cookie-preferences';
		a.id = 'edfm-cookie-preferences-link';
		a.textContent = 'Cookie Preferences';
		a.addEventListener('click', function (event) {
			event.preventDefault();
			showBanner();
		});
		li.appendChild(a);
		footer.appendChild(li);
	}

	function initCookiePrefs() {
		addPreferencesLink();
		var choice = parseChoice();
		if (!choice) { showBanner(); disableAnalytics(); return; }
		setAnalyticsCookie(!!choice.analytics);
		if (choice.analytics === true) {
			loadAnalyticsAfterConsent();
		} else {
			disableAnalytics();
		}
	}

	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', initCookiePrefs);
	} else {
		initCookiePrefs();
	}
	window.edfmShowCookiePreferences = showBanner;
}());