1<?php
2/**
3 * Collection of methods to generate HTML content
4 *
5 * Copyright © 2009 Aryeh Gregor
6 * https://www.mediawiki.org/
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21 * http://www.gnu.org/copyleft/gpl.html
22 *
23 * @file
24 */
25use MediaWiki\MediaWikiServices;
26
27/**
28 * This class is a collection of static functions that serve two purposes:
29 *
30 * 1) Implement any algorithms specified by HTML5, or other HTML
31 * specifications, in a convenient and self-contained way.
32 *
33 * 2) Allow HTML elements to be conveniently and safely generated, like the
34 * current Xml class but a) less confused (Xml supports HTML-specific things,
35 * but only sometimes!) and b) not necessarily confined to XML-compatible
36 * output.
37 *
38 * There are two important configuration options this class uses:
39 *
40 * $wgMimeType: If this is set to an xml MIME type then output should be
41 *     valid XHTML5.
42 *
43 * This class is meant to be confined to utility functions that are called from
44 * trusted code paths.  It does not do enforcement of policy like not allowing
45 * <a> elements.
46 *
47 * @since 1.16
48 */
49class Html {
50	/** @var string[] List of void elements from HTML5, section 8.1.2 as of 2016-09-19 */
51	private static $voidElements = [
52		'area',
53		'base',
54		'br',
55		'col',
56		'embed',
57		'hr',
58		'img',
59		'input',
60		'keygen',
61		'link',
62		'meta',
63		'param',
64		'source',
65		'track',
66		'wbr',
67	];
68
69	/**
70	 * Boolean attributes, which may have the value omitted entirely.  Manually
71	 * collected from the HTML5 spec as of 2011-08-12.
72	 * @var string[]
73	 */
74	private static $boolAttribs = [
75		'async',
76		'autofocus',
77		'autoplay',
78		'checked',
79		'controls',
80		'default',
81		'defer',
82		'disabled',
83		'formnovalidate',
84		'hidden',
85		'ismap',
86		'itemscope',
87		'loop',
88		'multiple',
89		'muted',
90		'novalidate',
91		'open',
92		'pubdate',
93		'readonly',
94		'required',
95		'reversed',
96		'scoped',
97		'seamless',
98		'selected',
99		'truespeed',
100		'typemustmatch',
101		// HTML5 Microdata
102		'itemscope',
103	];
104
105	/**
106	 * Modifies a set of attributes meant for button elements
107	 * and apply a set of default attributes when $wgUseMediaWikiUIEverywhere enabled.
108	 * @param array $attrs HTML attributes in an associative array
109	 * @param string[] $modifiers classes to add to the button
110	 * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
111	 * @return array $attrs A modified attribute array
112	 */
113	public static function buttonAttributes( array $attrs, array $modifiers = [] ) {
114		global $wgUseMediaWikiUIEverywhere;
115		if ( $wgUseMediaWikiUIEverywhere ) {
116			if ( isset( $attrs['class'] ) ) {
117				if ( is_array( $attrs['class'] ) ) {
118					$attrs['class'][] = 'mw-ui-button';
119					$attrs['class'] = array_merge( $attrs['class'], $modifiers );
120					// ensure compatibility with Xml
121					$attrs['class'] = implode( ' ', $attrs['class'] );
122				} else {
123					$attrs['class'] .= ' mw-ui-button ' . implode( ' ', $modifiers );
124				}
125			} else {
126				// ensure compatibility with Xml
127				$attrs['class'] = 'mw-ui-button ' . implode( ' ', $modifiers );
128			}
129		}
130		return $attrs;
131	}
132
133	/**
134	 * Modifies a set of attributes meant for text input elements
135	 * and apply a set of default attributes.
136	 * Removes size attribute when $wgUseMediaWikiUIEverywhere enabled.
137	 * @param array $attrs An attribute array.
138	 * @return array $attrs A modified attribute array
139	 */
140	public static function getTextInputAttributes( array $attrs ) {
141		global $wgUseMediaWikiUIEverywhere;
142		if ( $wgUseMediaWikiUIEverywhere ) {
143			if ( isset( $attrs['class'] ) ) {
144				if ( is_array( $attrs['class'] ) ) {
145					$attrs['class'][] = 'mw-ui-input';
146				} else {
147					$attrs['class'] .= ' mw-ui-input';
148				}
149			} else {
150				$attrs['class'] = 'mw-ui-input';
151			}
152		}
153		return $attrs;
154	}
155
156	/**
157	 * Returns an HTML link element in a string styled as a button
158	 * (when $wgUseMediaWikiUIEverywhere is enabled).
159	 *
160	 * @param string $text The text of the element. Will be escaped (not raw HTML)
161	 * @param array $attrs Associative array of attributes, e.g., [
162	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
163	 *   further documentation.
164	 * @param string[] $modifiers classes to add to the button
165	 * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
166	 * @return string Raw HTML
167	 */
168	public static function linkButton( $text, array $attrs, array $modifiers = [] ) {
169		return self::element( 'a',
170			self::buttonAttributes( $attrs, $modifiers ),
171			$text
172		);
173	}
174
175	/**
176	 * Returns an HTML link element in a string styled as a button
177	 * (when $wgUseMediaWikiUIEverywhere is enabled).
178	 *
179	 * @param string $contents The raw HTML contents of the element: *not*
180	 *   escaped!
181	 * @param array $attrs Associative array of attributes, e.g., [
182	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
183	 *   further documentation.
184	 * @param string[] $modifiers classes to add to the button
185	 * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers
186	 * @return string Raw HTML
187	 */
188	public static function submitButton( $contents, array $attrs, array $modifiers = [] ) {
189		$attrs['type'] = 'submit';
190		$attrs['value'] = $contents;
191		return self::element( 'input', self::buttonAttributes( $attrs, $modifiers ) );
192	}
193
194	/**
195	 * Returns an HTML element in a string.  The major advantage here over
196	 * manually typing out the HTML is that it will escape all attribute
197	 * values.
198	 *
199	 * This is quite similar to Xml::tags(), but it implements some useful
200	 * HTML-specific logic.  For instance, there is no $allowShortTag
201	 * parameter: the closing tag is magically omitted if $element has an empty
202	 * content model.
203	 *
204	 * @param string $element The element's name, e.g., 'a'
205	 * @param array $attribs Associative array of attributes, e.g., [
206	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
207	 *   further documentation.
208	 * @param string $contents The raw HTML contents of the element: *not*
209	 *   escaped!
210	 * @return string Raw HTML
211	 */
212	public static function rawElement( $element, $attribs = [], $contents = '' ) {
213		$start = self::openElement( $element, $attribs );
214		if ( in_array( $element, self::$voidElements ) ) {
215			// Silly XML.
216			return substr( $start, 0, -1 ) . '/>';
217		} else {
218			return $start . $contents . self::closeElement( $element );
219		}
220	}
221
222	/**
223	 * Identical to rawElement(), but HTML-escapes $contents (like
224	 * Xml::element()).
225	 *
226	 * @param string $element Name of the element, e.g., 'a'
227	 * @param array $attribs Associative array of attributes, e.g., [
228	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
229	 *   further documentation.
230	 * @param string $contents
231	 *
232	 * @return string
233	 */
234	public static function element( $element, $attribs = [], $contents = '' ) {
235		return self::rawElement( $element, $attribs, strtr( $contents, [
236			// There's no point in escaping quotes, >, etc. in the contents of
237			// elements.
238			'&' => '&amp;',
239			'<' => '&lt;'
240		] ) );
241	}
242
243	/**
244	 * Identical to rawElement(), but has no third parameter and omits the end
245	 * tag (and the self-closing '/' in XML mode for empty elements).
246	 *
247	 * @param string $element Name of the element, e.g., 'a'
248	 * @param array $attribs Associative array of attributes, e.g., [
249	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
250	 *   further documentation.
251	 *
252	 * @return string
253	 */
254	public static function openElement( $element, $attribs = [] ) {
255		$attribs = (array)$attribs;
256		// This is not required in HTML5, but let's do it anyway, for
257		// consistency and better compression.
258		$element = strtolower( $element );
259
260		// Some people were abusing this by passing things like
261		// 'h1 id="foo" to $element, which we don't want.
262		if ( strpos( $element, ' ' ) !== false ) {
263			wfWarn( __METHOD__ . " given element name with space '$element'" );
264		}
265
266		// Remove invalid input types
267		if ( $element == 'input' ) {
268			$validTypes = [
269				'hidden',
270				'text',
271				'password',
272				'checkbox',
273				'radio',
274				'file',
275				'submit',
276				'image',
277				'reset',
278				'button',
279
280				// HTML input types
281				'datetime',
282				'datetime-local',
283				'date',
284				'month',
285				'time',
286				'week',
287				'number',
288				'range',
289				'email',
290				'url',
291				'search',
292				'tel',
293				'color',
294			];
295			if ( isset( $attribs['type'] ) && !in_array( $attribs['type'], $validTypes ) ) {
296				unset( $attribs['type'] );
297			}
298		}
299
300		// According to standard the default type for <button> elements is "submit".
301		// Depending on compatibility mode IE might use "button", instead.
302		// We enforce the standard "submit".
303		if ( $element == 'button' && !isset( $attribs['type'] ) ) {
304			$attribs['type'] = 'submit';
305		}
306
307		return "<$element" . self::expandAttributes(
308			self::dropDefaults( $element, $attribs ) ) . '>';
309	}
310
311	/**
312	 * Returns "</$element>"
313	 *
314	 * @since 1.17
315	 * @param string $element Name of the element, e.g., 'a'
316	 * @return string A closing tag
317	 */
318	public static function closeElement( $element ) {
319		$element = strtolower( $element );
320
321		return "</$element>";
322	}
323
324	/**
325	 * Given an element name and an associative array of element attributes,
326	 * return an array that is functionally identical to the input array, but
327	 * possibly smaller.  In particular, attributes might be stripped if they
328	 * are given their default values.
329	 *
330	 * This method is not guaranteed to remove all redundant attributes, only
331	 * some common ones and some others selected arbitrarily at random.  It
332	 * only guarantees that the output array should be functionally identical
333	 * to the input array (currently per the HTML 5 draft as of 2009-09-06).
334	 *
335	 * @param string $element Name of the element, e.g., 'a'
336	 * @param array $attribs Associative array of attributes, e.g., [
337	 *   'href' => 'https://www.mediawiki.org/' ].  See expandAttributes() for
338	 *   further documentation.
339	 * @return array An array of attributes functionally identical to $attribs
340	 */
341	private static function dropDefaults( $element, array $attribs ) {
342		// Whenever altering this array, please provide a covering test case
343		// in HtmlTest::provideElementsWithAttributesHavingDefaultValues
344		static $attribDefaults = [
345			'area' => [ 'shape' => 'rect' ],
346			'button' => [
347				'formaction' => 'GET',
348				'formenctype' => 'application/x-www-form-urlencoded',
349			],
350			'canvas' => [
351				'height' => '150',
352				'width' => '300',
353			],
354			'form' => [
355				'action' => 'GET',
356				'autocomplete' => 'on',
357				'enctype' => 'application/x-www-form-urlencoded',
358			],
359			'input' => [
360				'formaction' => 'GET',
361				'type' => 'text',
362			],
363			'keygen' => [ 'keytype' => 'rsa' ],
364			'link' => [ 'media' => 'all' ],
365			'menu' => [ 'type' => 'list' ],
366			'script' => [ 'type' => 'text/javascript' ],
367			'style' => [
368				'media' => 'all',
369				'type' => 'text/css',
370			],
371			'textarea' => [ 'wrap' => 'soft' ],
372		];
373
374		$element = strtolower( $element );
375
376		foreach ( $attribs as $attrib => $value ) {
377			$lcattrib = strtolower( $attrib );
378			if ( is_array( $value ) ) {
379				$value = implode( ' ', $value );
380			} else {
381				$value = strval( $value );
382			}
383
384			// Simple checks using $attribDefaults
385			if ( isset( $attribDefaults[$element][$lcattrib] )
386				&& $attribDefaults[$element][$lcattrib] == $value
387			) {
388				unset( $attribs[$attrib] );
389			}
390
391			if ( $lcattrib == 'class' && $value == '' ) {
392				unset( $attribs[$attrib] );
393			}
394		}
395
396		// More subtle checks
397		if ( $element === 'link'
398			&& isset( $attribs['type'] ) && strval( $attribs['type'] ) == 'text/css'
399		) {
400			unset( $attribs['type'] );
401		}
402		if ( $element === 'input' ) {
403			$type = $attribs['type'] ?? null;
404			$value = $attribs['value'] ?? null;
405			if ( $type === 'checkbox' || $type === 'radio' ) {
406				// The default value for checkboxes and radio buttons is 'on'
407				// not ''. By stripping value="" we break radio boxes that
408				// actually wants empty values.
409				if ( $value === 'on' ) {
410					unset( $attribs['value'] );
411				}
412			} elseif ( $type === 'submit' ) {
413				// The default value for submit appears to be "Submit" but
414				// let's not bother stripping out localized text that matches
415				// that.
416			} else {
417				// The default value for nearly every other field type is ''
418				// The 'range' and 'color' types use different defaults but
419				// stripping a value="" does not hurt them.
420				if ( $value === '' ) {
421					unset( $attribs['value'] );
422				}
423			}
424		}
425		if ( $element === 'select' && isset( $attribs['size'] ) ) {
426			if ( in_array( 'multiple', $attribs )
427				|| ( isset( $attribs['multiple'] ) && $attribs['multiple'] !== false )
428			) {
429				// A multi-select
430				if ( strval( $attribs['size'] ) == '4' ) {
431					unset( $attribs['size'] );
432				}
433			} else {
434				// Single select
435				if ( strval( $attribs['size'] ) == '1' ) {
436					unset( $attribs['size'] );
437				}
438			}
439		}
440
441		return $attribs;
442	}
443
444	/**
445	 * Given an associative array of element attributes, generate a string
446	 * to stick after the element name in HTML output.  Like [ 'href' =>
447	 * 'https://www.mediawiki.org/' ] becomes something like
448	 * ' href="https://www.mediawiki.org"'.  Again, this is like
449	 * Xml::expandAttributes(), but it implements some HTML-specific logic.
450	 *
451	 * Attributes that can contain space-separated lists ('class', 'accesskey' and 'rel') array
452	 * values are allowed as well, which will automagically be normalized
453	 * and converted to a space-separated string. In addition to a numerical
454	 * array, the attribute value may also be an associative array. See the
455	 * example below for how that works.
456	 *
457	 * @par Numerical array
458	 * @code
459	 *     Html::element( 'em', [
460	 *         'class' => [ 'foo', 'bar' ]
461	 *     ] );
462	 *     // gives '<em class="foo bar"></em>'
463	 * @endcode
464	 *
465	 * @par Associative array
466	 * @code
467	 *     Html::element( 'em', [
468	 *         'class' => [ 'foo', 'bar', 'foo' => false, 'quux' => true ]
469	 *     ] );
470	 *     // gives '<em class="bar quux"></em>'
471	 * @endcode
472	 *
473	 * @param array $attribs Associative array of attributes, e.g., [
474	 *   'href' => 'https://www.mediawiki.org/' ].  Values will be HTML-escaped.
475	 *   A value of false or null means to omit the attribute.  For boolean attributes,
476	 *   you can omit the key, e.g., [ 'checked' ] instead of
477	 *   [ 'checked' => 'checked' ] or such.
478	 *
479	 * @throws MWException If an attribute that doesn't allow lists is set to an array
480	 * @return string HTML fragment that goes between element name and '>'
481	 *   (starting with a space if at least one attribute is output)
482	 */
483	public static function expandAttributes( array $attribs ) {
484		$ret = '';
485		foreach ( $attribs as $key => $value ) {
486			// Support intuitive [ 'checked' => true/false ] form
487			if ( $value === false || $value === null ) {
488				continue;
489			}
490
491			// For boolean attributes, support [ 'foo' ] instead of
492			// requiring [ 'foo' => 'meaningless' ].
493			if ( is_int( $key ) && in_array( strtolower( $value ), self::$boolAttribs ) ) {
494				$key = $value;
495			}
496
497			// Not technically required in HTML5 but we'd like consistency
498			// and better compression anyway.
499			$key = strtolower( $key );
500
501			// https://www.w3.org/TR/html401/index/attributes.html ("space-separated")
502			// https://www.w3.org/TR/html5/index.html#attributes-1 ("space-separated")
503			$spaceSeparatedListAttributes = [
504				'class', // html4, html5
505				'accesskey', // as of html5, multiple space-separated values allowed
506				// html4-spec doesn't document rel= as space-separated
507				// but has been used like that and is now documented as such
508				// in the html5-spec.
509				'rel',
510			];
511
512			// Specific features for attributes that allow a list of space-separated values
513			if ( in_array( $key, $spaceSeparatedListAttributes ) ) {
514				// Apply some normalization and remove duplicates
515
516				// Convert into correct array. Array can contain space-separated
517				// values. Implode/explode to get those into the main array as well.
518				if ( is_array( $value ) ) {
519					// If input wasn't an array, we can skip this step
520					$newValue = [];
521					foreach ( $value as $k => $v ) {
522						if ( is_string( $v ) ) {
523							// String values should be normal `[ 'foo' ]`
524							// Just append them
525							if ( !isset( $value[$v] ) ) {
526								// As a special case don't set 'foo' if a
527								// separate 'foo' => true/false exists in the array
528								// keys should be authoritative
529								$newValue[] = $v;
530							}
531						} elseif ( $v ) {
532							// If the value is truthy but not a string this is likely
533							// an [ 'foo' => true ], falsy values don't add strings
534							$newValue[] = $k;
535						}
536					}
537					$value = implode( ' ', $newValue );
538				}
539				$value = explode( ' ', $value );
540
541				// Normalize spacing by fixing up cases where people used
542				// more than 1 space and/or a trailing/leading space
543				$value = array_diff( $value, [ '', ' ' ] );
544
545				// Remove duplicates and create the string
546				$value = implode( ' ', array_unique( $value ) );
547			} elseif ( is_array( $value ) ) {
548				throw new MWException( "HTML attribute $key can not contain a list of values" );
549			}
550
551			$quote = '"';
552
553			if ( in_array( $key, self::$boolAttribs ) ) {
554				$ret .= " $key=\"\"";
555			} else {
556				$ret .= " $key=$quote" . Sanitizer::encodeAttribute( $value ) . $quote;
557			}
558		}
559		return $ret;
560	}
561
562	/**
563	 * Output an HTML script tag with the given contents.
564	 *
565	 * It is unsupported for the contents to contain the sequence `<script` or `</script`
566	 * (case-insensitive). This ensures the script can be terminated easily and consistently.
567	 * It is the responsibility of the caller to avoid such character sequence by escaping
568	 * or avoiding it. If found at run-time, the contents are replaced with a comment, and
569	 * a warning is logged server-side.
570	 *
571	 * @param string $contents JavaScript
572	 * @param string|null $nonce Nonce for CSP header, from OutputPage->getCSP()->getNonce()
573	 * @return string Raw HTML
574	 */
575	public static function inlineScript( $contents, $nonce = null ) {
576		$attrs = [];
577		if ( $nonce !== null ) {
578			$attrs['nonce'] = $nonce;
579		} elseif ( ContentSecurityPolicy::isNonceRequired( RequestContext::getMain()->getConfig() ) ) {
580			wfWarn( "no nonce set on script. CSP will break it" );
581		}
582
583		if ( preg_match( '/<\/?script/i', $contents ) ) {
584			wfLogWarning( __METHOD__ . ': Illegal character sequence found in inline script.' );
585			$contents = '/* ERROR: Invalid script */';
586		}
587
588		return self::rawElement( 'script', $attrs, $contents );
589	}
590
591	/**
592	 * Output a "<script>" tag linking to the given URL, e.g.,
593	 * "<script src=foo.js></script>".
594	 *
595	 * @param string $url
596	 * @param string|null $nonce Nonce for CSP header, from OutputPage->getCSP()->getNonce()
597	 * @return string Raw HTML
598	 */
599	public static function linkedScript( $url, $nonce = null ) {
600		$attrs = [ 'src' => $url ];
601		if ( $nonce !== null ) {
602			$attrs['nonce'] = $nonce;
603		} elseif ( ContentSecurityPolicy::isNonceRequired( RequestContext::getMain()->getConfig() ) ) {
604			wfWarn( "no nonce set on script. CSP will break it" );
605		}
606
607		return self::element( 'script', $attrs );
608	}
609
610	/**
611	 * Output a "<style>" tag with the given contents for the given media type
612	 * (if any).  TODO: do some useful escaping as well, like if $contents
613	 * contains literal "</style>" (admittedly unlikely).
614	 *
615	 * @param string $contents CSS
616	 * @param string $media A media type string, like 'screen'
617	 * @param array $attribs (since 1.31) Associative array of attributes, e.g., [
618	 *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
619	 *   further documentation.
620	 * @return string Raw HTML
621	 */
622	public static function inlineStyle( $contents, $media = 'all', $attribs = [] ) {
623		// Don't escape '>' since that is used
624		// as direct child selector.
625		// Remember, in css, there is no "x" for hexadecimal escapes, and
626		// the space immediately after an escape sequence is swallowed.
627		$contents = strtr( $contents, [
628			'<' => '\3C ',
629			// CDATA end tag for good measure, but the main security
630			// is from escaping the '<'.
631			']]>' => '\5D\5D\3E '
632		] );
633
634		if ( preg_match( '/[<&]/', $contents ) ) {
635			$contents = "/*<![CDATA[*/$contents/*]]>*/";
636		}
637
638		return self::rawElement( 'style', [
639			'media' => $media,
640		] + $attribs, $contents );
641	}
642
643	/**
644	 * Output a "<link rel=stylesheet>" linking to the given URL for the given
645	 * media type (if any).
646	 *
647	 * @param string $url
648	 * @param string $media A media type string, like 'screen'
649	 * @return string Raw HTML
650	 */
651	public static function linkedStyle( $url, $media = 'all' ) {
652		return self::element( 'link', [
653			'rel' => 'stylesheet',
654			'href' => $url,
655			'media' => $media,
656		] );
657	}
658
659	/**
660	 * Convenience function to produce an "<input>" element.  This supports the
661	 * new HTML5 input types and attributes.
662	 *
663	 * @param string $name Name attribute
664	 * @param string $value Value attribute
665	 * @param string $type Type attribute
666	 * @param array $attribs Associative array of miscellaneous extra
667	 *   attributes, passed to Html::element()
668	 * @return string Raw HTML
669	 */
670	public static function input( $name, $value = '', $type = 'text', array $attribs = [] ) {
671		$attribs['type'] = $type;
672		$attribs['value'] = $value;
673		$attribs['name'] = $name;
674		if ( in_array( $type, [ 'text', 'search', 'email', 'password', 'number' ] ) ) {
675			$attribs = self::getTextInputAttributes( $attribs );
676		}
677		if ( in_array( $type, [ 'button', 'reset', 'submit' ] ) ) {
678			$attribs = self::buttonAttributes( $attribs );
679		}
680		return self::element( 'input', $attribs );
681	}
682
683	/**
684	 * Convenience function to produce a checkbox (input element with type=checkbox)
685	 *
686	 * @param string $name Name attribute
687	 * @param bool $checked Whether the checkbox is checked or not
688	 * @param array $attribs Array of additional attributes
689	 * @return string Raw HTML
690	 */
691	public static function check( $name, $checked = false, array $attribs = [] ) {
692		if ( isset( $attribs['value'] ) ) {
693			$value = $attribs['value'];
694			unset( $attribs['value'] );
695		} else {
696			$value = 1;
697		}
698
699		if ( $checked ) {
700			$attribs[] = 'checked';
701		}
702
703		return self::input( $name, $value, 'checkbox', $attribs );
704	}
705
706	/**
707	 * Return the HTML for a message box.
708	 * @since 1.31
709	 * @param string $html of contents of box
710	 * @param string|array $className corresponding to box
711	 * @param string $heading (optional)
712	 * @return string of HTML representing a box.
713	 */
714	private static function messageBox( $html, $className, $heading = '' ) {
715		if ( $heading !== '' ) {
716			$html = self::element( 'h2', [], $heading ) . $html;
717		}
718		return self::rawElement( 'div', [ 'class' => $className ], $html );
719	}
720
721	/**
722	 * Return a warning box.
723	 * @since 1.31
724	 * @since 1.34 $className optional parameter added
725	 * @param string $html of contents of box
726	 * @param string $className (optional) corresponding to box
727	 * @return string of HTML representing a warning box.
728	 */
729	public static function warningBox( $html, $className = '' ) {
730		return self::messageBox( $html, [ 'warningbox', $className ] );
731	}
732
733	/**
734	 * Return an error box.
735	 * @since 1.31
736	 * @since 1.34 $className optional parameter added
737	 * @param string $html of contents of error box
738	 * @param string $heading (optional)
739	 * @param string $className (optional) corresponding to box
740	 * @return string of HTML representing an error box.
741	 */
742	public static function errorBox( $html, $heading = '', $className = '' ) {
743		return self::messageBox( $html, [ 'errorbox', $className ], $heading );
744	}
745
746	/**
747	 * Return a success box.
748	 * @since 1.31
749	 * @since 1.34 $className optional parameter added
750	 * @param string $html of contents of box
751	 * @param string $className (optional) corresponding to box
752	 * @return string of HTML representing a success box.
753	 */
754	public static function successBox( $html, $className = '' ) {
755		return self::messageBox( $html, [ 'successbox', $className ] );
756	}
757
758	/**
759	 * Convenience function to produce a radio button (input element with type=radio)
760	 *
761	 * @param string $name Name attribute
762	 * @param bool $checked Whether the radio button is checked or not
763	 * @param array $attribs Array of additional attributes
764	 * @return string Raw HTML
765	 */
766	public static function radio( $name, $checked = false, array $attribs = [] ) {
767		if ( isset( $attribs['value'] ) ) {
768			$value = $attribs['value'];
769			unset( $attribs['value'] );
770		} else {
771			$value = 1;
772		}
773
774		if ( $checked ) {
775			$attribs[] = 'checked';
776		}
777
778		return self::input( $name, $value, 'radio', $attribs );
779	}
780
781	/**
782	 * Convenience function for generating a label for inputs.
783	 *
784	 * @param string $label Contents of the label
785	 * @param string $id ID of the element being labeled
786	 * @param array $attribs Additional attributes
787	 * @return string Raw HTML
788	 */
789	public static function label( $label, $id, array $attribs = [] ) {
790		$attribs += [
791			'for' => $id
792		];
793		return self::element( 'label', $attribs, $label );
794	}
795
796	/**
797	 * Convenience function to produce an input element with type=hidden
798	 *
799	 * @param string $name Name attribute
800	 * @param mixed $value Value attribute
801	 * @param array $attribs Associative array of miscellaneous extra
802	 *   attributes, passed to Html::element()
803	 * @return string Raw HTML
804	 */
805	public static function hidden( $name, $value, array $attribs = [] ) {
806		return self::input( $name, $value, 'hidden', $attribs );
807	}
808
809	/**
810	 * Convenience function to produce a <textarea> element.
811	 *
812	 * This supports leaving out the cols= and rows= which Xml requires and are
813	 * required by HTML4/XHTML but not required by HTML5.
814	 *
815	 * @param string $name Name attribute
816	 * @param string $value Value attribute
817	 * @param array $attribs Associative array of miscellaneous extra
818	 *   attributes, passed to Html::element()
819	 * @return string Raw HTML
820	 */
821	public static function textarea( $name, $value = '', array $attribs = [] ) {
822		$attribs['name'] = $name;
823
824		if ( substr( $value, 0, 1 ) == "\n" ) {
825			// Workaround for T14130: browsers eat the initial newline
826			// assuming that it's just for show, but they do keep the later
827			// newlines, which we may want to preserve during editing.
828			// Prepending a single newline
829			$spacedValue = "\n" . $value;
830		} else {
831			$spacedValue = $value;
832		}
833		return self::element( 'textarea', self::getTextInputAttributes( $attribs ), $spacedValue );
834	}
835
836	/**
837	 * Helper for Html::namespaceSelector().
838	 * @param array $params See Html::namespaceSelector()
839	 * @return array
840	 */
841	public static function namespaceSelectorOptions( array $params = [] ) {
842		if ( !isset( $params['exclude'] ) || !is_array( $params['exclude'] ) ) {
843			$params['exclude'] = [];
844		}
845
846		if ( $params['in-user-lang'] ?? false ) {
847			global $wgLang;
848			$lang = $wgLang;
849		} else {
850			$lang = MediaWikiServices::getInstance()->getContentLanguage();
851		}
852
853		$optionsOut = [];
854		if ( isset( $params['all'] ) ) {
855			// add an option that would let the user select all namespaces.
856			// Value is provided by user, the name shown is localized for the user.
857			$optionsOut[$params['all']] = wfMessage( 'namespacesall' )->text();
858		}
859		// Add all namespaces as options
860		$options = $lang->getFormattedNamespaces();
861		// Filter out namespaces below 0 and massage labels
862		foreach ( $options as $nsId => $nsName ) {
863			if ( $nsId < NS_MAIN || in_array( $nsId, $params['exclude'] ) ) {
864				continue;
865			}
866			if ( $nsId === NS_MAIN ) {
867				// For other namespaces use the namespace prefix as label, but for
868				// main we don't use "" but the user message describing it (e.g. "(Main)" or "(Article)")
869				$nsName = wfMessage( 'blanknamespace' )->text();
870			} elseif ( is_int( $nsId ) ) {
871				$converter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
872					->getLanguageConverter( $lang );
873				$nsName = $converter->convertNamespace( $nsId );
874			}
875			$optionsOut[$nsId] = $nsName;
876		}
877
878		return $optionsOut;
879	}
880
881	/**
882	 * Build a drop-down box for selecting a namespace
883	 *
884	 * @param array $params Params to set.
885	 * - selected: [optional] Id of namespace which should be pre-selected
886	 * - all: [optional] Value of item for "all namespaces". If null or unset,
887	 *   no "<option>" is generated to select all namespaces.
888	 * - label: text for label to add before the field.
889	 * - exclude: [optional] Array of namespace ids to exclude.
890	 * - disable: [optional] Array of namespace ids for which the option should
891	 *   be disabled in the selector.
892	 * @param array $selectAttribs HTML attributes for the generated select element.
893	 * - id:   [optional], default: 'namespace'.
894	 * - name: [optional], default: 'namespace'.
895	 * @return string HTML code to select a namespace.
896	 */
897	public static function namespaceSelector( array $params = [],
898		array $selectAttribs = []
899	) {
900		ksort( $selectAttribs );
901
902		// Is a namespace selected?
903		if ( isset( $params['selected'] ) ) {
904			// If string only contains digits, convert to clean int. Selected could also
905			// be "all" or "" etc. which needs to be left untouched.
906			if ( !is_int( $params['selected'] ) && ctype_digit( (string)$params['selected'] ) ) {
907				$params['selected'] = (int)$params['selected'];
908			}
909			// else: leaves it untouched for later processing
910		} else {
911			$params['selected'] = '';
912		}
913
914		if ( !isset( $params['disable'] ) || !is_array( $params['disable'] ) ) {
915			$params['disable'] = [];
916		}
917
918		// Associative array between option-values and option-labels
919		$options = self::namespaceSelectorOptions( $params );
920
921		// Convert $options to HTML
922		$optionsHtml = [];
923		foreach ( $options as $nsId => $nsName ) {
924			$optionsHtml[] = self::element(
925				'option', [
926					'disabled' => in_array( $nsId, $params['disable'] ),
927					'value' => $nsId,
928					'selected' => $nsId === $params['selected'],
929				], $nsName
930			);
931		}
932
933		if ( !array_key_exists( 'id', $selectAttribs ) ) {
934			$selectAttribs['id'] = 'namespace';
935		}
936
937		if ( !array_key_exists( 'name', $selectAttribs ) ) {
938			$selectAttribs['name'] = 'namespace';
939		}
940
941		$ret = '';
942		if ( isset( $params['label'] ) ) {
943			$ret .= self::element(
944				'label', [
945					'for' => $selectAttribs['id'] ?? null,
946				], $params['label']
947			) . "\u{00A0}";
948		}
949
950		// Wrap options in a <select>
951		$ret .= self::openElement( 'select', $selectAttribs )
952			. "\n"
953			. implode( "\n", $optionsHtml )
954			. "\n"
955			. self::closeElement( 'select' );
956
957		return $ret;
958	}
959
960	/**
961	 * Constructs the opening html-tag with necessary doctypes depending on
962	 * global variables.
963	 *
964	 * @param array $attribs Associative array of miscellaneous extra
965	 *   attributes, passed to Html::element() of html tag.
966	 * @return string Raw HTML
967	 */
968	public static function htmlHeader( array $attribs = [] ) {
969		$ret = '';
970
971		global $wgHtml5Version, $wgMimeType, $wgXhtmlNamespaces;
972
973		$isXHTML = self::isXmlMimeType( $wgMimeType );
974
975		if ( $isXHTML ) { // XHTML5
976			// XML MIME-typed markup should have an xml header.
977			// However a DOCTYPE is not needed.
978			$ret .= "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n";
979
980			// Add the standard xmlns
981			$attribs['xmlns'] = 'http://www.w3.org/1999/xhtml';
982
983			// And support custom namespaces
984			foreach ( $wgXhtmlNamespaces as $tag => $ns ) {
985				$attribs["xmlns:$tag"] = $ns;
986			}
987		} else { // HTML5
988			$ret .= "<!DOCTYPE html>\n";
989		}
990
991		if ( $wgHtml5Version ) {
992			$attribs['version'] = $wgHtml5Version;
993		}
994
995		$ret .= self::openElement( 'html', $attribs );
996
997		return $ret;
998	}
999
1000	/**
1001	 * Determines if the given MIME type is xml.
1002	 *
1003	 * @param string $mimetype MIME type
1004	 * @return bool
1005	 */
1006	public static function isXmlMimeType( $mimetype ) {
1007		# https://html.spec.whatwg.org/multipage/infrastructure.html#xml-mime-type
1008		# * text/xml
1009		# * application/xml
1010		# * Any MIME type with a subtype ending in +xml (this implicitly includes application/xhtml+xml)
1011		return (bool)preg_match( '!^(text|application)/xml$|^.+/.+\+xml$!', $mimetype );
1012	}
1013
1014	/**
1015	 * Get HTML for an information message box with an icon.
1016	 *
1017	 * @internal For use by the WebInstaller class only.
1018	 * @deprecated since 1.36
1019	 *
1020	 * @param string $rawHtml HTML
1021	 * @param string $icon Path to icon file (used as 'src' attribute)
1022	 * @param string $alt Alternate text for the icon
1023	 * @param string $class Additional class name to add to the wrapper div
1024	 * @return string HTML
1025	 */
1026	public static function infoBox( $rawHtml, $icon, $alt, $class = '' ) {
1027		wfDeprecated( __METHOD__, '1.36' );
1028
1029		$s = self::openElement( 'div', [ 'class' => "mw-infobox $class" ] );
1030
1031		$s .= self::openElement( 'div', [ 'class' => 'mw-infobox-left' ] ) .
1032				self::element( 'img',
1033					[
1034						'src' => $icon,
1035						'alt' => $alt,
1036					]
1037				) .
1038				self::closeElement( 'div' );
1039
1040		$s .= self::openElement( 'div', [ 'class' => 'mw-infobox-right' ] ) .
1041				$rawHtml .
1042				self::closeElement( 'div' );
1043		$s .= self::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
1044
1045		$s .= self::closeElement( 'div' );
1046
1047		$s .= self::element( 'div', [ 'style' => 'clear: left;' ], ' ' );
1048
1049		return $s;
1050	}
1051
1052	/**
1053	 * Generate a srcset attribute value.
1054	 *
1055	 * Generates a srcset attribute value from an array mapping pixel densities
1056	 * to URLs. A trailing 'x' in pixel density values is optional.
1057	 *
1058	 * @note srcset width and height values are not supported.
1059	 *
1060	 * @see https://html.spec.whatwg.org/#attr-img-srcset
1061	 *
1062	 * @par Example:
1063	 * @code
1064	 *     Html::srcSet( [
1065	 *         '1x'   => 'standard.jpeg',
1066	 *         '1.5x' => 'large.jpeg',
1067	 *         '3x'   => 'extra-large.jpeg',
1068	 *     ] );
1069	 *     // gives 'standard.jpeg 1x, large.jpeg 1.5x, extra-large.jpeg 2x'
1070	 * @endcode
1071	 *
1072	 * @param string[] $urls
1073	 * @return string
1074	 */
1075	public static function srcSet( array $urls ) {
1076		$candidates = [];
1077		foreach ( $urls as $density => $url ) {
1078			// Cast density to float to strip 'x', then back to string to serve
1079			// as array index.
1080			$density = (string)(float)$density;
1081			$candidates[$density] = $url;
1082		}
1083
1084		// Remove duplicates that are the same as a smaller value
1085		ksort( $candidates, SORT_NUMERIC );
1086		$candidates = array_unique( $candidates );
1087
1088		// Append density info to the url
1089		foreach ( $candidates as $density => $url ) {
1090			$candidates[$density] = $url . ' ' . $density . 'x';
1091		}
1092
1093		return implode( ", ", $candidates );
1094	}
1095}
1096