MediaWiki:Gadget-wm-portal.js

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * This is the javascript module for the [[m:Project portals]] templates.
 *
 * Indention style: 1 tab
 *
 * Beware: This is used not only for www.wikipedia.org, but also for sister projects
 * like www.wiktionary.org and for portals without bookshelves like www.wikimedia.org.
 *
 * Warning: "mediaWiki" and "jQuery" are NOT available here. This is used outside
 * mediawiki-software output context, on the [[m:Project portals]] HTML pages.
 *
 * Don't be afraid to supplement code with comments, this script is loaded through
 * ResourceLoader on the portal pages and as such is minified and squeezed into a
 * tiny package served from load.php
 *
 * Validate with JSLint or JSHint.
 *
 * Usage:
 * <script src="//meta.wikimedia.org/w/load.php?debug=false&amp;lang=en&amp;modules=ext.gadget.wm-portal&amp;only=scripts&amp;skin=vector&amp;*"></script>
 */
/*jshint eqeqeq:true, strict:true, unused:true, curly:true, browser:true, quotmark:double */
(function () {
	"use strict";
	
	var attachedEvents = [];

	/**
	 * Returns the DOM element with the given ID.
	 */
	function $(id) {
		return document.getElementById(id);
	}
	
	/**
	 * Removes all event handlers in Internet Explorer 8 and below.
	 * 
	 * Any attached event handlers are stored in memory until IE exits, leaking
	 * every time you leave (or reload) the page. This method cleans up any
	 * event handlers that remain at the time the page is unloaded.
	 */
	window.onunload = function () {
		var i, evt;
		for (i = 0; i < attachedEvents.length; i++) {
			evt = attachedEvents[i];
			if (evt[0]) {
				evt[0].detachEvent("on" + evt[1], evt[2]);
			}
		}
		attachedEvents = [];
	};

	function addEvent(obj, evt, fn) {
		if (!obj) {
			return;
		}

		if (obj.addEventListener) {
			obj.addEventListener(evt, fn, false);
		} else if (obj.attachEvent) {
			attachedEvents.push([obj, evt, fn]);
			obj.attachEvent("on" + evt, fn);
		}
	}

	function removeEvent(obj, evt, fn) {
		if (!obj) {
			return;
		}

		if (obj.removeEventListener) {
			obj.removeEventListener(evt, fn);
		} else if (obj.detachEvent) {
			obj.detachEvent("on" + evt, fn);
		}
	}

	/**
	 * Queues the given function to be called once the DOM has finished loading.
	 * 
	 * Based on jquery/src/core/ready.js@825ac37 (MIT licensed)
	 */
	function doWhenReady(fn) {
		var ready = function () {
			removeEvent(document, "DOMContentLoaded", ready);
			removeEvent(window, "load", ready);
			fn();
		};
		
		if (document.readyState === "complete") {
			// Already ready, so call the function synchronously.
			fn();
		} else {
			// Wait until the DOM or whole page loads, whichever comes first.
			addEvent(document, "DOMContentLoaded", ready);
			addEvent(window, "load", ready);
		}
	}
	
	/**
	 * Replaces the “hero graphic” with the given language edition’s logo.
	 */
	function updateBranding(lang) {
		var option, logo;
		
		// Only Wiktionary has such a mess of logos.
		if (!document.querySelector || document.body.id !== "www-wiktionary-org" ||
			lang.match(/\W/)) {
			return;
		}
		
		option = document.querySelector("option[lang|='" + lang + "']");
		logo = option && option.getAttribute("data-logo");
		if (logo) {
			document.body.setAttribute("data-logo", logo);
		}
	}

	/**
	 * Returns the user's preferred language according to browser preferences.
	 */
	function getUALang() {
		var uiLang = ((navigator.languages && navigator.languages[0]) ||
				navigator.language || navigator.userLanguage || "");
		return uiLang.toLowerCase().split("-")[0];
	}

	/**
	 * Returns the preferred language as stored in a cookie. Falls back on the
	 * browser's language.
	 */
	function getSavedLang() {
		var match = document.cookie.match(/(?:^|\W)searchLang=([^;]+)/);
		return (match ? match[1] : getUALang()).toLowerCase();
	}
	
	/**
	 * Imitates `element.textContent = text` for back-compatibility.
	 *
	 * @param {HTMLElement} element
	 * @param {string} text
	 */
	function textContent(element, text) {
		while (element.firstChild) {
			element.removeChild(element.firstChild);
		}
		element.appendChild(document.createTextNode(text));
	}

	/**
	 * Converts Chinese strings from traditional to simplified.
	 *
	 * Convertible elements start out with traditional text and title attributes
	 * along with simplified counterparts in the data-*-Hans attributes.
	 */
	function convertChinese(lang) {
		var i, elt,
			txtAttr = "data-convert-Hans",
			titleAttr = "data-convertTitle-Hans";

		if ("zh-hans,zh-cn,zh-sg,zh-my,".indexOf(lang + ",") === -1) {
			return;
		}

		// If we ever drop support for IE 8 and below, we can put all these
		// elements in a "convertible" class and call
		// document.getElementsByClassName() instead.
		var ids = ["zh_art", "zh_others", "zh_search", "zh_tag", "zh_top10", "zh-yue_wiki", "gan_wiki", "hak_wiki", "wuu_wiki"];
		for (i = 0; i < ids.length; i += 1) {
			if ((elt = $(ids[i]))) {
				if (elt.hasAttribute(txtAttr)) {
					// HTML escaping for paranoia, as it should all be text anyways.
					textContent(elt, elt.getAttribute(txtAttr));
				}
				if (elt.hasAttribute(titleAttr)) {
					elt.title = elt.getAttribute(titleAttr);
				}
			}
		}
	}

	/**
	 * Modifies links to the Chinese language edition to point to traditional or
	 * simplified versions, based on the user's preference.
	 */
	function convertZhLinks(lang) {
		var locale;

		if (lang.indexOf("zh") !== 0) {
			return;
		}

		locale = lang.substring(3 /* "zh-".length */);
		if (locale === "mo") {
			locale = "hk";
		} else if (locale === "my") {
			locale = "sg";
		}

		if (locale && "cn,tw,hk,sg,".indexOf(locale + ",") >= 0) {
			$("zh_wiki").href += "zh-" + locale + "/";
			$("zh_others").href = $("zh_others").href.replace("wiki/", "zh-" + locale + "/");
		}

		convertChinese(lang);
	}

	/**
	 * Selects the language from the dropdown according to the user's preference.
	 */
	doWhenReady(function () {
		var iso639, select, options, i, len, matchingLang, matchingLink,
			customOption, customOptionText,
			lang = getSavedLang();

		if (!lang) {
			return;
		}

		convertZhLinks(lang);

		iso639 = lang.match(/^\w+/);
		if (!iso639) {
			return;
		}
		iso639 = (iso639[0] === "nb") ? "no" : iso639[0];
		select = $("searchLanguage");
		// Verify that an <option> exists for the langCode that was
		// in the cookie. If so, set the value to it.
		if (select) {
			options = select.getElementsByTagName("option");
			for (i = 0, len = options.length; !matchingLang && i < len; i += 1) {
				if (options[i].value === iso639) {
					matchingLang = iso639;
				}
			}
			if (!matchingLang && document.querySelector) {
				matchingLink = document.querySelector(".langlist a[lang|='" + iso639 + "']");
				if (matchingLink) {
					matchingLang = iso639;
					customOption = document.createElement("option");
					customOption.setAttribute("lang", iso639);
					customOption.setAttribute("value", iso639);
					customOptionText = matchingLink.textContent || matchingLink.innerText || iso639;
					textContent(customOption, customOptionText);
					select.appendChild(customOption);
				}
			}
			if (matchingLang) {
				select.value = matchingLang;
				updateBranding(matchingLang);
			}
		}
	});

	/**
	 * Invokes the MediaWiki API of the selected wiki to search for articles
	 * whose titles begin with the entered text.
	 */
	function setupSuggestions() {
		// For simplicity's sake, rely on the HTML5 <datalist> element available
		// on IE 10+ (and all other modern browsers).
		if (window.HTMLDataListElement === undefined) {
			return;
		}

		var list = document.createElement("datalist"),
			search = $("searchInput");

		list.id = "suggestions";
		document.body.appendChild(list);
		search.autocomplete = "off";
		search.setAttribute("list", "suggestions");

		addEvent(search, "input", function () {
			var head = document.getElementsByTagName("head")[0],
				hostname = window.location.hostname.replace("www.", $("searchLanguage").value + "."),
				script = $("api_opensearch"),
				query = encodeURIComponent(search.value);

			if (script) {
				head.removeChild(script);
			}
			script = document.createElement("script");
			script.id = "api_opensearch";
			script.src = "//" + encodeURIComponent(hostname) + "/w/api.php?action=opensearch&limit=10&format=json&callback=portalOpensearchCallback&search=" + query;
			head.appendChild(script);
		});
	}

	/**
	 * Sets the search box's data list to the results returned by the MediaWiki
	 * API. The results are returned in JSON-P format, so this callback must be
	 * global.
	 */
	window.portalOpensearchCallback = function(xhrResults) {
		var i,
			suggestions = $("suggestions"),
			oldOptions = suggestions.children;

		// Update the list, reusing any existing items from the last search.
		for (i = 0; i < xhrResults[1].length; i += 1) {
			var option = oldOptions[i] || document.createElement("option");
			option.value = xhrResults[1][i];
			if (!oldOptions[i]) {
				suggestions.appendChild(option);
			}
		}

		// If this search has fewer results than the last one, trim the list.
		for (i = suggestions.children.length - 1; i >= xhrResults[1].length; i -= 1) {
			suggestions.removeChild(suggestions.children[i]);
		}
	};

	/**
	 * Stores the user's preferred language in a cookie. This function is called
	 * once a language other than the browser's default is selected from the
	 * dropdown.
	 */
	function setLang(lang) {
		if (!lang) {
			return;
		}
		var uiLang = getUALang(),
			match = uiLang.match(/^\w+/),
			date = new Date();
		
		updateBranding(lang);
		if (match && match[0] === lang) {
			date.setTime(date.getTime() - 1);
		} else {
			date.setFullYear(date.getFullYear() + 1);
		}

		document.cookie = "searchLang=" + lang + ";expires=" +
			date.toUTCString() + ";domain=" + location.host + ";";
	}

	doWhenReady(function () {
		var params, i, param,
			search = $("searchInput"), select = $("searchLanguage");

		if (search) {
			// Add a search icon to the box in Safari.
			search.setAttribute("results", "10");
			setupSuggestions();

			if (search.autofocus === undefined) {
				// Focus the search box.
				search.focus();
			} else {
				// autofocus causes scrolling in most browsers that
				// support it.
				window.scroll(0, 0);
			}

			// Prefills the search box with the "search" URL parameter.
			params = location.search && location.search.substr(1).split("&");
			for (i = 0; i < params.length; i += 1) {
				param = params[i].split("=");
				if (param[0] === "search" && param[1]) {
					search.value = decodeURIComponent(param[1].replace(/\+/g, " "));
					return;
				}
			}
		}

		addEvent(select, "change", function () {
			setLang(select.value);
		});
	});

	doWhenReady(function () {
		var uselang = document.searchwiki && document.searchwiki.elements.uselang;
		if (uselang) {
			// Don't use getSavedLang() since that uses the cookie for the search form.
			// The searchwiki form should not be affected by the values in the searchpage form.
			uselang.value = getUALang();
		}
	});
	
	// Based on jquery.hidpi module with the jQuery removed and support for the
	// full srcset syntax added.
	
	/**
	 * Detects reported or approximate device pixel ratio.
	 * * 1.0 means 1 CSS pixel is 1 hardware pixel
	 * * 2.0 means 1 CSS pixel is 2 hardware pixels
	 * * etc.
	 *
	 * Uses window.devicePixelRatio if available, or CSS media queries on IE.
	 *
	 * @returns {number} Device pixel ratio
	 */
	function devicePixelRatio() {
		if ( window.devicePixelRatio !== undefined ) {
			// Most web browsers:
			// * WebKit (Safari, Chrome, Android browser, etc)
			// * Opera
			// * Firefox 18+
			return window.devicePixelRatio;
		} else if ( window.msMatchMedia !== undefined ) {
			// Windows 8 desktops / tablets, probably Windows Phone 8
			//
			// IE 10 doesn't report pixel ratio directly, but we can get the
			// screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for
			// simplicity, but you may get different values depending on zoom
			// factor, size of screen and orientation in Metro IE.
			if ( window.msMatchMedia( "(min-resolution: 192dpi)" ).matches ) {
				return 2;
			} else if ( window.msMatchMedia( "(min-resolution: 144dpi)" ).matches ) {
				return 1.5;
			} else {
				return 1;
			}
		} else {
			// Legacy browsers...
			// Assume 1 if unknown.
			return 1;
		}
	}
	
	/**
	 * Matches a srcset entry for the given device pixel ratio.
	 *
	 * @param {number} devicePixelRatio
	 * @param {string} srcset
	 * @return {mixed} null or the matching src string
	 */
	function matchSrcSet( devicePixelRatio, srcset ) {
		var candidates,
			candidate,
			i,
			ratio,
			selection = {ratio: 1};
		candidates = srcset.split( / *, */ );
		for ( i = 0; i < candidates.length; i++ ) {
			// http://www.w3.org/html/wg/drafts/srcset/w3c-srcset/#additions-to-the-img-element
			candidate = candidates[i].match(/\s*(\S+)(?:\s*([\d.]+)w)?(?:\s*([\d.]+)h)?(?:\s*([\d.]+)x)?\s*/);
			ratio = candidate[4] && parseFloat(candidate[4]);
			if ( ratio <= devicePixelRatio && ratio > selection.ratio ) {
				selection.ratio = ratio;
				selection.src = candidate[1];
				selection.width = candidate[2] && parseFloat(candidate[2]);
				selection.height = candidate[3] && parseFloat(candidate[3]);
			}
		}
		return selection;
	}
	
	/**
	 * Implements responsive images based on srcset attributes, if browser has
	 * no native srcset support.
	 */
	function hidpi() {
		var imgs, i,
			ratio = devicePixelRatio(),
			testImage = new Image();

		if ( ratio > 1 && testImage.srcset === undefined ) {
			// No native srcset support.
			imgs = document.getElementsByTagName( "img" );
			for ( i = 0; i < imgs.length; i++ ) {
				var img = imgs[i],
					srcset = img.getAttribute( "srcset" ),
					match;
				if ( typeof srcset === "string" && srcset !== "" ) {
					match = matchSrcSet( ratio, srcset );
					if ( match.src !== undefined ) {
						img.setAttribute( "src", match.src );
						if ( match.width !== undefined ) {
							img.setAttribute( "width", match.width );
						}
						if ( match.height !== undefined ) {
							img.setAttribute( "height", match.height );
						}
					}
				}
			}
		}
	}
	doWhenReady(hidpi);

}());

/*
 * Depending on how this script is loaded, it may not have
 * the mediaWiki global object.  Simulate if needed, for the
 * load.php?only=scripts response that calls mw.loader.state(.., ..);
 */
if (!window.mw) {
	window.mw = window.mediaWiki = {
		loader: {
			state: function () {}
		}
	};
}