Merge "jquery.tablesorter: Silence an expected "sort-rowspan-error" warning"
[mediawiki.git] / resources / src / mediawiki.util / util.js
bloba7532c53ecc3ae984a151a1570b54128e270eb36
1 'use strict';
3 let config = require( './config.json' );
4 const portletLinkOptions = require( './portletLinkOptions.json' );
5 const infinityValues = require( './infinityValues.json' );
7 require( './jquery.accessKeyLabel.js' );
9 /**
10 * Encode the string like PHP's rawurlencode.
12 * @ignore
13 * @param {string} str String to be encoded.
14 * @return {string} Encoded string
16 function rawurlencode( str ) {
17 return encodeURIComponent( String( str ) )
18 .replace( /!/g, '%21' )
19 .replace( /'/g, '%27' )
20 .replace( /\(/g, '%28' )
21 .replace( /\)/g, '%29' )
22 .replace( /\*/g, '%2A' )
23 .replace( /~/g, '%7E' );
26 /**
27 * Private helper function used by util.escapeId*()
29 * @ignore
30 * @param {string} str String to be encoded
31 * @param {string} mode Encoding mode, see documentation at
32 * MainConfigSchema::FragmentMode.
33 * @return {string} Encoded string
35 function escapeIdInternal( str, mode ) {
36 str = String( str );
38 switch ( mode ) {
39 case 'html5':
40 return str.replace( / /g, '_' );
41 case 'legacy':
42 return rawurlencode( str.replace( / /g, '_' ) )
43 .replace( /%3A/g, ':' )
44 .replace( /%/g, '.' );
45 default:
46 throw new Error( 'Unrecognized ID escaping mode ' + mode );
50 /**
51 * Library providing useful common skin-agnostic utility functions. Please see
52 * [mediawiki.util]{@link module:mediawiki.util}.
54 * Alias for the [mediawiki.util]{@link module:mediawiki.util} module.
56 * @namespace mw.util
59 /**
60 * Utility library provided by the `mediawiki.util` ResourceLoader module. Accessible inside ResourceLoader modules
61 * or for gadgets as part of the [mw global object]{@link mw}.
63 * @example
64 * // Inside MediaWiki extensions
65 * const util = require( 'mediawiki.util' );
66 * // In gadgets
67 * const mwUtil = mw.util;
68 * @exports mediawiki.util
70 const util = {
72 /**
73 * Encode the string like PHP's rawurlencode.
75 * @method
76 * @param {string} str String to be encoded.
77 * @return {string} Encoded string
79 rawurlencode: rawurlencode,
81 /**
82 * Encode a string as CSS id, for use as HTML id attribute value.
84 * Analog to `Sanitizer::escapeIdForAttribute()` in PHP.
86 * @since 1.30
87 * @param {string} str String to encode
88 * @return {string} Encoded string
90 escapeIdForAttribute: function ( str ) {
91 return escapeIdInternal( str, config.FragmentMode[ 0 ] );
94 /**
95 * Encode a string as URL fragment, for use as HTML anchor link.
97 * Analog to `Sanitizer::escapeIdForLink()` in PHP.
99 * @since 1.30
100 * @param {string} str String to encode
101 * @return {string} Encoded string
103 escapeIdForLink: function ( str ) {
104 return escapeIdInternal( str, config.FragmentMode[ 0 ] );
108 * Get the target element from a link hash.
110 * This is the same element as you would get from
111 * document.querySelectorAll(':target'), but can be used on
112 * an arbitrary hash fragment, or after pushState/replaceState
113 * has been used.
115 * Link fragments can be unencoded, fully encoded or partially
116 * encoded, as defined in the spec.
118 * We can't just use decodeURI as that assumes the fragment
119 * is fully encoded, and throws an error on a string like '%A',
120 * so we use the percent-decode.
122 * @param {string} [hash] Hash fragment, without the leading '#'.
123 * Taken from location.hash if omitted.
124 * @return {HTMLElement|null} Element, if found
126 getTargetFromFragment: function ( hash ) {
127 hash = hash || location.hash.slice( 1 );
128 if ( !hash ) {
129 // Firefox emits a console warning if you pass an empty string
130 // to getElementById (T272844).
131 return null;
133 // Per https://html.spec.whatwg.org/multipage/browsing-the-web.html#target-element
134 // we try the raw fragment first, then the percent-decoded fragment.
135 const element = document.getElementById( hash );
136 if ( element ) {
137 return element;
139 const decodedHash = this.percentDecodeFragment( hash );
140 if ( !decodedHash ) {
141 // decodedHash can return null, calling getElementById would cast it to a string
142 return null;
144 return document.getElementById( decodedHash );
148 * Percent-decode a string, as found in a URL hash fragment.
150 * Implements the percent-decode method as defined in
151 * https://url.spec.whatwg.org/#percent-decode.
153 * URLSearchParams implements https://url.spec.whatwg.org/#concept-urlencoded-parser
154 * which performs a '+' to ' ' substitution before running percent-decode.
156 * To get the desired behaviour we percent-encode any '+' in the fragment
157 * to effectively expose the percent-decode implementation.
159 * @param {string} text Text to decode
160 * @return {string|null} Decoded text, null if decoding failed
162 percentDecodeFragment: function ( text ) {
163 const params = new URLSearchParams(
164 'q=' +
165 text
166 // Query string param decoding replaces '+' with ' ' before doing the
167 // percent_decode, so encode '+' to prevent this.
168 .replace( /\+/g, '%2B' )
169 // Query strings are split on '&' and then '=' so encode these too.
170 .replace( /&/g, '%26' )
171 .replace( /=/g, '%3D' )
173 return params.get( 'q' );
177 * Return a function, that, as long as it continues to be invoked, will not
178 * be triggered. The function will be called after it stops being called for
179 * N milliseconds. If `immediate` is passed, trigger the function on the
180 * leading edge, instead of the trailing.
182 * Ported from Underscore.js 1.5.2, Copyright 2009-2013 Jeremy Ashkenas, DocumentCloud
183 * and Investigative Reporters & Editors, distributed under the MIT license, from
184 * <https://github.com/jashkenas/underscore/blob/1.5.2/underscore.js#L689>.
186 * @since 1.34
187 * @param {Function} func Function to debounce
188 * @param {number} [wait=0] Wait period in milliseconds
189 * @param {boolean} [immediate] Trigger on leading edge
190 * @return {Function} Debounced function
192 debounce: function ( func, wait, immediate ) {
193 // Old signature (wait, func).
194 if ( typeof func === 'number' ) {
195 const tmpWait = wait;
196 wait = func;
197 func = tmpWait;
199 let timeout;
200 return function () {
201 const context = this,
202 args = arguments,
203 later = function () {
204 timeout = null;
205 if ( !immediate ) {
206 func.apply( context, args );
209 if ( immediate && !timeout ) {
210 func.apply( context, args );
212 if ( !timeout || wait ) {
213 clearTimeout( timeout );
214 timeout = setTimeout( later, wait );
220 * Return a function, that, when invoked, will only be triggered at most once
221 * during a given window of time. If called again during that window, it will
222 * wait until the window ends and then trigger itself again.
224 * As it's not knowable to the caller whether the function will actually run
225 * when the wrapper is called, return values from the function are entirely
226 * discarded.
228 * Ported from OOUI.
230 * @param {Function} func Function to throttle
231 * @param {number} wait Throttle window length, in milliseconds
232 * @return {Function} Throttled function
234 throttle: function ( func, wait ) {
235 let context, args, timeout,
236 previous = Date.now() - wait;
237 const run = function () {
238 timeout = null;
239 previous = Date.now();
240 func.apply( context, args );
242 return function () {
243 // Check how long it's been since the last time the function was
244 // called, and whether it's more or less than the requested throttle
245 // period. If it's less, run the function immediately. If it's more,
246 // set a timeout for the remaining time -- but don't replace an
247 // existing timeout, since that'd indefinitely prolong the wait.
248 const remaining = Math.max( wait - ( Date.now() - previous ), 0 );
249 context = this;
250 args = arguments;
251 if ( !timeout ) {
252 // If time is up, do setTimeout( run, 0 ) so the function
253 // always runs asynchronously, just like Promise#then .
254 timeout = setTimeout( run, remaining );
260 * Encode page titles in a way that matches `wfUrlencode` in PHP.
262 * This is important both for readability and consistency in the user experience,
263 * as well as for caching. If URLs are not formatted in the canonical way, they
264 * may be subject to drastically shorter cache durations and/or miss automatic
265 * purging after edits, thus leading to stale content being served from a
266 * non-canonical URL.
268 * @method
269 * @param {string} str String to be encoded.
270 * @return {string} Encoded string
272 wikiUrlencode: mw.internalWikiUrlencode,
275 * Get the URL to a given local wiki page name.
277 * @param {string|null} [pageName=wgPageName] Page name
278 * @param {Object} [params] A mapping of query parameter names to values,
279 * e.g. `{ action: 'edit' }`
280 * @return {string} URL, relative to `wgServer`.
282 getUrl: function ( pageName, params ) {
283 let url, query, fragment,
284 title = typeof pageName === 'string' ? pageName : mw.config.get( 'wgPageName' );
286 // Find any fragment
287 const fragmentIdx = title.indexOf( '#' );
288 if ( fragmentIdx !== -1 ) {
289 fragment = title.slice( fragmentIdx + 1 );
290 // Exclude the fragment from the page name
291 title = title.slice( 0, fragmentIdx );
294 // Produce query string
295 if ( params ) {
296 query = $.param( params );
299 if ( !title && fragment ) {
300 // If only a fragment was given, make a fragment-only link (T288415)
301 url = '';
302 } else if ( query ) {
303 url = title ?
304 util.wikiScript() + '?title=' + util.wikiUrlencode( title ) + '&' + query :
305 util.wikiScript() + '?' + query;
306 } else {
307 // Specify a function as the replacement,
308 // so that "$" characters in title are not interpreted.
309 url = mw.config.get( 'wgArticlePath' )
310 .replace( '$1', () => util.wikiUrlencode( title ) );
313 // Append the encoded fragment
314 if ( fragment ) {
315 url += '#' + util.escapeIdForLink( fragment );
318 return url;
322 * Get URL to a MediaWiki entry point.
324 * Similar to `wfScript()` in PHP.
326 * @since 1.18
327 * @param {string} [str="index"] Name of entry point (e.g. 'index' or 'api')
328 * @return {string} URL to the script file (e.g. `/w/api.php`)
330 wikiScript: function ( str ) {
331 if ( !str || str === 'index' ) {
332 return mw.config.get( 'wgScript' );
333 } else if ( str === 'load' ) {
334 return config.LoadScript;
335 } else {
336 return mw.config.get( 'wgScriptPath' ) + '/' + str + '.php';
341 * Append a new style block to the head and return the CSSStyleSheet object.
343 * To access the `<style>` element, reference `sheet.ownerNode`, or call
344 * the {@link mw.loader.addStyleTag} method directly.
346 * This function returns the CSSStyleSheet object for convenience with features
347 * that are managed at that level, such as toggling of styles:
348 * ```
349 * var sheet = util.addCSS( '.foobar { display: none; }' );
350 * $( '#myButton' ).click( function () {
351 * // Toggle the sheet on and off
352 * sheet.disabled = !sheet.disabled;
353 * } );
354 * ```
356 * See also [MDN: CSSStyleSheet](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet).
358 * @param {string} text CSS to be appended
359 * @return {CSSStyleSheet} The sheet object
361 addCSS: function ( text ) {
362 const s = mw.loader.addStyleTag( text );
363 return s.sheet;
367 * Get the value for a given URL query parameter.
369 * @example
370 * mw.util.getParamValue( 'foo', '/?foo=x' ); // "x"
371 * mw.util.getParamValue( 'foo', '/?foo=' ); // ""
372 * mw.util.getParamValue( 'foo', '/' ); // null
374 * @param {string} param The parameter name.
375 * @param {string} [url=location.href] URL to search through, defaulting to the current browsing location.
376 * @return {string|null} Parameter value, or null if parameter was not found.
378 getParamValue: function ( param, url ) {
379 // Get last match, stop at hash
381 const re = new RegExp( '^[^#]*[&?]' + util.escapeRegExp( param ) + '=([^&#]*)' ),
382 m = re.exec( url !== undefined ? url : location.href );
384 if ( m ) {
385 // Beware that decodeURIComponent is not required to understand '+'
386 // by spec, as encodeURIComponent does not produce it.
387 try {
388 return decodeURIComponent( m[ 1 ].replace( /\+/g, '%20' ) );
389 } catch ( e ) {
390 // catch URIError if parameter is invalid UTF-8
391 // due to malformed or double-decoded values (T106244),
392 // e.g. "Autom%F3vil" instead of "Autom%C3%B3vil".
395 return null;
399 * Get the value for an array query parameter, combined according to similar rules as PHP uses.
400 * Currently this does not handle associative or multi-dimensional arrays, but that may be
401 * improved in the future.
403 * @example
404 * mw.util.getArrayParam( 'foo', new URLSearchParams( '?foo[0]=a&foo[1]=b' ) ); // [ 'a', 'b' ]
405 * mw.util.getArrayParam( 'foo', new URLSearchParams( '?foo[]=a&foo[]=b' ) ); // [ 'a', 'b' ]
406 * mw.util.getArrayParam( 'foo', new URLSearchParams( '?foo=a' ) ); // null
408 * @param {string} param The parameter name.
409 * @param {URLSearchParams} [params] Parsed URL parameters to search through, defaulting to the current browsing location.
410 * @return {string[]|null} Parameter value, or null if parameter was not found.
412 getArrayParam: function ( param, params ) {
414 const paramRe = new RegExp( '^' + util.escapeRegExp( param ) + '\\[(\\d*)\\]$' );
416 if ( !params ) {
417 params = new URLSearchParams( location.search );
420 const arr = [];
421 params.forEach( ( v, k ) => {
422 const paramMatch = k.match( paramRe );
423 if ( paramMatch ) {
424 let i = paramMatch[ 1 ];
425 if ( i === '' ) {
426 // If no explicit index, append at the end
427 i = arr.length;
429 arr[ i ] = v;
431 } );
433 return arr.length ? arr : null;
437 * The content wrapper of the skin (`.mw-body`, for example).
439 * Populated on document ready. To use this property,
440 * wait for `$.ready` and be sure to have a module dependency on
441 * `mediawiki.util` which will ensure
442 * your document ready handler fires after initialization.
444 * Because of the lazy-initialised nature of this property,
445 * you're discouraged from using it.
447 * If you need just the wikipage content (not any of the
448 * extra elements output by the skin), use `$( '#mw-content-text' )`
449 * instead. Or listen to {@link event:'wikipage.content' wikipage.content}
450 * which will allow your code to re-run when the page changes (e.g. live preview
451 * or re-render after ajax save).
453 * @type {jQuery}
455 $content: null,
458 * Hide a portlet.
460 * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
462 hidePortlet: function ( portletId ) {
463 const portlet = document.getElementById( portletId );
464 if ( portlet ) {
465 portlet.classList.add( 'emptyPortlet' );
470 * Whether a portlet is visible.
472 * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
473 * @return {boolean}
475 isPortletVisible: function ( portletId ) {
476 const portlet = document.getElementById( portletId );
477 return portlet && !portlet.classList.contains( 'emptyPortlet' );
481 * Reveal a portlet if it is hidden.
483 * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
485 showPortlet: function ( portletId ) {
486 const portlet = document.getElementById( portletId );
487 if ( portlet ) {
488 portlet.classList.remove( 'emptyPortlet' );
493 * Clears the entire subtitle if present in the page. Used for refreshing subtitle
494 * after edit with response from parse API.
496 clearSubtitle: function () {
497 const subtitle = document.getElementById( 'mw-content-subtitle' );
498 if ( subtitle ) {
499 subtitle.innerHTML = '';
504 * Create a message box element. Callers are responsible for ensuring suitable Codex styles
505 * have been added to the page e.g. mediawiki.codex.messagebox.styles.
507 * @since 1.43
508 * @param {string|Node} textOrElement text or node.
509 * @param {string} [type] defaults to notice.
510 * @param {boolean} [inline] whether the notice should be inline.
511 * @return {Element}
513 messageBox: function ( textOrElement, type = 'notice', inline = false ) {
514 const msgBoxElement = document.createElement( 'div' );
515 msgBoxElement.classList.add( 'cdx-message' );
517 if ( [ 'error', 'warning', 'success', 'notice' ].indexOf( type ) > -1 ) {
518 // The following CSS classes are used here:
519 // * cdx-message--notice
520 // * cdx-message--warning
521 // * cdx-message--error
522 // * cdx-message--success
523 msgBoxElement.classList.add( `cdx-message--${ type }` );
525 msgBoxElement.classList.add( inline ? 'cdx-message--inline' : 'cdx-message--block' );
527 if ( type === 'error' ) {
528 msgBoxElement.setAttribute( 'role', 'alert' );
529 } else {
530 msgBoxElement.setAttribute( 'aria-live', 'polite' );
533 const iconElement = document.createElement( 'span' );
534 iconElement.classList.add( 'cdx-message__icon' );
535 const contentElement = document.createElement( 'div' );
536 contentElement.classList.add( 'cdx-message__content' );
537 if ( typeof textOrElement === 'string' ) {
538 contentElement.textContent = textOrElement;
539 } else {
540 contentElement.appendChild( textOrElement );
542 msgBoxElement.appendChild( iconElement );
543 msgBoxElement.appendChild( contentElement );
544 return msgBoxElement;
548 * Add content to the subtitle of the skin.
550 * @param {HTMLElement|string} nodeOrHTMLString
552 addSubtitle: function ( nodeOrHTMLString ) {
553 const subtitle = document.getElementById( 'mw-content-subtitle' );
554 if ( subtitle ) {
555 if ( typeof nodeOrHTMLString === 'string' ) {
556 subtitle.innerHTML += nodeOrHTMLString;
557 } else {
558 subtitle.appendChild( nodeOrHTMLString );
560 } else {
561 throw new Error( 'This skin does not support additions to the subtitle.' );
566 * Creates a detached portlet Element in the skin with no elements.
568 * @example
569 * // Create a portlet with 2 menu items that is styled as a dropdown in certain skins.
570 * const p = mw.util.addPortlet( 'p-myportlet', 'My label', '#p-cactions' );
571 * mw.util.addPortletLink( 'p-myportlet', '#', 'Link 1' );
572 * mw.util.addPortletLink( 'p-myportlet', '#', 'Link 2' );
573 * @param {string} id of the new portlet.
574 * @param {string} [label] of the new portlet.
575 * @param {string} [selectorHint] selector of the element the new portlet would like to
576 * be inserted near. Typically the portlet will be inserted after this selector, but in some
577 * skins, the skin may relocate the element when provided to the closest available space.
578 * If this argument is not passed then the caller is responsible for appending the element
579 * to the DOM before using addPortletLink.
580 * To add a portlet in an exact position do not rely on this parameter, instead using the return
581 * element (make sure to also assign the result to a variable), use
582 * ```p.parentNode.appendChild( p );```
583 * When provided, skins can use the parameter to infer information about how the user intended
584 * the menu to be rendered. For example, in vector and vector-2022 targeting '#p-cactions' will
585 * result in the creation of a dropdown.
586 * @fires Hooks~'util.addPortlet'
587 * @return {HTMLElement|null} will be null if it was not possible to create an portlet with
588 * the required information e.g. the selector given in `selectorHint` parameter could not be resolved
589 * to an existing element in the page.
591 addPortlet: function ( id, label, selectorHint ) {
592 const portlet = document.createElement( 'div' );
593 // These classes should be kept in sync with includes/skins/components/SkinComponentMenu.php.
594 // eslint-disable-next-line mediawiki/class-doc
595 portlet.classList.add( 'mw-portlet', 'mw-portlet-' + id, 'emptyPortlet',
596 // Additional class is added to allow skins to track portlets added via this mechanism.
597 'mw-portlet-js'
599 portlet.id = id;
600 if ( label ) {
601 const labelNode = document.createElement( 'label' );
602 labelNode.textContent = label;
603 portlet.appendChild( labelNode );
605 const listWrapper = document.createElement( 'div' );
606 const list = document.createElement( 'ul' );
607 listWrapper.appendChild( list );
608 portlet.appendChild( listWrapper );
609 if ( selectorHint ) {
610 let referenceNode;
611 try {
612 referenceNode = document.querySelector( selectorHint );
613 } catch ( e ) {
614 // CSS selector not supported by browser.
616 if ( referenceNode ) {
617 const parentNode = referenceNode.parentNode;
618 parentNode.insertBefore( portlet, referenceNode );
619 } else {
620 return null;
624 * Fires when a portlet is successfully created.
626 * @event ~'util.addPortlet'
627 * @memberof Hooks
628 * @param {HTMLElement} portlet the portlet that was created.
629 * @param {string|null} selectorHint the css selector used to append to the DOM.
631 * @example
632 * mw.hook( 'util.addPortlet' ).add( ( p ) => {
633 * p.style.border = 'solid 1px black';
634 * } );
636 mw.hook( 'util.addPortlet' ).fire( portlet, selectorHint );
637 return portlet;
640 * Add a link to a portlet menu on the page.
642 * The portlets that are supported include:
644 * - p-cactions (Content actions)
645 * - p-personal (Personal tools)
646 * - p-navigation (Navigation)
647 * - p-tb (Toolbox)
648 * - p-associated-pages (For namespaces and special page tabs on supported skins)
649 * - p-namespaces (For namespaces on legacy skins)
651 * Additional menus can be discovered through the following code:
652 * ```$('.mw-portlet').toArray().map((el) => el.id);```
654 * Menu availability varies by skin, wiki, and current page.
656 * The first three parameters are required, the others are optional and
657 * may be null. Though providing an id and tooltip is recommended.
659 * By default, the new link will be added to the end of the menu. To
660 * add the link before an existing item, pass the DOM node or a CSS selector
661 * for that item, e.g. `'#foobar'` or `document.getElementById( 'foobar' )`.
662 * ```
663 * mw.util.addPortletLink(
664 * 'p-tb', 'https://www.mediawiki.org/',
665 * 'mediawiki.org', 't-mworg', 'Go to mediawiki.org', 'm', '#t-print'
666 * );
668 * var node = mw.util.addPortletLink(
669 * 'p-tb',
670 * mw.util.getUrl( 'Special:Example' ),
671 * 'Example'
672 * );
673 * $( node ).on( 'click', function ( e ) {
674 * console.log( 'Example' );
675 * e.preventDefault();
676 * } );
677 * ```
679 * Remember that to call this inside a user script, you may have to ensure the
680 * `mediawiki.util` is loaded first:
681 * ```
682 * $.when( mw.loader.using( [ 'mediawiki.util' ] ), $.ready ).then( function () {
683 * mw.util.addPortletLink( 'p-tb', 'https://www.mediawiki.org/', 'mediawiki.org' );
684 * } );
685 * ```
687 * @param {string} portletId ID of the target portlet (e.g. 'p-cactions' or 'p-personal')
688 * @param {string} href Link URL
689 * @param {string} text Link text
690 * @param {string} [id] ID of the list item, should be unique and preferably have
691 * the appropriate prefix ('ca-', 'pt-', 'n-' or 't-')
692 * @param {string} [tooltip] Text to show when hovering over the link, without accesskey suffix
693 * @param {string} [accesskey] Access key to activate this link. One character only,
694 * avoid conflicts with other links. Use `$( '[accesskey=x]' )` in the console to
695 * see if 'x' is already used.
696 * @param {HTMLElement|jQuery|string} [nextnode] Element that the new item should be added before.
697 * Must be another item in the same list, it will be ignored otherwise.
698 * Can be specified as DOM reference, as jQuery object, or as CSS selector string.
699 * @fires Hooks~'util.addPortletLink'
700 * @return {HTMLElement|null} The added list item, or null if no element was added.
702 addPortletLink: function ( portletId, href, text, id, tooltip, accesskey, nextnode ) {
703 if ( !portletId ) {
704 // Avoid confusing id="undefined" lookup
705 return null;
708 const portlet = document.getElementById( portletId );
709 if ( !portlet ) {
710 // Invalid portlet ID
711 return null;
714 // Setup the anchor tag and set any the properties
715 const link = document.createElement( 'a' );
716 link.href = href;
718 let linkChild = document.createTextNode( text );
719 let i = portletLinkOptions[ 'text-wrapper' ].length;
720 // Wrap link using text-wrapper option if provided
721 // Iterate backward since the wrappers are declared from outer to inner,
722 // and we build it up from the inside out.
723 while ( i-- ) {
724 const wrapper = portletLinkOptions[ 'text-wrapper' ][ i ];
725 const wrapperElement = document.createElement( wrapper.tag );
726 if ( wrapper.attributes ) {
727 $( wrapperElement ).attr( wrapper.attributes );
729 wrapperElement.appendChild( linkChild );
730 linkChild = wrapperElement;
732 link.appendChild( linkChild );
734 if ( tooltip ) {
735 link.title = tooltip;
737 if ( accesskey ) {
738 link.accessKey = accesskey;
741 // Unhide portlet if it was hidden before
742 util.showPortlet( portletId );
744 const item = $( '<li>' ).append( link )[ 0 ];
745 // mw-list-item-js distinguishes portlet links added via javascript and the server
746 item.className = 'mw-list-item mw-list-item-js';
747 if ( id ) {
748 item.id = id;
751 // Select the first (most likely only) unordered list inside the portlet
752 let ul = portlet.tagName.toLowerCase() === 'ul' ? portlet : portlet.querySelector( 'ul' );
753 if ( !ul ) {
754 // If it didn't have an unordered list yet, create one
755 ul = document.createElement( 'ul' );
756 const portletDiv = portlet.querySelector( 'div' );
757 if ( portletDiv ) {
758 // Support: Legacy skins have a div (such as div.body or div.pBody).
759 // Append the <ul> to that.
760 portletDiv.appendChild( ul );
761 } else {
762 // Append it to the portlet directly
763 portlet.appendChild( ul );
767 let next;
768 if ( nextnode && ( typeof nextnode === 'string' || nextnode.nodeType || nextnode.jquery ) ) {
769 // eslint-disable-next-line no-jquery/variable-pattern
770 nextnode = $( ul ).find( nextnode );
771 if ( nextnode.length === 1 && nextnode[ 0 ].parentNode === ul ) {
772 // Insertion point: Before nextnode
773 nextnode.before( item );
774 next = true;
776 // Else: Invalid nextnode value (no match, more than one match, or not a direct child)
777 // Else: Invalid nextnode type
780 if ( !next ) {
781 // Insertion point: End of list (default)
782 ul.appendChild( item );
785 // Update tooltip for the access key after inserting into DOM
786 // to get a localized access key label (T69946).
787 if ( accesskey ) {
788 $( link ).updateTooltipAccessKeys();
792 * Fires when a portlet link is successfully created.
794 * @event ~'util.addPortletLink'
795 * @memberof Hooks
796 * @param {HTMLElement} item the portlet link that was created.
797 * @param {Object} information about the item include id.
799 * @example
800 * mw.hook( 'util.addPortletLink' ).add( ( link ) => {
801 * const span = $( '<span class="icon">' );
802 * link.appendChild( span );
803 * } );
805 mw.hook( 'util.addPortletLink' ).fire( item, {
806 id: id
807 } );
808 return item;
812 * Validate a string as representing a valid e-mail address.
814 * This validation is based on the HTML5 specification.
816 * @example
817 * mw.util.validateEmail( "me@example.org" ) === true;
819 * @param {string} email E-mail address
820 * @return {boolean|null} True if valid, false if invalid, null if `email` was empty.
822 validateEmail: function ( email ) {
823 if ( email === '' ) {
824 return null;
827 // HTML5 defines a string as valid e-mail address if it matches
828 // the ABNF:
829 // 1 * ( atext / "." ) "@" ldh-str 1*( "." ldh-str )
830 // With:
831 // - atext : defined in RFC 5322 section 3.2.3
832 // - ldh-str : defined in RFC 1034 section 3.5
834 // (see STD 68 / RFC 5234 https://tools.ietf.org/html/std68)
835 // First, define the RFC 5322 'atext' which is pretty easy:
836 // atext = ALPHA / DIGIT / ; Printable US-ASCII
837 // "!" / "#" / ; characters not including
838 // "$" / "%" / ; specials. Used for atoms.
839 // "&" / "'" /
840 // "*" / "+" /
841 // "-" / "/" /
842 // "=" / "?" /
843 // "^" / "_" /
844 // "`" / "{" /
845 // "|" / "}" /
846 // "~"
847 const rfc5322Atext = 'a-z0-9!#$%&\'*+\\-/=?^_`{|}~';
849 // Next define the RFC 1034 'ldh-str'
850 // <domain> ::= <subdomain> | " "
851 // <subdomain> ::= <label> | <subdomain> "." <label>
852 // <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
853 // <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
854 // <let-dig-hyp> ::= <let-dig> | "-"
855 // <let-dig> ::= <letter> | <digit>
856 const rfc1034LdhStr = 'a-z0-9\\-';
858 const html5EmailRegexp = new RegExp(
859 // start of string
860 '^' +
861 // User part which is liberal :p
862 '[' + rfc5322Atext + '\\.]+' +
863 // 'at'
864 '@' +
865 // Domain first part
866 '[' + rfc1034LdhStr + ']+' +
867 // Optional second part and following are separated by a dot
868 '(?:\\.[' + rfc1034LdhStr + ']+)*' +
869 // End of string
870 '$',
871 // RegExp is case insensitive
874 return ( email.match( html5EmailRegexp ) !== null );
878 * Whether a string is a valid IPv4 address or not.
880 * Based on \Wikimedia\IPUtils::isIPv4 in PHP.
882 * @example
883 * // Valid
884 * mw.util.isIPv4Address( '80.100.20.101' );
885 * mw.util.isIPv4Address( '192.168.1.101' );
887 * // Invalid
888 * mw.util.isIPv4Address( '192.0.2.0/24' );
889 * mw.util.isIPv4Address( 'hello' );
891 * @param {string} address
892 * @param {boolean} [allowBlock=false]
893 * @return {boolean}
895 isIPv4Address: function ( address, allowBlock ) {
897 if ( typeof address !== 'string' ) {
898 return false;
901 const RE_IP_BYTE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
902 const RE_IP_ADD = '(?:' + RE_IP_BYTE + '\\.){3}' + RE_IP_BYTE;
903 const block = allowBlock ? '(?:\\/(?:3[0-2]|[12]?\\d))?' : '';
905 return ( new RegExp( '^' + RE_IP_ADD + block + '$' ).test( address ) );
909 * Whether a string is a valid IPv6 address or not.
911 * Based on \Wikimedia\IPUtils::isIPv6 in PHP.
913 * @example
914 * // Valid
915 * mw.util.isIPv6Address( '2001:db8:a:0:0:0:0:0' );
916 * mw.util.isIPv6Address( '2001:db8:a::' );
918 * // Invalid
919 * mw.util.isIPv6Address( '2001:db8:a::/32' );
920 * mw.util.isIPv6Address( 'hello' );
922 * @param {string} address
923 * @param {boolean} [allowBlock=false]
924 * @return {boolean}
926 isIPv6Address: function ( address, allowBlock ) {
927 if ( typeof address !== 'string' ) {
928 return false;
931 const block = allowBlock ? '(?:\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d))?' : '';
932 let RE_IPV6_ADD =
933 '(?:' + // starts with "::" (including "::")
934 ':(?::|(?::' +
935 '[0-9A-Fa-f]{1,4}' +
936 '){1,7})' +
937 '|' + // ends with "::" (except "::")
938 '[0-9A-Fa-f]{1,4}' +
939 '(?::' +
940 '[0-9A-Fa-f]{1,4}' +
941 '){0,6}::' +
942 '|' + // contains no "::"
943 '[0-9A-Fa-f]{1,4}' +
944 '(?::' +
945 '[0-9A-Fa-f]{1,4}' +
946 '){7}' +
947 ')';
949 if ( new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) ) {
950 return true;
953 // contains one "::" in the middle (single '::' check below)
954 RE_IPV6_ADD =
955 '[0-9A-Fa-f]{1,4}' +
956 '(?:::?' +
957 '[0-9A-Fa-f]{1,4}' +
958 '){1,6}';
960 return (
962 new RegExp( '^' + RE_IPV6_ADD + block + '$' ).test( address ) &&
963 /::/.test( address ) &&
964 !/::.*::/.test( address )
969 * Check whether a string is a valid IP address.
971 * @since 1.25
972 * @param {string} address String to check
973 * @param {boolean} [allowBlock=false] If a block of IPs should be allowed
974 * @return {boolean}
976 isIPAddress: function ( address, allowBlock ) {
977 return util.isIPv4Address( address, allowBlock ) ||
978 util.isIPv6Address( address, allowBlock );
982 * @typedef {Object} ResizeableThumbnailUrl
983 * @property {string} name File name (same format as Title.getMainText()).
984 * @property {number} [width] Thumbnail width, in pixels. Null when the file is not
985 * a thumbnail.
986 * @property {function(number):string} [resizeUrl] A function that takes a width
987 * parameter and returns a thumbnail URL (URL-encoded) with that width. The width
988 * parameter must be smaller than the width of the original image (or equal to it; that
989 * only works if MediaHandler::mustRender returns true for the file). Null when the
990 * file in the original URL is not a thumbnail.
991 * On wikis with $wgGenerateThumbnailOnParse set to true, this will fall back to using
992 * Special:Redirect which is less efficient. Otherwise, it is a direct thumbnail URL.
996 * Parse the URL of an image uploaded to MediaWiki, or a thumbnail for such an image,
997 * and return the image name, thumbnail size and a template that can be used to resize
998 * the image.
1000 * @param {string} url URL to parse (URL-encoded)
1001 * @return {ResizeableThumbnailUrl|null} null if the URL is not a valid MediaWiki
1002 * image/thumbnail URL.
1004 parseImageUrl: function ( url ) {
1005 let name, decodedName, width, urlTemplate;
1007 // thumb.php-generated thumbnails
1008 // thumb.php?f=<name>&w[idth]=<width>[px]
1009 if ( /thumb\.php/.test( url ) ) {
1010 decodedName = mw.util.getParamValue( 'f', url );
1011 name = encodeURIComponent( decodedName );
1012 width = mw.util.getParamValue( 'width', url ) || mw.util.getParamValue( 'w', url );
1013 urlTemplate = url.replace( /([&?])w(?:idth)?=[^&]+/g, '' ) + '&width={width}';
1014 } else {
1015 const regexes = [
1016 // Thumbnails
1017 // /<hash prefix>/<name>/[<options>-]<width>-<name*>[.<ext>]
1018 // where <name*> could be the filename, 'thumbnail.<ext>' (for long filenames)
1019 // or the base-36 SHA1 of the filename.
1021 /\/[\da-f]\/[\da-f]{2}\/([^\s/]+)\/(?:[^\s/]+-)?(\d+)px-(?:\1|thumbnail|[a-z\d]{31})(\.[^\s/]+)?$/,
1023 // Full size images
1024 // /<hash prefix>/<name>
1025 /\/[\da-f]\/[\da-f]{2}\/([^\s/]+)$/,
1027 // Thumbnails in non-hashed upload directories
1028 // /<name>/[<options>-]<width>-<name*>[.<ext>]
1030 /\/([^\s/]+)\/(?:[^\s/]+-)?(\d+)px-(?:\1|thumbnail|[a-z\d]{31})[^\s/]*$/,
1032 // Full-size images in non-hashed upload directories
1033 // /<name>
1034 /\/([^\s/]+)$/
1036 for ( let i = 0; i < regexes.length; i++ ) {
1037 const match = url.match( regexes[ i ] );
1038 if ( match ) {
1039 name = match[ 1 ];
1040 decodedName = decodeURIComponent( name );
1041 width = match[ 2 ] || null;
1042 break;
1047 if ( name ) {
1048 if ( width !== null ) {
1049 width = parseInt( width, 10 ) || null;
1051 if ( config.GenerateThumbnailOnParse ) {
1052 // The wiki cannot generate thumbnails on demand. Use a special page - this means
1053 // an extra redirect and PHP request, but it will generate the thumbnail if it does
1054 // not exist.
1055 urlTemplate = mw.util.getUrl( 'Special:Redirect/file/' + decodedName, { width: '{width}' } )
1056 // getUrl urlencodes the template variable, fix that
1057 .replace( '%7Bwidth%7D', '{width}' );
1058 } else if ( width && !urlTemplate ) {
1059 // Javascript does not expose regexp capturing group indexes, and the width
1060 // part could in theory also occur in the filename so hide that first.
1061 const strippedUrl = url.replace( name, '{name}' )
1062 .replace( name, '{name}' )
1063 .replace( width + 'px-', '{width}px-' );
1064 urlTemplate = strippedUrl.replace( /\{name\}/g, name );
1066 return {
1067 name: decodedName.replace( /_/g, ' ' ),
1068 width,
1069 resizeUrl: urlTemplate ? function ( w ) {
1070 return urlTemplate.replace( '{width}', w );
1071 } : null
1074 return null;
1078 * Escape string for safe inclusion in regular expression.
1080 * The following characters are escaped:
1082 * \ { } ( ) | . ? * + - ^ $ [ ]
1084 * @since 1.26; moved to mw.util in 1.34
1085 * @param {string} str String to escape
1086 * @return {string} Escaped string
1088 escapeRegExp: function ( str ) {
1089 // eslint-disable-next-line no-useless-escape
1090 return str.replace( /([\\{}()|.?*+\-^$\[\]])/g, '\\$1' );
1094 * Convert an IP into a verbose, uppercase, normalized form.
1096 * Both IPv4 and IPv6 addresses are trimmed. Additionally,
1097 * IPv6 addresses in octet notation are expanded to 8 words;
1098 * IPv4 addresses have leading zeros, in each octet, removed.
1100 * This functionality has been adapted from \Wikimedia\IPUtils::sanitizeIP()
1102 * @param {string} ip IP address in quad or octet form (CIDR or not).
1103 * @return {string|null}
1105 sanitizeIP: function ( ip ) {
1106 if ( typeof ip !== 'string' ) {
1107 return null;
1109 ip = ip.trim();
1110 if ( ip === '' ) {
1111 return null;
1113 if ( !this.isIPAddress( ip, true ) ) {
1114 return ip;
1116 if ( this.isIPv4Address( ip, true ) ) {
1117 return ip.replace( /(^|\.)0+(\d)/g, '$1$2' );
1119 ip = ip.toUpperCase();
1120 const abbrevPos = ip.indexOf( '::' );
1121 if ( abbrevPos !== -1 ) {
1122 const CIDRStart = ip.indexOf( '/' );
1123 const addressEnd = ( CIDRStart !== -1 ) ? CIDRStart - 1 : ip.length - 1;
1124 let repeatStr, extra, pad;
1125 if ( abbrevPos === 0 ) {
1126 repeatStr = '0:';
1127 extra = ip === '::' ? '0' : '';
1128 pad = 9;
1129 } else if ( abbrevPos === addressEnd - 1 ) {
1130 repeatStr = ':0';
1131 extra = '';
1132 pad = 9;
1133 } else {
1134 repeatStr = ':0';
1135 extra = ':';
1136 pad = 8;
1138 const count = pad - ( ip.split( ':' ).length - 1 );
1139 ip = ip.replace( '::', repeatStr.repeat( count ) + extra );
1141 return ip.replace( /(^|:)0+(([0-9A-Fa-f]{1,4}))/g, '$1$2' );
1145 * Prettify an IP for display to end users.
1147 * This will make it more compact and lower-case.
1149 * This functionality has been adapted from \Wikimedia\IPUtils::prettifyIP()
1151 * @param {string} ip IP address in quad or octet form (CIDR or not).
1152 * @return {string|null}
1154 prettifyIP: function ( ip ) {
1155 ip = this.sanitizeIP( ip );
1156 if ( ip === null ) {
1157 return null;
1159 if ( this.isIPv6Address( ip, true ) ) {
1160 let cidr, replaceZeros;
1161 if ( ip.indexOf( '/' ) !== -1 ) {
1162 const ipCidrSplit = ip.split( '/', 2 );
1163 ip = ipCidrSplit[ 0 ];
1164 cidr = ipCidrSplit[ 1 ];
1165 } else {
1166 cidr = '';
1168 const matches = ip.match( /(?:^|:)0(?::0)+(?:$|:)/g );
1169 if ( matches ) {
1170 replaceZeros = matches[ 0 ];
1171 for ( let i = 1; i < matches.length; i++ ) {
1172 if ( matches[ i ].length > replaceZeros.length ) {
1173 replaceZeros = matches[ i ];
1177 ip = ip.replace( replaceZeros, '::' );
1179 if ( cidr !== '' ) {
1180 ip = ip.concat( '/', cidr );
1182 ip = ip.toLowerCase();
1184 return ip;
1188 * Checks if the given username matches $wgAutoCreateTempUser.
1190 * This functionality has been adapted from MediaWiki\User\TempUser\Pattern::isMatch()
1192 * @param {string|null} username
1193 * @return {boolean}
1195 isTemporaryUser: function ( username ) {
1196 // Just return early if temporary accounts are not known about.
1197 if ( !config.AutoCreateTempUser.enabled && !config.AutoCreateTempUser.known ) {
1198 return false;
1200 if ( username === null ) {
1201 return false;
1203 /** @type {string|string[]} */
1204 let matchPatterns = config.AutoCreateTempUser.matchPattern;
1205 if ( typeof matchPatterns === 'string' ) {
1206 matchPatterns = [ matchPatterns ];
1207 } else if ( matchPatterns === null ) {
1208 matchPatterns = [ config.AutoCreateTempUser.genPattern ];
1210 for ( let i = 0; i < matchPatterns.length; i++ ) {
1211 const autoCreateUserMatchPattern = matchPatterns[ i ];
1212 // Check each match pattern, and if any matches then return a match.
1213 const position = autoCreateUserMatchPattern.indexOf( '$1' );
1215 // '$1' was not found in autoCreateUserMatchPattern
1216 if ( position === -1 ) {
1217 return false;
1219 const prefix = autoCreateUserMatchPattern.slice( 0, position );
1220 const suffix = autoCreateUserMatchPattern.slice( position + '$1'.length );
1222 let match = true;
1223 if ( prefix !== '' ) {
1224 match = ( username.indexOf( prefix ) === 0 );
1226 if ( match && suffix !== '' ) {
1227 match = ( username.slice( -suffix.length ) === suffix ) &&
1228 ( username.length >= prefix.length + suffix.length );
1230 if ( match ) {
1231 return true;
1234 // No match patterns matched the username, so the given username is not a temporary user.
1235 return false;
1239 * Determine if an input string represents a value of infinity.
1240 * This is used when testing for infinity in the context of expiries,
1241 * such as watchlisting, page protection, and block expiries.
1243 * @param {string|null} str
1244 * @return {boolean}
1245 * @stable
1247 isInfinity: function ( str ) {
1248 return infinityValues.indexOf( str ) !== -1;
1253 * Initialisation of mw.util.$content
1255 * @ignore
1257 function init() {
1258 // The preferred standard is class "mw-body".
1259 // You may also use class "mw-body mw-body-primary" if you use
1260 // mw-body in multiple locations. Or class "mw-body-primary" if
1261 // you use mw-body deeper in the DOM.
1262 const content = document.querySelector( '.mw-body-primary' ) ||
1263 document.querySelector( '.mw-body' ) ||
1264 // If the skin has no such class, fall back to the parser output
1265 document.querySelector( '#mw-content-text' ) ||
1266 // Should never happen..., except if the skin is still in development.
1267 document.body;
1269 util.$content = $( content );
1272 // Backwards-compatible alias for mediawiki.RegExp module.
1273 // @deprecated since 1.34
1274 mw.RegExp = {};
1275 mw.log.deprecate( mw.RegExp, 'escape', util.escapeRegExp, 'Use mw.util.escapeRegExp() instead.', 'mw.RegExp.escape' );
1277 if ( window.QUnit ) {
1278 // Not allowed outside unit tests
1279 util.setOptionsForTest = function ( opts ) {
1280 config = !opts ? require( './config.json' ) : Object.assign( {}, config, opts );
1282 util.init = init;
1283 } else {
1284 $( init );
1287 mw.util = util;
1288 module.exports = util;