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 '&' => '&', 239 '<' => '<' 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