diff options
author | Pierre Schmitz <pierre@archlinux.de> | 2014-12-27 15:41:37 +0100 |
---|---|---|
committer | Pierre Schmitz <pierre@archlinux.de> | 2014-12-31 11:43:28 +0100 |
commit | c1f9b1f7b1b77776192048005dcc66dcf3df2bfb (patch) | |
tree | 2b38796e738dd74cb42ecd9bfd151803108386bc /resources/src/mediawiki | |
parent | b88ab0086858470dd1f644e64cb4e4f62bb2be9b (diff) |
Update to MediaWiki 1.24.1
Diffstat (limited to 'resources/src/mediawiki')
50 files changed, 9419 insertions, 0 deletions
diff --git a/resources/src/mediawiki/images/arrow-collapsed-ltr.png b/resources/src/mediawiki/images/arrow-collapsed-ltr.png Binary files differnew file mode 100644 index 00000000..b17e578b --- /dev/null +++ b/resources/src/mediawiki/images/arrow-collapsed-ltr.png diff --git a/resources/src/mediawiki/images/arrow-collapsed-ltr.svg b/resources/src/mediawiki/images/arrow-collapsed-ltr.svg new file mode 100644 index 00000000..6233fd5e --- /dev/null +++ b/resources/src/mediawiki/images/arrow-collapsed-ltr.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M4 1.533v9.671l4.752-4.871z" fill="#797979"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/arrow-collapsed-rtl.png b/resources/src/mediawiki/images/arrow-collapsed-rtl.png Binary files differnew file mode 100644 index 00000000..a834548e --- /dev/null +++ b/resources/src/mediawiki/images/arrow-collapsed-rtl.png diff --git a/resources/src/mediawiki/images/arrow-collapsed-rtl.svg b/resources/src/mediawiki/images/arrow-collapsed-rtl.svg new file mode 100644 index 00000000..44d5587a --- /dev/null +++ b/resources/src/mediawiki/images/arrow-collapsed-rtl.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M8 1.533v9.671l-4.752-4.871z" fill="#797979"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/arrow-expanded.png b/resources/src/mediawiki/images/arrow-expanded.png Binary files differnew file mode 100644 index 00000000..2bec798e --- /dev/null +++ b/resources/src/mediawiki/images/arrow-expanded.png diff --git a/resources/src/mediawiki/images/arrow-expanded.svg b/resources/src/mediawiki/images/arrow-expanded.svg new file mode 100644 index 00000000..a0d217d2 --- /dev/null +++ b/resources/src/mediawiki/images/arrow-expanded.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1.165 3.624h9.671l-4.871 4.752z" fill="#797979"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/arrow-sort-ascending.png b/resources/src/mediawiki/images/arrow-sort-ascending.png Binary files differnew file mode 100644 index 00000000..f2d339de --- /dev/null +++ b/resources/src/mediawiki/images/arrow-sort-ascending.png diff --git a/resources/src/mediawiki/images/arrow-sort-ascending.svg b/resources/src/mediawiki/images/arrow-sort-ascending.svg new file mode 100644 index 00000000..1e7a0943 --- /dev/null +++ b/resources/src/mediawiki/images/arrow-sort-ascending.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1 10h10l-5-8.658z" fill="#00a"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/arrow-sort-descending.png b/resources/src/mediawiki/images/arrow-sort-descending.png Binary files differnew file mode 100644 index 00000000..8afbca96 --- /dev/null +++ b/resources/src/mediawiki/images/arrow-sort-descending.png diff --git a/resources/src/mediawiki/images/arrow-sort-descending.svg b/resources/src/mediawiki/images/arrow-sort-descending.svg new file mode 100644 index 00000000..cf11adb4 --- /dev/null +++ b/resources/src/mediawiki/images/arrow-sort-descending.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12"><path d="M1 2h10l-5 8.658z" fill="#00a"/></svg>
\ No newline at end of file diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png Binary files differnew file mode 100644 index 00000000..2a64fd03 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-ltr.png diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png Binary files differnew file mode 100644 index 00000000..78a493e6 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-fastforward-rtl.png diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png Binary files differnew file mode 100644 index 00000000..aa4fbf8c --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-ltr.png diff --git a/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png Binary files differnew file mode 100644 index 00000000..83df0684 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-disabled-forward-rtl.png diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png Binary files differnew file mode 100644 index 00000000..caf50331 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-fastforward-ltr.png diff --git a/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png Binary files differnew file mode 100644 index 00000000..52b32a5a --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-fastforward-rtl.png diff --git a/resources/src/mediawiki/images/pager-arrow-forward-ltr.png b/resources/src/mediawiki/images/pager-arrow-forward-ltr.png Binary files differnew file mode 100644 index 00000000..3f8fee38 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-forward-ltr.png diff --git a/resources/src/mediawiki/images/pager-arrow-forward-rtl.png b/resources/src/mediawiki/images/pager-arrow-forward-rtl.png Binary files differnew file mode 100644 index 00000000..f363bf66 --- /dev/null +++ b/resources/src/mediawiki/images/pager-arrow-forward-rtl.png diff --git a/resources/src/mediawiki/mediawiki.Title.js b/resources/src/mediawiki/mediawiki.Title.js new file mode 100644 index 00000000..7ced42fe --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Title.js @@ -0,0 +1,939 @@ +/*! + * @author Neil Kandalgaonkar, 2010 + * @author Timo Tijhof, 2011-2013 + * @since 1.18 + */ +( function ( mw, $ ) { + + /** + * @class mw.Title + * + * Parse titles into an object struture. Note that when using the constructor + * directly, passing invalid titles will result in an exception. Use #newFromText to use the + * logic directly and get null for invalid titles which is easier to work with. + * + * @constructor + * @param {string} title Title of the page. If no second argument given, + * this will be searched for a namespace + * @param {number} [namespace=NS_MAIN] If given, will used as default namespace for the given title + * @throws {Error} When the title is invalid + */ + function Title( title, namespace ) { + var parsed = parse( title, namespace ); + if ( !parsed ) { + throw new Error( 'Unable to parse title' ); + } + + this.namespace = parsed.namespace; + this.title = parsed.title; + this.ext = parsed.ext; + this.fragment = parsed.fragment; + + return this; + } + + /* Private members */ + + var + + /** + * @private + * @static + * @property NS_MAIN + */ + NS_MAIN = 0, + + /** + * @private + * @static + * @property NS_TALK + */ + NS_TALK = 1, + + /** + * @private + * @static + * @property NS_SPECIAL + */ + NS_SPECIAL = -1, + + /** + * @private + * @static + * @property NS_MEDIA + */ + NS_MEDIA = -2, + + /** + * @private + * @static + * @property NS_FILE + */ + NS_FILE = 6, + + /** + * @private + * @static + * @property FILENAME_MAX_BYTES + */ + FILENAME_MAX_BYTES = 240, + + /** + * @private + * @static + * @property TITLE_MAX_BYTES + */ + TITLE_MAX_BYTES = 255, + + /** + * Get the namespace id from a namespace name (either from the localized, canonical or alias + * name). + * + * Example: On a German wiki this would return 6 for any of 'File', 'Datei', 'Image' or + * even 'Bild'. + * + * @private + * @static + * @method getNsIdByName + * @param {string} ns Namespace name (case insensitive, leading/trailing space ignored) + * @return {number|boolean} Namespace id or boolean false + */ + getNsIdByName = function ( ns ) { + var id; + + // Don't cast non-strings to strings, because null or undefined should not result in + // returning the id of a potential namespace called "Null:" (e.g. on null.example.org/wiki) + // Also, toLowerCase throws exception on null/undefined, because it is a String method. + if ( typeof ns !== 'string' ) { + return false; + } + ns = ns.toLowerCase(); + id = mw.config.get( 'wgNamespaceIds' )[ns]; + if ( id === undefined ) { + return false; + } + return id; + }, + + rUnderscoreTrim = /^_+|_+$/g, + + rSplit = /^(.+?)_*:_*(.*)$/, + + // See Title.php#getTitleInvalidRegex + rInvalid = new RegExp( + '[^' + mw.config.get( 'wgLegalTitleChars' ) + ']' + + // URL percent encoding sequences interfere with the ability + // to round-trip titles -- you can't link to them consistently. + '|%[0-9A-Fa-f]{2}' + + // XML/HTML character references produce similar issues. + '|&[A-Za-z0-9\u0080-\uFFFF]+;' + + '|&#[0-9]+;' + + '|&#x[0-9A-Fa-f]+;' + ), + + // From MediaWikiTitleCodec.php#L225 @26fcab1f18c568a41 + // "Clean up whitespace" in function MediaWikiTitleCodec::splitTitleString() + rWhitespace = /[ _\u0009\u00A0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\s]+/g, + + /** + * Slightly modified from Flinfo. Credit goes to Lupo and Flominator. + * @private + * @static + * @property sanitationRules + */ + sanitationRules = [ + // "signature" + { + pattern: /~{3}/g, + replace: '', + generalRule: true + }, + // Space, underscore, tab, NBSP and other unusual spaces + { + pattern: rWhitespace, + replace: ' ', + generalRule: true + }, + // unicode bidi override characters: Implicit, Embeds, Overrides + { + pattern: /[\u200E\u200F\u202A-\u202E]/g, + replace: '', + generalRule: true + }, + // control characters + { + pattern: /[\x00-\x1f\x7f]/g, + replace: '', + generalRule: true + }, + // URL encoding (possibly) + { + pattern: /%([0-9A-Fa-f]{2})/g, + replace: '% $1', + generalRule: true + }, + // HTML-character-entities + { + pattern: /&(([A-Za-z0-9\x80-\xff]+|#[0-9]+|#x[0-9A-Fa-f]+);)/g, + replace: '& $1', + generalRule: true + }, + // slash, colon (not supported by file systems like NTFS/Windows, Mac OS 9 [:], ext4 [/]) + { + pattern: /[:\/#]/g, + replace: '-', + fileRule: true + }, + // brackets, greater than + { + pattern: /[\]\}>]/g, + replace: ')', + generalRule: true + }, + // brackets, lower than + { + pattern: /[\[\{<]/g, + replace: '(', + generalRule: true + }, + // everything that wasn't covered yet + { + pattern: new RegExp( rInvalid.source, 'g' ), + replace: '-', + generalRule: true + }, + // directory structures + { + pattern: /^(\.|\.\.|\.\/.*|\.\.\/.*|.*\/\.\/.*|.*\/\.\.\/.*|.*\/\.|.*\/\.\.)$/g, + replace: '', + generalRule: true + } + ], + + /** + * Internal helper for #constructor and #newFromtext. + * + * Based on Title.php#secureAndSplit + * + * @private + * @static + * @method parse + * @param {string} title + * @param {number} [defaultNamespace=NS_MAIN] + * @return {Object|boolean} + */ + parse = function ( title, defaultNamespace ) { + var namespace, m, id, i, fragment, ext; + + namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace; + + title = title + // Normalise whitespace to underscores and remove duplicates + .replace( /[ _\s]+/g, '_' ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + + // Process initial colon + if ( title !== '' && title.charAt( 0 ) === ':' ) { + // Initial colon means main namespace instead of specified default + namespace = NS_MAIN; + title = title + // Strip colon + .slice( 1 ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + } + + if ( title === '' ) { + return false; + } + + // Process namespace prefix (if any) + m = title.match( rSplit ); + if ( m ) { + id = getNsIdByName( m[1] ); + if ( id !== false ) { + // Ordinary namespace + namespace = id; + title = m[2]; + + // For Talk:X pages, make sure X has no "namespace" prefix + if ( namespace === NS_TALK && ( m = title.match( rSplit ) ) ) { + // Disallow titles like Talk:File:x (subject should roundtrip: talk:file:x -> file:x -> file_talk:x) + if ( getNsIdByName( m[1] ) !== false ) { + return false; + } + } + } + } + + // Process fragment + i = title.indexOf( '#' ); + if ( i === -1 ) { + fragment = null; + } else { + fragment = title + // Get segment starting after the hash + .slice( i + 1 ) + // Convert to text + // NB: Must not be trimmed ("Example#_foo" is not the same as "Example#foo") + .replace( /_/g, ' ' ); + + title = title + // Strip hash + .slice( 0, i ) + // Trim underscores, again (strips "_" from "bar" in "Foo_bar_#quux") + .replace( rUnderscoreTrim, '' ); + } + + // Reject illegal characters + if ( title.match( rInvalid ) ) { + return false; + } + + // Disallow titles that browsers or servers might resolve as directory navigation + if ( + title.indexOf( '.' ) !== -1 && ( + title === '.' || title === '..' || + title.indexOf( './' ) === 0 || + title.indexOf( '../' ) === 0 || + title.indexOf( '/./' ) !== -1 || + title.indexOf( '/../' ) !== -1 || + title.slice( -2 ) === '/.' || + title.slice( -3 ) === '/..' + ) + ) { + return false; + } + + // Disallow magic tilde sequence + if ( title.indexOf( '~~~' ) !== -1 ) { + return false; + } + + // Disallow titles exceeding the TITLE_MAX_BYTES byte size limit (size of underlying database field) + // Except for special pages, e.g. [[Special:Block/Long name]] + // Note: The PHP implementation also asserts that even in NS_SPECIAL, the title should + // be less than 512 bytes. + if ( namespace !== NS_SPECIAL && $.byteLength( title ) > TITLE_MAX_BYTES ) { + return false; + } + + // Can't make a link to a namespace alone. + if ( title === '' && namespace !== NS_MAIN ) { + return false; + } + + // Any remaining initial :s are illegal. + if ( title.charAt( 0 ) === ':' ) { + return false; + } + + // For backwards-compatibility with old mw.Title, we separate the extension from the + // rest of the title. + i = title.lastIndexOf( '.' ); + if ( i === -1 || title.length <= i + 1 ) { + // Extensions are the non-empty segment after the last dot + ext = null; + } else { + ext = title.slice( i + 1 ); + title = title.slice( 0, i ); + } + + return { + namespace: namespace, + title: title, + ext: ext, + fragment: fragment + }; + }, + + /** + * Convert db-key to readable text. + * + * @private + * @static + * @method text + * @param {string} s + * @return {string} + */ + text = function ( s ) { + if ( s !== null && s !== undefined ) { + return s.replace( /_/g, ' ' ); + } else { + return ''; + } + }, + + /** + * Sanitizes a string based on a rule set and a filter + * + * @private + * @static + * @method sanitize + * @param {string} s + * @param {Array} filter + * @return {string} + */ + sanitize = function ( s, filter ) { + var i, ruleLength, rule, m, filterLength, + rules = sanitationRules; + + for ( i = 0, ruleLength = rules.length; i < ruleLength; ++i ) { + rule = rules[i]; + for ( m = 0, filterLength = filter.length; m < filterLength; ++m ) { + if ( rule[filter[m]] ) { + s = s.replace( rule.pattern, rule.replace ); + } + } + } + return s; + }, + + /** + * Cuts a string to a specific byte length, assuming UTF-8 + * or less, if the last character is a multi-byte one + * + * @private + * @static + * @method trimToByteLength + * @param {string} s + * @param {number} length + * @return {string} + */ + trimToByteLength = function ( s, length ) { + var byteLength, chopOffChars, chopOffBytes; + + // bytelength is always greater or equal to the length in characters + s = s.substr( 0, length ); + while ( ( byteLength = $.byteLength( s ) ) > length ) { + // Calculate how many characters can be safely removed + // First, we need to know how many bytes the string exceeds the threshold + chopOffBytes = byteLength - length; + // A character in UTF-8 is at most 4 bytes + // One character must be removed in any case because the + // string is too long + chopOffChars = Math.max( 1, Math.floor( chopOffBytes / 4 ) ); + s = s.substr( 0, s.length - chopOffChars ); + } + return s; + }, + + /** + * Cuts a file name to a specific byte length + * + * @private + * @static + * @method trimFileNameToByteLength + * @param {string} name without extension + * @param {string} extension file extension + * @return {string} The full name, including extension + */ + trimFileNameToByteLength = function ( name, extension ) { + // There is a special byte limit for file names and ... remember the dot + return trimToByteLength( name, FILENAME_MAX_BYTES - extension.length - 1 ) + '.' + extension; + }, + + // Polyfill for ES5 Object.create + createObject = Object.create || ( function () { + return function ( o ) { + function Title() {} + if ( o !== Object( o ) ) { + throw new Error( 'Cannot inherit from a non-object' ); + } + Title.prototype = o; + return new Title(); + }; + }() ); + + /* Static members */ + + /** + * Constructor for Title objects with a null return instead of an exception for invalid titles. + * + * @static + * @param {string} title + * @param {number} [namespace=NS_MAIN] Default namespace + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.newFromText = function ( title, namespace ) { + var t, parsed = parse( title, namespace ); + if ( !parsed ) { + return null; + } + + t = createObject( Title.prototype ); + t.namespace = parsed.namespace; + t.title = parsed.title; + t.ext = parsed.ext; + t.fragment = parsed.fragment; + + return t; + }; + + /** + * Constructor for Title objects from user input altering that input to + * produce a title that MediaWiki will accept as legal + * + * @static + * @param {string} title + * @param {number} [defaultNamespace=NS_MAIN] + * If given, will used as default namespace for the given title. + * @param {Object} [options] additional options + * @param {string} [options.fileExtension=''] + * If the title is about to be created for the Media or File namespace, + * ensures the resulting Title has the correct extension. Useful, for example + * on systems that predict the type by content-sniffing, not by file extension. + * If different from empty string, `forUploading` is assumed. + * @param {boolean} [options.forUploading=true] + * Makes sure that a file is uploadable under the title returned. + * There are pages in the file namespace under which file upload is impossible. + * Automatically assumed if the title is created in the Media namespace. + * @return {mw.Title|null} A valid Title object or null if the input cannot be turned into a valid title + */ + Title.newFromUserInput = function ( title, defaultNamespace, options ) { + var namespace, m, id, ext, parts, normalizeExtension; + + // defaultNamespace is optional; check whether options moves up + if ( arguments.length < 3 && $.type( defaultNamespace ) === 'object' ) { + options = defaultNamespace; + defaultNamespace = undefined; + } + + // merge options into defaults + options = $.extend( { + fileExtension: '', + forUploading: true + }, options ); + + normalizeExtension = function ( extension ) { + // Remove only trailing space (that is removed by MW anyway) + extension = extension.toLowerCase().replace(/\s*$/, ''); + return extension; + }; + + namespace = defaultNamespace === undefined ? NS_MAIN : defaultNamespace; + + // Normalise whitespace and remove duplicates + title = $.trim( title.replace( rWhitespace, ' ' ) ); + + // Process initial colon + if ( title !== '' && title.charAt( 0 ) === ':' ) { + // Initial colon means main namespace instead of specified default + namespace = NS_MAIN; + title = title + // Strip colon + .substr( 1 ) + // Trim underscores + .replace( rUnderscoreTrim, '' ); + } + + // Process namespace prefix (if any) + m = title.match( rSplit ); + if ( m ) { + id = getNsIdByName( m[1] ); + if ( id !== false ) { + // Ordinary namespace + namespace = id; + title = m[2]; + } + } + + if ( namespace === NS_MEDIA + || ( ( options.forUploading || options.fileExtension ) && ( namespace === NS_FILE ) ) + ) { + + title = sanitize( title, [ 'generalRule', 'fileRule' ] ); + + // Operate on the file extension + // Although it is possible having spaces between the name and the ".ext" this isn't nice for + // operating systems hiding file extensions -> strip them later on + parts = title.split( '.' ); + + if ( parts.length > 1 ) { + + // Get the last part, which is supposed to be the file extension + ext = parts.pop(); + + // Does the supplied file name carry the desired file extension? + if ( options.fileExtension + && normalizeExtension( ext ) !== normalizeExtension( options.fileExtension ) + ) { + + // No, push back, whatever there was after the dot + parts.push( ext ); + + // And add the desired file extension later + ext = options.fileExtension; + } + + // Remove whitespace of the name part (that W/O extension) + title = $.trim( parts.join( '.' ) ); + + // Cut, if too long and append file extension + title = trimFileNameToByteLength( title, ext ); + + } else { + + // Missing file extension + title = $.trim( parts.join( '.' ) ); + + if ( options.fileExtension ) { + + // Cut, if too long and append the desired file extension + title = trimFileNameToByteLength( title, options.fileExtension ); + + } else { + + // Name has no file extension and a fallback wasn't provided either + return null; + } + } + } else { + + title = sanitize( title, [ 'generalRule' ] ); + + // Cut titles exceeding the TITLE_MAX_BYTES byte size limit + // (size of underlying database field) + if ( namespace !== NS_SPECIAL ) { + title = trimToByteLength( title, TITLE_MAX_BYTES ); + } + } + + // Any remaining initial :s are illegal. + title = title.replace( /^\:+/, '' ); + + return Title.newFromText( title, namespace ); + }; + + /** + * Sanitizes a file name as supplied by the user, originating in the user's file system + * so it is most likely a valid MediaWiki title and file name after processing. + * Returns null on fatal errors. + * + * @static + * @param {string} uncleanName The unclean file name including file extension but + * without namespace + * @param {string} [fileExtension] the desired file extension + * @return {mw.Title|null} A valid Title object or null if the title is invalid + */ + Title.newFromFileName = function ( uncleanName, fileExtension ) { + + return Title.newFromUserInput( 'File:' + uncleanName, { + fileExtension: fileExtension, + forUploading: true + } ); + }; + + /** + * Get the file title from an image element + * + * var title = mw.Title.newFromImg( $( 'img:first' ) ); + * + * @static + * @param {HTMLElement|jQuery} img The image to use as a base + * @return {mw.Title|null} The file title or null if unsuccessful + */ + Title.newFromImg = function ( img ) { + var matches, i, regex, src, decodedSrc, + + // thumb.php-generated thumbnails + thumbPhpRegex = /thumb\.php/, + regexes = [ + // Thumbnails + /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)\/[^\s\/]+-(?:\1|thumbnail)[^\s\/]*$/, + + // Thumbnails in non-hashed upload directories + /\/([^\s\/]+)\/[^\s\/]+-(?:\1|thumbnail)[^\s\/]*$/, + + // Full size images + /\/[a-f0-9]\/[a-f0-9]{2}\/([^\s\/]+)$/, + + // Full-size images in non-hashed upload directories + /\/([^\s\/]+)$/ + ], + + recount = regexes.length; + + src = img.jquery ? img[0].src : img.src; + + matches = src.match( thumbPhpRegex ); + + if ( matches ) { + return mw.Title.newFromText( 'File:' + mw.util.getParamValue( 'f', src ) ); + } + + decodedSrc = decodeURIComponent( src ); + + for ( i = 0; i < recount; i++ ) { + regex = regexes[i]; + matches = decodedSrc.match( regex ); + + if ( matches && matches[1] ) { + return mw.Title.newFromText( 'File:' + matches[1] ); + } + } + + return null; + }; + + /** + * Whether this title exists on the wiki. + * + * @static + * @param {string|mw.Title} title prefixed db-key name (string) or instance of Title + * @return {boolean|null} Boolean if the information is available, otherwise null + */ + Title.exists = function ( title ) { + var match, + type = $.type( title ), + obj = Title.exist.pages; + + if ( type === 'string' ) { + match = obj[title]; + } else if ( type === 'object' && title instanceof Title ) { + match = obj[title.toString()]; + } else { + throw new Error( 'mw.Title.exists: title must be a string or an instance of Title' ); + } + + if ( typeof match === 'boolean' ) { + return match; + } + + return null; + }; + + /** + * Store page existence + * + * @static + * @property {Object} exist + * @property {Object} exist.pages Keyed by title. Boolean true value indicates page does exist. + * + * @property {Function} exist.set The setter function. + * + * Example to declare existing titles: + * + * Title.exist.set( ['User:John_Doe', ...] ); + * + * Example to declare titles nonexistent: + * + * Title.exist.set( ['File:Foo_bar.jpg', ...], false ); + * + * @property {string|Array} exist.set.titles Title(s) in strict prefixedDb title form + * @property {boolean} [exist.set.state=true] State of the given titles + * @return {boolean} + */ + Title.exist = { + pages: {}, + + set: function ( titles, state ) { + titles = $.isArray( titles ) ? titles : [titles]; + state = state === undefined ? true : !!state; + var pages = this.pages, i, len = titles.length; + for ( i = 0; i < len; i++ ) { + pages[ titles[i] ] = state; + } + return true; + } + }; + + /* Public members */ + + Title.prototype = { + constructor: Title, + + /** + * Get the namespace number + * + * Example: 6 for "File:Example_image.svg". + * + * @return {number} + */ + getNamespaceId: function () { + return this.namespace; + }, + + /** + * Get the namespace prefix (in the content language) + * + * Example: "File:" for "File:Example_image.svg". + * In #NS_MAIN this is '', otherwise namespace name plus ':' + * + * @return {string} + */ + getNamespacePrefix: function () { + return this.namespace === NS_MAIN ? + '' : + ( mw.config.get( 'wgFormattedNamespaces' )[ this.namespace ].replace( / /g, '_' ) + ':' ); + }, + + /** + * Get the page name without extension or namespace prefix + * + * Example: "Example_image" for "File:Example_image.svg". + * + * For the page title (full page name without namespace prefix), see #getMain. + * + * @return {string} + */ + getName: function () { + if ( $.inArray( this.namespace, mw.config.get( 'wgCaseSensitiveNamespaces' ) ) !== -1 ) { + return this.title; + } else { + return $.ucFirst( this.title ); + } + }, + + /** + * Get the page name (transformed by #text) + * + * Example: "Example image" for "File:Example_image.svg". + * + * For the page title (full page name without namespace prefix), see #getMainText. + * + * @return {string} + */ + getNameText: function () { + return text( this.getName() ); + }, + + /** + * Get the extension of the page name (if any) + * + * @return {string|null} Name extension or null if there is none + */ + getExtension: function () { + return this.ext; + }, + + /** + * Shortcut for appendable string to form the main page name. + * + * Returns a string like ".json", or "" if no extension. + * + * @return {string} + */ + getDotExtension: function () { + return this.ext === null ? '' : '.' + this.ext; + }, + + /** + * Get the main page name + * + * Example: "Example_image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getMain: function () { + return this.getName() + this.getDotExtension(); + }, + + /** + * Get the main page name (transformed by #text) + * + * Example: "Example image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getMainText: function () { + return text( this.getMain() ); + }, + + /** + * Get the full page name + * + * Example: "File:Example_image.svg". + * Most useful for API calls, anything that must identify the "title". + * + * @return {string} + */ + getPrefixedDb: function () { + return this.getNamespacePrefix() + this.getMain(); + }, + + /** + * Get the full page name (transformed by #text) + * + * Example: "File:Example image.svg" for "File:Example_image.svg". + * + * @return {string} + */ + getPrefixedText: function () { + return text( this.getPrefixedDb() ); + }, + + /** + * Get the page name relative to a namespace + * + * Example: + * + * - "Foo:Bar" relative to the Foo namespace becomes "Bar". + * - "Bar" relative to any non-main namespace becomes ":Bar". + * - "Foo:Bar" relative to any namespace other than Foo stays "Foo:Bar". + * + * @param {number} namespace The namespace to be relative to + * @return {string} + */ + getRelativeText: function ( namespace ) { + if ( this.getNamespaceId() === namespace ) { + return this.getMainText(); + } else if ( this.getNamespaceId() === NS_MAIN ) { + return ':' + this.getPrefixedText(); + } else { + return this.getPrefixedText(); + } + }, + + /** + * Get the fragment (if any). + * + * Note that this method (by design) does not include the hash character and + * the value is not url encoded. + * + * @return {string|null} + */ + getFragment: function () { + return this.fragment; + }, + + /** + * Get the URL to this title + * + * @see mw.util#getUrl + * @param {Object} [params] A mapping of query parameter names to values, + * e.g. `{ action: 'edit' }`. + * @return {string} + */ + getUrl: function ( params ) { + return mw.util.getUrl( this.toString(), params ); + }, + + /** + * Whether this title exists on the wiki. + * + * @see #static-method-exists + * @return {boolean|null} Boolean if the information is available, otherwise null + */ + exists: function () { + return Title.exists( this ); + } + }; + + /** + * @alias #getPrefixedDb + * @method + */ + Title.prototype.toString = Title.prototype.getPrefixedDb; + + /** + * @alias #getPrefixedText + * @method + */ + Title.prototype.toText = Title.prototype.getPrefixedText; + + // Expose + mw.Title = Title; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.Uri.js b/resources/src/mediawiki/mediawiki.Uri.js new file mode 100644 index 00000000..55663128 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.Uri.js @@ -0,0 +1,403 @@ +/** + * Library for simple URI parsing and manipulation. + * + * Intended to be minimal, but featureful; do not expect full RFC 3986 compliance. The use cases we + * have in mind are constructing 'next page' or 'previous page' URLs, detecting whether we need to + * use cross-domain proxies for an API, constructing simple URL-based API calls, etc. Parsing here + * is regex-based, so may not work on all URIs, but is good enough for most. + * + * You can modify the properties directly, then use the #toString method to extract the full URI + * string again. Example: + * + * var uri = new mw.Uri( 'http://example.com/mysite/mypage.php?quux=2' ); + * + * if ( uri.host == 'example.com' ) { + * uri.host = 'foo.example.com'; + * uri.extend( { bar: 1 } ); + * + * $( 'a#id1' ).attr( 'href', uri ); + * // anchor with id 'id1' now links to http://foo.example.com/mysite/mypage.php?bar=1&quux=2 + * + * $( 'a#id2' ).attr( 'href', uri.clone().extend( { bar: 3, pif: 'paf' } ) ); + * // anchor with id 'id2' now links to http://foo.example.com/mysite/mypage.php?bar=3&quux=2&pif=paf + * } + * + * Given a URI like + * `http://usr:pwd@www.example.com:81/dir/dir.2/index.htm?q1=0&&test1&test2=&test3=value+%28escaped%29&r=1&r=2#top` + * the returned object will have the following properties: + * + * protocol 'http' + * user 'usr' + * password 'pwd' + * host 'www.example.com' + * port '81' + * path '/dir/dir.2/index.htm' + * query { + * q1: '0', + * test1: null, + * test2: '', + * test3: 'value (escaped)' + * r: ['1', '2'] + * } + * fragment 'top' + * + * (N.b., 'password' is technically not allowed for HTTP URIs, but it is possible with other kinds + * of URIs.) + * + * Parsing based on parseUri 1.2.2 (c) Steven Levithan <http://stevenlevithan.com>, MIT License. + * <http://stevenlevithan.com/demo/parseuri/js/> + * + * @class mw.Uri + */ + +( function ( mw, $ ) { + /** + * Function that's useful when constructing the URI string -- we frequently encounter the pattern + * of having to add something to the URI as we go, but only if it's present, and to include a + * character before or after if so. + * + * @private + * @static + * @param {string|undefined} pre To prepend + * @param {string} val To include + * @param {string} post To append + * @param {boolean} raw If true, val will not be encoded + * @return {string} Result + */ + function cat( pre, val, post, raw ) { + if ( val === undefined || val === null || val === '' ) { + return ''; + } + return pre + ( raw ? val : mw.Uri.encode( val ) ) + post; + } + + /** + * Regular expressions to parse many common URIs. + * + * @private + * @static + * @property {Object} parser + */ + var parser = { + strict: /^(?:([^:\/?#]+):)?(?:\/\/(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?)?((?:[^?#\/]*\/)*[^?#]*)(?:\?([^#]*))?(?:#(.*))?/, + loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?(?:(?:([^:@\/?#]*)(?::([^:@\/?#]*))?)?@)?([^:\/?#]*)(?::(\d*))?((?:\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?[^?#\/]*)(?:\?([^#]*))?(?:#(.*))?/ + }, + + /** + * The order here matches the order of captured matches in the `parser` property regexes. + * + * @private + * @static + * @property {Array} properties + */ + properties = [ + 'protocol', + 'user', + 'password', + 'host', + 'port', + 'path', + 'query', + 'fragment' + ]; + + /** + * @property {string} protocol For example `http` (always present) + */ + /** + * @property {string|undefined} user For example `usr` + */ + /** + * @property {string|undefined} password For example `pwd` + */ + /** + * @property {string} host For example `www.example.com` (always present) + */ + /** + * @property {string|undefined} port For example `81` + */ + /** + * @property {string} path For example `/dir/dir.2/index.htm` (always present) + */ + /** + * @property {Object} query For example `{ a: '0', b: '', c: 'value' }` (always present) + */ + /** + * @property {string|undefined} fragment For example `top` + */ + + /** + * A factory method to create a variation of mw.Uri with a different default location (for + * relative URLs, including protocol-relative URLs). Used so the library is still testable & + * purely functional. + * + * @method + * @member mw + */ + mw.UriRelative = function ( documentLocation ) { + var defaultUri; + + /** + * @class mw.Uri + * @constructor + * + * Construct a new URI object. Throws error if arguments are illegal/impossible, or + * otherwise don't parse. + * + * @param {Object|string} [uri] URI string, or an Object with appropriate properties (especially + * another URI object to clone). Object must have non-blank `protocol`, `host`, and `path` + * properties. If omitted (or set to `undefined`, `null` or empty string), then an object + * will be created for the default `uri` of this constructor (`document.location` for + * mw.Uri, other values for other instances -- see mw.UriRelative for details). + * @param {Object|boolean} [options] Object with options, or (backwards compatibility) a boolean + * for strictMode + * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url. + * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters + * override each other (`true`) or automagically convert them to an array (`false`). + */ + function Uri( uri, options ) { + options = typeof options === 'object' ? options : { strictMode: !!options }; + options = $.extend( { + strictMode: false, + overrideKeys: false + }, options ); + + if ( uri !== undefined && uri !== null && uri !== '' ) { + if ( typeof uri === 'string' ) { + this.parse( uri, options ); + } else if ( typeof uri === 'object' ) { + // Copy data over from existing URI object + for ( var prop in uri ) { + // Only copy direct properties, not inherited ones + if ( uri.hasOwnProperty( prop ) ) { + // Deep copy object properties + if ( $.isArray( uri[prop] ) || $.isPlainObject( uri[prop] ) ) { + this[prop] = $.extend( true, {}, uri[prop] ); + } else { + this[prop] = uri[prop]; + } + } + } + if ( !this.query ) { + this.query = {}; + } + } + } else { + // If we didn't get a URI in the constructor, use the default one. + return defaultUri.clone(); + } + + // protocol-relative URLs + if ( !this.protocol ) { + this.protocol = defaultUri.protocol; + } + // No host given: + if ( !this.host ) { + this.host = defaultUri.host; + // port ? + if ( !this.port ) { + this.port = defaultUri.port; + } + } + if ( this.path && this.path.charAt( 0 ) !== '/' ) { + // A real relative URL, relative to defaultUri.path. We can't really handle that since we cannot + // figure out whether the last path component of defaultUri.path is a directory or a file. + throw new Error( 'Bad constructor arguments' ); + } + if ( !( this.protocol && this.host && this.path ) ) { + throw new Error( 'Bad constructor arguments' ); + } + } + + /** + * Encode a value for inclusion in a url. + * + * Standard encodeURIComponent, with extra stuff to make all browsers work similarly and more + * compliant with RFC 3986. Similar to rawurlencode from PHP and our JS library + * mw.util.rawurlencode, except this also replaces spaces with `+`. + * + * @static + * @param {string} s String to encode + * @return {string} Encoded string for URI + */ + Uri.encode = function ( s ) { + return encodeURIComponent( s ) + .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' ) + .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ) + .replace( /%20/g, '+' ); + }; + + /** + * Decode a url encoded value. + * + * Reversed #encode. Standard decodeURIComponent, with addition of replacing + * `+` with a space. + * + * @static + * @param {string} s String to decode + * @return {string} Decoded string + */ + Uri.decode = function ( s ) { + return decodeURIComponent( s.replace( /\+/g, '%20' ) ); + }; + + Uri.prototype = { + + /** + * Parse a string and set our properties accordingly. + * + * @private + * @param {string} str URI, see constructor. + * @param {Object} options See constructor. + */ + parse: function ( str, options ) { + var q, matches, + uri = this; + + // Apply parser regex and set all properties based on the result + matches = parser[ options.strictMode ? 'strict' : 'loose' ].exec( str ); + $.each( properties, function ( i, property ) { + uri[ property ] = matches[ i + 1 ]; + } ); + + // uri.query starts out as the query string; we will parse it into key-val pairs then make + // that object the "query" property. + // we overwrite query in uri way to make cloning easier, it can use the same list of properties. + q = {}; + // using replace to iterate over a string + if ( uri.query ) { + uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( $0, $1, $2, $3 ) { + var k, v; + if ( $1 ) { + k = Uri.decode( $1 ); + v = ( $2 === '' || $2 === undefined ) ? null : Uri.decode( $3 ); + + // If overrideKeys, always (re)set top level value. + // If not overrideKeys but this key wasn't set before, then we set it as well. + if ( options.overrideKeys || q[ k ] === undefined ) { + q[ k ] = v; + + // Use arrays if overrideKeys is false and key was already seen before + } else { + // Once before, still a string, turn into an array + if ( typeof q[ k ] === 'string' ) { + q[ k ] = [ q[ k ] ]; + } + // Add to the array + if ( $.isArray( q[ k ] ) ) { + q[ k ].push( v ); + } + } + } + } ); + } + uri.query = q; + }, + + /** + * Get user and password section of a URI. + * + * @return {string} + */ + getUserInfo: function () { + return cat( '', this.user, cat( ':', this.password, '' ) ); + }, + + /** + * Get host and port section of a URI. + * + * @return {string} + */ + getHostPort: function () { + return this.host + cat( ':', this.port, '' ); + }, + + /** + * Get the userInfo, host and port section of the URI. + * + * In most real-world URLs this is simply the hostname, but the definition of 'authority' section is more general. + * + * @return {string} + */ + getAuthority: function () { + return cat( '', this.getUserInfo(), '@' ) + this.getHostPort(); + }, + + /** + * Get the query arguments of the URL, encoded into a string. + * + * Does not preserve the original order of arguments passed in the URI. Does handle escaping. + * + * @return {string} + */ + getQueryString: function () { + var args = []; + $.each( this.query, function ( key, val ) { + var k = Uri.encode( key ), + vals = $.isArray( val ) ? val : [ val ]; + $.each( vals, function ( i, v ) { + if ( v === null ) { + args.push( k ); + } else if ( k === 'title' ) { + args.push( k + '=' + mw.util.wikiUrlencode( v ) ); + } else { + args.push( k + '=' + Uri.encode( v ) ); + } + } ); + } ); + return args.join( '&' ); + }, + + /** + * Get everything after the authority section of the URI. + * + * @return {string} + */ + getRelativePath: function () { + return this.path + cat( '?', this.getQueryString(), '', true ) + cat( '#', this.fragment, '' ); + }, + + /** + * Get the entire URI string. + * + * May not be precisely the same as input due to order of query arguments. + * + * @return {string} The URI string + */ + toString: function () { + return this.protocol + '://' + this.getAuthority() + this.getRelativePath(); + }, + + /** + * Clone this URI + * + * @return {Object} New URI object with same properties + */ + clone: function () { + return new Uri( this ); + }, + + /** + * Extend the query section of the URI with new parameters. + * + * @param {Object} parameters Query parameters to add to ours (or to override ours with) as an + * object + * @return {Object} This URI object + */ + extend: function ( parameters ) { + $.extend( this.query, parameters ); + return this; + } + }; + + defaultUri = new Uri( documentLocation ); + + return Uri; + }; + + // If we are running in a browser, inject the current document location (for relative URLs). + if ( document && document.location && document.location.href ) { + mw.Uri = mw.UriRelative( document.location.href ); + } + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.content.json.css b/resources/src/mediawiki/mediawiki.content.json.css new file mode 100644 index 00000000..d93e291e --- /dev/null +++ b/resources/src/mediawiki/mediawiki.content.json.css @@ -0,0 +1,53 @@ +/*! + * CSS for styling HTML-formatted JSON Schema objects + * + * @file + * @author Munaf Assaf <massaf@wikimedia.org> + */ + +.mw-json { + border-collapse: collapse; + border-spacing: 0; + font-style: normal; +} + +.mw-json th, +.mw-json td { + border: 1px solid gray; + font-size: 16px; + padding: 0.5em 1em; +} + +.mw-json td { + background-color: #eee; + font-style: italic; +} + +.mw-json .value { + background-color: #dcfae3; + font-family: monospace, monospace; + white-space: pre-wrap; +} + +.mw-json tr { + margin-bottom: 0.5em; +} + +.mw-json th { + background-color: #fff; + font-weight: normal; +} + +.mw-json caption { + /* For stylistic reasons, suppress the caption of the outermost table */ + display: none; +} + +.mw-json table caption { + color: gray; + display: inline-block; + font-size: 10px; + font-style: italic; + margin-bottom: 0.5em; + text-align: left; +} diff --git a/resources/src/mediawiki/mediawiki.cookie.js b/resources/src/mediawiki/mediawiki.cookie.js new file mode 100644 index 00000000..6f9f0abb --- /dev/null +++ b/resources/src/mediawiki/mediawiki.cookie.js @@ -0,0 +1,126 @@ +( function ( mw, $ ) { + 'use strict'; + + /** + * Provides an API for getting and setting cookies that is + * syntactically and functionally similar to the server-side cookie + * API (`WebRequest#getCookie` and `WebResponse#setcookie`). + * + * @author Sam Smith <samsmith@wikimedia.org> + * @author Matthew Flaschen <mflaschen@wikimedia.org> + * @author Timo Tijhof <krinklemail@gmail.com> + * + * @class mw.cookie + * @singleton + */ + mw.cookie = { + + /** + * Sets or deletes a cookie. + * + * While this is natural in JavaScript, contrary to `WebResponse#setcookie` in PHP, the + * default values for the `options` properties only apply if that property isn't set + * already in your options object (e.g. passing `{ secure: null }` or `{ secure: undefined }` + * overrides the default value for `options.secure`). + * + * @param {string} key + * @param {string|null} value Value of cookie. If `value` is `null` then this method will + * instead remove a cookie by name of `key`. + * @param {Object|Date} [options] Options object, or expiry date + * @param {Date|null} [options.expires=wgCookieExpiration] The expiry date of the cookie. + * + * Default cookie expiration is based on `wgCookieExpiration`. If `wgCookieExpiration` is + * 0, a session cookie is set (expires when the browser is closed). For non-zero values of + * `wgCookieExpiration`, the cookie expires `wgCookieExpiration` seconds from now. + * + * If options.expires is null, then a session cookie is set. + * @param {string} [options.prefix=wgCookiePrefix] The prefix of the key + * @param {string} [options.domain=wgCookieDomain] The domain attribute of the cookie + * @param {string} [options.path=wgCookiePath] The path attribute of the cookie + * @param {boolean} [options.secure=false] Whether or not to include the secure attribute. + * (Does **not** use the wgCookieSecure configuration variable) + */ + set: function ( key, value, options ) { + var config, defaultOptions, date; + + // wgCookieSecure is not used for now, since 'detect' could not work with + // ResourceLoaderStartUpModule, as module cache is not fragmented by protocol. + config = mw.config.get( [ + 'wgCookiePrefix', + 'wgCookieDomain', + 'wgCookiePath', + 'wgCookieExpiration' + ] ); + + defaultOptions = { + prefix: config.wgCookiePrefix, + domain: config.wgCookieDomain, + path: config.wgCookiePath, + secure: false + }; + + // Options argument can also be a shortcut for the expiry + // Expiry can be a Date or null + if ( $.type( options ) !== 'object' ) { + // Also takes care of options = undefined, in which case we also don't need $.extend() + defaultOptions.expires = options; + options = defaultOptions; + } else { + options = $.extend( defaultOptions, options ); + } + + // $.cookie makes session cookies when expiry is omitted, + // however our default is to expire wgCookieExpiration seconds from now. + // Note: If wgCookieExpiration is 0, that is considered a special value indicating + // all cookies should be session cookies by default. + if ( options.expires === undefined && config.wgCookieExpiration !== 0 ) { + date = new Date(); + date.setTime( Number( date ) + ( config.wgCookieExpiration * 1000 ) ); + options.expires = date; + } else if ( options.expires === null ) { + // $.cookie makes a session cookie when expires is omitted + delete options.expires; + } + + // Process prefix + key = options.prefix + key; + delete options.prefix; + + // Process value + if ( value !== null ) { + value = String( value ); + } + + // Other options are handled by $.cookie + $.cookie( key, value, options ); + }, + + /** + * Gets the value of a cookie. + * + * @param {string} key + * @param {string} [prefix=wgCookiePrefix] The prefix of the key. If `prefix` is + * `undefined` or `null`, then `wgCookiePrefix` is used + * @param {Mixed} [defaultValue=null] + * @return {string} If the cookie exists, then the value of the + * cookie, otherwise `defaultValue` + */ + get: function ( key, prefix, defaultValue ) { + var result; + + if ( prefix === undefined || prefix === null ) { + prefix = mw.config.get( 'wgCookiePrefix' ); + } + + // Was defaultValue omitted? + if ( arguments.length < 3 ) { + defaultValue = null; + } + + result = $.cookie( prefix + key ); + + return result !== null ? result : defaultValue; + } + }; + +} ( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.debug.init.js b/resources/src/mediawiki/mediawiki.debug.init.js new file mode 100644 index 00000000..0f85e80d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.debug.init.js @@ -0,0 +1,3 @@ +jQuery( function () { + mediaWiki.Debug.init(); +} ); diff --git a/resources/src/mediawiki/mediawiki.debug.js b/resources/src/mediawiki/mediawiki.debug.js new file mode 100644 index 00000000..4935984f --- /dev/null +++ b/resources/src/mediawiki/mediawiki.debug.js @@ -0,0 +1,391 @@ +( function ( mw, $ ) { + 'use strict'; + + var debug, + hovzer = $.getFootHovzer(); + + /** + * Debug toolbar. + * + * Enabled server-side through `$wgDebugToolbar`. + * + * @class mw.Debug + * @singleton + * @author John Du Hart + * @since 1.19 + */ + debug = mw.Debug = { + /** + * Toolbar container element + * + * @property {jQuery} + */ + $container: null, + + /** + * Object containing data for the debug toolbar + * + * @property {Object} + */ + data: {}, + + /** + * Initialize the debugging pane + * + * Shouldn't be called before the document is ready + * (since it binds to elements on the page). + * + * @param {Object} [data] Defaults to 'debugInfo' from mw.config + */ + init: function ( data ) { + + this.data = data || mw.config.get( 'debugInfo' ); + this.buildHtml(); + + // Insert the container into the DOM + hovzer.$.append( this.$container ); + hovzer.update(); + + $( '.mw-debug-panelink' ).click( this.switchPane ); + }, + + /** + * Switch between panes + * + * Should be called with an HTMLElement as its thisArg, + * because it's meant to be an event handler. + * + * TODO: Store cookie for last pane open. + * + * @param {jQuery.Event} e + */ + switchPane: function ( e ) { + var currentPaneId = debug.$container.data( 'currentPane' ), + requestedPaneId = $( this ).prop( 'id' ).slice( 9 ), + $currentPane = $( '#mw-debug-pane-' + currentPaneId ), + $requestedPane = $( '#mw-debug-pane-' + requestedPaneId ), + hovDone = false; + + function updateHov() { + if ( !hovDone ) { + hovzer.update(); + hovDone = true; + } + } + + // Skip hash fragment handling. Prevents screen from jumping. + e.preventDefault(); + + $( this ).addClass( 'current ' ); + $( '.mw-debug-panelink' ).not( this ).removeClass( 'current ' ); + + // Hide the current pane + if ( requestedPaneId === currentPaneId ) { + $currentPane.slideUp( updateHov ); + debug.$container.data( 'currentPane', null ); + return; + } + + debug.$container.data( 'currentPane', requestedPaneId ); + + if ( currentPaneId === undefined || currentPaneId === null ) { + $requestedPane.slideDown( updateHov ); + } else { + $currentPane.hide(); + $requestedPane.show(); + updateHov(); + } + }, + + /** + * Construct the HTML for the debugging toolbar + */ + buildHtml: function () { + var $container, $bits, panes, id, gitInfo; + + $container = $( '<div id="mw-debug-toolbar" class="mw-debug" lang="en" dir="ltr"></div>' ); + + $bits = $( '<div class="mw-debug-bits"></div>' ); + + /** + * Returns a jQuery element for a debug-bit div + * + * @ignore + * @param {string} id + * @return {jQuery} + */ + function bitDiv( id ) { + return $( '<div>' ).prop( { + id: 'mw-debug-' + id, + className: 'mw-debug-bit' + } ) + .appendTo( $bits ); + } + + /** + * Returns a jQuery element for a pane link + * + * @ignore + * @param {string} id + * @param {string} text + * @return {jQuery} + */ + function paneLabel( id, text ) { + return $( '<a>' ) + .prop( { + className: 'mw-debug-panelabel', + href: '#mw-debug-pane-' + id + } ) + .text( text ); + } + + /** + * Returns a jQuery element for a debug-bit div with a for a pane link + * + * @ignore + * @param {string} id CSS id snippet. Will be prefixed with 'mw-debug-' + * @param {string} text Text to show + * @param {string} count Optional count to show + * @return {jQuery} + */ + function paneTriggerBitDiv( id, text, count ) { + if ( count ) { + text = text + ' (' + count + ')'; + } + return $( '<div>' ).prop( { + id: 'mw-debug-' + id, + className: 'mw-debug-bit mw-debug-panelink' + } ) + .append( paneLabel( id, text ) ) + .appendTo( $bits ); + } + + paneTriggerBitDiv( 'console', 'Console', this.data.log.length ); + + paneTriggerBitDiv( 'querylist', 'Queries', this.data.queries.length ); + + paneTriggerBitDiv( 'debuglog', 'Debug log', this.data.debugLog.length ); + + paneTriggerBitDiv( 'request', 'Request' ); + + paneTriggerBitDiv( 'includes', 'PHP includes', this.data.includes.length ); + + paneTriggerBitDiv( 'profile', 'Profile', this.data.profile.length ); + + gitInfo = ''; + if ( this.data.gitRevision !== false ) { + gitInfo = '(' + this.data.gitRevision.slice( 0, 7 ) + ')'; + if ( this.data.gitViewUrl !== false ) { + gitInfo = $( '<a>' ) + .attr( 'href', this.data.gitViewUrl ) + .text( gitInfo ); + } + } + + bitDiv( 'mwversion' ) + .append( $( '<a href="//www.mediawiki.org/">MediaWiki</a>' ) ) + .append( document.createTextNode( ': ' + this.data.mwVersion + ' ' ) ) + .append( gitInfo ); + + if ( this.data.gitBranch !== false ) { + bitDiv( 'gitbranch' ).text( 'Git branch: ' + this.data.gitBranch ); + } + + bitDiv( 'phpversion' ) + .append( $( this.data.phpEngine === 'HHVM' + ? '<a href="http://hhvm.com/">HHVM</a>' + : '<a href="https://php.net/">PHP</a>' + ) ) + .append( ': ' + this.data.phpVersion ); + + bitDiv( 'time' ) + .text( 'Time: ' + this.data.time.toFixed( 5 ) ); + + bitDiv( 'memory' ) + .text( 'Memory: ' + this.data.memory + ' (Peak: ' + this.data.memoryPeak + ')' ); + + $bits.appendTo( $container ); + + panes = { + console: this.buildConsoleTable(), + querylist: this.buildQueryTable(), + debuglog: this.buildDebugLogTable(), + request: this.buildRequestPane(), + includes: this.buildIncludesPane(), + profile: this.buildProfilePane() + }; + + for ( id in panes ) { + if ( !panes.hasOwnProperty( id ) ) { + continue; + } + + $( '<div>' ) + .prop( { + className: 'mw-debug-pane', + id: 'mw-debug-pane-' + id + } ) + .append( panes[id] ) + .appendTo( $container ); + } + + this.$container = $container; + }, + + /** + * Build the console panel + */ + buildConsoleTable: function () { + var $table, entryTypeText, i, length, entry; + + $table = $( '<table id="mw-debug-console">' ); + + $( '<colgroup>' ).css( 'width', /* padding = */ 20 + ( 10 * /* fontSize = */ 11 ) ).appendTo( $table ); + $( '<colgroup>' ).appendTo( $table ); + $( '<colgroup>' ).css( 'width', 350 ).appendTo( $table ); + + entryTypeText = function ( entryType ) { + switch ( entryType ) { + case 'log': + return 'Log'; + case 'warn': + return 'Warning'; + case 'deprecated': + return 'Deprecated'; + default: + return 'Unknown'; + } + }; + + for ( i = 0, length = this.data.log.length; i < length; i += 1 ) { + entry = this.data.log[i]; + entry.typeText = entryTypeText( entry.type ); + + $( '<tr>' ) + .append( $( '<td>' ) + .text( entry.typeText ) + .addClass( 'mw-debug-console-' + entry.type ) + ) + .append( $( '<td>' ).html( entry.msg ) ) + .append( $( '<td>' ).text( entry.caller ) ) + .appendTo( $table ); + } + + return $table; + }, + + /** + * Build query list pane + * + * @return {jQuery} + */ + buildQueryTable: function () { + var $table, i, length, query; + + $table = $( '<table id="mw-debug-querylist"></table>' ); + + $( '<tr>' ) + .append( $( '<th>#</th>' ).css( 'width', '4em' ) ) + .append( $( '<th>SQL</th>' ) ) + .append( $( '<th>Time</th>' ).css( 'width', '8em' ) ) + .append( $( '<th>Call</th>' ).css( 'width', '18em' ) ) + .appendTo( $table ); + + for ( i = 0, length = this.data.queries.length; i < length; i += 1 ) { + query = this.data.queries[i]; + + $( '<tr>' ) + .append( $( '<td>' ).text( i + 1 ) ) + .append( $( '<td>' ).text( query.sql ) ) + .append( $( '<td class="stats">' ).text( ( query.time * 1000 ).toFixed( 4 ) + 'ms' ) ) + .append( $( '<td>' ).text( query['function'] ) ) + .appendTo( $table ); + } + + return $table; + }, + + /** + * Build legacy debug log pane + * + * @return {jQuery} + */ + buildDebugLogTable: function () { + var $list, i, length, line; + $list = $( '<ul>' ); + + for ( i = 0, length = this.data.debugLog.length; i < length; i += 1 ) { + line = this.data.debugLog[i]; + $( '<li>' ) + .html( mw.html.escape( line ).replace( /\n/g, '<br />\n' ) ) + .appendTo( $list ); + } + + return $list; + }, + + /** + * Build request information pane + * + * @return {jQuery} + */ + buildRequestPane: function () { + + function buildTable( title, data ) { + var $unit, $table, key; + + $unit = $( '<div>' ).append( $( '<h2>' ).text( title ) ); + + $table = $( '<table>' ).appendTo( $unit ); + + $( '<tr>' ) + .html( '<th>Key</th><th>Value</th>' ) + .appendTo( $table ); + + for ( key in data ) { + if ( !data.hasOwnProperty( key ) ) { + continue; + } + + $( '<tr>' ) + .append( $( '<th>' ).text( key ) ) + .append( $( '<td>' ).text( data[key] ) ) + .appendTo( $table ); + } + + return $unit; + } + + return $( '<div>' ) + .text( this.data.request.method + ' ' + this.data.request.url ) + .append( buildTable( 'Headers', this.data.request.headers ) ) + .append( buildTable( 'Parameters', this.data.request.params ) ); + }, + + /** + * Build included files pane + * + * @return {jQuery} + */ + buildIncludesPane: function () { + var $table, i, length, file; + + $table = $( '<table>' ); + + for ( i = 0, length = this.data.includes.length; i < length; i += 1 ) { + file = this.data.includes[i]; + $( '<tr>' ) + .append( $( '<td>' ).text( file.name ) ) + .append( $( '<td class="nr">' ).text( file.size ) ) + .appendTo( $table ); + } + + return $table; + }, + + buildProfilePane: function () { + return mw.Debug.profile.init(); + } + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.debug.less b/resources/src/mediawiki/mediawiki.debug.less new file mode 100644 index 00000000..949c5586 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.debug.less @@ -0,0 +1,189 @@ +.mw-debug { + width: 100%; + background-color: #eee; + border-top: 1px solid #aaa; + + pre { + font-size: 11px; + padding: 0; + margin: 0; + background: none; + border: none; + } + + table { + border-spacing: 0; + width: 100%; + table-layout: fixed; + + td, + th { + padding: 4px 10px; + } + + td { + border-bottom: 1px solid #eee; + word-wrap: break-word; + + &.nr { + text-align: right; + } + + span.stats { + color: #808080; + } + } + + tr { + background-color: #fff; + + &:nth-child(even) { + background-color: #f9f9f9; + } + } + } + + ul { + margin: 0; + list-style: none; + } + + li { + padding: 4px 0; + width: 100%; + } +} + +.mw-debug-bits { + text-align: center; + border-bottom: 1px solid #aaa; +} + +.mw-debug-bit { + display: inline-block; + padding: 10px 5px; + font-size: 13px; + /* IE-hack for display: inline-block */ + zoom: 1; + *display:inline; +} + +.mw-debug-panelink { + background-color: #eee; + border-right: 1px solid #ccc; + + &:first-child { + border-left: 1px solid #ccc; + } + + &:hover { + background-color: #fefefe; + cursor: pointer; + } + + &.current { + background-color: #dedede; + } +} + +a.mw-debug-panelabel, +a.mw-debug-panelabel:visited { + color: #000; +} + +.mw-debug-pane { + height: 300px; + overflow: scroll; + display: none; + font-size: 11px; + background-color: #e1eff2; + box-sizing: border-box; +} + +#mw-debug-pane-debuglog, +#mw-debug-pane-request { + padding: 20px; +} + +#mw-debug-pane-request { + table { + width: 100%; + margin: 10px 0 30px; + } + + tr, + th, + td, + table { + border: 1px solid #D0DBB3; + border-collapse: collapse; + margin: 0; + } + + th, + td { + font-size: 12px; + padding: 8px 10px; + } + + th { + background-color: #F1F7E2; + font-weight: bold; + } + + td { + background-color: white; + } +} + +#mw-debug-console tr td { + &:first-child { + font-weight: bold; + vertical-align: top; + } + + &:last-child { + vertical-align: top; + } +} + +.mw-debug-backtrace { + padding: 5px 10px; + margin: 5px; + background-color: #dedede; + + span { + font-weight: bold; + color: #111; + } + + ul { + padding-left: 10px; + } + + li { + width: auto; + padding: 0; + color: #333; + font-size: 10px; + margin-bottom: 0; + line-height: 1em; + } +} + +.mw-debug-console-log { + background-color: #add8e6; +} + +.mw-debug-console-warn { + background-color: #ffa07a; +} + +.mw-debug-console-deprecated { + background-color: #ffb6c1; +} + +/* Cheapo hack to hide the first 3 lines of the backtrace */ +.mw-debug-backtrace li:nth-child(-n+3) { + display: none; +} diff --git a/resources/src/mediawiki/mediawiki.debug.profile.css b/resources/src/mediawiki/mediawiki.debug.profile.css new file mode 100644 index 00000000..ab27da9d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.debug.profile.css @@ -0,0 +1,45 @@ +.mw-debug-profile-tipsy .tipsy-inner { + /* undo max-width from vector on .tipsy-inner */ + max-width: none; + /* needed for some browsers to provide space for the scrollbar without wrapping text */ + min-width: 100%; + max-height: 150px; + overflow-y: auto; +} + +.mw-debug-profile-underline { + stroke-width: 1; + stroke: #dfdfdf; +} + +.mw-debug-profile-period { + fill: red; +} + +/* connecting line between endpoints on long events */ +.mw-debug-profile-period line { + stroke: red; + stroke-width: 2; +} + +.mw-debug-profile-tipsy, +.mw-debug-profile-timeline text { + color: #444; + fill: #444; + /* using em's causes the two locations to have different sizes */ + font-size: 12px; + font-family: sans-serif; +} + +.mw-debug-profile-meta, +.mw-debug-profile-timeline tspan { + /* using em's causes the two locations to have different sizes */ + font-size: 10px; +} + +.mw-debug-profile-no-data { + text-align: center; + padding-top: 5em; + font-weight: bold; + font-size: 1.2em; +} diff --git a/resources/src/mediawiki/mediawiki.debug.profile.js b/resources/src/mediawiki/mediawiki.debug.profile.js new file mode 100644 index 00000000..04f7acd0 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.debug.profile.js @@ -0,0 +1,556 @@ +/*! + * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar + * and StartProfiler.php. + * + * @author Erik Bernhardson + * @since 1.23 + */ + +( function ( mw, $ ) { + 'use strict'; + + /** + * @singleton + * @class mw.Debug.profile + */ + var profile = mw.Debug.profile = { + /** + * Object containing data for the debug toolbar + * + * @property ProfileData + */ + data: null, + + /** + * @property DOMElement + */ + container: null, + + /** + * Initializes the profiling pane. + */ + init: function ( data, width, mergeThresholdPx, dropThresholdPx ) { + data = data || mw.config.get( 'debugInfo' ).profile; + profile.width = width || $(window).width() - 20; + // merge events from same pixel(some events are very granular) + mergeThresholdPx = mergeThresholdPx || 2; + // only drop events if requested + dropThresholdPx = dropThresholdPx || 0; + + if ( + !Array.prototype.map || + !Array.prototype.reduce || + !Array.prototype.filter || + !document.createElementNS || + !document.createElementNS.bind + ) { + profile.container = profile.buildRequiresBrowserFeatures(); + } else if ( data.length === 0 ) { + profile.container = profile.buildNoData(); + } else { + // Initialize createSvgElement (now that we know we have + // document.createElementNS and bind) + this.createSvgElement = document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' ); + + // generate a flyout + profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx ); + // draw it + profile.container = profile.buildSvg( profile.container ); + profile.attachFlyout(); + } + + return profile.container; + }, + + buildRequiresBrowserFeatures: function () { + return $( '<div>' ) + .text( 'Certain browser features, including parts of ECMAScript 5 and document.createElementNS, are required for the profile visualization.' ) + .get( 0 ); + }, + + buildNoData: function () { + return $( '<div>' ).addClass( 'mw-debug-profile-no-data' ) + .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' ) + .get( 0 ); + }, + + /** + * Creates DOM nodes appropriately namespaced for SVG. + * Initialized in init after checking support + * + * @param string tag to create + * @return DOMElement + */ + createSvgElement: null, + + /** + * @param DOMElement|undefined + */ + buildSvg: function ( node ) { + var container, group, i, g, + timespan = profile.data.timespan, + gapPerEvent = 38, + space = 10.5, + currentHeight = space, + totalHeight = 0; + + profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start ); + totalHeight += gapPerEvent * profile.data.groups.length; + + if ( node ) { + $( node ).empty(); + } else { + node = profile.createSvgElement( 'svg' ); + node.setAttribute( 'version', '1.2' ); + node.setAttribute( 'baseProfile', 'tiny' ); + } + node.style.height = totalHeight; + node.style.width = profile.width; + + // use a container that can be transformed + container = profile.createSvgElement( 'g' ); + node.appendChild( container ); + + for ( i = 0; i < profile.data.groups.length; i++ ) { + group = profile.data.groups[i]; + g = profile.buildTimeline( group ); + + g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' ); + container.appendChild( g ); + + currentHeight += gapPerEvent; + } + + return node; + }, + + /** + * @param Object group of periods to transform into graphics + */ + buildTimeline: function ( group ) { + var text, tspan, line, i, + sum = group.timespan.sum, + ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms', + timeline = profile.createSvgElement( 'g' ); + + timeline.setAttribute( 'class', 'mw-debug-profile-timeline' ); + + // draw label + text = profile.createSvgElement( 'text' ); + text.setAttribute( 'x', profile.xCoord( group.timespan.start ) ); + text.setAttribute( 'y', 0 ); + text.textContent = group.name; + timeline.appendChild( text ); + + // draw metadata + tspan = profile.createSvgElement( 'tspan' ); + tspan.textContent = ms; + text.appendChild( tspan ); + + // draw timeline periods + for ( i = 0; i < group.periods.length; i++ ) { + timeline.appendChild( profile.buildPeriod( group.periods[i] ) ); + } + + // full-width line under each timeline + line = profile.createSvgElement( 'line' ); + line.setAttribute( 'class', 'mw-debug-profile-underline' ); + line.setAttribute( 'x1', 0 ); + line.setAttribute( 'y1', 28 ); + line.setAttribute( 'x2', profile.width ); + line.setAttribute( 'y2', 28 ); + timeline.appendChild( line ); + + return timeline; + }, + + /** + * @param Object period to transform into graphics + */ + buildPeriod: function ( period ) { + var node, + head = profile.xCoord( period.start ), + tail = profile.xCoord( period.end ), + g = profile.createSvgElement( 'g' ); + + g.setAttribute( 'class', 'mw-debug-profile-period' ); + $( g ).data( 'period', period ); + + if ( head + 16 > tail ) { + node = profile.createSvgElement( 'rect' ); + node.setAttribute( 'x', head ); + node.setAttribute( 'y', 8 ); + node.setAttribute( 'width', 2 ); + node.setAttribute( 'height', 9 ); + g.appendChild( node ); + + node = profile.createSvgElement( 'rect' ); + node.setAttribute( 'x', head ); + node.setAttribute( 'y', 8 ); + node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 ); + node.setAttribute( 'height', 6 ); + g.appendChild( node ); + } else { + node = profile.createSvgElement( 'polygon' ); + node.setAttribute( 'points', pointList( [ + [ head, 8 ], + [ head, 19 ], + [ head + 8, 8 ], + [ head, 8] + ] ) ); + g.appendChild( node ); + + node = profile.createSvgElement( 'polygon' ); + node.setAttribute( 'points', pointList( [ + [ tail, 8 ], + [ tail, 19 ], + [ tail - 8, 8 ], + [ tail, 8 ] + ] ) ); + g.appendChild( node ); + + node = profile.createSvgElement( 'line' ); + node.setAttribute( 'x1', head ); + node.setAttribute( 'y1', 9 ); + node.setAttribute( 'x2', tail ); + node.setAttribute( 'y2', 9 ); + g.appendChild( node ); + } + + return g; + }, + + /** + * @param Object + */ + buildFlyout: function ( period ) { + var contained, sum, ms, mem, i, + node = $( '<div>' ); + + for ( i = 0; i < period.contained.length; i++ ) { + contained = period.contained[i]; + sum = contained.end - contained.start; + ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms'; + mem = formatBytes( contained.memory ); + + $( '<div>' ).text( contained.source.name ) + .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) ) + .appendTo( node ); + } + + return node; + }, + + /** + * Attach a hover flyout to all .mw-debug-profile-period groups. + */ + attachFlyout: function () { + // for some reason addClass and removeClass from jQuery + // arn't working on svg elements in chrome <= 33.0 (possibly more) + var $container = $( profile.container ), + addClass = function ( node, value ) { + var current = node.getAttribute( 'class' ), + list = current ? current.split( ' ' ) : false, + idx = list ? list.indexOf( value ) : -1; + + if ( idx === -1 ) { + node.setAttribute( 'class', current ? ( current + ' ' + value ) : value ); + } + }, + removeClass = function ( node, value ) { + var current = node.getAttribute( 'class' ), + list = current ? current.split( ' ' ) : false, + idx = list ? list.indexOf( value ) : -1; + + if ( idx !== -1 ) { + list.splice( idx, 1 ); + node.setAttribute( 'class', list.join( ' ' ) ); + } + }, + // hide all tipsy flyouts + hide = function () { + $container.find( '.mw-debug-profile-period.tipsy-visible' ) + .each( function () { + removeClass( this, 'tipsy-visible' ); + $( this ).tipsy( 'hide' ); + } ); + }; + + $container.find( '.mw-debug-profile-period' ).tipsy( { + fade: true, + gravity: function () { + return $.fn.tipsy.autoNS.call( this ) + $.fn.tipsy.autoWE.call( this ); + }, + className: 'mw-debug-profile-tipsy', + center: false, + html: true, + trigger: 'manual', + title: function () { + return profile.buildFlyout( $( this ).data( 'period' ) ).html(); + } + } ).on( 'mouseenter', function () { + hide(); + addClass( this, 'tipsy-visible' ); + $( this ).tipsy( 'show' ); + } ); + + $container.on( 'mouseleave', function ( event ) { + var $from = $( event.relatedTarget ), + $to = $( event.target ); + // only close the tipsy if we are not + if ( $from.closest( '.tipsy' ).length === 0 && + $to.closest( '.tipsy' ).length === 0 && + $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg' + ) { + hide(); + } + } ).on( 'click', function () { + // convenience method for closing + hide(); + } ); + }, + + /** + * @return number the x co-ordinate for the specified timestamp + */ + xCoord: function ( msTimestamp ) { + return ( msTimestamp - profile.data.timespan.start ) * profile.ratio; + } + }; + + function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) { + // validate input data + this.data = data.map( function ( event ) { + event.periods = event.periods.filter( function ( period ) { + return period.start && period.end + && period.start < period.end + // period start must be a reasonable ms timestamp + && period.start > 1000000; + } ); + return event; + } ).filter( function ( event ) { + return event.name && event.periods.length > 0; + } ); + + // start and end time of the data + this.timespan = this.data.reduce( function ( result, event ) { + return event.periods.reduce( periodMinMax, result ); + }, periodMinMax.initial() ); + + // transform input data + this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx ); + + return this; + } + + /** + * There are too many unique events to display a line for each, + * so this does a basic grouping. + */ + ProfileData.groupOf = function ( label ) { + var pos, prefix = 'Profile section ended by close(): '; + if ( label.indexOf( prefix ) === 0 ) { + label = label.slice( prefix.length ); + } + + pos = [ '::', ':', '-' ].reduce( function ( result, separator ) { + var pos = label.indexOf( separator ); + if ( pos === -1 ) { + return result; + } else if ( result === -1 ) { + return pos; + } else { + return Math.min( result, pos ); + } + }, -1 ); + + if ( pos === -1 ) { + return label; + } else { + return label.slice( 0, pos ); + } + }; + + /** + * @return Array list of objects with `name` and `events` keys + */ + ProfileData.groupEvents = function ( events ) { + var group, i, + groups = {}; + + // Group events together + for ( i = events.length - 1; i >= 0; i-- ) { + group = ProfileData.groupOf( events[i].name ); + if ( groups[group] ) { + groups[group].push( events[i] ); + } else { + groups[group] = [events[i]]; + } + } + + // Return an array of groups + return Object.keys( groups ).map( function ( group ) { + return { + name: group, + events: groups[group] + }; + } ); + }; + + ProfileData.periodSorter = function ( a, b ) { + if ( a.start === b.start ) { + return a.end - b.end; + } + return a.start - b.start; + }; + + ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) { + return function ( result, period ) { + if ( result.length === 0 ) { + // period is first result + return [{ + start: period.start, + end: period.end, + contained: [period] + }]; + } + var last = result[result.length - 1]; + if ( period.end < last.end ) { + // end is contained within previous + result[result.length - 1].contained.push( period ); + } else if ( period.start - mergeThresholdMs < last.end ) { + // neighbors within merging distance + result[result.length - 1].end = period.end; + result[result.length - 1].contained.push( period ); + } else { + // period is next result + result.push( { + start: period.start, + end: period.end, + contained: [period] + } ); + } + return result; + }; + }; + + /** + * Collect all periods from the grouped events and apply merge and + * drop transformations + */ + ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) { + // collect the periods from all events + return events.reduce( function ( result, event ) { + if ( !event.periods.length ) { + return result; + } + result.push.apply( result, event.periods.map( function ( period ) { + // maintain link from period to event + period.source = event; + return period; + } ) ); + return result; + }, [] ) + // sort combined periods + .sort( ProfileData.periodSorter ) + // Apply merge threshold. Original periods + // are maintained in the `contained` property + .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] ) + // Apply drop threshold + .filter( function ( period ) { + return period.end - period.start > dropThresholdMs; + } ); + }; + + /** + * runs a callback on all periods in the group. Only valid after + * groups.periods[0..n].contained are populated. This runs against + * un-transformed data and is better suited to summing or other + * stat collection + */ + ProfileData.reducePeriods = function ( group, callback, result ) { + return group.periods.reduce( function ( result, period ) { + return period.contained.reduce( callback, result ); + }, result ); + }; + + /** + * Transforms this.data grouping by labels, merging neighboring + * events in the groups, and drops events and groups below the + * display threshold. Groups are returned sorted by starting time. + */ + ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) { + // ms to pixel ratio + var ratio = ( this.timespan.end - this.timespan.start ) / width, + // transform thresholds to ms + mergeThresholdMs = mergeThresholdPx * ratio, + dropThresholdMs = dropThresholdPx * ratio; + + return ProfileData.groupEvents( this.data ) + // generate data about the grouped events + .map( function ( group ) { + // Cleaned periods from all events + group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs ); + // min and max timestamp per group + group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() ); + // ms from first call to end of last call + group.timespan.length = group.timespan.end - group.timespan.start; + // collect the un-transformed periods + group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) { + result.push( period ); + return result; + }, [] ) + // sort by start time + .sort( ProfileData.periodSorter ) + // merge overlapping + .reduce( ProfileData.genMergePeriodReducer( 0 ), [] ) + // sum + .reduce( function ( result, period ) { + return result + period.end - period.start; + }, 0 ); + + return group; + }, this ) + // remove groups that have had all their periods filtered + .filter( function ( group ) { + return group.periods.length > 0; + } ) + // sort events by first start + .sort( function ( a, b ) { + return ProfileData.periodSorter( a.timespan, b.timespan ); + } ); + }; + + // reducer to find edges of period array + function periodMinMax( result, period ) { + if ( period.start < result.start ) { + result.start = period.start; + } + if ( period.end > result.end ) { + result.end = period.end; + } + return result; + } + + periodMinMax.initial = function () { + return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY }; + }; + + function formatBytes( bytes ) { + var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if ( bytes === 0 ) { + return '0 Bytes'; + } + i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 ); + return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i]; + } + + // turns a 2d array into a point list for svg + // polygon points attribute + // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2' + function pointList( pairs ) { + return pairs.map( function ( pair ) { + return pair.join( ',' ); + } ).join( ' ' ); + } +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.feedback.css b/resources/src/mediawiki/mediawiki.feedback.css new file mode 100644 index 00000000..6bd47bb2 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.feedback.css @@ -0,0 +1,9 @@ +.feedback-spinner { + display: inline-block; + zoom: 1; + *display: inline; /* IE7 and below */ + /* @embed */ + background: url(mediawiki.feedback.spinner.gif); + width: 18px; + height: 18px; +} diff --git a/resources/src/mediawiki/mediawiki.feedback.js b/resources/src/mediawiki/mediawiki.feedback.js new file mode 100644 index 00000000..1c0d8332 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.feedback.js @@ -0,0 +1,320 @@ +/*! + * mediawiki.feedback + * + * @author Ryan Kaldari, 2010 + * @author Neil Kandalgaonkar, 2010-11 + * @since 1.19 + */ +( function ( mw, $ ) { + /** + * This is a way of getting simple feedback from users. It's useful + * for testing new features -- users can give you feedback without + * the difficulty of opening a whole new talk page. For this reason, + * it also tends to collect a wider range of both positive and negative + * comments. However you do need to tend to the feedback page. It will + * get long relatively quickly, and you often get multiple messages + * reporting the same issue. + * + * It takes the form of thing on your page which, when clicked, opens a small + * dialog box. Submitting that dialog box appends its contents to a + * wiki page that you specify, as a new section. + * + * This feature works with classic MediaWiki pages + * and is not compatible with LiquidThreads or Flow. + * + * Minimal usage example: + * + * var feedback = new mw.Feedback(); + * $( '#myButton' ).click( function () { feedback.launch(); } ); + * + * You can also launch the feedback form with a prefilled subject and body. + * See the docs for the #launch() method. + * + * @class + * @constructor + * @param {Object} [options] + * @param {mw.Api} [options.api] if omitted, will just create a standard API + * @param {mw.Title} [options.title="Feedback"] The title of the page where you collect + * feedback. + * @param {string} [options.dialogTitleMessageKey="feedback-submit"] Message key for the + * title of the dialog box + * @param {string} [options.bugsLink="//bugzilla.wikimedia.org/enter_bug.cgi"] URL where + * bugs can be posted + * @param {mw.Uri|string} [options.bugsListLink="//bugzilla.wikimedia.org/query.cgi"] + * URL where bugs can be listed + */ + mw.Feedback = function ( options ) { + if ( options === undefined ) { + options = {}; + } + + if ( options.api === undefined ) { + options.api = new mw.Api(); + } + + if ( options.title === undefined ) { + options.title = new mw.Title( 'Feedback' ); + } + + if ( options.dialogTitleMessageKey === undefined ) { + options.dialogTitleMessageKey = 'feedback-submit'; + } + + if ( options.bugsLink === undefined ) { + options.bugsLink = '//bugzilla.wikimedia.org/enter_bug.cgi'; + } + + if ( options.bugsListLink === undefined ) { + options.bugsListLink = '//bugzilla.wikimedia.org/query.cgi'; + } + + $.extend( this, options ); + this.setup(); + }; + + mw.Feedback.prototype = { + /** + * Sets up interface + */ + setup: function () { + var $feedbackPageLink, + $bugNoteLink, + $bugsListLink, + fb = this; + + $feedbackPageLink = $( '<a>' ) + .attr( { + href: fb.title.getUrl(), + target: '_blank' + } ) + .css( { + whiteSpace: 'nowrap' + } ); + + $bugNoteLink = $( '<a>' ).attr( { href: '#' } ).click( function () { + fb.displayBugs(); + } ); + + $bugsListLink = $( '<a>' ).attr( { + href: fb.bugsListLink, + target: '_blank' + } ); + + // TODO: Use a stylesheet instead of these inline styles + this.$dialog = + $( '<div style="position: relative;"></div>' ).append( + $( '<div class="feedback-mode feedback-form"></div>' ).append( + $( '<small>' ).append( + $( '<p>' ).msg( + 'feedback-bugornote', + $bugNoteLink, + fb.title.getNameText(), + $feedbackPageLink.clone() + ) + ), + $( '<div style="margin-top: 1em;"></div>' ) + .msg( 'feedback-subject' ) + .append( + $( '<br>' ), + $( '<input type="text" class="feedback-subject" name="subject" maxlength="60" style="width: 100%; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box;"/>' ) + ), + $( '<div style="margin-top: 0.4em;"></div>' ) + .msg( 'feedback-message' ) + .append( + $( '<br>' ), + $( '<textarea name="message" class="feedback-message" rows="5" cols="60"></textarea>' ) + ) + ), + $( '<div class="feedback-mode feedback-bugs"></div>' ).append( + $( '<p>' ).msg( 'feedback-bugcheck', $bugsListLink ) + ), + $( '<div class="feedback-mode feedback-submitting" style="text-align: center; margin: 3em 0;"></div>' ) + .msg( 'feedback-adding' ) + .append( + $( '<br>' ), + $( '<span class="feedback-spinner"></span>' ) + ), + $( '<div class="feedback-mode feedback-thanks" style="text-align: center; margin:1em"></div>' ).msg( + 'feedback-thanks', fb.title.getNameText(), $feedbackPageLink.clone() + ), + $( '<div class="feedback-mode feedback-error" style="position: relative;"></div>' ).append( + $( '<div class="feedback-error-msg style="color: #990000; margin-top: 0.4em;"></div>' ) + ) + ); + + this.$dialog.dialog( { + width: 500, + autoOpen: false, + title: mw.message( this.dialogTitleMessageKey ).escaped(), + modal: true, + buttons: fb.buttons + } ); + + this.subjectInput = this.$dialog.find( 'input.feedback-subject' ).get( 0 ); + this.messageInput = this.$dialog.find( 'textarea.feedback-message' ).get( 0 ); + }, + + /** + * Displays a section of the dialog. + * + * @param {"form"|"bugs"|"submitting"|"thanks"|"error"} s + * The section of the dialog to show. + */ + display: function ( s ) { + // Hide the buttons + this.$dialog.dialog( { buttons: {} } ); + // Hide everything + this.$dialog.find( '.feedback-mode' ).hide(); + // Show the desired div + this.$dialog.find( '.feedback-' + s ).show(); + }, + + /** + * Display the submitting section. + */ + displaySubmitting: function () { + this.display( 'submitting' ); + }, + + /** + * Display the bugs section. + */ + displayBugs: function () { + var fb = this, + bugsButtons = {}; + + this.display( 'bugs' ); + bugsButtons[ mw.msg( 'feedback-bugnew' ) ] = function () { + window.open( fb.bugsLink, '_blank' ); + }; + bugsButtons[ mw.msg( 'feedback-cancel' ) ] = function () { + fb.cancel(); + }; + this.$dialog.dialog( { + buttons: bugsButtons + } ); + }, + + /** + * Display the thanks section. + */ + displayThanks: function () { + var fb = this, + closeButton = {}; + + this.display( 'thanks' ); + closeButton[ mw.msg( 'feedback-close' ) ] = function () { + fb.$dialog.dialog( 'close' ); + }; + this.$dialog.dialog( { + buttons: closeButton + } ); + }, + + /** + * Display the feedback form + * @param {Object} [contents] Prefilled contents for the feedback form. + * @param {string} [contents.subject] The subject of the feedback + * @param {string} [contents.message] The content of the feedback + */ + displayForm: function ( contents ) { + var fb = this, + formButtons = {}; + + this.subjectInput.value = ( contents && contents.subject ) ? contents.subject : ''; + this.messageInput.value = ( contents && contents.message ) ? contents.message : ''; + + this.display( 'form' ); + + // Set up buttons for dialog box. We have to do it the hard way since the json keys are localized + formButtons[ mw.msg( 'feedback-submit' ) ] = function () { + fb.submit(); + }; + formButtons[ mw.msg( 'feedback-cancel' ) ] = function () { + fb.cancel(); + }; + this.$dialog.dialog( { buttons: formButtons } ); // put the buttons back + }, + + /** + * Display an error on the form. + * + * @param {string} message Should be a valid message key. + */ + displayError: function ( message ) { + var fb = this, + closeButton = {}; + + this.display( 'error' ); + this.$dialog.find( '.feedback-error-msg' ).msg( message ); + closeButton[ mw.msg( 'feedback-close' ) ] = function () { + fb.$dialog.dialog( 'close' ); + }; + this.$dialog.dialog( { buttons: closeButton } ); + }, + + /** + * Close the feedback form. + */ + cancel: function () { + this.$dialog.dialog( 'close' ); + }, + + /** + * Submit the feedback form. + */ + submit: function () { + var subject, message, + fb = this; + + // Get the values to submit. + subject = this.subjectInput.value; + + // We used to include "mw.html.escape( navigator.userAgent )" but there are legal issues + // with posting this without their explicit consent + message = this.messageInput.value; + if ( message.indexOf( '~~~' ) === -1 ) { + message += ' ~~~~'; + } + + this.displaySubmitting(); + + // Post the message, resolving redirects + this.api.newSection( + this.title, + subject, + message, + { redirect: true } + ) + .done( function ( result ) { + if ( result.edit !== undefined ) { + if ( result.edit.result === 'Success' ) { + fb.displayThanks(); + } else { + // unknown API result + fb.displayError( 'feedback-error1' ); + } + } else { + // edit failed + fb.displayError( 'feedback-error2' ); + } + } ) + .fail( function () { + // ajax request failed + fb.displayError( 'feedback-error3' ); + } ); + }, + + /** + * Modify the display form, and then open it, focusing interface on the subject. + * @param {Object} [contents] Prefilled contents for the feedback form. + * @param {string} [contents.subject] The subject of the feedback + * @param {string} [contents.message] The content of the feedback + */ + launch: function ( contents ) { + this.displayForm( contents ); + this.$dialog.dialog( 'open' ); + this.subjectInput.focus(); + } + }; +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.feedback.spinner.gif b/resources/src/mediawiki/mediawiki.feedback.spinner.gif Binary files differnew file mode 100644 index 00000000..aed0ea41 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.feedback.spinner.gif diff --git a/resources/src/mediawiki/mediawiki.hidpi.js b/resources/src/mediawiki/mediawiki.hidpi.js new file mode 100644 index 00000000..ecee450c --- /dev/null +++ b/resources/src/mediawiki/mediawiki.hidpi.js @@ -0,0 +1,5 @@ +jQuery( function ( $ ) { + // Apply hidpi images on DOM-ready + // Some may have already partly preloaded at low resolution. + $( 'body' ).hidpi(); +} ); diff --git a/resources/src/mediawiki/mediawiki.hlist.css b/resources/src/mediawiki/mediawiki.hlist.css new file mode 100644 index 00000000..adcb8104 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.hlist.css @@ -0,0 +1,78 @@ +/*! + * Stylesheet for mediawiki.hlist module + * @author [[User:Edokter]] + */ +.hlist dl, +.hlist ol, +.hlist ul { + margin: 0; + padding: 0; +} +/* Display list items inline */ +.hlist dd, +.hlist dt, +.hlist li { + margin: 0; + display: inline; +} +/* Display nested lists inline */ +.hlist dl dl, .hlist dl ol, .hlist dl ul, +.hlist ol dl, .hlist ol ol, .hlist ol ul, +.hlist ul dl, .hlist ul ol, .hlist ul ul { + display: inline; +} +/* Generate interpuncts */ +.hlist dt:after { + content: ":"; +} +.hlist dd:after, +.hlist li:after { + content: " ·"; + font-weight: bold; +} +.hlist dd:last-child:after, +.hlist dt:last-child:after, +.hlist li:last-child:after { + content: none; +} +/* For IE8 */ +.hlist dd.hlist-last-child:after, +.hlist dt.hlist-last-child:after, +.hlist li.hlist-last-child:after { + content: none; +} +/* Add parentheses around nested lists */ +.hlist dd dd:first-child:before, .hlist dd dt:first-child:before, .hlist dd li:first-child:before, +.hlist dt dd:first-child:before, .hlist dt dt:first-child:before, .hlist dt li:first-child:before, +.hlist li dd:first-child:before, .hlist li dt:first-child:before, .hlist li li:first-child:before { + content: "("; + font-weight: normal; +} +.hlist dd dd:last-child:after, .hlist dd dt:last-child:after, .hlist dd li:last-child:after, +.hlist dt dd:last-child:after, .hlist dt dt:last-child:after, .hlist dt li:last-child:after, +.hlist li dd:last-child:after, .hlist li dt:last-child:after, .hlist li li:last-child:after { + content: ")"; + font-weight: normal; +} +/* For IE8 */ +.hlist dd dd.hlist-last-child:after, .hlist dd dt.hlist-last-child:after, .hlist dd li.hlist-last-child:after, +.hlist dt dd.hlist-last-child:after, .hlist dt dt.hlist-last-child:after, .hlist dt li.hlist-last-child:after, +.hlist li dd.hlist-last-child:after, .hlist li dt.hlist-last-child:after, .hlist li li.hlist-last-child:after { + content: ")"; + font-weight: normal; +} +/* Put ordinals in front of ordered list items */ +.hlist ol { + counter-reset: list-item; +} +.hlist ol > li { + counter-increment: list-item; +} +.hlist ol > li:before { + content: counter(list-item) " "; +} +.hlist dd ol > li:first-child:before, +.hlist dt ol > li:first-child:before, +.hlist li ol > li:first-child:before { + content: "(" counter(list-item) " "; +} diff --git a/resources/src/mediawiki/mediawiki.hlist.js b/resources/src/mediawiki/mediawiki.hlist.js new file mode 100644 index 00000000..0bbf8fad --- /dev/null +++ b/resources/src/mediawiki/mediawiki.hlist.js @@ -0,0 +1,31 @@ +/*! + * .hlist fallbacks for IE 6, 7 and 8. + * @author [[User:Edokter]] + */ +( function ( mw, $ ) { + var profile = $.client.profile(); + + if ( profile.name === 'msie' ) { + if ( profile.versionNumber === 8 ) { + /* IE 8: Add pseudo-selector class to last-child list items */ + mw.hook( 'wikipage.content' ).add( function ( $content ) { + $content.find( '.hlist' ).find( 'dd:last-child, dt:last-child, li:last-child' ) + .addClass( 'hlist-last-child' ); + } ); + } + else if ( profile.versionNumber <= 7 ) { + /* IE 7 and below: Generate interpuncts and parentheses */ + mw.hook( 'wikipage.content' ).add( function ( $content ) { + var $hlists = $content.find( '.hlist' ); + $hlists.find( 'dt:not(:last-child)' ) + .append( ': ' ); + $hlists.find( 'dd:not(:last-child)' ) + .append( '<b>·</b> ' ); + $hlists.find( 'li:not(:last-child)' ) + .append( '<b>·</b> ' ); + $hlists.find( 'dl dl, dl ol, dl ul, ol dl, ol ol, ol ul, ul dl, ul ol, ul ul' ) + .prepend( '( ' ).append( ') ' ); + } ); + } + } +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.htmlform.js b/resources/src/mediawiki/mediawiki.htmlform.js new file mode 100644 index 00000000..594800e1 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.htmlform.js @@ -0,0 +1,408 @@ +/** + * Utility functions for jazzing up HTMLForm elements. + * + * @class jQuery.plugin.htmlform + */ +( function ( mw, $ ) { + + var cloneCounter = 0; + + /** + * Helper function for hide-if to find the nearby form field. + * + * Find the closest match for the given name, "closest" being the minimum + * level of parents to go to find a form field matching the given name or + * ending in array keys matching the given name (e.g. "baz" matches + * "foo[bar][baz]"). + * + * @private + * @param {jQuery} element + * @param {string} name + * @return {jQuery|null} + */ + function hideIfGetField( $el, name ) { + var $found, $p, + suffix = name.replace( /^([^\[]+)/, '[$1]' ); + + function nameFilter() { + return this.name === name || + ( this.name === ( 'wp' + name ) ) || + this.name.slice( -suffix.length ) === suffix; + } + + for ( $p = $el.parent(); $p.length > 0; $p = $p.parent() ) { + $found = $p.find( '[name]' ).filter( nameFilter ); + if ( $found.length ) { + return $found; + } + } + return null; + } + + /** + * Helper function for hide-if to return a test function and list of + * dependent fields for a hide-if specification. + * + * @private + * @param {jQuery} element + * @param {Array} hide-if spec + * @return {Array} + * @return {jQuery} return.0 Dependent fields + * @return {Function} return.1 Test function + */ + function hideIfParse( $el, spec ) { + var op, i, l, v, $field, $fields, fields, func, funcs, getVal; + + op = spec[0]; + l = spec.length; + switch ( op ) { + case 'AND': + case 'OR': + case 'NAND': + case 'NOR': + funcs = []; + fields = []; + for ( i = 1; i < l; i++ ) { + if ( !$.isArray( spec[i] ) ) { + throw new Error( op + ' parameters must be arrays' ); + } + v = hideIfParse( $el, spec[i] ); + fields = fields.concat( v[0].toArray() ); + funcs.push( v[1] ); + } + $fields = $( fields ); + + l = funcs.length; + switch ( op ) { + case 'AND': + func = function () { + var i; + for ( i = 0; i < l; i++ ) { + if ( !funcs[i]() ) { + return false; + } + } + return true; + }; + break; + + case 'OR': + func = function () { + var i; + for ( i = 0; i < l; i++ ) { + if ( funcs[i]() ) { + return true; + } + } + return false; + }; + break; + + case 'NAND': + func = function () { + var i; + for ( i = 0; i < l; i++ ) { + if ( !funcs[i]() ) { + return true; + } + } + return false; + }; + break; + + case 'NOR': + func = function () { + var i; + for ( i = 0; i < l; i++ ) { + if ( funcs[i]() ) { + return false; + } + } + return true; + }; + break; + } + + return [ $fields, func ]; + + case 'NOT': + if ( l !== 2 ) { + throw new Error( 'NOT takes exactly one parameter' ); + } + if ( !$.isArray( spec[1] ) ) { + throw new Error( 'NOT parameters must be arrays' ); + } + v = hideIfParse( $el, spec[1] ); + $fields = v[0]; + func = v[1]; + return [ $fields, function () { + return !func(); + } ]; + + case '===': + case '!==': + if ( l !== 3 ) { + throw new Error( op + ' takes exactly two parameters' ); + } + $field = hideIfGetField( $el, spec[1] ); + if ( !$field ) { + return [ $(), function () { + return false; + } ]; + } + v = spec[2]; + + if ( $field.first().prop( 'type' ) === 'radio' || + $field.first().prop( 'type' ) === 'checkbox' + ) { + getVal = function () { + var $selected = $field.filter( ':checked' ); + return $selected.length ? $selected.val() : ''; + }; + } else { + getVal = function () { + return $field.val(); + }; + } + + switch ( op ) { + case '===': + func = function () { + return getVal() === v; + }; + break; + case '!==': + func = function () { + return getVal() !== v; + }; + break; + } + + return [ $field, func ]; + + default: + throw new Error( 'Unrecognized operation \'' + op + '\'' ); + } + } + + /** + * jQuery plugin to fade or snap to visible state. + * + * @param {boolean} [instantToggle=false] + * @return {jQuery} + * @chainable + */ + $.fn.goIn = function ( instantToggle ) { + if ( instantToggle === true ) { + return this.show(); + } + return this.stop( true, true ).fadeIn(); + }; + + /** + * jQuery plugin to fade or snap to hiding state. + * + * @param {boolean} [instantToggle=false] + * @return jQuery + * @chainable + */ + $.fn.goOut = function ( instantToggle ) { + if ( instantToggle === true ) { + return this.hide(); + } + return this.stop( true, true ).fadeOut(); + }; + + /** + * Bind a function to the jQuery object via live(), and also immediately trigger + * the function on the objects with an 'instant' parameter set to true. + * + * @method liveAndTestAtStart + * @deprecated since 1.24 Use .on() and .each() directly. + * @param {Function} callback + * @param {boolean|jQuery.Event} callback.immediate True when the event is called immediately, + * an event object when triggered from an event. + * @return jQuery + * @chainable + */ + mw.log.deprecate( $.fn, 'liveAndTestAtStart', function ( callback ) { + this + // Can't really migrate to .on() generically, needs knowledge of + // calling code to know the correct selector. Fix callers and + // get rid of this .liveAndTestAtStart() hack. + .live( 'change', callback ) + .each( function () { + callback.call( this, true ); + } ); + } ); + + function enhance( $root ) { + var $matrixTooltips, $autocomplete; + + /** + * @ignore + * @param {boolean|jQuery.Event} instant + */ + function handleSelectOrOther( instant ) { + var $other = $root.find( '#' + $( this ).attr( 'id' ) + '-other' ); + $other = $other.add( $other.siblings( 'br' ) ); + if ( $( this ).val() === 'other' ) { + $other.goIn( instant ); + } else { + $other.goOut( instant ); + } + } + + // Animate the SelectOrOther fields, to only show the text field when + // 'other' is selected. + $root + .on( 'change', '.mw-htmlform-select-or-other', handleSelectOrOther ) + .each( function () { + handleSelectOrOther.call( this, true ); + } ); + + // Set up hide-if elements + $root.find( '.mw-htmlform-hide-if' ).each( function () { + var v, $fields, test, func, + $el = $( this ), + spec = $el.data( 'hideIf' ); + + if ( !spec ) { + return; + } + + v = hideIfParse( $el, spec ); + $fields = v[0]; + test = v[1]; + func = function () { + if ( test() ) { + $el.hide(); + } else { + $el.show(); + } + }; + $fields.on( 'change', func ); + func(); + } ); + + function addMulti( $oldContainer, $container ) { + var name = $oldContainer.find( 'input:first-child' ).attr( 'name' ), + oldClass = ( ' ' + $oldContainer.attr( 'class' ) + ' ' ).replace( /(mw-htmlform-field-HTMLMultiSelectField|mw-chosen)/g, '' ), + $select = $( '<select>' ), + dataPlaceholder = mw.message( 'htmlform-chosen-placeholder' ); + oldClass = $.trim( oldClass ); + $select.attr( { + name: name, + multiple: 'multiple', + 'data-placeholder': dataPlaceholder.plain(), + 'class': 'htmlform-chzn-select mw-input ' + oldClass + } ); + $oldContainer.find( 'input' ).each( function () { + var $oldInput = $( this ), + checked = $oldInput.prop( 'checked' ), + $option = $( '<option>' ); + $option.prop( 'value', $oldInput.prop( 'value' ) ); + if ( checked ) { + $option.prop( 'selected', true ); + } + $option.text( $oldInput.prop( 'value' ) ); + $select.append( $option ); + } ); + $container.append( $select ); + } + + function convertCheckboxesToMulti( $oldContainer, type ) { + var $fieldLabel = $( '<td>' ), + $td = $( '<td>' ), + $fieldLabelText = $( '<label>' ), + $container; + if ( type === 'tr' ) { + addMulti( $oldContainer, $td ); + $container = $( '<tr>' ); + $container.append( $td ); + } else if ( type === 'div' ) { + $fieldLabel = $( '<div>' ); + $container = $( '<div>' ); + addMulti( $oldContainer, $container ); + } + $fieldLabel.attr( 'class', 'mw-label' ); + $fieldLabelText.text( $oldContainer.find( '.mw-label label' ).text() ); + $fieldLabel.append( $fieldLabelText ); + $container.prepend( $fieldLabel ); + $oldContainer.replaceWith( $container ); + return $container; + } + + if ( $root.find( '.mw-chosen' ).length ) { + mw.loader.using( 'jquery.chosen', function () { + $root.find( '.mw-chosen' ).each( function () { + var type = this.nodeName.toLowerCase(), + $converted = convertCheckboxesToMulti( $( this ), type ); + $converted.find( '.htmlform-chzn-select' ).chosen( { width: 'auto' } ); + } ); + } ); + } + + $matrixTooltips = $root.find( '.mw-htmlform-matrix .mw-htmlform-tooltip' ); + if ( $matrixTooltips.length ) { + mw.loader.using( 'jquery.tipsy', function () { + $matrixTooltips.tipsy( { gravity: 's' } ); + } ); + } + + // Set up autocomplete fields + $autocomplete = $root.find( '.mw-htmlform-autocomplete' ); + if ( $autocomplete.length ) { + mw.loader.using( 'jquery.suggestions', function () { + $autocomplete.suggestions( { + fetch: function ( val ) { + var $el = $( this ); + $el.suggestions( 'suggestions', + $.grep( $el.data( 'autocomplete' ), function ( v ) { + return v.indexOf( val ) === 0; + } ) + ); + } + } ); + } ); + } + + // Add/remove cloner clones without having to resubmit the form + $root.find( '.mw-htmlform-cloner-delete-button' ).click( function ( ev ) { + ev.preventDefault(); + $( this ).closest( 'li.mw-htmlform-cloner-li' ).remove(); + } ); + + $root.find( '.mw-htmlform-cloner-create-button' ).click( function ( ev ) { + var $ul, $li, html; + + ev.preventDefault(); + + $ul = $( this ).prev( 'ul.mw-htmlform-cloner-ul' ); + + html = $ul.data( 'template' ).replace( + new RegExp( $.escapeRE( $ul.data( 'uniqueId' ) ), 'g' ), + 'clone' + ( ++cloneCounter ) + ); + + $li = $( '<li>' ) + .addClass( 'mw-htmlform-cloner-li' ) + .html( html ) + .appendTo( $ul ); + + enhance( $li ); + } ); + + mw.hook( 'htmlform.enhance' ).fire( $root ); + + } + + $( function () { + enhance( $( document ) ); + } ); + + /** + * @class jQuery + * @mixins jQuery.plugin.htmlform + */ +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.icon.less b/resources/src/mediawiki/mediawiki.icon.less new file mode 100644 index 00000000..49f0f70f --- /dev/null +++ b/resources/src/mediawiki/mediawiki.icon.less @@ -0,0 +1,19 @@ +/* General-purpose icons via CSS. Classes here should be named "mw-icon-*". */ + +@import "mediawiki.mixins"; + +/* For the collapsed and expanded arrows, we also provide selectors to make it + * easy to use them with jquery.makeCollapsible. */ +.mw-icon-arrow-collapsed, +.mw-collapsible-arrow.mw-collapsible-toggle-collapsed { + .background-image-svg('images/arrow-collapsed-ltr.svg', 'images/arrow-collapsed-ltr.png'); + background-repeat: no-repeat; + background-position: left bottom; +} + +.mw-icon-arrow-expanded, +.mw-collapsible-arrow.mw-collapsible-toggle-expanded { + .background-image-svg('images/arrow-expanded.svg', 'images/arrow-expanded.png'); + background-repeat: no-repeat; + background-position: left bottom; +} diff --git a/resources/src/mediawiki/mediawiki.inspect.js b/resources/src/mediawiki/mediawiki.inspect.js new file mode 100644 index 00000000..8e9fc89f --- /dev/null +++ b/resources/src/mediawiki/mediawiki.inspect.js @@ -0,0 +1,284 @@ +/*! + * Tools for inspecting page composition and performance. + * + * @author Ori Livneh + * @since 1.22 + */ +/*jshint devel:true */ +( function ( mw, $ ) { + + function sortByProperty( array, prop, descending ) { + var order = descending ? -1 : 1; + return array.sort( function ( a, b ) { + return a[prop] > b[prop] ? order : a[prop] < b[prop] ? -order : 0; + } ); + } + + function humanSize( bytes ) { + if ( !$.isNumeric( bytes ) || bytes === 0 ) { return bytes; } + var i = 0, units = [ '', ' kB', ' MB', ' GB', ' TB', ' PB' ]; + for ( ; bytes >= 1024; bytes /= 1024 ) { i++; } + return bytes.toFixed( 1 ) + units[i]; + } + + /** + * @class mw.inspect + * @singleton + */ + var inspect = { + + /** + * Return a map of all dependency relationships between loaded modules. + * + * @return {Object} Maps module names to objects. Each sub-object has + * two properties, 'requires' and 'requiredBy'. + */ + getDependencyGraph: function () { + var modules = inspect.getLoadedModules(), graph = {}; + + $.each( modules, function ( moduleIndex, moduleName ) { + var dependencies = mw.loader.moduleRegistry[moduleName].dependencies || []; + + graph[moduleName] = graph[moduleName] || { requiredBy: [] }; + graph[moduleName].requires = dependencies; + + $.each( dependencies, function ( depIndex, depName ) { + graph[depName] = graph[depName] || { requiredBy: [] }; + graph[depName].requiredBy.push( moduleName ); + } ); + } ); + return graph; + }, + + /** + * Calculate the byte size of a ResourceLoader module. + * + * @param {string} moduleName The name of the module + * @return {number|null} Module size in bytes or null + */ + getModuleSize: function ( moduleName ) { + var module = mw.loader.moduleRegistry[ moduleName ], + payload = 0; + + if ( mw.loader.getState( moduleName ) !== 'ready' ) { + return null; + } + + if ( !module.style && !module.script ) { + return null; + } + + // Tally CSS + if ( module.style && $.isArray( module.style.css ) ) { + $.each( module.style.css, function ( i, stylesheet ) { + payload += $.byteLength( stylesheet ); + } ); + } + + // Tally JavaScript + if ( $.isFunction( module.script ) ) { + payload += $.byteLength( module.script.toString() ); + } + + return payload; + }, + + /** + * Given CSS source, count both the total number of selectors it + * contains and the number which match some element in the current + * document. + * + * @param {string} css CSS source + * @return Selector counts + * @return {number} return.selectors Total number of selectors + * @return {number} return.matched Number of matched selectors + */ + auditSelectors: function ( css ) { + var selectors = { total: 0, matched: 0 }, + style = document.createElement( 'style' ), + sheet, rules; + + style.textContent = css; + document.body.appendChild( style ); + // Standards-compliant browsers use .sheet.cssRules, IE8 uses .styleSheet.rules… + sheet = style.sheet || style.styleSheet; + rules = sheet.cssRules || sheet.rules; + $.each( rules, function ( index, rule ) { + selectors.total++; + if ( document.querySelector( rule.selectorText ) !== null ) { + selectors.matched++; + } + } ); + document.body.removeChild( style ); + return selectors; + }, + + /** + * Get a list of all loaded ResourceLoader modules. + * + * @return {Array} List of module names + */ + getLoadedModules: function () { + return $.grep( mw.loader.getModuleNames(), function ( module ) { + return mw.loader.getState( module ) === 'ready'; + } ); + }, + + /** + * Print tabular data to the console, using console.table, console.log, + * or mw.log (in declining order of preference). + * + * @param {Array} data Tabular data represented as an array of objects + * with common properties. + */ + dumpTable: function ( data ) { + try { + // Bartosz made me put this here. + if ( window.opera ) { throw window.opera; } + // Use Function.prototype#call to force an exception on Firefox, + // which doesn't define console#table but doesn't complain if you + // try to invoke it. + console.table.call( console, data ); + return; + } catch ( e ) {} + try { + console.log( JSON.stringify( data, null, 2 ) ); + return; + } catch ( e ) {} + mw.log( data ); + }, + + /** + * Generate and print one more reports. When invoked with no arguments, + * print all reports. + * + * @param {string...} [reports] Report names to run, or unset to print + * all available reports. + */ + runReports: function () { + var reports = arguments.length > 0 ? + Array.prototype.slice.call( arguments ) : + $.map( inspect.reports, function ( v, k ) { return k; } ); + + $.each( reports, function ( index, name ) { + inspect.dumpTable( inspect.reports[name]() ); + } ); + }, + + /** + * @class mw.inspect.reports + * @singleton + */ + reports: { + /** + * Generate a breakdown of all loaded modules and their size in + * kilobytes. Modules are ordered from largest to smallest. + */ + size: function () { + // Map each module to a descriptor object. + var modules = $.map( inspect.getLoadedModules(), function ( module ) { + return { + name: module, + size: inspect.getModuleSize( module ) + }; + } ); + + // Sort module descriptors by size, largest first. + sortByProperty( modules, 'size', true ); + + // Convert size to human-readable string. + $.each( modules, function ( i, module ) { + module.size = humanSize( module.size ); + } ); + + return modules; + }, + + /** + * For each module with styles, count the number of selectors, and + * count how many match against some element currently in the DOM. + */ + css: function () { + var modules = []; + + $.each( inspect.getLoadedModules(), function ( index, name ) { + var css, stats, module = mw.loader.moduleRegistry[name]; + + try { + css = module.style.css.join(); + } catch ( e ) { return; } // skip + + stats = inspect.auditSelectors( css ); + modules.push( { + module: name, + allSelectors: stats.total, + matchedSelectors: stats.matched, + percentMatched: stats.total !== 0 ? + ( stats.matched / stats.total * 100 ).toFixed( 2 ) + '%' : null + } ); + } ); + sortByProperty( modules, 'allSelectors', true ); + return modules; + }, + + /** + * Report stats on mw.loader.store: the number of localStorage + * cache hits and misses, the number of items purged from the + * cache, and the total size of the module blob in localStorage. + */ + store: function () { + var raw, stats = { enabled: mw.loader.store.enabled }; + if ( stats.enabled ) { + $.extend( stats, mw.loader.store.stats ); + try { + raw = localStorage.getItem( mw.loader.store.getStoreKey() ); + stats.totalSize = humanSize( $.byteLength( raw ) ); + } catch ( e ) {} + } + return [stats]; + } + }, + + /** + * Perform a string search across the JavaScript and CSS source code + * of all loaded modules and return an array of the names of the + * modules that matched. + * + * @param {string|RegExp} pattern String or regexp to match. + * @return {Array} Array of the names of modules that matched. + */ + grep: function ( pattern ) { + if ( typeof pattern.test !== 'function' ) { + // Based on Y.Escape.regex from YUI v3.15.0 + pattern = new RegExp( pattern.replace( /[\-$\^*()+\[\]{}|\\,.?\s]/g, '\\$&' ), 'g' ); + } + + return $.grep( inspect.getLoadedModules(), function ( moduleName ) { + var module = mw.loader.moduleRegistry[moduleName]; + + // Grep module's JavaScript + if ( $.isFunction( module.script ) && pattern.test( module.script.toString() ) ) { + return true; + } + + // Grep module's CSS + if ( + $.isPlainObject( module.style ) && $.isArray( module.style.css ) + && pattern.test( module.style.css.join( '' ) ) + ) { + // Module's CSS source matches + return true; + } + + return false; + } ); + } + }; + + if ( mw.config.get( 'debug' ) ) { + mw.log( 'mw.inspect: reports are not available in debug mode.' ); + } + + mw.inspect = inspect; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.js b/resources/src/mediawiki/mediawiki.jqueryMsg.js new file mode 100644 index 00000000..ad71b083 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.jqueryMsg.js @@ -0,0 +1,1251 @@ +/*! +* Experimental advanced wikitext parser-emitter. +* See: https://www.mediawiki.org/wiki/Extension:UploadWizard/MessageParser for docs +* +* @author neilk@wikimedia.org +* @author mflaschen@wikimedia.org +*/ +( function ( mw, $ ) { + /** + * @class mw.jqueryMsg + * @singleton + */ + + var oldParser, + slice = Array.prototype.slice, + parserDefaults = { + magic: { + 'SITENAME': mw.config.get( 'wgSiteName' ) + }, + // This is a whitelist based on, but simpler than, Sanitizer.php. + // Self-closing tags are not currently supported. + allowedHtmlElements: [ + 'b', + 'i' + ], + // Key tag name, value allowed attributes for that tag. + // See Sanitizer::setupAttributeWhitelist + allowedHtmlCommonAttributes: [ + // HTML + 'id', + 'class', + 'style', + 'lang', + 'dir', + 'title', + + // WAI-ARIA + 'role' + ], + + // Attributes allowed for specific elements. + // Key is element name in lower case + // Value is array of allowed attributes for that element + allowedHtmlAttributesByElement: {}, + messages: mw.messages, + language: mw.language, + + // Same meaning as in mediawiki.js. + // + // Only 'text', 'parse', and 'escaped' are supported, and the + // actual escaping for 'escaped' is done by other code (generally + // through mediawiki.js). + // + // However, note that this default only + // applies to direct calls to jqueryMsg. The default for mediawiki.js itself + // is 'text', including when it uses jqueryMsg. + format: 'parse' + + }; + + /** + * Wrapper around jQuery append that converts all non-objects to TextNode so append will not + * convert what it detects as an htmlString to an element. + * + * Object elements of children (jQuery, HTMLElement, TextNode, etc.) will be left as is. + * + * @private + * @param {jQuery} $parent Parent node wrapped by jQuery + * @param {Object|string|Array} children What to append, with the same possible types as jQuery + * @return {jQuery} $parent + */ + function appendWithoutParsing( $parent, children ) { + var i, len; + + if ( !$.isArray( children ) ) { + children = [children]; + } + + for ( i = 0, len = children.length; i < len; i++ ) { + if ( typeof children[i] !== 'object' ) { + children[i] = document.createTextNode( children[i] ); + } + } + + return $parent.append( children ); + } + + /** + * Decodes the main HTML entities, those encoded by mw.html.escape. + * + * @private + * @param {string} encoded Encoded string + * @return {string} String with those entities decoded + */ + function decodePrimaryHtmlEntities( encoded ) { + return encoded + .replace( /'/g, '\'' ) + .replace( /"/g, '"' ) + .replace( /</g, '<' ) + .replace( />/g, '>' ) + .replace( /&/g, '&' ); + } + + /** + * Given parser options, return a function that parses a key and replacements, returning jQuery object + * + * Try to parse a key and optional replacements, returning a jQuery object that may be a tree of jQuery nodes. + * If there was an error parsing, return the key and the error message (wrapped in jQuery). This should put the error right into + * the interface, without causing the page to halt script execution, and it hopefully should be clearer how to fix it. + * @private + * @param {Object} options Parser options + * @return {Function} + * @return {Array} return.args First element is the key, replacements may be in array in 2nd element, or remaining elements. + * @return {jQuery} return.return + */ + function getFailableParserFn( options ) { + var parser = new mw.jqueryMsg.parser( options ); + + return function ( args ) { + var fallback, + key = args[0], + argsArray = $.isArray( args[1] ) ? args[1] : slice.call( args, 1 ); + try { + return parser.parse( key, argsArray ); + } catch ( e ) { + fallback = parser.settings.messages.get( key ); + mw.log.warn( 'mediawiki.jqueryMsg: ' + key + ': ' + e.message ); + return $( '<span>' ).text( fallback ); + } + }; + } + + mw.jqueryMsg = {}; + + /** + * Returns a function suitable for use as a global, to construct strings from the message key (and optional replacements). + * e.g. + * + * window.gM = mediaWiki.parser.getMessageFunction( options ); + * $( 'p#headline' ).html( gM( 'hello-user', username ) ); + * + * Like the old gM() function this returns only strings, so it destroys any bindings. If you want to preserve bindings use the + * jQuery plugin version instead. This is only included for backwards compatibility with gM(). + * + * N.B. replacements are variadic arguments or an array in second parameter. In other words: + * somefunction( a, b, c, d ) + * is equivalent to + * somefunction( a, [b, c, d] ) + * + * @param {Object} options parser options + * @return {Function} Function suitable for assigning to window.gM + * @return {string} return.key Message key. + * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). + * @return {string} return.return Rendered HTML. + */ + mw.jqueryMsg.getMessageFunction = function ( options ) { + var failableParserFn = getFailableParserFn( options ), + format; + + if ( options && options.format !== undefined ) { + format = options.format; + } else { + format = parserDefaults.format; + } + + return function () { + var failableResult = failableParserFn( arguments ); + if ( format === 'text' || format === 'escaped' ) { + return failableResult.text(); + } else { + return failableResult.html(); + } + }; + }; + + /** + * Returns a jQuery plugin which parses the message in the message key, doing replacements optionally, and appends the nodes to + * the current selector. Bindings to passed-in jquery elements are preserved. Functions become click handlers for [$1 linktext] links. + * e.g. + * + * $.fn.msg = mediaWiki.parser.getJqueryPlugin( options ); + * var userlink = $( '<a>' ).click( function () { alert( "hello!!" ) } ); + * $( 'p#headline' ).msg( 'hello-user', userlink ); + * + * N.B. replacements are variadic arguments or an array in second parameter. In other words: + * somefunction( a, b, c, d ) + * is equivalent to + * somefunction( a, [b, c, d] ) + * + * We append to 'this', which in a jQuery plugin context will be the selected elements. + * + * @param {Object} options Parser options + * @return {Function} Function suitable for assigning to jQuery plugin, such as jQuery#msg + * @return {string} return.key Message key. + * @return {Array|Mixed} return.replacements Optional variable replacements (variadically or an array). + * @return {jQuery} return.return + */ + mw.jqueryMsg.getPlugin = function ( options ) { + var failableParserFn = getFailableParserFn( options ); + + return function () { + var $target = this.empty(); + // TODO: Simply appendWithoutParsing( $target, failableParserFn( arguments ).contents() ) + // or Simply appendWithoutParsing( $target, failableParserFn( arguments ) ) + $.each( failableParserFn( arguments ).contents(), function ( i, node ) { + appendWithoutParsing( $target, node ); + } ); + return $target; + }; + }; + + /** + * The parser itself. + * Describes an object, whose primary duty is to .parse() message keys. + * + * @class + * @private + * @param {Object} options + */ + mw.jqueryMsg.parser = function ( options ) { + this.settings = $.extend( {}, parserDefaults, options ); + this.settings.onlyCurlyBraceTransform = ( this.settings.format === 'text' || this.settings.format === 'escaped' ); + + this.emitter = new mw.jqueryMsg.htmlEmitter( this.settings.language, this.settings.magic ); + }; + + mw.jqueryMsg.parser.prototype = { + /** + * Cache mapping MediaWiki message keys and the value onlyCurlyBraceTransform, to the AST of the message. + * + * In most cases, the message is a string so this is identical. + * (This is why we would like to move this functionality server-side). + * + * The two parts of the key are separated by colon. For example: + * + * "message-key:true": ast + * + * if they key is "message-key" and onlyCurlyBraceTransform is true. + * + * This cache is shared by all instances of mw.jqueryMsg.parser. + * + * NOTE: We promise, it's static - when you create this empty object + * in the prototype, each new instance of the class gets a reference + * to the same object. + * + * @static + * @property {Object} + */ + astCache: {}, + + /** + * Where the magic happens. + * Parses a message from the key, and swaps in replacements as necessary, wraps in jQuery + * If an error is thrown, returns original key, and logs the error + * @param {string} key Message key. + * @param {Array} replacements Variable replacements for $1, $2... $n + * @return {jQuery} + */ + parse: function ( key, replacements ) { + return this.emitter.emit( this.getAst( key ), replacements ); + }, + + /** + * Fetch the message string associated with a key, return parsed structure. Memoized. + * Note that we pass '[' + key + ']' back for a missing message here. + * @param {string} key + * @return {string|Array} string of '[key]' if message missing, simple string if possible, array of arrays if needs parsing + */ + getAst: function ( key ) { + var cacheKey = [key, this.settings.onlyCurlyBraceTransform].join( ':' ), wikiText; + + if ( this.astCache[ cacheKey ] === undefined ) { + wikiText = this.settings.messages.get( key ); + if ( typeof wikiText !== 'string' ) { + wikiText = '\\[' + key + '\\]'; + } + this.astCache[ cacheKey ] = this.wikiTextToAst( wikiText ); + } + return this.astCache[ cacheKey ]; + }, + + /** + * Parses the input wikiText into an abstract syntax tree, essentially an s-expression. + * + * CAVEAT: This does not parse all wikitext. It could be more efficient, but it's pretty good already. + * n.b. We want to move this functionality to the server. Nothing here is required to be on the client. + * + * @param {string} input Message string wikitext + * @throws Error + * @return {Mixed} abstract syntax tree + */ + wikiTextToAst: function ( input ) { + var pos, settings = this.settings, concat = Array.prototype.concat, + regularLiteral, regularLiteralWithoutBar, regularLiteralWithoutSpace, regularLiteralWithSquareBrackets, + doubleQuote, singleQuote, backslash, anyCharacter, asciiAlphabetLiteral, + escapedOrLiteralWithoutSpace, escapedOrLiteralWithoutBar, escapedOrRegularLiteral, + whitespace, dollar, digits, htmlDoubleQuoteAttributeValue, htmlSingleQuoteAttributeValue, + htmlAttributeEquals, openHtmlStartTag, optionalForwardSlash, openHtmlEndTag, closeHtmlTag, + openExtlink, closeExtlink, wikilinkPage, wikilinkContents, openWikilink, closeWikilink, templateName, pipe, colon, + templateContents, openTemplate, closeTemplate, + nonWhitespaceExpression, paramExpression, expression, curlyBraceTransformExpression, result; + + // Indicates current position in input as we parse through it. + // Shared among all parsing functions below. + pos = 0; + + // ========================================================= + // parsing combinators - could be a library on its own + // ========================================================= + + /** + * Try parsers until one works, if none work return null + * @private + * @param {Function[]} ps + * @return {string|null} + */ + function choice( ps ) { + return function () { + var i, result; + for ( i = 0; i < ps.length; i++ ) { + result = ps[i](); + if ( result !== null ) { + return result; + } + } + return null; + }; + } + + /** + * Try several ps in a row, all must succeed or return null. + * This is the only eager one. + * @private + * @param {Function[]} ps + * @return {string|null} + */ + function sequence( ps ) { + var i, res, + originalPos = pos, + result = []; + for ( i = 0; i < ps.length; i++ ) { + res = ps[i](); + if ( res === null ) { + pos = originalPos; + return null; + } + result.push( res ); + } + return result; + } + + /** + * Run the same parser over and over until it fails. + * Must succeed a minimum of n times or return null. + * @private + * @param {number} n + * @param {Function} p + * @return {string|null} + */ + function nOrMore( n, p ) { + return function () { + var originalPos = pos, + result = [], + parsed = p(); + while ( parsed !== null ) { + result.push( parsed ); + parsed = p(); + } + if ( result.length < n ) { + pos = originalPos; + return null; + } + return result; + }; + } + + /** + * There is a general pattern -- parse a thing, if that worked, apply transform, otherwise return null. + * + * TODO: But using this as a combinator seems to cause problems when combined with #nOrMore(). + * May be some scoping issue + * + * @private + * @param {Function} p + * @param {Function} fn + * @return {string|null} + */ + function transform( p, fn ) { + return function () { + var result = p(); + return result === null ? null : fn( result ); + }; + } + + /** + * Just make parsers out of simpler JS builtin types + * @private + * @param {string} s + * @return {Function} + * @return {string} return.return + */ + function makeStringParser( s ) { + var len = s.length; + return function () { + var result = null; + if ( input.substr( pos, len ) === s ) { + result = s; + pos += len; + } + return result; + }; + } + + /** + * Makes a regex parser, given a RegExp object. + * The regex being passed in should start with a ^ to anchor it to the start + * of the string. + * + * @private + * @param {RegExp} regex anchored regex + * @return {Function} function to parse input based on the regex + */ + function makeRegexParser( regex ) { + return function () { + var matches = input.slice( pos ).match( regex ); + if ( matches === null ) { + return null; + } + pos += matches[0].length; + return matches[0]; + }; + } + + // =================================================================== + // General patterns above this line -- wikitext specific parsers below + // =================================================================== + + // Parsing functions follow. All parsing functions work like this: + // They don't accept any arguments. + // Instead, they just operate non destructively on the string 'input' + // As they can consume parts of the string, they advance the shared variable pos, + // and return tokens (or whatever else they want to return). + // some things are defined as closures and other things as ordinary functions + // converting everything to a closure makes it a lot harder to debug... errors pop up + // but some debuggers can't tell you exactly where they come from. Also the mutually + // recursive functions seem not to work in all browsers then. (Tested IE6-7, Opera, Safari, FF) + // This may be because, to save code, memoization was removed + + regularLiteral = makeRegexParser( /^[^{}\[\]$<\\]/ ); + regularLiteralWithoutBar = makeRegexParser( /^[^{}\[\]$\\|]/ ); + regularLiteralWithoutSpace = makeRegexParser( /^[^{}\[\]$\s]/ ); + regularLiteralWithSquareBrackets = makeRegexParser( /^[^{}$\\]/ ); + + backslash = makeStringParser( '\\' ); + doubleQuote = makeStringParser( '"' ); + singleQuote = makeStringParser( '\'' ); + anyCharacter = makeRegexParser( /^./ ); + + openHtmlStartTag = makeStringParser( '<' ); + optionalForwardSlash = makeRegexParser( /^\/?/ ); + openHtmlEndTag = makeStringParser( '</' ); + htmlAttributeEquals = makeRegexParser( /^\s*=\s*/ ); + closeHtmlTag = makeRegexParser( /^\s*>/ ); + + function escapedLiteral() { + var result = sequence( [ + backslash, + anyCharacter + ] ); + return result === null ? null : result[1]; + } + escapedOrLiteralWithoutSpace = choice( [ + escapedLiteral, + regularLiteralWithoutSpace + ] ); + escapedOrLiteralWithoutBar = choice( [ + escapedLiteral, + regularLiteralWithoutBar + ] ); + escapedOrRegularLiteral = choice( [ + escapedLiteral, + regularLiteral + ] ); + // Used to define "literals" without spaces, in space-delimited situations + function literalWithoutSpace() { + var result = nOrMore( 1, escapedOrLiteralWithoutSpace )(); + return result === null ? null : result.join( '' ); + } + // Used to define "literals" within template parameters. The pipe character is the parameter delimeter, so by default + // it is not a literal in the parameter + function literalWithoutBar() { + var result = nOrMore( 1, escapedOrLiteralWithoutBar )(); + return result === null ? null : result.join( '' ); + } + + // Used for wikilink page names. Like literalWithoutBar, but + // without allowing escapes. + function unescapedLiteralWithoutBar() { + var result = nOrMore( 1, regularLiteralWithoutBar )(); + return result === null ? null : result.join( '' ); + } + + function literal() { + var result = nOrMore( 1, escapedOrRegularLiteral )(); + return result === null ? null : result.join( '' ); + } + + function curlyBraceTransformExpressionLiteral() { + var result = nOrMore( 1, regularLiteralWithSquareBrackets )(); + return result === null ? null : result.join( '' ); + } + + asciiAlphabetLiteral = makeRegexParser( /[A-Za-z]+/ ); + htmlDoubleQuoteAttributeValue = makeRegexParser( /^[^"]*/ ); + htmlSingleQuoteAttributeValue = makeRegexParser( /^[^']*/ ); + + whitespace = makeRegexParser( /^\s+/ ); + dollar = makeStringParser( '$' ); + digits = makeRegexParser( /^\d+/ ); + + function replacement() { + var result = sequence( [ + dollar, + digits + ] ); + if ( result === null ) { + return null; + } + return [ 'REPLACE', parseInt( result[1], 10 ) - 1 ]; + } + openExtlink = makeStringParser( '[' ); + closeExtlink = makeStringParser( ']' ); + // this extlink MUST have inner contents, e.g. [foo] not allowed; [foo bar] [foo <i>bar</i>], etc. are allowed + function extlink() { + var result, parsedResult; + result = null; + parsedResult = sequence( [ + openExtlink, + nonWhitespaceExpression, + whitespace, + nOrMore( 1, expression ), + closeExtlink + ] ); + if ( parsedResult !== null ) { + result = [ 'EXTLINK', parsedResult[1] ]; + // TODO (mattflaschen, 2013-03-22): Clean this up if possible. + // It's avoiding CONCAT for single nodes, so they at least doesn't get the htmlEmitter span. + if ( parsedResult[3].length === 1 ) { + result.push( parsedResult[3][0] ); + } else { + result.push( ['CONCAT'].concat( parsedResult[3] ) ); + } + } + return result; + } + // this is the same as the above extlink, except that the url is being passed on as a parameter + function extLinkParam() { + var result = sequence( [ + openExtlink, + dollar, + digits, + whitespace, + expression, + closeExtlink + ] ); + if ( result === null ) { + return null; + } + return [ 'EXTLINKPARAM', parseInt( result[2], 10 ) - 1, result[4] ]; + } + openWikilink = makeStringParser( '[[' ); + closeWikilink = makeStringParser( ']]' ); + pipe = makeStringParser( '|' ); + + function template() { + var result = sequence( [ + openTemplate, + templateContents, + closeTemplate + ] ); + return result === null ? null : result[1]; + } + + wikilinkPage = choice( [ + unescapedLiteralWithoutBar, + template + ] ); + + function pipedWikilink() { + var result = sequence( [ + wikilinkPage, + pipe, + expression + ] ); + return result === null ? null : [ result[0], result[2] ]; + } + + wikilinkContents = choice( [ + pipedWikilink, + wikilinkPage // unpiped link + ] ); + + function wikilink() { + var result, parsedResult, parsedLinkContents; + result = null; + + parsedResult = sequence( [ + openWikilink, + wikilinkContents, + closeWikilink + ] ); + if ( parsedResult !== null ) { + parsedLinkContents = parsedResult[1]; + result = [ 'WIKILINK' ].concat( parsedLinkContents ); + } + return result; + } + + // TODO: Support data- if appropriate + function doubleQuotedHtmlAttributeValue() { + var parsedResult = sequence( [ + doubleQuote, + htmlDoubleQuoteAttributeValue, + doubleQuote + ] ); + return parsedResult === null ? null : parsedResult[1]; + } + + function singleQuotedHtmlAttributeValue() { + var parsedResult = sequence( [ + singleQuote, + htmlSingleQuoteAttributeValue, + singleQuote + ] ); + return parsedResult === null ? null : parsedResult[1]; + } + + function htmlAttribute() { + var parsedResult = sequence( [ + whitespace, + asciiAlphabetLiteral, + htmlAttributeEquals, + choice( [ + doubleQuotedHtmlAttributeValue, + singleQuotedHtmlAttributeValue + ] ) + ] ); + return parsedResult === null ? null : [parsedResult[1], parsedResult[3]]; + } + + /** + * Checks if HTML is allowed + * + * @param {string} startTagName HTML start tag name + * @param {string} endTagName HTML start tag name + * @param {Object} attributes array of consecutive key value pairs, + * with index 2 * n being a name and 2 * n + 1 the associated value + * @return {boolean} true if this is HTML is allowed, false otherwise + */ + function isAllowedHtml( startTagName, endTagName, attributes ) { + var i, len, attributeName; + + startTagName = startTagName.toLowerCase(); + endTagName = endTagName.toLowerCase(); + if ( startTagName !== endTagName || $.inArray( startTagName, settings.allowedHtmlElements ) === -1 ) { + return false; + } + + for ( i = 0, len = attributes.length; i < len; i += 2 ) { + attributeName = attributes[i]; + if ( $.inArray( attributeName, settings.allowedHtmlCommonAttributes ) === -1 && + $.inArray( attributeName, settings.allowedHtmlAttributesByElement[startTagName] || [] ) === -1 ) { + return false; + } + } + + return true; + } + + function htmlAttributes() { + var parsedResult = nOrMore( 0, htmlAttribute )(); + // Un-nest attributes array due to structure of jQueryMsg operations (see emit). + return concat.apply( ['HTMLATTRIBUTES'], parsedResult ); + } + + // Subset of allowed HTML markup. + // Most elements and many attributes allowed on the server are not supported yet. + function html() { + var result = null, parsedOpenTagResult, parsedHtmlContents, + parsedCloseTagResult, wrappedAttributes, attributes, + startTagName, endTagName, startOpenTagPos, startCloseTagPos, + endOpenTagPos, endCloseTagPos; + + // Break into three sequence calls. That should allow accurate reconstruction of the original HTML, and requiring an exact tag name match. + // 1. open through closeHtmlTag + // 2. expression + // 3. openHtmlEnd through close + // This will allow recording the positions to reconstruct if HTML is to be treated as text. + + startOpenTagPos = pos; + parsedOpenTagResult = sequence( [ + openHtmlStartTag, + asciiAlphabetLiteral, + htmlAttributes, + optionalForwardSlash, + closeHtmlTag + ] ); + + if ( parsedOpenTagResult === null ) { + return null; + } + + endOpenTagPos = pos; + startTagName = parsedOpenTagResult[1]; + + parsedHtmlContents = nOrMore( 0, expression )(); + + startCloseTagPos = pos; + parsedCloseTagResult = sequence( [ + openHtmlEndTag, + asciiAlphabetLiteral, + closeHtmlTag + ] ); + + if ( parsedCloseTagResult === null ) { + // Closing tag failed. Return the start tag and contents. + return [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] + .concat( parsedHtmlContents ); + } + + endCloseTagPos = pos; + endTagName = parsedCloseTagResult[1]; + wrappedAttributes = parsedOpenTagResult[2]; + attributes = wrappedAttributes.slice( 1 ); + if ( isAllowedHtml( startTagName, endTagName, attributes ) ) { + result = [ 'HTMLELEMENT', startTagName, wrappedAttributes ] + .concat( parsedHtmlContents ); + } else { + // HTML is not allowed, so contents will remain how + // it was, while HTML markup at this level will be + // treated as text + // E.g. assuming script tags are not allowed: + // + // <script>[[Foo|bar]]</script> + // + // results in '<script>' and '</script>' + // (not treated as an HTML tag), surrounding a fully + // parsed HTML link. + // + // Concatenate everything from the tag, flattening the contents. + result = [ 'CONCAT', input.slice( startOpenTagPos, endOpenTagPos ) ] + .concat( parsedHtmlContents, input.slice( startCloseTagPos, endCloseTagPos ) ); + } + + return result; + } + + templateName = transform( + // see $wgLegalTitleChars + // not allowing : due to the need to catch "PLURAL:$1" + makeRegexParser( /^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/ ), + function ( result ) { return result.toString(); } + ); + function templateParam() { + var expr, result; + result = sequence( [ + pipe, + nOrMore( 0, paramExpression ) + ] ); + if ( result === null ) { + return null; + } + expr = result[1]; + // use a CONCAT operator if there are multiple nodes, otherwise return the first node, raw. + return expr.length > 1 ? [ 'CONCAT' ].concat( expr ) : expr[0]; + } + + function templateWithReplacement() { + var result = sequence( [ + templateName, + colon, + replacement + ] ); + return result === null ? null : [ result[0], result[2] ]; + } + function templateWithOutReplacement() { + var result = sequence( [ + templateName, + colon, + paramExpression + ] ); + return result === null ? null : [ result[0], result[2] ]; + } + function templateWithOutFirstParameter() { + var result = sequence( [ + templateName, + colon + ] ); + return result === null ? null : [ result[0], '' ]; + } + colon = makeStringParser( ':' ); + templateContents = choice( [ + function () { + var res = sequence( [ + // templates can have placeholders for dynamic replacement eg: {{PLURAL:$1|one car|$1 cars}} + // or no placeholders eg: {{GRAMMAR:genitive|{{SITENAME}}} + choice( [ templateWithReplacement, templateWithOutReplacement, templateWithOutFirstParameter ] ), + nOrMore( 0, templateParam ) + ] ); + return res === null ? null : res[0].concat( res[1] ); + }, + function () { + var res = sequence( [ + templateName, + nOrMore( 0, templateParam ) + ] ); + if ( res === null ) { + return null; + } + return [ res[0] ].concat( res[1] ); + } + ] ); + openTemplate = makeStringParser( '{{' ); + closeTemplate = makeStringParser( '}}' ); + nonWhitespaceExpression = choice( [ + template, + wikilink, + extLinkParam, + extlink, + replacement, + literalWithoutSpace + ] ); + paramExpression = choice( [ + template, + wikilink, + extLinkParam, + extlink, + replacement, + literalWithoutBar + ] ); + + expression = choice( [ + template, + wikilink, + extLinkParam, + extlink, + replacement, + html, + literal + ] ); + + // Used when only {{-transformation is wanted, for 'text' + // or 'escaped' formats + curlyBraceTransformExpression = choice( [ + template, + replacement, + curlyBraceTransformExpressionLiteral + ] ); + + /** + * Starts the parse + * + * @param {Function} rootExpression root parse function + */ + function start( rootExpression ) { + var result = nOrMore( 0, rootExpression )(); + if ( result === null ) { + return null; + } + return [ 'CONCAT' ].concat( result ); + } + // everything above this point is supposed to be stateless/static, but + // I am deferring the work of turning it into prototypes & objects. It's quite fast enough + // finally let's do some actual work... + + // If you add another possible rootExpression, you must update the astCache key scheme. + result = start( this.settings.onlyCurlyBraceTransform ? curlyBraceTransformExpression : expression ); + + /* + * For success, the p must have gotten to the end of the input + * and returned a non-null. + * n.b. This is part of language infrastructure, so we do not throw an internationalizable message. + */ + if ( result === null || pos !== input.length ) { + throw new Error( 'Parse error at position ' + pos.toString() + ' in input: ' + input ); + } + return result; + } + + }; + + /** + * htmlEmitter - object which primarily exists to emit HTML from parser ASTs + */ + mw.jqueryMsg.htmlEmitter = function ( language, magic ) { + this.language = language; + var jmsg = this; + $.each( magic, function ( key, val ) { + jmsg[ key.toLowerCase() ] = function () { + return val; + }; + } ); + + /** + * (We put this method definition here, and not in prototype, to make sure it's not overwritten by any magic.) + * Walk entire node structure, applying replacements and template functions when appropriate + * @param {Mixed} node Abstract syntax tree (top node or subnode) + * @param {Array} replacements for $1, $2, ... $n + * @return {Mixed} single-string node or array of nodes suitable for jQuery appending + */ + this.emit = function ( node, replacements ) { + var ret, subnodes, operation, + jmsg = this; + switch ( typeof node ) { + case 'string': + case 'number': + ret = node; + break; + // typeof returns object for arrays + case 'object': + // node is an array of nodes + subnodes = $.map( node.slice( 1 ), function ( n ) { + return jmsg.emit( n, replacements ); + } ); + operation = node[0].toLowerCase(); + if ( typeof jmsg[operation] === 'function' ) { + ret = jmsg[ operation ]( subnodes, replacements ); + } else { + throw new Error( 'Unknown operation "' + operation + '"' ); + } + break; + case 'undefined': + // Parsing the empty string (as an entire expression, or as a paramExpression in a template) results in undefined + // Perhaps a more clever parser can detect this, and return the empty string? Or is that useful information? + // The logical thing is probably to return the empty string here when we encounter undefined. + ret = ''; + break; + default: + throw new Error( 'Unexpected type in AST: ' + typeof node ); + } + return ret; + }; + }; + + // For everything in input that follows double-open-curly braces, there should be an equivalent parser + // function. For instance {{PLURAL ... }} will be processed by 'plural'. + // If you have 'magic words' then configure the parser to have them upon creation. + // + // An emitter method takes the parent node, the array of subnodes and the array of replacements (the values that $1, $2... should translate to). + // Note: all such functions must be pure, with the exception of referring to other pure functions via this.language (convertPlural and so on) + mw.jqueryMsg.htmlEmitter.prototype = { + /** + * Parsing has been applied depth-first we can assume that all nodes here are single nodes + * Must return a single node to parents -- a jQuery with synthetic span + * However, unwrap any other synthetic spans in our children and pass them upwards + * @param {Mixed[]} nodes Some single nodes, some arrays of nodes + * @return {jQuery} + */ + concat: function ( nodes ) { + var $span = $( '<span>' ).addClass( 'mediaWiki_htmlEmitter' ); + $.each( nodes, function ( i, node ) { + if ( node instanceof jQuery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) { + $.each( node.contents(), function ( j, childNode ) { + appendWithoutParsing( $span, childNode ); + } ); + } else { + // Let jQuery append nodes, arrays of nodes and jQuery objects + // other things (strings, numbers, ..) are appended as text nodes (not as HTML strings) + appendWithoutParsing( $span, node ); + } + } ); + return $span; + }, + + /** + * Return escaped replacement of correct index, or string if unavailable. + * Note that we expect the parsed parameter to be zero-based. i.e. $1 should have become [ 0 ]. + * if the specified parameter is not found return the same string + * (e.g. "$99" -> parameter 98 -> not found -> return "$99" ) + * + * TODO: Throw error if nodes.length > 1 ? + * + * @param {Array} nodes List of one element, integer, n >= 0 + * @param {Array} replacements List of at least n strings + * @return {String} replacement + */ + replace: function ( nodes, replacements ) { + var index = parseInt( nodes[0], 10 ); + + if ( index < replacements.length ) { + return replacements[index]; + } else { + // index not found, fallback to displaying variable + return '$' + ( index + 1 ); + } + }, + + /** + * Transform wiki-link + * + * TODO: + * It only handles basic cases, either no pipe, or a pipe with an explicit + * anchor. + * + * It does not attempt to handle features like the pipe trick. + * However, the pipe trick should usually not be present in wikitext retrieved + * from the server, since the replacement is done at save time. + * It may, though, if the wikitext appears in extension-controlled content. + * + * @param nodes + */ + wikilink: function ( nodes ) { + var page, anchor, url; + + page = nodes[0]; + url = mw.util.getUrl( page ); + + // [[Some Page]] or [[Namespace:Some Page]] + if ( nodes.length === 1 ) { + anchor = page; + } + + /* + * [[Some Page|anchor text]] or + * [[Namespace:Some Page|anchor] + */ + else { + anchor = nodes[1]; + } + + return $( '<a>' ).attr( { + title: page, + href: url + } ).text( anchor ); + }, + + /** + * Converts array of HTML element key value pairs to object + * + * @param {Array} nodes Array of consecutive key value pairs, with index 2 * n being a + * name and 2 * n + 1 the associated value + * @return {Object} Object mapping attribute name to attribute value + */ + htmlattributes: function ( nodes ) { + var i, len, mapping = {}; + for ( i = 0, len = nodes.length; i < len; i += 2 ) { + mapping[nodes[i]] = decodePrimaryHtmlEntities( nodes[i + 1] ); + } + return mapping; + }, + + /** + * Handles an (already-validated) HTML element. + * + * @param {Array} nodes Nodes to process when creating element + * @return {jQuery|Array} jQuery node for valid HTML or array for disallowed element + */ + htmlelement: function ( nodes ) { + var tagName, attributes, contents, $element; + + tagName = nodes.shift(); + attributes = nodes.shift(); + contents = nodes; + $element = $( document.createElement( tagName ) ).attr( attributes ); + return appendWithoutParsing( $element, contents ); + }, + + /** + * Transform parsed structure into external link + * If the href is a jQuery object, treat it as "enclosing" the link text. + * + * - ... function, treat it as the click handler. + * - ... string, treat it as a URI. + * + * TODO: throw an error if nodes.length > 2 ? + * + * @param {Array} nodes List of two elements, {jQuery|Function|String} and {String} + * @return {jQuery} + */ + extlink: function ( nodes ) { + var $el, + arg = nodes[0], + contents = nodes[1]; + if ( arg instanceof jQuery ) { + $el = arg; + } else { + $el = $( '<a>' ); + if ( typeof arg === 'function' ) { + $el.attr( 'href', '#' ) + .click( function ( e ) { + e.preventDefault(); + } ) + .click( arg ); + } else { + $el.attr( 'href', arg.toString() ); + } + } + return appendWithoutParsing( $el, contents ); + }, + + /** + * This is basically use a combination of replace + external link (link with parameter + * as url), but we don't want to run the regular replace here-on: inserting a + * url as href-attribute of a link will automatically escape it already, so + * we don't want replace to (manually) escape it as well. + * + * TODO: throw error if nodes.length > 1 ? + * + * @param {Array} nodes List of one element, integer, n >= 0 + * @param {Array} replacements List of at least n strings + * @return {string} replacement + */ + extlinkparam: function ( nodes, replacements ) { + var replacement, + index = parseInt( nodes[0], 10 ); + if ( index < replacements.length ) { + replacement = replacements[index]; + } else { + replacement = '$' + ( index + 1 ); + } + return this.extlink( [ replacement, nodes[1] ] ); + }, + + /** + * Transform parsed structure into pluralization + * n.b. The first node may be a non-integer (for instance, a string representing an Arabic number). + * So convert it back with the current language's convertNumber. + * @param {Array} nodes List of nodes, [ {string|number}, {string}, {string} ... ] + * @return {string} selected pluralized form according to current language + */ + plural: function ( nodes ) { + var forms, formIndex, node, count; + count = parseFloat( this.language.convertNumber( nodes[0], true ) ); + forms = nodes.slice( 1 ); + for ( formIndex = 0; formIndex < forms.length; formIndex++ ) { + node = forms[formIndex]; + if ( node.jquery && node.hasClass( 'mediaWiki_htmlEmitter' ) ) { + // This is a nested node, already expanded. + forms[formIndex] = forms[formIndex].html(); + } + } + return forms.length ? this.language.convertPlural( count, forms ) : ''; + }, + + /** + * Transform parsed structure according to gender. + * + * Usage: {{gender:[ mw.user object | '' | 'male' | 'female' | 'unknown' ] | masculine form | feminine form | neutral form}}. + * + * The first node must be one of: + * - the mw.user object (or a compatible one) + * - an empty string - indicating the current user, same effect as passing the mw.user object + * - a gender string ('male', 'female' or 'unknown') + * + * @param {Array} nodes List of nodes, [ {string|mw.user}, {string}, {string}, {string} ] + * @return {string} Selected gender form according to current language + */ + gender: function ( nodes ) { + var gender, + maybeUser = nodes[0], + forms = nodes.slice( 1 ); + + if ( maybeUser === '' ) { + maybeUser = mw.user; + } + + // If we are passed a mw.user-like object, check their gender. + // Otherwise, assume the gender string itself was passed . + if ( maybeUser && maybeUser.options instanceof mw.Map ) { + gender = maybeUser.options.get( 'gender' ); + } else { + gender = maybeUser; + } + + return this.language.gender( gender, forms ); + }, + + /** + * Transform parsed structure into grammar conversion. + * Invoked by putting `{{grammar:form|word}}` in a message + * @param {Array} nodes List of nodes [{Grammar case eg: genitive}, {string word}] + * @return {string} selected grammatical form according to current language + */ + grammar: function ( nodes ) { + var form = nodes[0], + word = nodes[1]; + return word && form && this.language.convertGrammar( word, form ); + }, + + /** + * Tranform parsed structure into a int: (interface language) message include + * Invoked by putting `{{int:othermessage}}` into a message + * @param {Array} nodes List of nodes + * @return {string} Other message + */ + 'int': function ( nodes ) { + return mw.jqueryMsg.getMessageFunction()( nodes[0].toLowerCase() ); + }, + + /** + * Takes an unformatted number (arab, no group separators and . as decimal separator) + * and outputs it in the localized digit script and formatted with decimal + * separator, according to the current language. + * @param {Array} nodes List of nodes + * @return {number|string} Formatted number + */ + formatnum: function ( nodes ) { + var isInteger = ( nodes[1] && nodes[1] === 'R' ) ? true : false, + number = nodes[0]; + + return this.language.convertNumber( number, isInteger ); + } + }; + + // Deprecated! don't rely on gM existing. + // The window.gM ought not to be required - or if required, not required here. + // But moving it to extensions breaks it (?!) + // Need to fix plugin so it could do attributes as well, then will be okay to remove this. + // @deprecated since 1.23 + mw.log.deprecate( window, 'gM', mw.jqueryMsg.getMessageFunction(), 'Use mw.message( ... ).parse() instead.' ); + + /** + * @method + * @member jQuery + * @see mw.jqueryMsg#getPlugin + */ + $.fn.msg = mw.jqueryMsg.getPlugin(); + + // Replace the default message parser with jqueryMsg + oldParser = mw.Message.prototype.parser; + mw.Message.prototype.parser = function () { + var messageFunction; + + // TODO: should we cache the message function so we don't create a new one every time? Benchmark this maybe? + // Caching is somewhat problematic, because we do need different message functions for different maps, so + // we'd have to cache the parser as a member of this.map, which sounds a bit ugly. + // Do not use mw.jqueryMsg unless required + if ( this.format === 'plain' || !/\{\{|[\[<>]/.test( this.map.get( this.key ) ) ) { + // Fall back to mw.msg's simple parser + return oldParser.apply( this ); + } + + messageFunction = mw.jqueryMsg.getMessageFunction( { + 'messages': this.map, + // For format 'escaped', escaping part is handled by mediawiki.js + 'format': this.format + } ); + return messageFunction( this.key, this.parameters ); + }; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.jqueryMsg.peg b/resources/src/mediawiki/mediawiki.jqueryMsg.peg new file mode 100644 index 00000000..716c3261 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.jqueryMsg.peg @@ -0,0 +1,85 @@ +/* PEG grammar for a subset of wikitext, useful in the MediaWiki frontend */ + +start + = e:expression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; } + +expression + = template + / link + / extlink + / replacement + / literal + +paramExpression + = template + / link + / extlink + / replacement + / literalWithoutBar + +template + = "{{" t:templateContents "}}" { return t; } + +templateContents + = twr:templateWithReplacement p:templateParam* { return twr.concat(p) } + / twr:templateWithOutReplacement p:templateParam* { return twr.concat(p) } + / twr:templateWithOutFirstParameter p:templateParam* { return twr.concat(p) } + / t:templateName p:templateParam* { return p.length ? [ t, p ] : [ t ] } + +templateWithReplacement + = t:templateName ":" r:replacement { return [ t, r ] } + +templateWithOutReplacement + = t:templateName ":" p:paramExpression { return [ t, p ] } + +templateWithOutFirstParameter + = t:templateName ":" { return [ t, "" ] } + +templateParam + = "|" e:paramExpression* { return e.length > 1 ? [ "CONCAT" ].concat(e) : e[0]; } + +templateName + = tn:[A-Za-z_]+ { return tn.join('').toUpperCase() } + +/* TODO: Update to reflect separate piped and unpiped handling */ +link + = "[[" w:expression "]]" { return [ 'WLINK', w ]; } + +extlink + = "[" url:url whitespace text:expression "]" { return [ 'LINK', url, text ] } + +url + = url:[^ ]+ { return url.join(''); } + +whitespace + = [ ]+ + +replacement + = '$' digits:digits { return [ 'REPLACE', parseInt( digits, 10 ) - 1 ] } + +digits + = [0-9]+ + +literal + = lit:escapedOrRegularLiteral+ { return lit.join(''); } + +literalWithoutBar + = lit:escapedOrLiteralWithoutBar+ { return lit.join(''); } + +escapedOrRegularLiteral + = escapedLiteral + / regularLiteral + +escapedOrLiteralWithoutBar + = escapedLiteral + / regularLiteralWithoutBar + +escapedLiteral + = "\\" escaped:. { return escaped; } + +regularLiteral + = [^{}\[\]$\\] + +regularLiteralWithoutBar + = [^{}\[\]$\\|] + diff --git a/resources/src/mediawiki/mediawiki.js b/resources/src/mediawiki/mediawiki.js new file mode 100644 index 00000000..e29c734d --- /dev/null +++ b/resources/src/mediawiki/mediawiki.js @@ -0,0 +1,2399 @@ +/** + * Base library for MediaWiki. + * + * Exposed as globally as `mediaWiki` with `mw` as shortcut. + * + * @class mw + * @alternateClassName mediaWiki + * @singleton + */ +( function ( $ ) { + 'use strict'; + + /* Private Members */ + + var mw, + hasOwn = Object.prototype.hasOwnProperty, + slice = Array.prototype.slice, + trackCallbacks = $.Callbacks( 'memory' ), + trackQueue = []; + + /** + * Log a message to window.console, if possible. Useful to force logging of some + * errors that are otherwise hard to detect (I.e., this logs also in production mode). + * Gets console references in each invocation, so that delayed debugging tools work + * fine. No need for optimization here, which would only result in losing logs. + * + * @private + * @method log_ + * @param {string} msg text for the log entry. + * @param {Error} [e] + */ + function log( msg, e ) { + var console = window.console; + if ( console && console.log ) { + console.log( msg ); + // If we have an exception object, log it through .error() to trigger + // proper stacktraces in browsers that support it. There are no (known) + // browsers that don't support .error(), that do support .log() and + // have useful exception handling through .log(). + if ( e && console.error ) { + console.error( String( e ), e ); + } + } + } + + /* Object constructors */ + + /** + * Creates an object that can be read from or written to from prototype functions + * that allow both single and multiple variables at once. + * + * @example + * + * var addies, wanted, results; + * + * // Create your address book + * addies = new mw.Map(); + * + * // This data could be coming from an external source (eg. API/AJAX) + * addies.set( { + * 'John Doe' : '10 Wall Street, New York, USA', + * 'Jane Jackson' : '21 Oxford St, London, UK', + * 'Dominique van Halen' : 'Kalverstraat 7, Amsterdam, NL' + * } ); + * + * wanted = ['Dominique van Halen', 'George Johnson', 'Jane Jackson']; + * + * // You can detect missing keys first + * if ( !addies.exists( wanted ) ) { + * // One or more are missing (in this case: "George Johnson") + * mw.log( 'One or more names were not found in your address book' ); + * } + * + * // Or just let it give you what it can + * results = addies.get( wanted, 'Middle of Nowhere, Alaska, US' ); + * mw.log( results['Jane Jackson'] ); // "21 Oxford St, London, UK" + * mw.log( results['George Johnson'] ); // "Middle of Nowhere, Alaska, US" + * + * @class mw.Map + * + * @constructor + * @param {Object|boolean} [values] Value-bearing object to map, or boolean + * true to map over the global object. Defaults to an empty object. + */ + function Map( values ) { + this.values = values === true ? window : ( values || {} ); + return this; + } + + Map.prototype = { + /** + * Get the value of one or multiple a keys. + * + * If called with no arguments, all values will be returned. + * + * @param {string|Array} selection String key or array of keys to get values for. + * @param {Mixed} [fallback] Value to use in case key(s) do not exist. + * @return mixed If selection was a string returns the value or null, + * If selection was an array, returns an object of key/values (value is null if not found), + * If selection was not passed or invalid, will return the 'values' object member (be careful as + * objects are always passed by reference in JavaScript!). + * @return {string|Object|null} Values as a string or object, null if invalid/inexistant. + */ + get: function ( selection, fallback ) { + var results, i; + // If we only do this in the `return` block, it'll fail for the + // call to get() from the mutli-selection block. + fallback = arguments.length > 1 ? fallback : null; + + if ( $.isArray( selection ) ) { + selection = slice.call( selection ); + results = {}; + for ( i = 0; i < selection.length; i++ ) { + results[selection[i]] = this.get( selection[i], fallback ); + } + return results; + } + + if ( typeof selection === 'string' ) { + if ( !hasOwn.call( this.values, selection ) ) { + return fallback; + } + return this.values[selection]; + } + + if ( selection === undefined ) { + return this.values; + } + + // invalid selection key + return null; + }, + + /** + * Sets one or multiple key/value pairs. + * + * @param {string|Object} selection String key to set value for, or object mapping keys to values. + * @param {Mixed} [value] Value to set (optional, only in use when key is a string) + * @return {Boolean} This returns true on success, false on failure. + */ + set: function ( selection, value ) { + var s; + + if ( $.isPlainObject( selection ) ) { + for ( s in selection ) { + this.values[s] = selection[s]; + } + return true; + } + if ( typeof selection === 'string' && arguments.length > 1 ) { + this.values[selection] = value; + return true; + } + return false; + }, + + /** + * Checks if one or multiple keys exist. + * + * @param {Mixed} selection String key or array of keys to check + * @return {boolean} Existence of key(s) + */ + exists: function ( selection ) { + var s; + + if ( $.isArray( selection ) ) { + for ( s = 0; s < selection.length; s++ ) { + if ( typeof selection[s] !== 'string' || !hasOwn.call( this.values, selection[s] ) ) { + return false; + } + } + return true; + } + return typeof selection === 'string' && hasOwn.call( this.values, selection ); + } + }; + + /** + * Object constructor for messages. + * + * Similar to the Message class in MediaWiki PHP. + * + * Format defaults to 'text'. + * + * @example + * + * var obj, str; + * mw.messages.set( { + * 'hello': 'Hello world', + * 'hello-user': 'Hello, $1!', + * 'welcome-user': 'Welcome back to $2, $1! Last visit by $1: $3' + * } ); + * + * obj = new mw.Message( mw.messages, 'hello' ); + * mw.log( obj.text() ); + * // Hello world + * + * obj = new mw.Message( mw.messages, 'hello-user', [ 'John Doe' ] ); + * mw.log( obj.text() ); + * // Hello, John Doe! + * + * obj = new mw.Message( mw.messages, 'welcome-user', [ 'John Doe', 'Wikipedia', '2 hours ago' ] ); + * mw.log( obj.text() ); + * // Welcome back to Wikipedia, John Doe! Last visit by John Doe: 2 hours ago + * + * // Using mw.message shortcut + * obj = mw.message( 'hello-user', 'John Doe' ); + * mw.log( obj.text() ); + * // Hello, John Doe! + * + * // Using mw.msg shortcut + * str = mw.msg( 'hello-user', 'John Doe' ); + * mw.log( str ); + * // Hello, John Doe! + * + * // Different formats + * obj = new mw.Message( mw.messages, 'hello-user', [ 'John "Wiki" <3 Doe' ] ); + * + * obj.format = 'text'; + * str = obj.toString(); + * // Same as: + * str = obj.text(); + * + * mw.log( str ); + * // Hello, John "Wiki" <3 Doe! + * + * mw.log( obj.escaped() ); + * // Hello, John "Wiki" <3 Doe! + * + * @class mw.Message + * + * @constructor + * @param {mw.Map} map Message storage + * @param {string} key + * @param {Array} [parameters] + */ + function Message( map, key, parameters ) { + this.format = 'text'; + this.map = map; + this.key = key; + this.parameters = parameters === undefined ? [] : slice.call( parameters ); + return this; + } + + Message.prototype = { + /** + * Simple message parser, does $N replacement and nothing else. + * + * This may be overridden to provide a more complex message parser. + * + * The primary override is in mediawiki.jqueryMsg. + * + * This function will not be called for nonexistent messages. + */ + parser: function () { + var parameters = this.parameters; + return this.map.get( this.key ).replace( /\$(\d+)/g, function ( str, match ) { + var index = parseInt( match, 10 ) - 1; + return parameters[index] !== undefined ? parameters[index] : '$' + match; + } ); + }, + + /** + * Appends (does not replace) parameters for replacement to the .parameters property. + * + * @param {Array} parameters + * @chainable + */ + params: function ( parameters ) { + var i; + for ( i = 0; i < parameters.length; i += 1 ) { + this.parameters.push( parameters[i] ); + } + return this; + }, + + /** + * Converts message object to its string form based on the state of format. + * + * @return {string} Message as a string in the current form or `<key>` if key does not exist. + */ + toString: function () { + var text; + + if ( !this.exists() ) { + // Use <key> as text if key does not exist + if ( this.format === 'escaped' || this.format === 'parse' ) { + // format 'escaped' and 'parse' need to have the brackets and key html escaped + return mw.html.escape( '<' + this.key + '>' ); + } + return '<' + this.key + '>'; + } + + if ( this.format === 'plain' || this.format === 'text' || this.format === 'parse' ) { + text = this.parser(); + } + + if ( this.format === 'escaped' ) { + text = this.parser(); + text = mw.html.escape( text ); + } + + return text; + }, + + /** + * Changes format to 'parse' and converts message to string + * + * If jqueryMsg is loaded, this parses the message text from wikitext + * (where supported) to HTML + * + * Otherwise, it is equivalent to plain. + * + * @return {string} String form of parsed message + */ + parse: function () { + this.format = 'parse'; + return this.toString(); + }, + + /** + * Changes format to 'plain' and converts message to string + * + * This substitutes parameters, but otherwise does not change the + * message text. + * + * @return {string} String form of plain message + */ + plain: function () { + this.format = 'plain'; + return this.toString(); + }, + + /** + * Changes format to 'text' and converts message to string + * + * If jqueryMsg is loaded, {{-transformation is done where supported + * (such as {{plural:}}, {{gender:}}, {{int:}}). + * + * Otherwise, it is equivalent to plain. + */ + text: function () { + this.format = 'text'; + return this.toString(); + }, + + /** + * Changes the format to 'escaped' and converts message to string + * + * This is equivalent to using the 'text' format (see text method), then + * HTML-escaping the output. + * + * @return {string} String form of html escaped message + */ + escaped: function () { + this.format = 'escaped'; + return this.toString(); + }, + + /** + * Checks if message exists + * + * @see mw.Map#exists + * @return {boolean} + */ + exists: function () { + return this.map.exists( this.key ); + } + }; + + /** + * @class mw + */ + mw = { + /* Public Members */ + + /** + * Get the current time, measured in milliseconds since January 1, 1970 (UTC). + * + * On browsers that implement the Navigation Timing API, this function will produce floating-point + * values with microsecond precision that are guaranteed to be monotonic. On all other browsers, + * it will fall back to using `Date`. + * + * @return {number} Current time + */ + now: ( function () { + var perf = window.performance, + navStart = perf && perf.timing && perf.timing.navigationStart; + return navStart && typeof perf.now === 'function' ? + function () { return navStart + perf.now(); } : + function () { return +new Date(); }; + }() ), + + /** + * Track an analytic event. + * + * This method provides a generic means for MediaWiki JavaScript code to capture state + * information for analysis. Each logged event specifies a string topic name that describes + * the kind of event that it is. Topic names consist of dot-separated path components, + * arranged from most general to most specific. Each path component should have a clear and + * well-defined purpose. + * + * Data handlers are registered via `mw.trackSubscribe`, and receive the full set of + * events that match their subcription, including those that fired before the handler was + * bound. + * + * @param {string} topic Topic name + * @param {Object} [data] Data describing the event, encoded as an object + */ + track: function ( topic, data ) { + trackQueue.push( { topic: topic, timeStamp: mw.now(), data: data } ); + trackCallbacks.fire( trackQueue ); + }, + + /** + * Register a handler for subset of analytic events, specified by topic + * + * Handlers will be called once for each tracked event, including any events that fired before the + * handler was registered; 'this' is set to a plain object with a 'timeStamp' property indicating + * the exact time at which the event fired, a string 'topic' property naming the event, and a + * 'data' property which is an object of event-specific data. The event topic and event data are + * also passed to the callback as the first and second arguments, respectively. + * + * @param {string} topic Handle events whose name starts with this string prefix + * @param {Function} callback Handler to call for each matching tracked event + */ + trackSubscribe: function ( topic, callback ) { + var seen = 0; + + trackCallbacks.add( function ( trackQueue ) { + var event; + for ( ; seen < trackQueue.length; seen++ ) { + event = trackQueue[ seen ]; + if ( event.topic.indexOf( topic ) === 0 ) { + callback.call( event, event.topic, event.data ); + } + } + } ); + }, + + // Make the Map constructor publicly available. + Map: Map, + + // Make the Message constructor publicly available. + Message: Message, + + /** + * Map of configuration values + * + * Check out [the complete list of configuration values](https://www.mediawiki.org/wiki/Manual:Interface/JavaScript#mw.config) + * on mediawiki.org. + * + * If `$wgLegacyJavaScriptGlobals` is true, this Map will add its values to the + * global `window` object. + * + * @property {mw.Map} config + */ + // Dummy placeholder. Re-assigned in ResourceLoaderStartupModule to an instance of `mw.Map`. + config: null, + + /** + * Empty object that plugins can be installed in. + * @property + */ + libs: {}, + + /** + * Access container for deprecated functionality that can be moved from + * from their legacy location and attached to this object (e.g. a global + * function that is deprecated and as stop-gap can be exposed through here). + * + * This was reserved for future use but never ended up being used. + * + * @deprecated since 1.22 Let deprecated identifiers keep their original name + * and use mw.log#deprecate to create an access container for tracking. + * @property + */ + legacy: {}, + + /** + * Localization system + * @property {mw.Map} + */ + messages: new Map(), + + /* Public Methods */ + + /** + * Get a message object. + * + * Shorcut for `new mw.Message( mw.messages, key, parameters )`. + * + * @see mw.Message + * @param {string} key Key of message to get + * @param {Mixed...} parameters Parameters for the $N replacements in messages. + * @return {mw.Message} + */ + message: function ( key ) { + // Variadic arguments + var parameters = slice.call( arguments, 1 ); + return new Message( mw.messages, key, parameters ); + }, + + /** + * Get a message string using the (default) 'text' format. + * + * Shortcut for `mw.message( key, parameters... ).text()`. + * + * @see mw.Message + * @param {string} key Key of message to get + * @param {Mixed...} parameters Parameters for the $N replacements in messages. + * @return {string} + */ + msg: function () { + return mw.message.apply( mw.message, arguments ).toString(); + }, + + /** + * Dummy placeholder for {@link mw.log} + * @method + */ + log: ( function () { + // Also update the restoration of methods in mediawiki.log.js + // when adding or removing methods here. + var log = function () {}; + + /** + * @class mw.log + * @singleton + */ + + /** + * Write a message the console's warning channel. + * Also logs a stacktrace for easier debugging. + * Each action is silently ignored if the browser doesn't support it. + * + * @param {string...} msg Messages to output to console + */ + log.warn = function () { + var console = window.console; + if ( console && console.warn && console.warn.apply ) { + console.warn.apply( console, arguments ); + if ( console.trace ) { + console.trace(); + } + } + }; + + /** + * Create a property in a host object that, when accessed, will produce + * a deprecation warning in the console with backtrace. + * + * @param {Object} obj Host object of deprecated property + * @param {string} key Name of property to create in `obj` + * @param {Mixed} val The value this property should return when accessed + * @param {string} [msg] Optional text to include in the deprecation message. + */ + log.deprecate = !Object.defineProperty ? function ( obj, key, val ) { + obj[key] = val; + } : function ( obj, key, val, msg ) { + msg = 'Use of "' + key + '" is deprecated.' + ( msg ? ( ' ' + msg ) : '' ); + try { + Object.defineProperty( obj, key, { + configurable: true, + enumerable: true, + get: function () { + mw.track( 'mw.deprecate', key ); + mw.log.warn( msg ); + return val; + }, + set: function ( newVal ) { + mw.track( 'mw.deprecate', key ); + mw.log.warn( msg ); + val = newVal; + } + } ); + } catch ( err ) { + // IE8 can throw on Object.defineProperty + obj[key] = val; + } + }; + + return log; + }() ), + + /** + * Client-side module loader which integrates with the MediaWiki ResourceLoader + * @class mw.loader + * @singleton + */ + loader: ( function () { + + /* Private Members */ + + /** + * Mapping of registered modules + * + * The jquery module is pre-registered, because it must have already + * been provided for this object to have been built, and in debug mode + * jquery would have been provided through a unique loader request, + * making it impossible to hold back registration of jquery until after + * mediawiki. + * + * For exact details on support for script, style and messages, look at + * mw.loader.implement. + * + * Format: + * { + * 'moduleName': { + * // At registry + * 'version': ############## (unix timestamp), + * 'dependencies': ['required.foo', 'bar.also', ...], (or) function () {} + * 'group': 'somegroup', (or) null, + * 'source': 'local', 'someforeignwiki', (or) null + * 'state': 'registered', 'loaded', 'loading', 'ready', 'error' or 'missing' + * 'skip': 'return !!window.Example', (or) null + * + * // Added during implementation + * 'skipped': true, + * 'script': ..., + * 'style': ..., + * 'messages': { 'key': 'value' }, + * } + * } + * + * @property + * @private + */ + var registry = {}, + // + // Mapping of sources, keyed by source-id, values are strings. + // Format: + // { + // 'sourceId': 'http://foo.bar/w/load.php' + // } + // + sources = {}, + // List of modules which will be loaded as when ready + batch = [], + // List of modules to be loaded + queue = [], + // List of callback functions waiting for modules to be ready to be called + jobs = [], + // Selector cache for the marker element. Use getMarker() to get/use the marker! + $marker = null, + // Buffer for addEmbeddedCSS. + cssBuffer = '', + // Callbacks for addEmbeddedCSS. + cssCallbacks = $.Callbacks(); + + /* Private methods */ + + function getMarker() { + // Cached + if ( !$marker ) { + $marker = $( 'meta[name="ResourceLoaderDynamicStyles"]' ); + if ( !$marker.length ) { + mw.log( 'No <meta name="ResourceLoaderDynamicStyles"> found, inserting dynamically' ); + $marker = $( '<meta>' ).attr( 'name', 'ResourceLoaderDynamicStyles' ).appendTo( 'head' ); + } + } + return $marker; + } + + /** + * Create a new style tag and add it to the DOM. + * + * @private + * @param {string} text CSS text + * @param {HTMLElement|jQuery} [nextnode=document.head] The element where the style tag should be + * inserted before. Otherwise it will be appended to `<head>`. + * @return {HTMLElement} Reference to the created `<style>` element. + */ + function newStyleTag( text, nextnode ) { + var s = document.createElement( 'style' ); + // Insert into document before setting cssText (bug 33305) + if ( nextnode ) { + // Must be inserted with native insertBefore, not $.fn.before. + // When using jQuery to insert it, like $nextnode.before( s ), + // then IE6 will throw "Access is denied" when trying to append + // to .cssText later. Some kind of weird security measure. + // http://stackoverflow.com/q/12586482/319266 + // Works: jsfiddle.net/zJzMy/1 + // Fails: jsfiddle.net/uJTQz + // Works again: http://jsfiddle.net/Azr4w/ (diff: the next 3 lines) + if ( nextnode.jquery ) { + nextnode = nextnode.get( 0 ); + } + nextnode.parentNode.insertBefore( s, nextnode ); + } else { + document.getElementsByTagName( 'head' )[0].appendChild( s ); + } + if ( s.styleSheet ) { + // IE + s.styleSheet.cssText = text; + } else { + // Other browsers. + // (Safari sometimes borks on non-string values, + // play safe by casting to a string, just in case.) + s.appendChild( document.createTextNode( String( text ) ) ); + } + return s; + } + + /** + * Checks whether it is safe to add this css to a stylesheet. + * + * @private + * @param {string} cssText + * @return {boolean} False if a new one must be created. + */ + function canExpandStylesheetWith( cssText ) { + // Makes sure that cssText containing `@import` + // rules will end up in a new stylesheet (as those only work when + // placed at the start of a stylesheet; bug 35562). + return cssText.indexOf( '@import' ) === -1; + } + + /** + * Add a bit of CSS text to the current browser page. + * + * The CSS will be appended to an existing ResourceLoader-created `<style>` tag + * or create a new one based on whether the given `cssText` is safe for extension. + * + * @param {string} [cssText=cssBuffer] If called without cssText, + * the internal buffer will be inserted instead. + * @param {Function} [callback] + */ + function addEmbeddedCSS( cssText, callback ) { + var $style, styleEl; + + if ( callback ) { + cssCallbacks.add( callback ); + } + + // Yield once before inserting the <style> tag. There are likely + // more calls coming up which we can combine this way. + // Appending a stylesheet and waiting for the browser to repaint + // is fairly expensive, this reduces it (bug 45810) + if ( cssText ) { + // Be careful not to extend the buffer with css that needs a new stylesheet + if ( !cssBuffer || canExpandStylesheetWith( cssText ) ) { + // Linebreak for somewhat distinguishable sections + // (the rl-cachekey comment separating each) + cssBuffer += '\n' + cssText; + // TODO: Use requestAnimationFrame in the future which will + // perform even better by not injecting styles while the browser + // is paiting. + setTimeout( function () { + // Can't pass addEmbeddedCSS to setTimeout directly because Firefox + // (below version 13) has the non-standard behaviour of passing a + // numerical "lateness" value as first argument to this callback + // http://benalman.com/news/2009/07/the-mysterious-firefox-settime/ + addEmbeddedCSS(); + } ); + return; + } + + // This is a delayed call and we got a buffer still + } else if ( cssBuffer ) { + cssText = cssBuffer; + cssBuffer = ''; + } else { + // This is a delayed call, but buffer is already cleared by + // another delayed call. + return; + } + + // By default, always create a new <style>. Appending text to a <style> + // tag is bad as it means the contents have to be re-parsed (bug 45810). + // + // Except, of course, in IE 9 and below. In there we default to re-using and + // appending to a <style> tag due to the IE stylesheet limit (bug 31676). + if ( 'documentMode' in document && document.documentMode <= 9 ) { + + $style = getMarker().prev(); + // Verify that the the element before Marker actually is a + // <style> tag and one that came from ResourceLoader + // (not some other style tag or even a `<meta>` or `<script>`). + if ( $style.data( 'ResourceLoaderDynamicStyleTag' ) === true ) { + // There's already a dynamic <style> tag present and + // canExpandStylesheetWith() gave a green light to append more to it. + styleEl = $style.get( 0 ); + if ( styleEl.styleSheet ) { + try { + styleEl.styleSheet.cssText += cssText; // IE + } catch ( e ) { + log( 'Stylesheet error', e ); + } + } else { + styleEl.appendChild( document.createTextNode( String( cssText ) ) ); + } + cssCallbacks.fire().empty(); + return; + } + } + + $( newStyleTag( cssText, getMarker() ) ).data( 'ResourceLoaderDynamicStyleTag', true ); + + cssCallbacks.fire().empty(); + } + + /** + * Generates an ISO8601 "basic" string from a UNIX timestamp + * @private + */ + function formatVersionNumber( timestamp ) { + var d = new Date(); + function pad( a, b, c ) { + return [a < 10 ? '0' + a : a, b < 10 ? '0' + b : b, c < 10 ? '0' + c : c].join( '' ); + } + d.setTime( timestamp * 1000 ); + return [ + pad( d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate() ), 'T', + pad( d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds() ), 'Z' + ].join( '' ); + } + + /** + * Resolves dependencies and detects circular references. + * + * @private + * @param {string} module Name of the top-level module whose dependencies shall be + * resolved and sorted. + * @param {Array} resolved Returns a topological sort of the given module and its + * dependencies, such that later modules depend on earlier modules. The array + * contains the module names. If the array contains already some module names, + * this function appends its result to the pre-existing array. + * @param {Object} [unresolved] Hash used to track the current dependency + * chain; used to report loops in the dependency graph. + * @throws {Error} If any unregistered module or a dependency loop is encountered + */ + function sortDependencies( module, resolved, unresolved ) { + var n, deps, len, skip; + + if ( registry[module] === undefined ) { + throw new Error( 'Unknown dependency: ' + module ); + } + + if ( registry[module].skip !== null ) { + /*jshint evil:true */ + skip = new Function( registry[module].skip ); + registry[module].skip = null; + if ( skip() ) { + registry[module].skipped = true; + registry[module].dependencies = []; + registry[module].state = 'ready'; + handlePending( module ); + return; + } + } + + // Resolves dynamic loader function and replaces it with its own results + if ( $.isFunction( registry[module].dependencies ) ) { + registry[module].dependencies = registry[module].dependencies(); + // Ensures the module's dependencies are always in an array + if ( typeof registry[module].dependencies !== 'object' ) { + registry[module].dependencies = [registry[module].dependencies]; + } + } + if ( $.inArray( module, resolved ) !== -1 ) { + // Module already resolved; nothing to do. + return; + } + // unresolved is optional, supply it if not passed in + if ( !unresolved ) { + unresolved = {}; + } + // Tracks down dependencies + deps = registry[module].dependencies; + len = deps.length; + for ( n = 0; n < len; n += 1 ) { + if ( $.inArray( deps[n], resolved ) === -1 ) { + if ( unresolved[deps[n]] ) { + throw new Error( + 'Circular reference detected: ' + module + + ' -> ' + deps[n] + ); + } + + // Add to unresolved + unresolved[module] = true; + sortDependencies( deps[n], resolved, unresolved ); + delete unresolved[module]; + } + } + resolved[resolved.length] = module; + } + + /** + * Gets a list of module names that a module depends on in their proper dependency + * order. + * + * @private + * @param {string} module Module name or array of string module names + * @return {Array} list of dependencies, including 'module'. + * @throws {Error} If circular reference is detected + */ + function resolve( module ) { + var m, resolved; + + // Allow calling with an array of module names + if ( $.isArray( module ) ) { + resolved = []; + for ( m = 0; m < module.length; m += 1 ) { + sortDependencies( module[m], resolved ); + } + return resolved; + } + + if ( typeof module === 'string' ) { + resolved = []; + sortDependencies( module, resolved ); + return resolved; + } + + throw new Error( 'Invalid module argument: ' + module ); + } + + /** + * Narrows a list of module names down to those matching a specific + * state (see comment on top of this scope for a list of valid states). + * One can also filter for 'unregistered', which will return the + * modules names that don't have a registry entry. + * + * @private + * @param {string|string[]} states Module states to filter by + * @param {Array} [modules] List of module names to filter (optional, by default the entire + * registry is used) + * @return {Array} List of filtered module names + */ + function filter( states, modules ) { + var list, module, s, m; + + // Allow states to be given as a string + if ( typeof states === 'string' ) { + states = [states]; + } + // If called without a list of modules, build and use a list of all modules + list = []; + if ( modules === undefined ) { + modules = []; + for ( module in registry ) { + modules[modules.length] = module; + } + } + // Build a list of modules which are in one of the specified states + for ( s = 0; s < states.length; s += 1 ) { + for ( m = 0; m < modules.length; m += 1 ) { + if ( registry[modules[m]] === undefined ) { + // Module does not exist + if ( states[s] === 'unregistered' ) { + // OK, undefined + list[list.length] = modules[m]; + } + } else { + // Module exists, check state + if ( registry[modules[m]].state === states[s] ) { + // OK, correct state + list[list.length] = modules[m]; + } + } + } + } + return list; + } + + /** + * Determine whether all dependencies are in state 'ready', which means we may + * execute the module or job now. + * + * @private + * @param {Array} dependencies Dependencies (module names) to be checked. + * @return {boolean} True if all dependencies are in state 'ready', false otherwise + */ + function allReady( dependencies ) { + return filter( 'ready', dependencies ).length === dependencies.length; + } + + /** + * A module has entered state 'ready', 'error', or 'missing'. Automatically update pending jobs + * and modules that depend upon this module. if the given module failed, propagate the 'error' + * state up the dependency tree; otherwise, execute all jobs/modules that now have all their + * dependencies satisfied. On jobs depending on a failed module, run the error callback, if any. + * + * @private + * @param {string} module Name of module that entered one of the states 'ready', 'error', or 'missing'. + */ + function handlePending( module ) { + var j, job, hasErrors, m, stateChange; + + // Modules. + if ( $.inArray( registry[module].state, ['error', 'missing'] ) !== -1 ) { + // If the current module failed, mark all dependent modules also as failed. + // Iterate until steady-state to propagate the error state upwards in the + // dependency tree. + do { + stateChange = false; + for ( m in registry ) { + if ( $.inArray( registry[m].state, ['error', 'missing'] ) === -1 ) { + if ( filter( ['error', 'missing'], registry[m].dependencies ).length > 0 ) { + registry[m].state = 'error'; + stateChange = true; + } + } + } + } while ( stateChange ); + } + + // Execute all jobs whose dependencies are either all satisfied or contain at least one failed module. + for ( j = 0; j < jobs.length; j += 1 ) { + hasErrors = filter( ['error', 'missing'], jobs[j].dependencies ).length > 0; + if ( hasErrors || allReady( jobs[j].dependencies ) ) { + // All dependencies satisfied, or some have errors + job = jobs[j]; + jobs.splice( j, 1 ); + j -= 1; + try { + if ( hasErrors ) { + if ( $.isFunction( job.error ) ) { + job.error( new Error( 'Module ' + module + ' has failed dependencies' ), [module] ); + } + } else { + if ( $.isFunction( job.ready ) ) { + job.ready(); + } + } + } catch ( e ) { + // A user-defined callback raised an exception. + // Swallow it to protect our state machine! + log( 'Exception thrown by user callback', e ); + } + } + } + + if ( registry[module].state === 'ready' ) { + // The current module became 'ready'. Set it in the module store, and recursively execute all + // dependent modules that are loaded and now have all dependencies satisfied. + mw.loader.store.set( module, registry[module] ); + for ( m in registry ) { + if ( registry[m].state === 'loaded' && allReady( registry[m].dependencies ) ) { + execute( m ); + } + } + } + } + + /** + * Adds a script tag to the DOM, either using document.write or low-level DOM manipulation, + * depending on whether document-ready has occurred yet and whether we are in async mode. + * + * @private + * @param {string} src URL to script, will be used as the src attribute in the script tag + * @param {Function} [callback] Callback which will be run when the script is done + * @param {boolean} [async=false] Whether to load modules asynchronously. + * Ignored (and defaulted to `true`) if the document-ready event has already occurred. + */ + function addScript( src, callback, async ) { + // Using isReady directly instead of storing it locally from a $().ready callback (bug 31895) + if ( $.isReady || async ) { + $.ajax( { + url: src, + dataType: 'script', + // Force jQuery behaviour to be for crossDomain. Otherwise jQuery would use + // XHR for a same domain request instead of <script>, which changes the request + // headers (potentially missing a cache hit), and reduces caching in general + // since browsers cache XHR much less (if at all). And XHR means we retreive + // text, so we'd need to $.globalEval, which then messes up line numbers. + crossDomain: true, + cache: true, + async: true + } ).always( callback ); + } else { + /*jshint evil:true */ + document.write( mw.html.element( 'script', { 'src': src }, '' ) ); + if ( callback ) { + // Document.write is synchronous, so this is called when it's done. + // FIXME: That's a lie. doc.write isn't actually synchronous. + callback(); + } + } + } + + /** + * Executes a loaded module, making it ready to use + * + * @private + * @param {string} module Module name to execute + */ + function execute( module ) { + var key, value, media, i, urls, cssHandle, checkCssHandles, + cssHandlesRegistered = false; + + if ( registry[module] === undefined ) { + throw new Error( 'Module has not been registered yet: ' + module ); + } else if ( registry[module].state === 'registered' ) { + throw new Error( 'Module has not been requested from the server yet: ' + module ); + } else if ( registry[module].state === 'loading' ) { + throw new Error( 'Module has not completed loading yet: ' + module ); + } else if ( registry[module].state === 'ready' ) { + throw new Error( 'Module has already been executed: ' + module ); + } + + /** + * Define loop-function here for efficiency + * and to avoid re-using badly scoped variables. + * @ignore + */ + function addLink( media, url ) { + var el = document.createElement( 'link' ); + // For IE: Insert in document *before* setting href + getMarker().before( el ); + el.rel = 'stylesheet'; + if ( media && media !== 'all' ) { + el.media = media; + } + // If you end up here from an IE exception "SCRIPT: Invalid property value.", + // see #addEmbeddedCSS, bug 31676, and bug 47277 for details. + el.href = url; + } + + function runScript() { + var script, markModuleReady, nestedAddScript; + try { + script = registry[module].script; + markModuleReady = function () { + registry[module].state = 'ready'; + handlePending( module ); + }; + nestedAddScript = function ( arr, callback, async, i ) { + // Recursively call addScript() in its own callback + // for each element of arr. + if ( i >= arr.length ) { + // We're at the end of the array + callback(); + return; + } + + addScript( arr[i], function () { + nestedAddScript( arr, callback, async, i + 1 ); + }, async ); + }; + + if ( $.isArray( script ) ) { + nestedAddScript( script, markModuleReady, registry[module].async, 0 ); + } else if ( $.isFunction( script ) ) { + registry[module].state = 'ready'; + // Pass jQuery twice so that the signature of the closure which wraps + // the script can bind both '$' and 'jQuery'. + script( $, $ ); + handlePending( module ); + } + } catch ( e ) { + // This needs to NOT use mw.log because these errors are common in production mode + // and not in debug mode, such as when a symbol that should be global isn't exported + log( 'Exception thrown by ' + module, e ); + registry[module].state = 'error'; + handlePending( module ); + } + } + + // This used to be inside runScript, but since that is now fired asychronously + // (after CSS is loaded) we need to set it here right away. It is crucial that + // when execute() is called this is set synchronously, otherwise modules will get + // executed multiple times as the registry will state that it isn't loading yet. + registry[module].state = 'loading'; + + // Add localizations to message system + if ( $.isPlainObject( registry[module].messages ) ) { + mw.messages.set( registry[module].messages ); + } + + if ( $.isReady || registry[module].async ) { + // Make sure we don't run the scripts until all (potentially asynchronous) + // stylesheet insertions have completed. + ( function () { + var pending = 0; + checkCssHandles = function () { + // cssHandlesRegistered ensures we don't take off too soon, e.g. when + // one of the cssHandles is fired while we're still creating more handles. + if ( cssHandlesRegistered && pending === 0 && runScript ) { + runScript(); + runScript = undefined; // Revoke + } + }; + cssHandle = function () { + var check = checkCssHandles; + pending++; + return function () { + if (check) { + pending--; + check(); + check = undefined; // Revoke + } + }; + }; + }() ); + } else { + // We are in blocking mode, and so we can't afford to wait for CSS + cssHandle = function () {}; + // Run immediately + checkCssHandles = runScript; + } + + // Process styles (see also mw.loader.implement) + // * back-compat: { <media>: css } + // * back-compat: { <media>: [url, ..] } + // * { "css": [css, ..] } + // * { "url": { <media>: [url, ..] } } + if ( $.isPlainObject( registry[module].style ) ) { + for ( key in registry[module].style ) { + value = registry[module].style[key]; + media = undefined; + + if ( key !== 'url' && key !== 'css' ) { + // Backwards compatibility, key is a media-type + if ( typeof value === 'string' ) { + // back-compat: { <media>: css } + // Ignore 'media' because it isn't supported (nor was it used). + // Strings are pre-wrapped in "@media". The media-type was just "" + // (because it had to be set to something). + // This is one of the reasons why this format is no longer used. + addEmbeddedCSS( value, cssHandle() ); + } else { + // back-compat: { <media>: [url, ..] } + media = key; + key = 'bc-url'; + } + } + + // Array of css strings in key 'css', + // or back-compat array of urls from media-type + if ( $.isArray( value ) ) { + for ( i = 0; i < value.length; i += 1 ) { + if ( key === 'bc-url' ) { + // back-compat: { <media>: [url, ..] } + addLink( media, value[i] ); + } else if ( key === 'css' ) { + // { "css": [css, ..] } + addEmbeddedCSS( value[i], cssHandle() ); + } + } + // Not an array, but a regular object + // Array of urls inside media-type key + } else if ( typeof value === 'object' ) { + // { "url": { <media>: [url, ..] } } + for ( media in value ) { + urls = value[media]; + for ( i = 0; i < urls.length; i += 1 ) { + addLink( media, urls[i] ); + } + } + } + } + } + + // Kick off. + cssHandlesRegistered = true; + checkCssHandles(); + } + + /** + * Adds a dependencies to the queue with optional callbacks to be run + * when the dependencies are ready or fail + * + * @private + * @param {string|string[]} dependencies Module name or array of string module names + * @param {Function} [ready] Callback to execute when all dependencies are ready + * @param {Function} [error] Callback to execute when any dependency fails + * @param {boolean} [async=false] Whether to load modules asynchronously. + * Ignored (and defaulted to `true`) if the document-ready event has already occurred. + */ + function request( dependencies, ready, error, async ) { + var n; + + // Allow calling by single module name + if ( typeof dependencies === 'string' ) { + dependencies = [dependencies]; + } + + // Add ready and error callbacks if they were given + if ( ready !== undefined || error !== undefined ) { + jobs[jobs.length] = { + 'dependencies': filter( + ['registered', 'loading', 'loaded'], + dependencies + ), + 'ready': ready, + 'error': error + }; + } + + // Queue up any dependencies that are registered + dependencies = filter( ['registered'], dependencies ); + for ( n = 0; n < dependencies.length; n += 1 ) { + if ( $.inArray( dependencies[n], queue ) === -1 ) { + queue[queue.length] = dependencies[n]; + if ( async ) { + // Mark this module as async in the registry + registry[dependencies[n]].async = true; + } + } + } + + // Work the queue + mw.loader.work(); + } + + function sortQuery( o ) { + var sorted = {}, key, a = []; + for ( key in o ) { + if ( hasOwn.call( o, key ) ) { + a.push( key ); + } + } + a.sort(); + for ( key = 0; key < a.length; key += 1 ) { + sorted[a[key]] = o[a[key]]; + } + return sorted; + } + + /** + * Converts a module map of the form { foo: [ 'bar', 'baz' ], bar: [ 'baz, 'quux' ] } + * to a query string of the form foo.bar,baz|bar.baz,quux + * @private + */ + function buildModulesString( moduleMap ) { + var arr = [], p, prefix; + for ( prefix in moduleMap ) { + p = prefix === '' ? '' : prefix + '.'; + arr.push( p + moduleMap[prefix].join( ',' ) ); + } + return arr.join( '|' ); + } + + /** + * Asynchronously append a script tag to the end of the body + * that invokes load.php + * @private + * @param {Object} moduleMap Module map, see #buildModulesString + * @param {Object} currReqBase Object with other parameters (other than 'modules') to use in the request + * @param {string} sourceLoadScript URL of load.php + * @param {boolean} async Whether to load modules asynchronously. + * Ignored (and defaulted to `true`) if the document-ready event has already occurred. + */ + function doRequest( moduleMap, currReqBase, sourceLoadScript, async ) { + var request = $.extend( + { modules: buildModulesString( moduleMap ) }, + currReqBase + ); + request = sortQuery( request ); + // Append &* to avoid triggering the IE6 extension check + addScript( sourceLoadScript + '?' + $.param( request ) + '&*', null, async ); + } + + /* Public Members */ + return { + /** + * The module registry is exposed as an aid for debugging and inspecting page + * state; it is not a public interface for modifying the registry. + * + * @see #registry + * @property + * @private + */ + moduleRegistry: registry, + + /** + * @inheritdoc #newStyleTag + * @method + */ + addStyleTag: newStyleTag, + + /** + * Batch-request queued dependencies from the server. + */ + work: function () { + var reqBase, splits, maxQueryLength, q, b, bSource, bGroup, bSourceGroup, + source, concatSource, origBatch, group, g, i, modules, maxVersion, sourceLoadScript, + currReqBase, currReqBaseLength, moduleMap, l, + lastDotIndex, prefix, suffix, bytesAdded, async; + + // Build a list of request parameters common to all requests. + reqBase = { + skin: mw.config.get( 'skin' ), + lang: mw.config.get( 'wgUserLanguage' ), + debug: mw.config.get( 'debug' ) + }; + // Split module batch by source and by group. + splits = {}; + maxQueryLength = mw.config.get( 'wgResourceLoaderMaxQueryLength', -1 ); + + // Appends a list of modules from the queue to the batch + for ( q = 0; q < queue.length; q += 1 ) { + // Only request modules which are registered + if ( registry[queue[q]] !== undefined && registry[queue[q]].state === 'registered' ) { + // Prevent duplicate entries + if ( $.inArray( queue[q], batch ) === -1 ) { + batch[batch.length] = queue[q]; + // Mark registered modules as loading + registry[queue[q]].state = 'loading'; + } + } + } + + mw.loader.store.init(); + if ( mw.loader.store.enabled ) { + concatSource = []; + origBatch = batch; + batch = $.grep( batch, function ( module ) { + var source = mw.loader.store.get( module ); + if ( source ) { + concatSource.push( source ); + return false; + } + return true; + } ); + try { + $.globalEval( concatSource.join( ';' ) ); + } catch ( err ) { + // Not good, the cached mw.loader.implement calls failed! This should + // never happen, barring ResourceLoader bugs, browser bugs and PEBKACs. + // Depending on how corrupt the string is, it is likely that some + // modules' implement() succeeded while the ones after the error will + // never run and leave their modules in the 'loading' state forever. + + // Since this is an error not caused by an individual module but by + // something that infected the implement call itself, don't take any + // risks and clear everything in this cache. + mw.loader.store.clear(); + // Re-add the ones still pending back to the batch and let the server + // repopulate these modules to the cache. + // This means that at most one module will be useless (the one that had + // the error) instead of all of them. + log( 'Error while evaluating data from mw.loader.store', err ); + origBatch = $.grep( origBatch, function ( module ) { + return registry[module].state === 'loading'; + } ); + batch = batch.concat( origBatch ); + } + } + + // Early exit if there's nothing to load... + if ( !batch.length ) { + return; + } + + // The queue has been processed into the batch, clear up the queue. + queue = []; + + // Always order modules alphabetically to help reduce cache + // misses for otherwise identical content. + batch.sort(); + + // Split batch by source and by group. + for ( b = 0; b < batch.length; b += 1 ) { + bSource = registry[batch[b]].source; + bGroup = registry[batch[b]].group; + if ( splits[bSource] === undefined ) { + splits[bSource] = {}; + } + if ( splits[bSource][bGroup] === undefined ) { + splits[bSource][bGroup] = []; + } + bSourceGroup = splits[bSource][bGroup]; + bSourceGroup[bSourceGroup.length] = batch[b]; + } + + // Clear the batch - this MUST happen before we append any + // script elements to the body or it's possible that a script + // will be locally cached, instantly load, and work the batch + // again, all before we've cleared it causing each request to + // include modules which are already loaded. + batch = []; + + for ( source in splits ) { + + sourceLoadScript = sources[source]; + + for ( group in splits[source] ) { + + // Cache access to currently selected list of + // modules for this group from this source. + modules = splits[source][group]; + + // Calculate the highest timestamp + maxVersion = 0; + for ( g = 0; g < modules.length; g += 1 ) { + if ( registry[modules[g]].version > maxVersion ) { + maxVersion = registry[modules[g]].version; + } + } + + currReqBase = $.extend( { version: formatVersionNumber( maxVersion ) }, reqBase ); + // For user modules append a user name to the request. + if ( group === 'user' && mw.config.get( 'wgUserName' ) !== null ) { + currReqBase.user = mw.config.get( 'wgUserName' ); + } + currReqBaseLength = $.param( currReqBase ).length; + async = true; + // We may need to split up the request to honor the query string length limit, + // so build it piece by piece. + l = currReqBaseLength + 9; // '&modules='.length == 9 + + moduleMap = {}; // { prefix: [ suffixes ] } + + for ( i = 0; i < modules.length; i += 1 ) { + // Determine how many bytes this module would add to the query string + lastDotIndex = modules[i].lastIndexOf( '.' ); + + // If lastDotIndex is -1, substr() returns an empty string + prefix = modules[i].substr( 0, lastDotIndex ); + suffix = modules[i].slice( lastDotIndex + 1 ); + + bytesAdded = moduleMap[prefix] !== undefined + ? suffix.length + 3 // '%2C'.length == 3 + : modules[i].length + 3; // '%7C'.length == 3 + + // If the request would become too long, create a new one, + // but don't create empty requests + if ( maxQueryLength > 0 && !$.isEmptyObject( moduleMap ) && l + bytesAdded > maxQueryLength ) { + // This request would become too long, create a new one + // and fire off the old one + doRequest( moduleMap, currReqBase, sourceLoadScript, async ); + moduleMap = {}; + async = true; + l = currReqBaseLength + 9; + } + if ( moduleMap[prefix] === undefined ) { + moduleMap[prefix] = []; + } + moduleMap[prefix].push( suffix ); + if ( !registry[modules[i]].async ) { + // If this module is blocking, make the entire request blocking + // This is slightly suboptimal, but in practice mixing of blocking + // and async modules will only occur in debug mode. + async = false; + } + l += bytesAdded; + } + // If there's anything left in moduleMap, request that too + if ( !$.isEmptyObject( moduleMap ) ) { + doRequest( moduleMap, currReqBase, sourceLoadScript, async ); + } + } + } + }, + + /** + * Register a source. + * + * The #work method will use this information to split up requests by source. + * + * mw.loader.addSource( 'mediawikiwiki', '//www.mediawiki.org/w/load.php' ); + * + * @param {string} id Short string representing a source wiki, used internally for + * registered modules to indicate where they should be loaded from (usually lowercase a-z). + * @param {Object|string} loadUrl load.php url, may be an object for backwards-compatibility + * @return {boolean} + */ + addSource: function ( id, loadUrl ) { + var source; + // Allow multiple additions + if ( typeof id === 'object' ) { + for ( source in id ) { + mw.loader.addSource( source, id[source] ); + } + return true; + } + + if ( sources[id] !== undefined ) { + throw new Error( 'source already registered: ' + id ); + } + + if ( typeof loadUrl === 'object' ) { + loadUrl = loadUrl.loadScript; + } + + sources[id] = loadUrl; + + return true; + }, + + /** + * Register a module, letting the system know about it and its + * properties. Startup modules contain calls to this function. + * + * @param {string} module Module name + * @param {number} version Module version number as a timestamp (falls backs to 0) + * @param {string|Array|Function} dependencies One string or array of strings of module + * names on which this module depends, or a function that returns that array. + * @param {string} [group=null] Group which the module is in + * @param {string} [source='local'] Name of the source + * @param {string} [skip=null] Script body of the skip function + */ + register: function ( module, version, dependencies, group, source, skip ) { + var m; + // Allow multiple registration + if ( typeof module === 'object' ) { + for ( m = 0; m < module.length; m += 1 ) { + // module is an array of module names + if ( typeof module[m] === 'string' ) { + mw.loader.register( module[m] ); + // module is an array of arrays + } else if ( typeof module[m] === 'object' ) { + mw.loader.register.apply( mw.loader, module[m] ); + } + } + return; + } + // Validate input + if ( typeof module !== 'string' ) { + throw new Error( 'module must be a string, not a ' + typeof module ); + } + if ( registry[module] !== undefined ) { + throw new Error( 'module already registered: ' + module ); + } + // List the module as registered + registry[module] = { + version: version !== undefined ? parseInt( version, 10 ) : 0, + dependencies: [], + group: typeof group === 'string' ? group : null, + source: typeof source === 'string' ? source : 'local', + state: 'registered', + skip: typeof skip === 'string' ? skip : null + }; + if ( typeof dependencies === 'string' ) { + // Allow dependencies to be given as a single module name + registry[module].dependencies = [ dependencies ]; + } else if ( typeof dependencies === 'object' || $.isFunction( dependencies ) ) { + // Allow dependencies to be given as an array of module names + // or a function which returns an array + registry[module].dependencies = dependencies; + } + }, + + /** + * Implement a module given the components that make up the module. + * + * When #load or #using requests one or more modules, the server + * response contain calls to this function. + * + * All arguments are required. + * + * @param {string} module Name of module + * @param {Function|Array} script Function with module code or Array of URLs to + * be used as the src attribute of a new `<script>` tag. + * @param {Object} style Should follow one of the following patterns: + * + * { "css": [css, ..] } + * { "url": { <media>: [url, ..] } } + * + * And for backwards compatibility (needs to be supported forever due to caching): + * + * { <media>: css } + * { <media>: [url, ..] } + * + * The reason css strings are not concatenated anymore is bug 31676. We now check + * whether it's safe to extend the stylesheet (see #canExpandStylesheetWith). + * + * @param {Object} msgs List of key/value pairs to be added to mw#messages. + */ + implement: function ( module, script, style, msgs ) { + // Validate input + if ( typeof module !== 'string' ) { + throw new Error( 'module must be a string, not a ' + typeof module ); + } + if ( !$.isFunction( script ) && !$.isArray( script ) ) { + throw new Error( 'script must be a function or an array, not a ' + typeof script ); + } + if ( !$.isPlainObject( style ) ) { + throw new Error( 'style must be an object, not a ' + typeof style ); + } + if ( !$.isPlainObject( msgs ) ) { + throw new Error( 'msgs must be an object, not a ' + typeof msgs ); + } + // Automatically register module + if ( registry[module] === undefined ) { + mw.loader.register( module ); + } + // Check for duplicate implementation + if ( registry[module] !== undefined && registry[module].script !== undefined ) { + throw new Error( 'module already implemented: ' + module ); + } + // Attach components + registry[module].script = script; + registry[module].style = style; + registry[module].messages = msgs; + // The module may already have been marked as erroneous + if ( $.inArray( registry[module].state, ['error', 'missing'] ) === -1 ) { + registry[module].state = 'loaded'; + if ( allReady( registry[module].dependencies ) ) { + execute( module ); + } + } + }, + + /** + * Execute a function as soon as one or more required modules are ready. + * + * Example of inline dependency on OOjs: + * + * mw.loader.using( 'oojs', function () { + * OO.compare( [ 1 ], [ 1 ] ); + * } ); + * + * @param {string|Array} dependencies Module name or array of modules names the callback + * dependends on to be ready before executing + * @param {Function} [ready] Callback to execute when all dependencies are ready + * @param {Function} [error] Callback to execute if one or more dependencies failed + * @return {jQuery.Promise} + */ + using: function ( dependencies, ready, error ) { + var deferred = $.Deferred(); + + // Allow calling with a single dependency as a string + if ( typeof dependencies === 'string' ) { + dependencies = [ dependencies ]; + } else if ( !$.isArray( dependencies ) ) { + // Invalid input + throw new Error( 'Dependencies must be a string or an array' ); + } + + if ( ready ) { + deferred.done( ready ); + } + if ( error ) { + deferred.fail( error ); + } + + // Resolve entire dependency map + dependencies = resolve( dependencies ); + if ( allReady( dependencies ) ) { + // Run ready immediately + deferred.resolve(); + } else if ( filter( ['error', 'missing'], dependencies ).length ) { + // Execute error immediately if any dependencies have errors + deferred.reject( + new Error( 'One or more dependencies failed to load' ), + dependencies + ); + } else { + // Not all dependencies are ready: queue up a request + request( dependencies, deferred.resolve, deferred.reject ); + } + + return deferred.promise(); + }, + + /** + * Load an external script or one or more modules. + * + * @param {string|Array} modules Either the name of a module, array of modules, + * or a URL of an external script or style + * @param {string} [type='text/javascript'] MIME type to use if calling with a URL of an + * external script or style; acceptable values are "text/css" and + * "text/javascript"; if no type is provided, text/javascript is assumed. + * @param {boolean} [async] Whether to load modules asynchronously. + * Ignored (and defaulted to `true`) if the document-ready event has already occurred. + * Defaults to `true` if loading a URL, `false` otherwise. + */ + load: function ( modules, type, async ) { + var filtered, m, module, l; + + // Validate input + if ( typeof modules !== 'object' && typeof modules !== 'string' ) { + throw new Error( 'modules must be a string or an array, not a ' + typeof modules ); + } + // Allow calling with an external url or single dependency as a string + if ( typeof modules === 'string' ) { + // Support adding arbitrary external scripts + if ( /^(https?:)?\/\//.test( modules ) ) { + if ( async === undefined ) { + // Assume async for bug 34542 + async = true; + } + if ( type === 'text/css' ) { + // IE7-8 throws security warnings when inserting a <link> tag + // with a protocol-relative URL set though attributes (instead of + // properties) - when on HTTPS. See also bug 41331. + l = document.createElement( 'link' ); + l.rel = 'stylesheet'; + l.href = modules; + $( 'head' ).append( l ); + return; + } + if ( type === 'text/javascript' || type === undefined ) { + addScript( modules, null, async ); + return; + } + // Unknown type + throw new Error( 'invalid type for external url, must be text/css or text/javascript. not ' + type ); + } + // Called with single module + modules = [ modules ]; + } + + // Filter out undefined modules, otherwise resolve() will throw + // an exception for trying to load an undefined module. + // Undefined modules are acceptable here in load(), because load() takes + // an array of unrelated modules, whereas the modules passed to + // using() are related and must all be loaded. + for ( filtered = [], m = 0; m < modules.length; m += 1 ) { + module = registry[modules[m]]; + if ( module !== undefined ) { + if ( $.inArray( module.state, ['error', 'missing'] ) === -1 ) { + filtered[filtered.length] = modules[m]; + } + } + } + + if ( filtered.length === 0 ) { + return; + } + // Resolve entire dependency map + filtered = resolve( filtered ); + // If all modules are ready, nothing to be done + if ( allReady( filtered ) ) { + return; + } + // If any modules have errors: also quit. + if ( filter( ['error', 'missing'], filtered ).length ) { + return; + } + // Since some modules are not yet ready, queue up a request. + request( filtered, undefined, undefined, async ); + }, + + /** + * Change the state of one or more modules. + * + * @param {string|Object} module Module name or object of module name/state pairs + * @param {string} state State name + */ + state: function ( module, state ) { + var m; + + if ( typeof module === 'object' ) { + for ( m in module ) { + mw.loader.state( m, module[m] ); + } + return; + } + if ( registry[module] === undefined ) { + mw.loader.register( module ); + } + if ( $.inArray( state, ['ready', 'error', 'missing'] ) !== -1 + && registry[module].state !== state ) { + // Make sure pending modules depending on this one get executed if their + // dependencies are now fulfilled! + registry[module].state = state; + handlePending( module ); + } else { + registry[module].state = state; + } + }, + + /** + * Get the version of a module. + * + * @param {string} module Name of module to get version for + * @return {string|null} The version, or null if the module (or its version) is not + * in the registry. + */ + getVersion: function ( module ) { + if ( registry[module] !== undefined && registry[module].version !== undefined ) { + return formatVersionNumber( registry[module].version ); + } + return null; + }, + + /** + * Get the state of a module. + * + * @param {string} module Name of module to get state for + */ + getState: function ( module ) { + if ( registry[module] !== undefined && registry[module].state !== undefined ) { + return registry[module].state; + } + return null; + }, + + /** + * Get the names of all registered modules. + * + * @return {Array} + */ + getModuleNames: function () { + return $.map( registry, function ( i, key ) { + return key; + } ); + }, + + /** + * @inheritdoc mw.inspect#runReports + * @method + */ + inspect: function () { + var args = slice.call( arguments ); + mw.loader.using( 'mediawiki.inspect', function () { + mw.inspect.runReports.apply( mw.inspect, args ); + } ); + }, + + /** + * On browsers that implement the localStorage API, the module store serves as a + * smart complement to the browser cache. Unlike the browser cache, the module store + * can slice a concatenated response from ResourceLoader into its constituent + * modules and cache each of them separately, using each module's versioning scheme + * to determine when the cache should be invalidated. + * + * @singleton + * @class mw.loader.store + */ + store: { + // Whether the store is in use on this page. + enabled: null, + + // The contents of the store, mapping '[module name]@[version]' keys + // to module implementations. + items: {}, + + // Cache hit stats + stats: { hits: 0, misses: 0, expired: 0 }, + + /** + * Construct a JSON-serializable object representing the content of the store. + * @return {Object} Module store contents. + */ + toJSON: function () { + return { items: mw.loader.store.items, vary: mw.loader.store.getVary() }; + }, + + /** + * Get the localStorage key for the entire module store. The key references + * $wgDBname to prevent clashes between wikis which share a common host. + * + * @return {string} localStorage item key + */ + getStoreKey: function () { + return 'MediaWikiModuleStore:' + mw.config.get( 'wgDBname' ); + }, + + /** + * Get a string key on which to vary the module cache. + * @return {string} String of concatenated vary conditions. + */ + getVary: function () { + return [ + mw.config.get( 'skin' ), + mw.config.get( 'wgResourceLoaderStorageVersion' ), + mw.config.get( 'wgUserLanguage' ) + ].join( ':' ); + }, + + /** + * Get a string key for a specific module. The key format is '[name]@[version]'. + * + * @param {string} module Module name + * @return {string|null} Module key or null if module does not exist + */ + getModuleKey: function ( module ) { + return typeof registry[module] === 'object' ? + ( module + '@' + registry[module].version ) : null; + }, + + /** + * Initialize the store. + * + * Retrieves store from localStorage and (if successfully retrieved) decoding + * the stored JSON value to a plain object. + * + * The try / catch block is used for JSON & localStorage feature detection. + * See the in-line documentation for Modernizr's localStorage feature detection + * code for a full account of why we need a try / catch: + * <https://github.com/Modernizr/Modernizr/blob/v2.7.1/modernizr.js#L771-L796>. + */ + init: function () { + var raw, data; + + if ( mw.loader.store.enabled !== null ) { + // Init already ran + return; + } + + if ( !mw.config.get( 'wgResourceLoaderStorageEnabled' ) || mw.config.get( 'debug' ) ) { + // Disabled by configuration, or because debug mode is set + mw.loader.store.enabled = false; + return; + } + + try { + raw = localStorage.getItem( mw.loader.store.getStoreKey() ); + // If we get here, localStorage is available; mark enabled + mw.loader.store.enabled = true; + data = JSON.parse( raw ); + if ( data && typeof data.items === 'object' && data.vary === mw.loader.store.getVary() ) { + mw.loader.store.items = data.items; + return; + } + } catch ( e ) { + log( 'Storage error', e ); + } + + if ( raw === undefined ) { + // localStorage failed; disable store + mw.loader.store.enabled = false; + } else { + mw.loader.store.update(); + } + }, + + /** + * Retrieve a module from the store and update cache hit stats. + * + * @param {string} module Module name + * @return {string|boolean} Module implementation or false if unavailable + */ + get: function ( module ) { + var key; + + if ( !mw.loader.store.enabled ) { + return false; + } + + key = mw.loader.store.getModuleKey( module ); + if ( key in mw.loader.store.items ) { + mw.loader.store.stats.hits++; + return mw.loader.store.items[key]; + } + mw.loader.store.stats.misses++; + return false; + }, + + /** + * Stringify a module and queue it for storage. + * + * @param {string} module Module name + * @param {Object} descriptor The module's descriptor as set in the registry + */ + set: function ( module, descriptor ) { + var args, key; + + if ( !mw.loader.store.enabled ) { + return false; + } + + key = mw.loader.store.getModuleKey( module ); + + if ( + // Already stored a copy of this exact version + key in mw.loader.store.items || + // Module failed to load + descriptor.state !== 'ready' || + // Unversioned, private, or site-/user-specific + ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user', 'site' ] ) !== -1 ) || + // Partial descriptor + $.inArray( undefined, [ descriptor.script, descriptor.style, descriptor.messages ] ) !== -1 + ) { + // Decline to store + return false; + } + + try { + args = [ + JSON.stringify( module ), + typeof descriptor.script === 'function' ? + String( descriptor.script ) : + JSON.stringify( descriptor.script ), + JSON.stringify( descriptor.style ), + JSON.stringify( descriptor.messages ) + ]; + // Attempted workaround for a possible Opera bug (bug 57567). + // This regex should never match under sane conditions. + if ( /^\s*\(/.test( args[1] ) ) { + args[1] = 'function' + args[1]; + log( 'Detected malformed function stringification (bug 57567)' ); + } + } catch ( e ) { + log( 'Storage error', e ); + return; + } + + mw.loader.store.items[key] = 'mw.loader.implement(' + args.join( ',' ) + ');'; + mw.loader.store.update(); + }, + + /** + * Iterate through the module store, removing any item that does not correspond + * (in name and version) to an item in the module registry. + */ + prune: function () { + var key, module; + + if ( !mw.loader.store.enabled ) { + return false; + } + + for ( key in mw.loader.store.items ) { + module = key.slice( 0, key.indexOf( '@' ) ); + if ( mw.loader.store.getModuleKey( module ) !== key ) { + mw.loader.store.stats.expired++; + delete mw.loader.store.items[key]; + } + } + }, + + /** + * Clear the entire module store right now. + */ + clear: function () { + mw.loader.store.items = {}; + localStorage.removeItem( mw.loader.store.getStoreKey() ); + }, + + /** + * Sync modules to localStorage. + * + * This function debounces localStorage updates. When called multiple times in + * quick succession, the calls are coalesced into a single update operation. + * This allows us to call #update without having to consider the module load + * queue; the call to localStorage.setItem will be naturally deferred until the + * page is quiescent. + * + * Because localStorage is shared by all pages with the same origin, if multiple + * pages are loaded with different module sets, the possibility exists that + * modules saved by one page will be clobbered by another. But the impact would + * be minor and the problem would be corrected by subsequent page views. + * + * @method + */ + update: ( function () { + var timer; + + function flush() { + var data, + key = mw.loader.store.getStoreKey(); + + if ( !mw.loader.store.enabled ) { + return false; + } + mw.loader.store.prune(); + try { + // Replacing the content of the module store might fail if the new + // contents would exceed the browser's localStorage size limit. To + // avoid clogging the browser with stale data, always remove the old + // value before attempting to set the new one. + localStorage.removeItem( key ); + data = JSON.stringify( mw.loader.store ); + localStorage.setItem( key, data ); + } catch ( e ) { + log( 'Storage error', e ); + } + } + + return function () { + clearTimeout( timer ); + timer = setTimeout( flush, 2000 ); + }; + }() ) + } + }; + }() ), + + /** + * HTML construction helper functions + * + * @example + * + * var Html, output; + * + * Html = mw.html; + * output = Html.element( 'div', {}, new Html.Raw( + * Html.element( 'img', { src: '<' } ) + * ) ); + * mw.log( output ); // <div><img src="<"/></div> + * + * @class mw.html + * @singleton + */ + html: ( function () { + function escapeCallback( s ) { + switch ( s ) { + case '\'': + return '''; + case '"': + return '"'; + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + } + } + + return { + /** + * Escape a string for HTML. + * + * Converts special characters to HTML entities. + * + * mw.html.escape( '< > \' & "' ); + * // Returns < > ' & " + * + * @param {string} s The string to escape + * @return {string} HTML + */ + escape: function ( s ) { + return s.replace( /['"<>&]/g, escapeCallback ); + }, + + /** + * Create an HTML element string, with safe escaping. + * + * @param {string} name The tag name. + * @param {Object} attrs An object with members mapping element names to values + * @param {Mixed} contents The contents of the element. May be either: + * + * - string: The string is escaped. + * - null or undefined: The short closing form is used, e.g. `<br/>`. + * - this.Raw: The value attribute is included without escaping. + * - this.Cdata: The value attribute is included, and an exception is + * thrown if it contains an illegal ETAGO delimiter. + * See <http://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.3.2>. + * @return {string} HTML + */ + element: function ( name, attrs, contents ) { + var v, attrName, s = '<' + name; + + for ( attrName in attrs ) { + v = attrs[attrName]; + // Convert name=true, to name=name + if ( v === true ) { + v = attrName; + // Skip name=false + } else if ( v === false ) { + continue; + } + s += ' ' + attrName + '="' + this.escape( String( v ) ) + '"'; + } + if ( contents === undefined || contents === null ) { + // Self close tag + s += '/>'; + return s; + } + // Regular open tag + s += '>'; + switch ( typeof contents ) { + case 'string': + // Escaped + s += this.escape( contents ); + break; + case 'number': + case 'boolean': + // Convert to string + s += String( contents ); + break; + default: + if ( contents instanceof this.Raw ) { + // Raw HTML inclusion + s += contents.value; + } else if ( contents instanceof this.Cdata ) { + // CDATA + if ( /<\/[a-zA-z]/.test( contents.value ) ) { + throw new Error( 'mw.html.element: Illegal end tag found in CDATA' ); + } + s += contents.value; + } else { + throw new Error( 'mw.html.element: Invalid type of contents' ); + } + } + s += '</' + name + '>'; + return s; + }, + + /** + * Wrapper object for raw HTML passed to mw.html.element(). + * @class mw.html.Raw + */ + Raw: function ( value ) { + this.value = value; + }, + + /** + * Wrapper object for CDATA element contents passed to mw.html.element() + * @class mw.html.Cdata + */ + Cdata: function ( value ) { + this.value = value; + } + }; + }() ), + + // Skeleton user object. mediawiki.user.js extends this + user: { + options: new Map(), + tokens: new Map() + }, + + /** + * Registry and firing of events. + * + * MediaWiki has various interface components that are extended, enhanced + * or manipulated in some other way by extensions, gadgets and even + * in core itself. + * + * This framework helps streamlining the timing of when these other + * code paths fire their plugins (instead of using document-ready, + * which can and should be limited to firing only once). + * + * Features like navigating to other wiki pages, previewing an edit + * and editing itself – without a refresh – can then retrigger these + * hooks accordingly to ensure everything still works as expected. + * + * Example usage: + * + * mw.hook( 'wikipage.content' ).add( fn ).remove( fn ); + * mw.hook( 'wikipage.content' ).fire( $content ); + * + * Handlers can be added and fired for arbitrary event names at any time. The same + * event can be fired multiple times. The last run of an event is memorized + * (similar to `$(document).ready` and `$.Deferred().done`). + * This means if an event is fired, and a handler added afterwards, the added + * function will be fired right away with the last given event data. + * + * Like Deferreds and Promises, the mw.hook object is both detachable and chainable. + * Thus allowing flexible use and optimal maintainability and authority control. + * You can pass around the `add` and/or `fire` method to another piece of code + * without it having to know the event name (or `mw.hook` for that matter). + * + * var h = mw.hook( 'bar.ready' ); + * new mw.Foo( .. ).fetch( { callback: h.fire } ); + * + * Note: Events are documented with an underscore instead of a dot in the event + * name due to jsduck not supporting dots in that position. + * + * @class mw.hook + */ + hook: ( function () { + var lists = {}; + + /** + * Create an instance of mw.hook. + * + * @method hook + * @member mw + * @param {string} name Name of hook. + * @return {mw.hook} + */ + return function ( name ) { + var list = hasOwn.call( lists, name ) ? + lists[name] : + lists[name] = $.Callbacks( 'memory' ); + + return { + /** + * Register a hook handler + * @param {Function...} handler Function to bind. + * @chainable + */ + add: list.add, + + /** + * Unregister a hook handler + * @param {Function...} handler Function to unbind. + * @chainable + */ + remove: list.remove, + + /** + * Run a hook. + * @param {Mixed...} data + * @chainable + */ + fire: function () { + return list.fireWith.call( this, null, slice.call( arguments ) ); + } + }; + }; + }() ) + }; + + // Alias $j to jQuery for backwards compatibility + // @deprecated since 1.23 Use $ or jQuery instead + mw.log.deprecate( window, '$j', $, 'Use $ or jQuery instead.' ); + + // Attach to window and globally alias + window.mw = window.mediaWiki = mw; + + // Auto-register from pre-loaded startup scripts + if ( $.isFunction( window.startUp ) ) { + window.startUp(); + window.startUp = undefined; + } + +}( jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.log.js b/resources/src/mediawiki/mediawiki.log.js new file mode 100644 index 00000000..ad68967a --- /dev/null +++ b/resources/src/mediawiki/mediawiki.log.js @@ -0,0 +1,84 @@ +/*! + * Logger for MediaWiki javascript. + * Implements the stub left by the main 'mediawiki' module. + * + * @author Michael Dale <mdale@wikimedia.org> + * @author Trevor Parscal <tparscal@wikimedia.org> + */ + +( function ( mw, $ ) { + + // Reference to dummy + // We don't need the dummy, but it has other methods on it + // that we need to restore afterwards. + var original = mw.log, + slice = Array.prototype.slice; + + /** + * Logs a message to the console in debug mode. + * + * In the case the browser does not have a console API, a console is created on-the-fly by appending + * a `<div id="mw-log-console">` element to the bottom of the body and then appending this and future + * messages to that, instead of the console. + * + * @member mw.log + * @param {string...} msg Messages to output to console. + */ + mw.log = function () { + // Turn arguments into an array + var args = slice.call( arguments ), + // Allow log messages to use a configured prefix to identify the source window (ie. frame) + prefix = mw.config.exists( 'mw.log.prefix' ) ? mw.config.get( 'mw.log.prefix' ) + '> ' : ''; + + // Try to use an existing console + // Generally we can cache this, but in this case we want to re-evaluate this as a + // global property live so that things like Firebug Lite can take precedence. + if ( window.console && window.console.log && window.console.log.apply ) { + args.unshift( prefix ); + window.console.log.apply( window.console, args ); + return; + } + + // If there is no console, use our own log box + mw.loader.using( 'jquery.footHovzer', function () { + + var hovzer, + d = new Date(), + // Create HH:MM:SS.MIL timestamp + time = ( d.getHours() < 10 ? '0' + d.getHours() : d.getHours() ) + + ':' + ( d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes() ) + + ':' + ( d.getSeconds() < 10 ? '0' + d.getSeconds() : d.getSeconds() ) + + '.' + ( d.getMilliseconds() < 10 ? '00' + d.getMilliseconds() : ( d.getMilliseconds() < 100 ? '0' + d.getMilliseconds() : d.getMilliseconds() ) ), + $log = $( '#mw-log-console' ); + + if ( !$log.length ) { + $log = $( '<div id="mw-log-console"></div>' ).css( { + overflow: 'auto', + height: '150px', + backgroundColor: 'white', + borderTop: 'solid 2px #ADADAD' + } ); + hovzer = $.getFootHovzer(); + hovzer.$.append( $log ); + hovzer.update(); + } + $log.append( + $( '<div>' ) + .css( { + borderBottom: 'solid 1px #DDDDDD', + fontSize: 'small', + fontFamily: 'monospace', + whiteSpace: 'pre-wrap', + padding: '0.125em 0.25em' + } ) + .text( prefix + args.join( ', ' ) ) + .prepend( '<span style="float: right;">[' + time + ']</span>' ) + ); + } ); + }; + + // Restore original methods + mw.log.warn = original.warn; + mw.log.deprecate = original.deprecate; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.notification.css b/resources/src/mediawiki/mediawiki.notification.css new file mode 100644 index 00000000..ae399ce7 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.notification.css @@ -0,0 +1,27 @@ +.mw-notification-area { + position: absolute; + top: 0; + right: 0; + padding: 1em 1em 0 0; + width: 20em; + line-height: 1.35; + z-index: 10000; +} + +.mw-notification-area-floating { + position: fixed; +} + +.mw-notification { + padding: 0.25em 1em; + margin-bottom: 0.5em; + border: solid 1px #ddd; + background-color: #fcfcfc; + /* Message hides on-click */ + /* See also mediawiki.notification.js */ + cursor: pointer; +} + +.mw-notification-title { + font-weight: bold; +} diff --git a/resources/src/mediawiki/mediawiki.notification.hideForPrint.css b/resources/src/mediawiki/mediawiki.notification.hideForPrint.css new file mode 100644 index 00000000..4f9162e2 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.notification.hideForPrint.css @@ -0,0 +1,3 @@ +.mw-notification-area { + display: none; +} diff --git a/resources/src/mediawiki/mediawiki.notification.js b/resources/src/mediawiki/mediawiki.notification.js new file mode 100644 index 00000000..1968aa94 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.notification.js @@ -0,0 +1,523 @@ +( function ( mw, $ ) { + 'use strict'; + + var notification, + // The #mw-notification-area div that all notifications are contained inside. + $area, + // Number of open notification boxes at any time + openNotificationCount = 0, + isPageReady = false, + preReadyNotifQueue = []; + + /** + * A Notification object for 1 message. + * + * The "_" in the name is to avoid a bug (http://github.com/senchalabs/jsduck/issues/304). + * It is not part of the actual class name. + * + * @class mw.Notification_ + * @alternateClassName mw.Notification + * + * @constructor The constructor is not publicly accessible; use mw.notification#notify instead. + * This does not insert anything into the document (see #start). + * @private + */ + function Notification( message, options ) { + var $notification, $notificationTitle, $notificationContent; + + $notification = $( '<div class="mw-notification"></div>' ) + .data( 'mw.notification', this ) + .addClass( options.autoHide ? 'mw-notification-autohide' : 'mw-notification-noautohide' ); + + if ( options.tag ) { + // Sanitize options.tag before it is used by any code. (Including Notification class methods) + options.tag = options.tag.replace( /[ _\-]+/g, '-' ).replace( /[^\-a-z0-9]+/ig, '' ); + if ( options.tag ) { + $notification.addClass( 'mw-notification-tag-' + options.tag ); + } else { + delete options.tag; + } + } + + if ( options.title ) { + $notificationTitle = $( '<div class="mw-notification-title"></div>' ) + .text( options.title ) + .appendTo( $notification ); + } + + $notificationContent = $( '<div class="mw-notification-content"></div>' ); + + if ( typeof message === 'object' ) { + // Handle mw.Message objects separately from DOM nodes and jQuery objects + if ( message instanceof mw.Message ) { + $notificationContent.html( message.parse() ); + } else { + $notificationContent.append( message ); + } + } else { + $notificationContent.text( message ); + } + + $notificationContent.appendTo( $notification ); + + // Private state parameters, meant for internal use only + // isOpen: Set to true after .start() is called to avoid double calls. + // Set back to false after .close() to avoid duplicating the close animation. + // isPaused: false after .resume(), true after .pause(). Avoids duplicating or breaking the hide timeouts. + // Set to true initially so .start() can call .resume(). + // message: The message passed to the notification. Unused now but may be used in the future + // to stop replacement of a tagged notification with another notification using the same message. + // options: The options passed to the notification with a little sanitization. Used by various methods. + // $notification: jQuery object containing the notification DOM node. + this.isOpen = false; + this.isPaused = true; + this.message = message; + this.options = options; + this.$notification = $notification; + } + + /** + * Start the notification. Called automatically by mw.notification#notify + * (possibly asynchronously on document-ready). + * + * This inserts the notification into the page, closes any matching tagged notifications, + * handles the fadeIn animations and replacement transitions, and starts autoHide timers. + * + * @private + */ + Notification.prototype.start = function () { + var + // Local references + $notification, options, + // Original opacity so that we can animate back to it later + opacity, + // Other notification elements matching the same tag + $tagMatches, + outerHeight, + placeholderHeight, + autohideCount, + notif; + + $area.show(); + + if ( this.isOpen ) { + return; + } + + this.isOpen = true; + openNotificationCount++; + + options = this.options; + $notification = this.$notification; + + opacity = this.$notification.css( 'opacity' ); + + // Set the opacity to 0 so we can fade in later. + $notification.css( 'opacity', 0 ); + + if ( options.tag ) { + // Check to see if there are any tagged notifications with the same tag as the new one + $tagMatches = $area.find( '.mw-notification-tag-' + options.tag ); + } + + // If we found a tagged notification use the replacement pattern instead of the new + // notification fade-in pattern. + if ( options.tag && $tagMatches.length ) { + + // Iterate over the tag matches to find the outerHeight we should use + // for the placeholder. + outerHeight = 0; + $tagMatches.each( function () { + var notif = $( this ).data( 'mw.notification' ); + if ( notif ) { + // Use the notification's height + padding + border + margins + // as the placeholder height. + outerHeight = notif.$notification.outerHeight( true ); + if ( notif.$replacementPlaceholder ) { + // Grab the height of a placeholder that has not finished animating. + placeholderHeight = notif.$replacementPlaceholder.height(); + // Remove any placeholders added by a previous tagged + // notification that was in the middle of replacing another. + // This also makes sure that we only grab the placeholderHeight + // for the most recent notification. + notif.$replacementPlaceholder.remove(); + delete notif.$replacementPlaceholder; + } + // Close the previous tagged notification + // Since we're replacing it do this with a fast speed and don't output a placeholder + // since we're taking care of that transition ourselves. + notif.close( { speed: 'fast', placeholder: false } ); + } + } ); + if ( placeholderHeight !== undefined ) { + // If the other tagged notification was in the middle of replacing another + // tagged notification, continue from the placeholder's height instead of + // using the outerHeight of the notification. + outerHeight = placeholderHeight; + } + + $notification + // Insert the new notification before the tagged notification(s) + .insertBefore( $tagMatches.first() ) + .css( { + // Use an absolute position so that we can use a placeholder to gracefully push other notifications + // into the right spot. + position: 'absolute', + width: $notification.width() + } ) + // Fade-in the notification + .animate( { opacity: opacity }, + { + duration: 'slow', + complete: function () { + // After we've faded in clear the opacity and let css take over + $( this ).css( { opacity: '' } ); + } + } ); + + notif = this; + + // Create a clear placeholder we can use to make the notifications around the notification that is being + // replaced expand or contract gracefully to fit the height of the new notification. + notif.$replacementPlaceholder = $( '<div>' ) + // Set the height to the space the previous notification or placeholder took + .css( 'height', outerHeight ) + // Make sure that this placeholder is at the very end of this tagged notification group + .insertAfter( $tagMatches.eq( -1 ) ) + // Animate the placeholder height to the space that this new notification will take up + .animate( { height: $notification.outerHeight( true ) }, + { + // Do space animations fast + speed: 'fast', + complete: function () { + // Reset the notification position after we've finished the space animation + // However do not do it if the placeholder was removed because another tagged + // notification went and closed this one. + if ( notif.$replacementPlaceholder ) { + $notification.css( 'position', '' ); + } + // Finally, remove the placeholder from the DOM + $( this ).remove(); + } + } ); + } else { + // Append to the notification area and fade in to the original opacity. + $notification + .appendTo( $area ) + .animate( { opacity: opacity }, + { + duration: 'fast', + complete: function () { + // After we've faded in clear the opacity and let css take over + $( this ).css( 'opacity', '' ); + } + } + ); + } + + // By default a notification is paused. + // If this notification is within the first {autoHideLimit} notifications then + // start the auto-hide timer as soon as it's created. + autohideCount = $area.find( '.mw-notification-autohide' ).length; + if ( autohideCount <= notification.autoHideLimit ) { + this.resume(); + } + }; + + /** + * Pause any running auto-hide timer for this notification + */ + Notification.prototype.pause = function () { + if ( this.isPaused ) { + return; + } + this.isPaused = true; + + if ( this.timeout ) { + clearTimeout( this.timeout ); + delete this.timeout; + } + }; + + /** + * Start autoHide timer if not already started. + * Does nothing if autoHide is disabled. + * Either to resume from pause or to make the first start. + */ + Notification.prototype.resume = function () { + var notif = this; + if ( !notif.isPaused ) { + return; + } + // Start any autoHide timeouts + if ( notif.options.autoHide ) { + notif.isPaused = false; + notif.timeout = setTimeout( function () { + // Already finished, so don't try to re-clear it + delete notif.timeout; + notif.close(); + }, notification.autoHideSeconds * 1000 ); + } + }; + + /** + * Close/hide the notification. + * + * @param {Object} options An object containing options for the closing of the notification. + * + * - speed: Use a close speed different than the default 'slow'. + * - placeholder: Set to false to disable the placeholder transition. + */ + Notification.prototype.close = function ( options ) { + if ( !this.isOpen ) { + return; + } + this.isOpen = false; + openNotificationCount--; + // Clear any remaining timeout on close + this.pause(); + + options = $.extend( { + speed: 'slow', + placeholder: true + }, options ); + + // Remove the mw-notification-autohide class from the notification to avoid + // having a half-closed notification counted as a notification to resume + // when handling {autoHideLimit}. + this.$notification.removeClass( 'mw-notification-autohide' ); + + // Now that a notification is being closed. Start auto-hide timers for any + // notification that has now become one of the first {autoHideLimit} notifications. + notification.resume(); + + this.$notification + .css( { + // Don't trigger any mouse events while fading out, just in case the cursor + // happens to be right above us when we transition upwards. + pointerEvents: 'none', + // Set an absolute position so we can move upwards in the animation. + // Notification replacement doesn't look right unless we use an animation like this. + position: 'absolute', + // We must fix the width to avoid it shrinking horizontally. + width: this.$notification.width() + } ) + // Fix the top/left position to the current computed position from which we + // can animate upwards. + .css( this.$notification.position() ); + + // This needs to be done *after* notification's position has been made absolute. + if ( options.placeholder ) { + // Insert a placeholder with a height equal to the height of the + // notification plus it's vertical margins in place of the notification + var $placeholder = $( '<div>' ) + .css( 'height', this.$notification.outerHeight( true ) ) + .insertBefore( this.$notification ); + } + + // Animate opacity and top to create fade upwards animation for notification closing + this.$notification + .animate( { + opacity: 0, + top: '-=35' + }, { + duration: options.speed, + complete: function () { + // Remove the notification + $( this ).remove(); + // Hide the area manually after closing the last notification, since it has padding, + // causing it to obscure whatever is behind it in spite of being invisible (bug 52659). + // It's okay to do this before getting rid of the placeholder, as it's invisible as well. + if ( openNotificationCount === 0 ) { + $area.hide(); + } + if ( options.placeholder ) { + // Use a fast slide up animation after closing to make it look like the notifications + // below slide up into place when the notification disappears + $placeholder.slideUp( 'fast', function () { + // Remove the placeholder + $( this ).remove(); + } ); + } + } + } ); + }; + + /** + * Helper function, take a list of notification divs and call + * a function on the Notification instance attached to them. + * + * @private + * @static + * @param {jQuery} $notifications A jQuery object containing notification divs + * @param {string} fn The name of the function to call on the Notification instance + */ + function callEachNotification( $notifications, fn ) { + $notifications.each( function () { + var notif = $( this ).data( 'mw.notification' ); + if ( notif ) { + notif[fn](); + } + } ); + } + + /** + * Initialisation. + * Must only be called once, and not before the document is ready. + * @ignore + */ + function init() { + var offset, $window = $( window ); + + $area = $( '<div id="mw-notification-area" class="mw-notification-area mw-notification-area-layout"></div>' ) + // Pause auto-hide timers when the mouse is in the notification area. + .on( { + mouseenter: notification.pause, + mouseleave: notification.resume + } ) + // When clicking on a notification close it. + .on( 'click', '.mw-notification', function () { + var notif = $( this ).data( 'mw.notification' ); + if ( notif ) { + notif.close(); + } + } ) + // Stop click events from <a> tags from propogating to prevent clicking. + // on links from hiding a notification. + .on( 'click', 'a', function ( e ) { + e.stopPropagation(); + } ) + .hide(); + + // Prepend the notification area to the content area and save it's object. + mw.util.$content.prepend( $area ); + offset = $area.offset(); + + function updateAreaMode() { + var isFloating = $window.scrollTop() > offset.top; + $area + .toggleClass( 'mw-notification-area-floating', isFloating ) + .toggleClass( 'mw-notification-area-layout', !isFloating ); + } + + $window.on( 'scroll', updateAreaMode ); + + // Initial mode + updateAreaMode(); + } + + /** + * @class mw.notification + * @singleton + */ + notification = { + /** + * Pause auto-hide timers for all notifications. + * Notifications will not auto-hide until resume is called. + * @see mw.Notification#pause + */ + pause: function () { + callEachNotification( + $area.children( '.mw-notification' ), + 'pause' + ); + }, + + /** + * Resume any paused auto-hide timers from the beginning. + * Only the first #autoHideLimit timers will be resumed. + */ + resume: function () { + callEachNotification( + // Only call resume on the first #autoHideLimit notifications. + // Exclude noautohide notifications to avoid bugs where #autoHideLimit + // `{ autoHide: false }` notifications are at the start preventing any + // auto-hide notifications from being autohidden. + $area.children( '.mw-notification-autohide' ).slice( 0, notification.autoHideLimit ), + 'resume' + ); + }, + + /** + * Display a notification message to the user. + * + * @param {HTMLElement|HTMLElement[]|jQuery|mw.Message|string} message + * @param {Object} options The options to use for the notification. + * See #defaults for details. + * @return {mw.Notification} Notification object + */ + notify: function ( message, options ) { + var notif; + options = $.extend( {}, notification.defaults, options ); + + notif = new Notification( message, options ); + + if ( isPageReady ) { + notif.start(); + } else { + preReadyNotifQueue.push( notif ); + } + + return notif; + }, + + /** + * @property {Object} + * The defaults for #notify options parameter. + * + * - autoHide: + * A boolean indicating whether the notifification should automatically + * be hidden after shown. Or if it should persist. + * + * - tag: + * An optional string. When a notification is tagged only one message + * with that tag will be displayed. Trying to display a new notification + * with the same tag as one already being displayed will cause the other + * notification to be closed and this new notification to open up inside + * the same place as the previous notification. + * + * - title: + * An optional title for the notification. Will be displayed above the + * content. Usually in bold. + */ + defaults: { + autoHide: true, + tag: false, + title: undefined + }, + + /** + * @property {number} + * Number of seconds to wait before auto-hiding notifications. + */ + autoHideSeconds: 5, + + /** + * @property {number} + * Maximum number of notifications to count down auto-hide timers for. + * Only the first #autoHideLimit notifications being displayed will + * auto-hide. Any notifications further down in the list will only start + * counting down to auto-hide after the first few messages have closed. + * + * This basically represents the number of notifications the user should + * be able to process in #autoHideSeconds time. + */ + autoHideLimit: 3 + }; + + $( function () { + var notif; + + init(); + + // Handle pre-ready queue. + isPageReady = true; + while ( preReadyNotifQueue.length ) { + notif = preReadyNotifQueue.shift(); + notif.start(); + } + } ); + + mw.notification = notification; + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.notify.js b/resources/src/mediawiki/mediawiki.notify.js new file mode 100644 index 00000000..c1e1dabf --- /dev/null +++ b/resources/src/mediawiki/mediawiki.notify.js @@ -0,0 +1,27 @@ +/** + * @class mw.plugin.notify + */ +( function ( mw ) { + 'use strict'; + + /** + * @see mw.notification#notify + * @param message + * @param options + * @return {jQuery.Promise} + */ + mw.notify = function ( message, options ) { + // Don't bother loading the whole notification system if we never use it. + return mw.loader.using( 'mediawiki.notification' ) + .then( function () { + // Call notify with the notification the user requested of us. + return mw.notification.notify( message, options ); + } ); + }; + + /** + * @class mw + * @mixins mw.plugin.notify + */ + +}( mediaWiki ) ); diff --git a/resources/src/mediawiki/mediawiki.pager.tablePager.less b/resources/src/mediawiki/mediawiki.pager.tablePager.less new file mode 100644 index 00000000..d37aec5b --- /dev/null +++ b/resources/src/mediawiki/mediawiki.pager.tablePager.less @@ -0,0 +1,84 @@ +/*! + * Structures generated by the TablePager PHP class + * in MediaWiki (used e.g. on Special:ListFiles). + */ + +@import "mediawiki.mixins"; + +.TablePager { + min-width: 80%; +} + +.TablePager .TablePager_sort-ascending a { + padding-left: 15px; + background: none left center no-repeat; + .background-image-svg('images/arrow-sort-ascending.svg', 'images/arrow-sort-ascending.png'); +} + +.TablePager .TablePager_sort-descending a { + padding-left: 15px; + background: none left center no-repeat; + .background-image-svg('images/arrow-sort-descending.svg', 'images/arrow-sort-descending.png'); +} + +.TablePager_nav { + margin: 0 auto; +} + +.TablePager_nav td { + padding: 3px; + text-align: center; + vertical-align: center; +} + +.TablePager_nav a { + text-decoration: none; +} + +.TablePager_nav td.TablePager_nav-first .TablePager_nav-disabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-disabled-fastforward-rtl.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-prev .TablePager_nav-disabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-disabled-forward-rtl.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-next .TablePager_nav-disabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-disabled-forward-ltr.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-last .TablePager_nav-disabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-disabled-fastforward-ltr.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-first .TablePager_nav-enabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-fastforward-rtl.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-prev .TablePager_nav-enabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-forward-rtl.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-next .TablePager_nav-enabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-forward-ltr.png) center top no-repeat; +} + +.TablePager_nav td.TablePager_nav-last .TablePager_nav-enabled { + padding-top: 25px; + /* @embed */ + background: url(images/pager-arrow-fastforward-ltr.png) center top no-repeat; +} diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.css b/resources/src/mediawiki/mediawiki.searchSuggest.css new file mode 100644 index 00000000..df144ce9 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.searchSuggest.css @@ -0,0 +1,24 @@ +/* Make sure the links are not underlined or colored, ever. */ +/* There is already a :focus / :hover indication on the <div>. */ +.suggestions a.mw-searchSuggest-link, +.suggestions a.mw-searchSuggest-link:hover, +.suggestions a.mw-searchSuggest-link:active, +.suggestions a.mw-searchSuggest-link:focus { + color: black; + text-decoration: none; +} + +.suggestions-result-current a.mw-searchSuggest-link, +.suggestions-result-current a.mw-searchSuggest-link:hover, +.suggestions-result-current a.mw-searchSuggest-link:active, +.suggestions-result-current a.mw-searchSuggest-link:focus { + color: white; +} + +.suggestions a.mw-searchSuggest-link .special-query { + /* Apply ellipsis to suggestions */ + overflow: hidden; + -o-text-overflow: ellipsis; /* Opera 9 to 10 */ + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/resources/src/mediawiki/mediawiki.searchSuggest.js b/resources/src/mediawiki/mediawiki.searchSuggest.js new file mode 100644 index 00000000..a214cb3f --- /dev/null +++ b/resources/src/mediawiki/mediawiki.searchSuggest.js @@ -0,0 +1,199 @@ +/*! + * Add search suggestions to the search form. + */ +( function ( mw, $ ) { + $( function () { + var api, map, resultRenderCache, searchboxesSelectors, + // Region where the suggestions box will appear directly below + // (using the same width). Can be a container element or the input + // itself, depending on what suits best in the environment. + // For Vector the suggestion box should align with the simpleSearch + // container's borders, in other skins it should align with the input + // element (not the search form, as that would leave the buttons + // vertically between the input and the suggestions). + $searchRegion = $( '#simpleSearch, #searchInput' ).first(), + $searchInput = $( '#searchInput' ); + + // Compatibility map + map = { + // SimpleSearch is broken in Opera < 9.6 + opera: [['>=', 9.6]], + // Older Konquerors are unable to position the suggestions correctly (bug 50805) + konqueror: [['>=', '4.11']], + docomo: false, + blackberry: false, + // Support for iOS 6 or higher. It has not been tested on iOS 5 or lower + ipod: [['>=', 6]], + iphone: [['>=', 6]] + }; + + if ( !$.client.test( map ) ) { + return; + } + + // Compute form data for search suggestions functionality. + function computeResultRenderCache( context ) { + var $form, baseHref, linkParams; + + // Compute common parameters for links' hrefs + $form = context.config.$region.closest( 'form' ); + + baseHref = $form.attr( 'action' ); + baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?'; + + linkParams = {}; + $.each( $form.serializeArray(), function ( idx, obj ) { + linkParams[ obj.name ] = obj.value; + } ); + + return { + textParam: context.data.$textbox.attr( 'name' ), + linkParams: linkParams, + baseHref: baseHref + }; + } + + // The function used to render the suggestions. + function renderFunction( text, context ) { + if ( !resultRenderCache ) { + resultRenderCache = computeResultRenderCache( context ); + } + + // linkParams object is modified and reused + resultRenderCache.linkParams[ resultRenderCache.textParam ] = text; + + // this is the container <div>, jQueryfied + this.text( text ) + .wrap( + $( '<a>' ) + .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) ) + .attr( 'title', text ) + .addClass( 'mw-searchSuggest-link' ) + ); + } + + function specialRenderFunction( query, context ) { + var $el = this; + + if ( !resultRenderCache ) { + resultRenderCache = computeResultRenderCache( context ); + } + + // linkParams object is modified and reused + resultRenderCache.linkParams[ resultRenderCache.textParam ] = query; + + if ( $el.children().length === 0 ) { + $el + .append( + $( '<div>' ) + .addClass( 'special-label' ) + .text( mw.msg( 'searchsuggest-containing' ) ), + $( '<div>' ) + .addClass( 'special-query' ) + .text( query ) + ) + .show(); + } else { + $el.find( '.special-query' ) + .text( query ); + } + + if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) { + $el.parent().attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' ); + } else { + $el.wrap( + $( '<a>' ) + .attr( 'href', resultRenderCache.baseHref + $.param( resultRenderCache.linkParams ) + '&fulltext=1' ) + .addClass( 'mw-searchSuggest-link' ) + ); + } + } + + // Generic suggestions functionality for all search boxes + searchboxesSelectors = [ + // Primary searchbox on every page in standard skins + '#searchInput', + // Special:Search + '#powerSearchText', + '#searchText', + // Generic selector for skins with multiple searchboxes (used by CologneBlue) + // and for MediaWiki itself (special pages with page title inputs) + '.mw-searchInput' + ]; + $( searchboxesSelectors.join( ', ' ) ) + .suggestions( { + fetch: function ( query, response ) { + var node = this[0]; + + api = api || new mw.Api(); + + $.data( node, 'request', api.get( { + action: 'opensearch', + search: query, + namespace: 0, + suggest: '' + } ).done( function ( data ) { + response( data[ 1 ] ); + } ) ); + }, + cancel: function () { + var node = this[0], + request = $.data( node, 'request' ); + + if ( request ) { + request.abort(); + $.removeData( node, 'request' ); + } + }, + result: { + render: renderFunction, + select: function () { + // allow the form to be submitted + return true; + } + }, + cache: true, + highlightInput: true + } ) + .bind( 'paste cut drop', function () { + // make sure paste and cut events from the mouse and drag&drop events + // trigger the keypress handler and cause the suggestions to update + $( this ).trigger( 'keypress' ); + } ) + // In most skins (at least Monobook and Vector), the font-size is messed up in <body>. + // (they use 2 elements to get a sane font-height). So, instead of making exceptions for + // each skin or adding more stylesheets, just copy it from the active element so auto-fit. + .each( function () { + var $this = $( this ); + $this + .data( 'suggestions-context' ) + .data.$container + .css( 'fontSize', $this.css( 'fontSize' ) ); + } ); + + // Ensure that the thing is actually present! + if ( $searchRegion.length === 0 ) { + // Don't try to set anything up if simpleSearch is disabled sitewide. + // The loader code loads us if the option is present, even if we're + // not actually enabled (anymore). + return; + } + + // Special suggestions functionality for skin-provided search box + $searchInput.suggestions( { + special: { + render: specialRenderFunction, + select: function ( $input ) { + $input.closest( 'form' ) + .append( $( '<input type="hidden" name="fulltext" value="1"/>' ) ); + return true; // allow the form to be submitted + } + }, + $region: $searchRegion + } ); + + // If the form includes any fallback fulltext search buttons, remove them + $searchInput.closest( 'form' ).find( '.mw-fallbackSearchButton' ).remove(); + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.toc.js b/resources/src/mediawiki/mediawiki.toc.js new file mode 100644 index 00000000..45338ea7 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.toc.js @@ -0,0 +1,60 @@ +( function ( mw, $ ) { + 'use strict'; + + // Table of contents toggle + mw.hook( 'wikipage.content' ).add( function ( $content ) { + var $toc, $tocTitle, $tocToggleLink, $tocList, hideToc; + $toc = $content.find( '#toc' ); + $tocTitle = $content.find( '#toctitle' ); + $tocToggleLink = $content.find( '#togglelink' ); + $tocList = $toc.find( 'ul' ).eq( 0 ); + + // Hide/show the table of contents element + function toggleToc() { + if ( $tocList.is( ':hidden' ) ) { + $tocList.slideDown( 'fast' ); + $tocToggleLink.text( mw.msg( 'hidetoc' ) ); + $toc.removeClass( 'tochidden' ); + $.cookie( 'mw_hidetoc', null, { + expires: 30, + path: '/' + } ); + } else { + $tocList.slideUp( 'fast' ); + $tocToggleLink.text( mw.msg( 'showtoc' ) ); + $toc.addClass( 'tochidden' ); + $.cookie( 'mw_hidetoc', '1', { + expires: 30, + path: '/' + } ); + } + } + + // Only add it if there is a complete TOC and it doesn't + // have a toggle added already + if ( $toc.length && $tocTitle.length && $tocList.length && !$tocToggleLink.length ) { + hideToc = $.cookie( 'mw_hidetoc' ) === '1'; + + $tocToggleLink = $( '<a href="#" id="togglelink"></a>' ) + .text( hideToc ? mw.msg( 'showtoc' ) : mw.msg( 'hidetoc' ) ) + .click( function ( e ) { + e.preventDefault(); + toggleToc(); + } ); + + $tocTitle.append( + $tocToggleLink + .wrap( '<span class="toctoggle"></span>' ) + .parent() + .prepend( ' [' ) + .append( '] ' ) + ); + + if ( hideToc ) { + $tocList.hide(); + $toc.addClass( 'tochidden' ); + } + } + } ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.user.js b/resources/src/mediawiki/mediawiki.user.js new file mode 100644 index 00000000..e93707ec --- /dev/null +++ b/resources/src/mediawiki/mediawiki.user.js @@ -0,0 +1,258 @@ +/** + * @class mw.user + * @singleton + */ +( function ( mw, $ ) { + var user, + deferreds = {}, + // Extend the skeleton mw.user from mediawiki.js + // This is kind of ugly but we're stuck with this for b/c reasons + options = mw.user.options || new mw.Map(), + tokens = mw.user.tokens || new mw.Map(); + + /** + * Get the current user's groups or rights + * + * @private + * @param {string} info One of 'groups' or 'rights' + * @return {jQuery.Promise} + */ + function getUserInfo( info ) { + var api; + if ( !deferreds[info] ) { + + deferreds.rights = $.Deferred(); + deferreds.groups = $.Deferred(); + + api = new mw.Api(); + api.get( { + action: 'query', + meta: 'userinfo', + uiprop: 'rights|groups' + } ).always( function ( data ) { + var rights, groups; + if ( data.query && data.query.userinfo ) { + rights = data.query.userinfo.rights; + groups = data.query.userinfo.groups; + } + deferreds.rights.resolve( rights || [] ); + deferreds.groups.resolve( groups || [] ); + } ); + + } + + return deferreds[info].promise(); + } + + mw.user = user = { + options: options, + tokens: tokens, + + /** + * Generate a random user session ID (32 alpha-numeric characters) + * + * This information would potentially be stored in a cookie to identify a user during a + * session or series of sessions. Its uniqueness should not be depended on. + * + * @return {string} Random set of 32 alpha-numeric characters + */ + generateRandomSessionId: function () { + var i, r, + id = '', + seed = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + for ( i = 0; i < 32; i++ ) { + r = Math.floor( Math.random() * seed.length ); + id += seed.charAt( r ); + } + return id; + }, + + /** + * Get the current user's database id + * + * Not to be confused with #id. + * + * @return {number} Current user's id, or 0 if user is anonymous + */ + getId: function () { + return mw.config.get( 'wgUserId', 0 ); + }, + + /** + * Get the current user's name + * + * @return {string|null} User name string or null if user is anonymous + */ + getName: function () { + return mw.config.get( 'wgUserName' ); + }, + + /** + * Get date user registered, if available + * + * @return {Date|boolean|null} Date user registered, or false for anonymous users, or + * null when data is not available + */ + getRegistration: function () { + var registration = mw.config.get( 'wgUserRegistration' ); + if ( user.isAnon() ) { + return false; + } else if ( registration === null ) { + // Information may not be available if they signed up before + // MW began storing this. + return null; + } else { + return new Date( registration ); + } + }, + + /** + * Whether the current user is anonymous + * + * @return {boolean} + */ + isAnon: function () { + return user.getName() === null; + }, + + /** + * Get an automatically generated random ID (stored in a session cookie) + * + * This ID is ephemeral for everyone, staying in their browser only until they close + * their browser. + * + * @return {string} Random session ID + */ + sessionId: function () { + var sessionId = $.cookie( 'mediaWiki.user.sessionId' ); + if ( sessionId === undefined || sessionId === null ) { + sessionId = user.generateRandomSessionId(); + $.cookie( 'mediaWiki.user.sessionId', sessionId, { expires: null, path: '/' } ); + } + return sessionId; + }, + + /** + * Get the current user's name or the session ID + * + * Not to be confused with #getId. + * + * @return {string} User name or random session ID + */ + id: function () { + return user.getName() || user.sessionId(); + }, + + /** + * Get the user's bucket (place them in one if not done already) + * + * mw.user.bucket( 'test', { + * buckets: { ignored: 50, control: 25, test: 25 }, + * version: 1, + * expires: 7 + * } ); + * + * @deprecated since 1.23 + * @param {string} key Name of bucket + * @param {Object} options Bucket configuration options + * @param {Object} options.buckets List of bucket-name/relative-probability pairs (required, + * must have at least one pair) + * @param {number} [options.version=0] Version of bucket test, changing this forces + * rebucketing + * @param {number} [options.expires=30] Length of time (in days) until the user gets + * rebucketed + * @return {string} Bucket name - the randomly chosen key of the `options.buckets` object + */ + bucket: function ( key, options ) { + var cookie, parts, version, bucket, + range, k, rand, total; + + options = $.extend( { + buckets: {}, + version: 0, + expires: 30 + }, options || {} ); + + cookie = $.cookie( 'mediaWiki.user.bucket:' + key ); + + // Bucket information is stored as 2 integers, together as version:bucket like: "1:2" + if ( typeof cookie === 'string' && cookie.length > 2 && cookie.indexOf( ':' ) !== -1 ) { + parts = cookie.split( ':' ); + if ( parts.length > 1 && Number( parts[0] ) === options.version ) { + version = Number( parts[0] ); + bucket = String( parts[1] ); + } + } + + if ( bucket === undefined ) { + if ( !$.isPlainObject( options.buckets ) ) { + throw new Error( 'Invalid bucket. Object expected for options.buckets.' ); + } + + version = Number( options.version ); + + // Find range + range = 0; + for ( k in options.buckets ) { + range += options.buckets[k]; + } + + // Select random value within range + rand = Math.random() * range; + + // Determine which bucket the value landed in + total = 0; + for ( k in options.buckets ) { + bucket = k; + total += options.buckets[k]; + if ( total >= rand ) { + break; + } + } + + $.cookie( + 'mediaWiki.user.bucket:' + key, + version + ':' + bucket, + { path: '/', expires: Number( options.expires ) } + ); + } + + return bucket; + }, + + /** + * Get the current user's groups + * + * @param {Function} [callback] + * @return {jQuery.Promise} + */ + getGroups: function ( callback ) { + return getUserInfo( 'groups' ).done( callback ); + }, + + /** + * Get the current user's rights + * + * @param {Function} [callback] + * @return {jQuery.Promise} + */ + getRights: function ( callback ) { + return getUserInfo( 'rights' ).done( callback ); + } + }; + + /** + * @method name + * @inheritdoc #getName + * @deprecated since 1.20 Use #getName instead + */ + mw.log.deprecate( user, 'name', user.getName, 'Use mw.user.getName instead.' ); + + /** + * @method anonymous + * @inheritdoc #isAnon + * @deprecated since 1.20 Use #isAnon instead + */ + mw.log.deprecate( user, 'anonymous', user.isAnon, 'Use mw.user.isAnon instead.' ); + +}( mediaWiki, jQuery ) ); diff --git a/resources/src/mediawiki/mediawiki.util.js b/resources/src/mediawiki/mediawiki.util.js new file mode 100644 index 00000000..26629137 --- /dev/null +++ b/resources/src/mediawiki/mediawiki.util.js @@ -0,0 +1,531 @@ +( function ( mw, $ ) { + 'use strict'; + + /** + * Utility library + * @class mw.util + * @singleton + */ + var util = { + + /** + * Initialisation + * (don't call before document ready) + */ + init: function () { + util.$content = ( function () { + var i, l, $node, selectors; + + selectors = [ + // The preferred standard is class "mw-body". + // You may also use class "mw-body mw-body-primary" if you use + // mw-body in multiple locations. Or class "mw-body-primary" if + // you use mw-body deeper in the DOM. + '.mw-body-primary', + '.mw-body', + + // If the skin has no such class, fall back to the parser output + '#mw-content-text', + + // Should never happen... well, it could if someone is not finished writing a + // skin and has not yet inserted bodytext yet. + 'body' + ]; + + for ( i = 0, l = selectors.length; i < l; i++ ) { + $node = $( selectors[i] ); + if ( $node.length ) { + return $node.first(); + } + } + + // Preserve existing customized value in case it was preset + return util.$content; + }() ); + }, + + /* Main body */ + + /** + * Encode the string like PHP's rawurlencode + * + * @param {string} str String to be encoded. + */ + rawurlencode: function ( str ) { + str = String( str ); + return encodeURIComponent( str ) + .replace( /!/g, '%21' ).replace( /'/g, '%27' ).replace( /\(/g, '%28' ) + .replace( /\)/g, '%29' ).replace( /\*/g, '%2A' ).replace( /~/g, '%7E' ); + }, + + /** + * Encode page titles for use in a URL + * + * We want / and : to be included as literal characters in our title URLs + * as they otherwise fatally break the title. + * + * The others are decoded because we can, it's prettier and matches behaviour + * of `wfUrlencode` in PHP. + * + * @param {string} str String to be encoded. + */ + wikiUrlencode: function ( str ) { + return util.rawurlencode( str ) + .replace( /%20/g, '_' ) + // wfUrlencode replacements + .replace( /%3B/g, ';' ) + .replace( /%40/g, '@' ) + .replace( /%24/g, '$' ) + .replace( /%21/g, '!' ) + .replace( /%2A/g, '*' ) + .replace( /%28/g, '(' ) + .replace( /%29/g, ')' ) + .replace( /%2C/g, ',' ) + .replace( /%2F/g, '/' ) + .replace( /%3A/g, ':' ); + }, + + /** + * Get the link to a page name (relative to `wgServer`), + * + * @param {string} str Page name + * @param {Object} [params] A mapping of query parameter names to values, + * e.g. `{ action: 'edit' }` + * @return {string} Url of the page with name of `str` + */ + getUrl: function ( str, params ) { + var url = mw.config.get( 'wgArticlePath' ).replace( + '$1', + util.wikiUrlencode( typeof str === 'string' ? str : mw.config.get( 'wgPageName' ) ) + ); + + if ( params && !$.isEmptyObject( params ) ) { + url += ( url.indexOf( '?' ) !== -1 ? '&' : '?' ) + $.param( params ); + } + + return url; + }, + + /** + * Get address to a script in the wiki root. + * For index.php use `mw.config.get( 'wgScript' )`. + * + * @since 1.18 + * @param str string Name of script (eg. 'api'), defaults to 'index' + * @return string Address to script (eg. '/w/api.php' ) + */ + wikiScript: function ( str ) { + str = str || 'index'; + if ( str === 'index' ) { + return mw.config.get( 'wgScript' ); + } else if ( str === 'load' ) { + return mw.config.get( 'wgLoadScript' ); + } else { + return mw.config.get( 'wgScriptPath' ) + '/' + str + + mw.config.get( 'wgScriptExtension' ); + } + }, + + /** + * Append a new style block to the head and return the CSSStyleSheet object. + * Use .ownerNode to access the `<style>` element, or use mw.loader#addStyleTag. + * This function returns the styleSheet object for convience (due to cross-browsers + * difference as to where it is located). + * + * var sheet = mw.util.addCSS( '.foobar { display: none; }' ); + * $( foo ).click( function () { + * // Toggle the sheet on and off + * sheet.disabled = !sheet.disabled; + * } ); + * + * @param {string} text CSS to be appended + * @return {CSSStyleSheet} Use .ownerNode to get to the `<style>` element. + */ + addCSS: function ( text ) { + var s = mw.loader.addStyleTag( text ); + return s.sheet || s.styleSheet || s; + }, + + /** + * Grab the URL parameter value for the given parameter. + * Returns null if not found. + * + * @param {string} param The parameter name. + * @param {string} [url=document.location.href] URL to search through, defaulting to the current document's URL. + * @return {Mixed} Parameter value or null. + */ + getParamValue: function ( param, url ) { + if ( url === undefined ) { + url = document.location.href; + } + // Get last match, stop at hash + var re = new RegExp( '^[^#]*[&?]' + $.escapeRE( param ) + '=([^&#]*)' ), + m = re.exec( url ); + if ( m ) { + // Beware that decodeURIComponent is not required to understand '+' + // by spec, as encodeURIComponent does not produce it. + return decodeURIComponent( m[1].replace( /\+/g, '%20' ) ); + } + return null; + }, + + /** + * The content wrapper of the skin (e.g. `.mw-body`). + * + * Populated on document ready by #init. To use this property, + * wait for `$.ready` and be sure to have a module depedendency on + * `mediawiki.util` and `mediawiki.page.startup` which will ensure + * your document ready handler fires after #init. + * + * Because of the lazy-initialised nature of this property, + * you're discouraged from using it. + * + * If you need just the wikipage content (not any of the + * extra elements output by the skin), use `$( '#mw-content-text' )` + * instead. Or listen to mw.hook#wikipage_content which will + * allow your code to re-run when the page changes (e.g. live preview + * or re-render after ajax save). + * + * @property {jQuery} + */ + $content: null, + + /** + * Add a link to a portlet menu on the page, such as: + * + * p-cactions (Content actions), p-personal (Personal tools), + * p-navigation (Navigation), p-tb (Toolbox) + * + * The first three paramters are required, the others are optional and + * may be null. Though providing an id and tooltip is recommended. + * + * By default the new link will be added to the end of the list. To + * add the link before a given existing item, pass the DOM node + * (e.g. `document.getElementById( 'foobar' )`) or a jQuery-selector + * (e.g. `'#foobar'`) for that item. + * + * mw.util.addPortletLink( + * 'p-tb', 'http://mediawiki.org/', + * 'MediaWiki.org', 't-mworg', 'Go to MediaWiki.org ', 'm', '#t-print' + * ); + * + * @param {string} portlet ID of the target portlet ( 'p-cactions' or 'p-personal' etc.) + * @param {string} href Link URL + * @param {string} text Link text + * @param {string} [id] ID of the new item, should be unique and preferably have + * the appropriate prefix ( 'ca-', 'pt-', 'n-' or 't-' ) + * @param {string} [tooltip] Text to show when hovering over the link, without accesskey suffix + * @param {string} [accesskey] Access key to activate this link (one character, try + * to avoid conflicts. Use `$( '[accesskey=x]' ).get()` in the console to + * see if 'x' is already used. + * @param {HTMLElement|jQuery|string} [nextnode] Element or jQuery-selector string to the item that + * the new item should be added before, should be another item in the same + * list, it will be ignored otherwise + * + * @return {HTMLElement|null} The added element (a ListItem or Anchor element, + * depending on the skin) or null if no element was added to the document. + */ + addPortletLink: function ( portlet, href, text, id, tooltip, accesskey, nextnode ) { + var $item, $link, $portlet, $ul; + + // Check if there's atleast 3 arguments to prevent a TypeError + if ( arguments.length < 3 ) { + return null; + } + // Setup the anchor tag + $link = $( '<a>' ).attr( 'href', href ).text( text ); + if ( tooltip ) { + $link.attr( 'title', tooltip ); + } + + // Select the specified portlet + $portlet = $( '#' + portlet ); + if ( $portlet.length === 0 ) { + return null; + } + // Select the first (most likely only) unordered list inside the portlet + $ul = $portlet.find( 'ul' ).eq( 0 ); + + // If it didn't have an unordered list yet, create it + if ( $ul.length === 0 ) { + + $ul = $( '<ul>' ); + + // If there's no <div> inside, append it to the portlet directly + if ( $portlet.find( 'div:first' ).length === 0 ) { + $portlet.append( $ul ); + } else { + // otherwise if there's a div (such as div.body or div.pBody) + // append the <ul> to last (most likely only) div + $portlet.find( 'div' ).eq( -1 ).append( $ul ); + } + } + // Just in case.. + if ( $ul.length === 0 ) { + return null; + } + + // Unhide portlet if it was hidden before + $portlet.removeClass( 'emptyPortlet' ); + + // Wrap the anchor tag in a list item (and a span if $portlet is a Vector tab) + // and back up the selector to the list item + if ( $portlet.hasClass( 'vectorTabs' ) ) { + $item = $link.wrap( '<li><span></span></li>' ).parent().parent(); + } else { + $item = $link.wrap( '<li></li>' ).parent(); + } + + // Implement the properties passed to the function + if ( id ) { + $item.attr( 'id', id ); + } + + if ( accesskey ) { + $link.attr( 'accesskey', accesskey ); + } + + if ( tooltip ) { + $link.attr( 'title', tooltip ).updateTooltipAccessKeys(); + } + + if ( nextnode ) { + if ( nextnode.nodeType || typeof nextnode === 'string' ) { + // nextnode is a DOM element (was the only option before MW 1.17, in wikibits.js) + // or nextnode is a CSS selector for jQuery + nextnode = $ul.find( nextnode ); + } else if ( !nextnode.jquery || ( nextnode.length && nextnode[0].parentNode !== $ul[0] ) ) { + // Fallback + $ul.append( $item ); + return $item[0]; + } + if ( nextnode.length === 1 ) { + // nextnode is a jQuery object that represents exactly one element + nextnode.before( $item ); + return $item[0]; + } + } + + // Fallback (this is the default behavior) + $ul.append( $item ); + return $item[0]; + + }, + + /** + * Validate a string as representing a valid e-mail address + * according to HTML5 specification. Please note the specification + * does not validate a domain with one character. + * + * FIXME: should be moved to or replaced by a validation module. + * + * @param {string} mailtxt E-mail address to be validated. + * @return {boolean|null} Null if `mailtxt` was an empty string, otherwise true/false + * as determined by validation. + */ + validateEmail: function ( mailtxt ) { + var rfc5322Atext, rfc1034LdhStr, html5EmailRegexp; + + if ( mailtxt === '' ) { + return null; + } + + // HTML5 defines a string as valid e-mail address if it matches + // the ABNF: + // 1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str ) + // With: + // - atext : defined in RFC 5322 section 3.2.3 + // - ldh-str : defined in RFC 1034 section 3.5 + // + // (see STD 68 / RFC 5234 http://tools.ietf.org/html/std68) + // First, define the RFC 5322 'atext' which is pretty easy: + // atext = ALPHA / DIGIT / ; Printable US-ASCII + // "!" / "#" / ; characters not including + // "$" / "%" / ; specials. Used for atoms. + // "&" / "'" / + // "*" / "+" / + // "-" / "/" / + // "=" / "?" / + // "^" / "_" / + // "`" / "{" / + // "|" / "}" / + // "~" + rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~'; + + // Next define the RFC 1034 'ldh-str' + // <domain> ::= <subdomain> | " " + // <subdomain> ::= <label> | <subdomain> "." <label> + // <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ] + // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str> + // <let-dig-hyp> ::= <let-dig> | "-" + // <let-dig> ::= <letter> | <digit> + rfc1034LdhStr = 'a-z0-9\\-'; + + html5EmailRegexp = new RegExp( + // start of string + '^' + + + // User part which is liberal :p + '[' + rfc5322Atext + '\\.]+' + + + // 'at' + '@' + + + // Domain first part + '[' + rfc1034LdhStr + ']+' + + + // Optional second part and following are separated by a dot + '(?:\\.[' + rfc1034LdhStr + ']+)*' + + + // End of string + '$', + // RegExp is case insensitive + 'i' + ); + return ( mailtxt.match( html5EmailRegexp ) !== null ); + }, + + /** + * Note: borrows from IP::isIPv4 + * + * @param {string} address + * @param {boolean} allowBlock + * @return {boolean} + */ + isIPv4Address: function ( address, allowBlock ) { + if ( typeof address !== 'string' ) { + return false; + } + + var block = allowBlock ? '(?:\\/(?:3[0-2]|[12]?\\d))?' : '', + RE_IP_BYTE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])', + RE_IP_ADD = '(?:' + RE_IP_BYTE + '\\.){3}' + RE_IP_BYTE; + + return address.search( new RegExp( '^' + RE_IP_ADD + block + '$' ) ) !== -1; + }, + + /** + * Note: borrows from IP::isIPv6 + * + * @param {string} address + * @param {boolean} allowBlock + * @return {boolean} + */ + isIPv6Address: function ( address, allowBlock ) { + if ( typeof address !== 'string' ) { + return false; + } + + var block = allowBlock ? '(?:\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d))?' : '', + RE_IPV6_ADD = + '(?:' + // starts with "::" (including "::") + ':(?::|(?::' + '[0-9A-Fa-f]{1,4}' + '){1,7})' + + '|' + // ends with "::" (except "::") + '[0-9A-Fa-f]{1,4}' + '(?::' + '[0-9A-Fa-f]{1,4}' + '){0,6}::' + + '|' + // contains no "::" + '[0-9A-Fa-f]{1,4}' + '(?::' + '[0-9A-Fa-f]{1,4}' + '){7}' + + ')'; + + if ( address.search( new RegExp( '^' + RE_IPV6_ADD + block + '$' ) ) !== -1 ) { + return true; + } + + RE_IPV6_ADD = // contains one "::" in the middle (single '::' check below) + '[0-9A-Fa-f]{1,4}' + '(?:::?' + '[0-9A-Fa-f]{1,4}' + '){1,6}'; + + return address.search( new RegExp( '^' + RE_IPV6_ADD + block + '$' ) ) !== -1 + && address.search( /::/ ) !== -1 && address.search( /::.*::/ ) === -1; + } + }; + + /** + * @method wikiGetlink + * @inheritdoc #getUrl + * @deprecated since 1.23 Use #getUrl instead. + */ + mw.log.deprecate( util, 'wikiGetlink', util.getUrl, 'Use mw.util.getUrl instead.' ); + + /** + * Access key prefix. Might be wrong for browsers implementing the accessKeyLabel property. + * @property {string} tooltipAccessKeyPrefix + * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead. + */ + mw.log.deprecate( util, 'tooltipAccessKeyPrefix', $.fn.updateTooltipAccessKeys.getAccessKeyPrefix(), 'Use jquery.accessKeyLabel instead.' ); + + /** + * Regex to match accesskey tooltips. + * + * Should match: + * + * - "[ctrl-option-x]" + * - "[alt-shift-x]" + * - "[ctrl-alt-x]" + * - "[ctrl-x]" + * + * The accesskey is matched in group $6. + * + * Will probably not work for browsers implementing the accessKeyLabel property. + * + * @property {RegExp} tooltipAccessKeyRegexp + * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead. + */ + mw.log.deprecate( util, 'tooltipAccessKeyRegexp', /\[(ctrl-)?(option-)?(alt-)?(shift-)?(esc-)?(.)\]$/, 'Use jquery.accessKeyLabel instead.' ); + + /** + * Add the appropriate prefix to the accesskey shown in the tooltip. + * + * If the `$nodes` parameter is given, only those nodes are updated; + * otherwise, depending on browser support, we update either all elements + * with accesskeys on the page or a bunch of elements which are likely to + * have them on core skins. + * + * @method updateTooltipAccessKeys + * @param {Array|jQuery} [$nodes] A jQuery object, or array of nodes to update. + * @deprecated since 1.24 Use the module jquery.accessKeyLabel instead. + */ + mw.log.deprecate( util, 'updateTooltipAccessKeys', function ( $nodes ) { + if ( !$nodes ) { + if ( document.querySelectorAll ) { + // If we're running on a browser where we can do this efficiently, + // just find all elements that have accesskeys. We can't use jQuery's + // polyfill for the selector since looping over all elements on page + // load might be too slow. + $nodes = $( document.querySelectorAll( '[accesskey]' ) ); + } else { + // Otherwise go through some elements likely to have accesskeys rather + // than looping over all of them. Unfortunately this will not fully + // work for custom skins with different HTML structures. Input, label + // and button should be rare enough that no optimizations are needed. + $nodes = $( '#column-one a, #mw-head a, #mw-panel a, #p-logo a, input, label, button' ); + } + } else if ( !( $nodes instanceof $ ) ) { + $nodes = $( $nodes ); + } + + $nodes.updateTooltipAccessKeys(); + }, 'Use jquery.accessKeyLabel instead.' ); + + /** + * Add a little box at the top of the screen to inform the user of + * something, replacing any previous message. + * Calling with no arguments, with an empty string or null will hide the message + * + * @method jsMessage + * @deprecated since 1.20 Use mw#notify + * @param {Mixed} message The DOM-element, jQuery object or HTML-string to be put inside the message box. + * to allow CSS/JS to hide different boxes. null = no class used. + */ + mw.log.deprecate( util, 'jsMessage', function ( message ) { + if ( !arguments.length || message === '' || message === null ) { + return true; + } + if ( typeof message !== 'object' ) { + message = $.parseHTML( message ); + } + mw.notify( message, { autoHide: true, tag: 'legacy' } ); + return true; + }, 'Use mw.notify instead.' ); + + mw.util = util; + +}( mediaWiki, jQuery ) ); |