Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.92% covered (warning)
76.92%
250 / 325
57.89% covered (warning)
57.89%
22 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
Html
76.92% covered (warning)
76.92%
250 / 325
57.89% covered (warning)
57.89%
22 / 38
358.98
0.00% covered (danger)
0.00%
0 / 1
 buttonAttributes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getTextInputAttributes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addClass
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 linkButton
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 submitButton
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 rawElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 element
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 openElement
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 closeElement
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 dropDefaults
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
20.18
 expandClassList
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 expandAttributes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 inlineScript
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 linkedScript
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 inlineStyle
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 linkedStyle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 input
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 check
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 messageBox
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 noticeBox
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 warningBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 errorBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 successBox
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 radio
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 label
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 hidden
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 textarea
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 namespaceSelectorOptions
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
13
 namespaceSelector
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
8
 htmlHeader
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 isXmlMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 srcSet
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 encodeJsVar
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 encodeJsCall
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 encodeJsList
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 listDropdownOptions
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
11.31
 listDropdownOptionsOoui
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 listDropdownOptionsCodex
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Collection of methods to generate HTML content
4 *
5 * Copyright © 2009 Aryeh Gregor
6 * https://www.mediawiki.org/
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 */
11
12namespace MediaWiki\Html;
13
14use MediaWiki\Json\FormatJson;
15use MediaWiki\MainConfigNames;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Parser\Sanitizer;
18use MediaWiki\Request\ContentSecurityPolicy;
19use UnexpectedValueException;
20
21/**
22 * This class is a collection of static functions that serve two purposes:
23 *
24 * 1) Implement any algorithms specified by HTML5, or other HTML
25 * specifications, in a convenient and self-contained way.
26 *
27 * 2) Allow HTML elements to be conveniently and safely generated, like the
28 * current Xml class but a) less confused (Xml supports HTML-specific things,
29 * but only sometimes!) and b) not necessarily confined to XML-compatible
30 * output.
31 *
32 * There are two important configuration options this class uses:
33 *
34 * $wgMimeType: If this is set to an xml MIME type then output should be
35 *     valid XHTML5.
36 *
37 * This class is meant to be confined to utility functions that are called from
38 * trusted code paths.  It does not do enforcement of policy like not allowing
39 * <a> elements.
40 *
41 * @since 1.16
42 */
43class Html {
44    /** List of void elements from HTML5, section 8.1.2 as of 2016-09-19 */
45    private const VOID_ELEMENTS = [
46        'area' => true,
47        'base' => true,
48        'br' => true,
49        'col' => true,
50        'embed' => true,
51        'hr' => true,
52        'img' => true,
53        'input' => true,
54        'keygen' => true,
55        'link' => true,
56        'meta' => true,
57        'param' => true,
58        'source' => true,
59        'track' => true,
60        'wbr' => true,
61    ];
62
63    /**
64     * Boolean attributes, which may have the value omitted entirely.  Manually
65     * collected from the HTML5 spec as of 2011-08-12.
66     */
67    private const BOOL_ATTRIBS = [
68        'async' => true,
69        'autofocus' => true,
70        'autoplay' => true,
71        'checked' => true,
72        'controls' => true,
73        'default' => true,
74        'defer' => true,
75        'disabled' => true,
76        'formnovalidate' => true,
77        'hidden' => true,
78        'ismap' => true,
79        'itemscope' => true,
80        'loop' => true,
81        'multiple' => true,
82        'muted' => true,
83        'novalidate' => true,
84        'open' => true,
85        'pubdate' => true,
86        'readonly' => true,
87        'required' => true,
88        'reversed' => true,
89        'scoped' => true,
90        'seamless' => true,
91        'selected' => true,
92        'truespeed' => true,
93        'typemustmatch' => true,
94    ];
95
96    /**
97     * Whenever altering this array, please provide a covering test case
98     * in HtmlTest::provideElementsWithAttributesHavingDefaultValues
99     */
100    private const ATTRIBS_DEFAULTS = [
101        'area' => [ 'shape' => 'rect' ],
102        'button' => [
103            'formaction' => 'GET',
104            'formenctype' => 'application/x-www-form-urlencoded',
105        ],
106        'canvas' => [
107            'height' => '150',
108            'width' => '300',
109        ],
110        'form' => [
111            'action' => 'GET',
112            'autocomplete' => 'on',
113            'enctype' => 'application/x-www-form-urlencoded',
114        ],
115        'input' => [
116            'formaction' => 'GET',
117            'type' => 'text',
118        ],
119        'keygen' => [ 'keytype' => 'rsa' ],
120        'link' => [
121            'media' => 'all',
122            'type' => 'text/css',
123        ],
124        'menu' => [ 'type' => 'list' ],
125        'script' => [ 'type' => 'text/javascript' ],
126        'style' => [
127            'media' => 'all',
128            'type' => 'text/css',
129        ],
130        'textarea' => [ 'wrap' => 'soft' ],
131    ];
132
133    /**
134     * https://www.w3.org/TR/html401/index/attributes.html ("space-separated")
135     * https://www.w3.org/TR/html5/index.html#attributes-1 ("space-separated")
136     */
137    private const SPACE_SEPARATED_LIST_ATTRIBUTES = [
138        'class' => true, // html4, html5
139        'accesskey' => true, // as of html5, multiple space-separated values allowed
140        // html4-spec doesn't document rel= as space-separated
141        // but has been used like that and is now documented as such
142        // in the html5-spec.
143        'rel' => true,
144    ];
145
146    private const INPUT_ELEMENT_VALID_TYPES = [
147        'hidden' => true,
148        'text' => true,
149        'password' => true,
150        'checkbox' => true,
151        'radio' => true,
152        'file' => true,
153        'submit' => true,
154        'image' => true,
155        'reset' => true,
156        'button' => true,
157
158        // HTML input types
159        'datetime' => true,
160        'datetime-local' => true,
161        'date' => true,
162        'month' => true,
163        'time' => true,
164        'week' => true,
165        'number' => true,
166        'range' => true,
167        'email' => true,
168        'url' => true,
169        'search' => true,
170        'tel' => true,
171        'color' => true,
172    ];
173
174    /**
175     * Modifies a set of attributes meant for button elements.
176     *
177     * @param array $attrs HTML attributes in an associative array
178     * @param string[] $modifiers Unused
179     * @return array Modified attributes array
180     * @deprecated since 1.42 No-op
181     */
182    public static function buttonAttributes( array $attrs, array $modifiers = [] ) {
183        wfDeprecated( __METHOD__, '1.42' );
184        return $attrs;
185    }
186
187    /**
188     * Modifies a set of attributes meant for text input elements.
189     *
190     * @param array $attrs An attribute array.
191     * @return array Modified attributes array
192     * @deprecated since 1.42 No-op
193     */
194    public static function getTextInputAttributes( array $attrs ) {
195        wfDeprecated( __METHOD__, '1.42' );
196        return $attrs;
197    }
198
199    /**
200     * Add a class to a 'class' attribute in a format accepted by Html::element().
201     *
202     * This method may also be used for any other space-separated attribute, such as 'rel'.
203     *
204     * @param array|string|null &$classes Class list to modify in-place
205     * @param string $class Class to add
206     * @phan-assert non-empty-array $classes
207     * @since 1.44
208     */
209    public static function addClass( &$classes, string $class ): void {
210        $classes = (array)$classes;
211        // Detect mistakes where $attrs is passed as $classes instead of $attrs['class']
212        foreach ( $classes as $key => $val ) {
213            if (
214                ( is_int( $key ) && is_string( $val ) ) ||
215                ( is_string( $key ) && is_bool( $val ) )
216            ) {
217                // Valid formats for class array entries
218                continue;
219            }
220            wfWarn( __METHOD__ . ": Argument doesn't look like a class array: " . var_export( $classes, true ) );
221            break;
222        }
223        $classes[] = $class;
224    }
225
226    /**
227     * Returns an HTML link element in a string.
228     *
229     * @param string $text The text of the element. Will be escaped (not raw HTML)
230     * @param array $attrs Associative array of attributes, e.g., [
231     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
232     *   further documentation.
233     * @param string[] $modifiers Unused
234     * @return string Raw HTML
235     */
236    public static function linkButton( $text, array $attrs, array $modifiers = [] ) {
237        return self::element(
238            'a',
239            $attrs,
240            $text
241        );
242    }
243
244    /**
245     * Returns an HTML input element in a string.
246     *
247     * @param string $contents Plain text label for the button value
248     * @param array $attrs Associative array of attributes, e.g., [
249     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
250     *   further documentation.
251     * @param string[] $modifiers Unused
252     * @return string Raw HTML
253     */
254    public static function submitButton( $contents, array $attrs = [], array $modifiers = [] ) {
255        $attrs['type'] = 'submit';
256        $attrs['value'] = $contents;
257        return self::element( 'input', $attrs );
258    }
259
260    /**
261     * Returns an HTML element in a string.  The major advantage here over
262     * manually typing out the HTML is that it will escape all attribute
263     * values.
264     *
265     * This is quite similar to Xml::tags(), but it implements some useful
266     * HTML-specific logic.  For instance, there is no $allowShortTag
267     * parameter: the closing tag is magically omitted if $element has an empty
268     * content model.
269     *
270     * @param string $element The element's name, e.g., 'a'
271     * @param-taint $element tainted
272     * @param array $attribs Associative array of attributes, e.g., [
273     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
274     *   further documentation.
275     * @param-taint $attribs escapes_html
276     * @param string $contents The raw HTML contents of the element: *not*
277     *   escaped!
278     * @param-taint $contents tainted
279     * @return string Raw HTML
280     * @return-taint escaped
281     */
282    public static function rawElement( $element, $attribs = [], $contents = '' ) {
283        $start = self::openElement( $element, $attribs );
284        if ( isset( self::VOID_ELEMENTS[$element] ) ) {
285            return $start;
286        } else {
287            $contents = Sanitizer::escapeCombiningChar( $contents ?? '' );
288            return $start . $contents . self::closeElement( $element );
289        }
290    }
291
292    /**
293     * Identical to rawElement(), but HTML-escapes $contents (like
294     * Xml::element()).
295     *
296     * @param string $element Name of the element, e.g., 'a'
297     * @param-taint $element tainted
298     * @param array $attribs Associative array of attributes, e.g., [
299     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
300     *   further documentation.
301     * @param-taint $attribs escapes_html
302     * @param string $contents
303     * @param-taint $contents escapes_html
304     *
305     * @return string
306     * @return-taint escaped
307     */
308    public static function element( $element, $attribs = [], $contents = '' ) {
309        return self::rawElement(
310            $element,
311            $attribs,
312            strtr( $contents ?? '', [
313                // There's no point in escaping quotes, >, etc. in the contents of
314                // elements.
315                '&' => '&amp;',
316                '<' => '&lt;',
317            ] )
318        );
319    }
320
321    /**
322     * Identical to rawElement(), but has no third parameter and omits the end
323     * tag (and the self-closing '/' in XML mode for empty elements).
324     *
325     * @param string $element Name of the element, e.g., 'a'
326     * @param array $attribs Associative array of attributes, e.g., [
327     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
328     *   further documentation.
329     *
330     * @return string
331     */
332    public static function openElement( $element, $attribs = [] ) {
333        $attribs = (array)$attribs;
334        // This is not required in HTML5, but let's do it anyway, for
335        // consistency and better compression.
336        $element = strtolower( $element );
337
338        // Some people were abusing this by passing things like
339        // 'h1 id="foo" to $element, which we don't want.
340        if ( str_contains( $element, ' ' ) ) {
341            wfWarn( __METHOD__ . " given element name with space '$element'" );
342        }
343
344        // Remove invalid input types
345        if ( $element == 'input' ) {
346            if ( isset( $attribs['type'] ) && !isset( self::INPUT_ELEMENT_VALID_TYPES[$attribs['type']] ) ) {
347                unset( $attribs['type'] );
348            }
349        }
350
351        // According to standard the default type for <button> elements is "submit".
352        // Depending on compatibility mode IE might use "button", instead.
353        // We enforce the standard "submit".
354        if ( $element == 'button' && !isset( $attribs['type'] ) ) {
355            $attribs['type'] = 'submit';
356        }
357
358        return "<$element" . self::expandAttributes(
359            self::dropDefaults( $element, $attribs ) ) . '>';
360    }
361
362    /**
363     * Returns "</$element>"
364     *
365     * @since 1.17
366     * @param string $element Name of the element, e.g., 'a'
367     * @return string A closing tag
368     */
369    public static function closeElement( $element ) {
370        $element = strtolower( $element );
371
372        return "</$element>";
373    }
374
375    /**
376     * Given an element name and an associative array of element attributes,
377     * return an array that is functionally identical to the input array, but
378     * possibly smaller.  In particular, attributes might be stripped if they
379     * are given their default values.
380     *
381     * This method is not guaranteed to remove all redundant attributes, only
382     * some common ones and some others selected arbitrarily at random.  It
383     * only guarantees that the output array should be functionally identical
384     * to the input array (currently per the HTML 5 draft as of 2009-09-06).
385     *
386     * @param string $element Name of the element, e.g., 'a'
387     * @param array $attribs Associative array of attributes, e.g., [
388     *   'href' => 'https://www.mediawiki.org/' ].  See expandAttributes() for
389     *   further documentation.
390     * @return array An array of attributes functionally identical to $attribs
391     */
392    private static function dropDefaults( $element, array $attribs ) {
393        foreach ( $attribs as $attrib => $value ) {
394            if ( $attrib === 'class' ) {
395                if ( $value === '' || $value === [] || $value === [ '' ] ) {
396                    unset( $attribs[$attrib] );
397                }
398            } elseif ( isset( self::ATTRIBS_DEFAULTS[$element][$attrib] ) ) {
399                if ( is_array( $value ) ) {
400                    $value = implode( ' ', $value );
401                } else {
402                    $value = strval( $value );
403                }
404                if ( self::ATTRIBS_DEFAULTS[$element][$attrib] == $value ) {
405                    unset( $attribs[$attrib] );
406                }
407            }
408        }
409
410        // More subtle checks
411        if ( $element === 'input' ) {
412            $type = $attribs['type'] ?? null;
413            $value = $attribs['value'] ?? null;
414            if ( $type === 'checkbox' || $type === 'radio' ) {
415                // The default value for checkboxes and radio buttons is 'on'
416                // not ''. By stripping value="" we break radio boxes that
417                // actually wants empty values.
418                if ( $value === 'on' ) {
419                    unset( $attribs['value'] );
420                }
421            } elseif ( $type === 'submit' ) {
422                // The default value for submit appears to be "Submit" but
423                // let's not bother stripping out localized text that matches
424                // that.
425            } else {
426                // The default value for nearly every other field type is ''
427                // The 'range' and 'color' types use different defaults but
428                // stripping a value="" does not hurt them.
429                if ( $value === '' ) {
430                    unset( $attribs['value'] );
431                }
432            }
433        }
434        if ( $element === 'select' && isset( $attribs['size'] ) ) {
435            $multiple = ( $attribs['multiple'] ?? false ) !== false ||
436                in_array( 'multiple', $attribs );
437            $default = $multiple ? 4 : 1;
438            if ( (int)$attribs['size'] === $default ) {
439                unset( $attribs['size'] );
440            }
441        }
442
443        return $attribs;
444    }
445
446    /**
447     * Convert a value for a 'class' attribute in a format accepted by Html::element() and similar
448     * methods to a single string.
449     *
450     * This method may also be used for any other space-separated attribute, such as 'rel'.
451     *
452     * @param array|string $classes
453     * @return string
454     * @since 1.44
455     */
456    public static function expandClassList( $classes ): string {
457        // Convert into correct array. Array can contain space-separated
458        // values. Implode/explode to get those into the main array as well.
459        if ( is_array( $classes ) ) {
460            // If input wasn't an array, we can skip this step
461            $arrayValue = [];
462            foreach ( $classes as $k => $v ) {
463                if ( is_string( $v ) ) {
464                    // String values should be normal `[ 'foo' ]`
465                    // Just append them
466                    if ( !isset( $classes[$v] ) ) {
467                        // As a special case don't set 'foo' if a
468                        // separate 'foo' => true/false exists in the array
469                        // keys should be authoritative
470                        foreach ( explode( ' ', $v ) as $part ) {
471                            // Normalize spacing by fixing up cases where people used
472                            // more than 1 space and/or a trailing/leading space
473                            if ( $part !== '' && $part !== ' ' ) {
474                                $arrayValue[] = $part;
475                            }
476                        }
477                    }
478                } elseif ( $v ) {
479                    // If the value is truthy but not a string this is likely
480                    // an [ 'foo' => true ], falsy values don't add strings
481                    $arrayValue[] = $k;
482                }
483            }
484        } else {
485            $arrayValue = explode( ' ', $classes );
486            // Normalize spacing by fixing up cases where people used
487            // more than 1 space and/or a trailing/leading space
488            $arrayValue = array_diff( $arrayValue, [ '', ' ' ] );
489        }
490
491        // Remove duplicates and create the string
492        return implode( ' ', array_unique( $arrayValue ) );
493    }
494
495    /**
496     * Given an associative array of element attributes, generate a string
497     * to stick after the element name in HTML output.  Like [ 'href' =>
498     * 'https://www.mediawiki.org/' ] becomes something like
499     * ' href="https://www.mediawiki.org"'.  Again, this is like
500     * Xml::expandAttributes(), but it implements some HTML-specific logic.
501     *
502     * Attributes that can contain space-separated lists ('class', 'accesskey' and 'rel') array
503     * values are allowed as well, which will automagically be normalized
504     * and converted to a space-separated string. In addition to a numerical
505     * array, the attribute value may also be an associative array. See the
506     * example below for how that works.
507     *
508     * @par Numerical array
509     * @code
510     *     Html::element( 'em', [
511     *         'class' => [ 'foo', 'bar' ]
512     *     ] );
513     *     // gives '<em class="foo bar"></em>'
514     * @endcode
515     *
516     * @par Associative array
517     * @code
518     *     Html::element( 'em', [
519     *         'class' => [ 'foo', 'bar', 'foo' => false, 'quux' => true ]
520     *     ] );
521     *     // gives '<em class="bar quux"></em>'
522     * @endcode
523     *
524     * @param array $attribs Associative array of attributes, e.g., [
525     *   'href' => 'https://www.mediawiki.org/' ].  Values will be HTML-escaped.
526     *   A value of false or null means to omit the attribute.  For boolean attributes,
527     *   you can omit the key, e.g., [ 'checked' ] instead of
528     *   [ 'checked' => 'checked' ] or such.
529     *
530     * @return string HTML fragment that goes between element name and '>'
531     *   (starting with a space if at least one attribute is output)
532     */
533    public static function expandAttributes( array $attribs ) {
534        $ret = '';
535        foreach ( $attribs as $key => $value ) {
536            // Support intuitive [ 'checked' => true/false ] form
537            if ( $value === false || $value === null ) {
538                continue;
539            }
540
541            // For boolean attributes, support [ 'foo' ] instead of
542            // requiring [ 'foo' => 'meaningless' ].
543            if ( is_int( $key ) && isset( self::BOOL_ATTRIBS[strtolower( $value )] ) ) {
544                $key = $value;
545            }
546
547            // Not technically required in HTML5 but we'd like consistency
548            // and better compression anyway.
549            $key = strtolower( $key );
550
551            // Specific features for attributes that allow a list of space-separated values
552            if ( isset( self::SPACE_SEPARATED_LIST_ATTRIBUTES[$key] ) ) {
553                // Apply some normalization and remove duplicates
554                $value = self::expandClassList( $value );
555
556                // Optimization: Skip below boolAttribs check and jump straight
557                // to its `else` block. The current self::SPACE_SEPARATED_LIST_ATTRIBUTES
558                // block is mutually exclusive with self::BOOL_ATTRIBS.
559                // phpcs:ignore Generic.PHP.DiscourageGoto
560                goto not_bool; // NOSONAR
561            } elseif ( is_array( $value ) ) {
562                throw new UnexpectedValueException( "HTML attribute $key can not contain a list of values" );
563            }
564
565            if ( isset( self::BOOL_ATTRIBS[$key] ) ) {
566                $ret .= " $key=\"\"";
567            } else {
568                // phpcs:ignore Generic.PHP.DiscourageGoto
569                not_bool:
570                // Inlined from Sanitizer::encodeAttribute() for improved performance
571                $encValue = htmlspecialchars( $value, ENT_QUOTES );
572                // Whitespace is normalized during attribute decoding,
573                // so if we've been passed non-spaces we must encode them
574                // ahead of time or they won't be preserved.
575                $encValue = strtr( $encValue, [
576                    "\n" => '&#10;',
577                    "\r" => '&#13;',
578                    "\t" => '&#9;',
579                ] );
580                $ret .= " $key=\"$encValue\"";
581            }
582        }
583        return $ret;
584    }
585
586    /**
587     * Output an HTML script tag with the given contents.
588     *
589     * It is unsupported for the contents to contain the sequence `<script` or `</script`
590     * (case-insensitive). This ensures the script can be terminated easily and consistently.
591     * It is the responsibility of the caller to avoid such character sequence by escaping
592     * or avoiding it. If found at run-time, the contents are replaced with a comment, and
593     * a warning is logged server-side.
594     *
595     * @param string $contents JavaScript
596     * @param string|null $nonce Unused
597     * @return string Raw HTML
598     */
599    public static function inlineScript( $contents, $nonce = null ) {
600        if ( preg_match( '/<\/?script/i', $contents ) ) {
601            wfLogWarning( __METHOD__ . ': Illegal character sequence found in inline script.' );
602            $contents = '/* ERROR: Invalid script */';
603        }
604
605        return self::rawElement( 'script', [], $contents );
606    }
607
608    /**
609     * Output a "<script>" tag linking to the given URL, e.g.,
610     * "<script src=foo.js></script>".
611     *
612     * @param string $url
613     * @param string|null $nonce Nonce for CSP header, from OutputPage->getCSP()->getNonce()
614     * @return string Raw HTML
615     */
616    public static function linkedScript( $url, $nonce = null ) {
617        $attrs = [ 'src' => $url ];
618        if ( $nonce !== null ) {
619            $attrs['nonce'] = $nonce;
620        } elseif ( ContentSecurityPolicy::isNonceRequired( MediaWikiServices::getInstance()->getMainConfig() ) ) {
621            wfWarn( "no nonce set on script. CSP will break it" );
622        }
623
624        return self::element( 'script', $attrs );
625    }
626
627    /**
628     * Output a "<style>" tag with the given contents for the given media type
629     * (if any).  TODO: do some useful escaping as well, like if $contents
630     * contains literal "</style>" (admittedly unlikely).
631     *
632     * @param string $contents CSS
633     * @param string $media A media type string, like 'screen'
634     * @param array $attribs (since 1.31) Associative array of attributes, e.g., [
635     *   'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for
636     *   further documentation.
637     * @return string Raw HTML
638     */
639    public static function inlineStyle( $contents, $media = 'all', $attribs = [] ) {
640        // Don't escape '>' since that is used
641        // as direct child selector.
642        // Remember, in css, there is no "x" for hexadecimal escapes, and
643        // the space immediately after an escape sequence is swallowed.
644        $contents = strtr( $contents, [
645            '<' => '\3C ',
646            // CDATA end tag for good measure, but the main security
647            // is from escaping the '<'.
648            ']]>' => '\5D\5D\3E '
649        ] );
650
651        if ( preg_match( '/[<&]/', $contents ) ) {
652            $contents = "/*<![CDATA[*/$contents/*]]>*/";
653        }
654
655        return self::rawElement( 'style', [
656            'media' => $media,
657        ] + $attribs, $contents );
658    }
659
660    /**
661     * Output a "<link rel=stylesheet>" linking to the given URL for the given
662     * media type (if any).
663     *
664     * @param string $url
665     * @param string $media A media type string, like 'screen'
666     * @return string Raw HTML
667     */
668    public static function linkedStyle( $url, $media = 'all' ) {
669        return self::element( 'link', [
670            'rel' => 'stylesheet',
671            'href' => $url,
672            'media' => $media,
673        ] );
674    }
675
676    /**
677     * Convenience function to produce an `<input>` element.  This supports the
678     * new HTML5 input types and attributes.
679     *
680     * @param string $name Name attribute
681     * @param string $value Value attribute
682     * @param string $type Type attribute
683     * @param array $attribs Associative array of miscellaneous extra
684     *   attributes, passed to Html::element()
685     * @return string Raw HTML
686     */
687    public static function input( $name, $value = '', $type = 'text', array $attribs = [] ) {
688        $attribs['type'] = $type;
689        $attribs['value'] = $value;
690        $attribs['name'] = $name;
691        return self::element( 'input', $attribs );
692    }
693
694    /**
695     * Convenience function to produce a checkbox (input element with type=checkbox)
696     *
697     * @param string $name Name attribute
698     * @param bool $checked Whether the checkbox is checked or not
699     * @param array $attribs Array of additional attributes
700     * @return string Raw HTML
701     */
702    public static function check( $name, $checked = false, array $attribs = [] ) {
703        $value = $attribs['value'] ?? 1;
704        unset( $attribs['value'] );
705        return self::element( 'input', [
706            ...$attribs,
707            'checked' => (bool)$checked,
708            'type' => 'checkbox',
709            'value' => $value,
710            'name' => $name,
711        ] );
712    }
713
714    /**
715     * Return the HTML for a message box.
716     * @since 1.31
717     * @param string $html of contents of box
718     * @param-taint $html tainted
719     * @param string|array $className corresponding to box
720     * @param string $heading (optional)
721     * @param string $iconClassName (optional) corresponding to box icon
722     * @return string of HTML representing a box.
723     */
724    private static function messageBox( $html, $className, $heading = '', $iconClassName = '' ) {
725        if ( $heading !== '' ) {
726            $html = self::element( 'h2', [], $heading ) . $html;
727        }
728        self::addClass( $className, 'cdx-message' );
729        self::addClass( $className, 'cdx-message--block' );
730        return self::rawElement( 'div', [ 'class' => $className ],
731            self::element( 'span', [ 'class' => [
732                'cdx-message__icon',
733                $iconClassName
734            ] ] ) .
735            self::rawElement( 'div', [
736                'class' => 'cdx-message__content'
737            ], $html )
738        );
739    }
740
741    /**
742     * Return the HTML for a notice message box.
743     *
744     * This method produces HTML that requires CSS styles for the Codex MessageBox component
745     * that need to be supplied by mediawiki.codex.messagebox.styles or another suitable
746     * Codex style module.
747     *
748     * @since 1.38
749     * @param string $html of contents of notice
750     * @param-taint $html tainted
751     * @param string|array $className corresponding to notice
752     * @param string $heading (optional)
753     * @param string|array $iconClassName (optional) corresponding to notice icon
754     * @return string of HTML representing the notice
755     */
756    public static function noticeBox( $html, $className = '', $heading = '', $iconClassName = '' ) {
757        return self::messageBox( $html, [
758            'cdx-message--notice',
759            $className
760        ], $heading, $iconClassName );
761    }
762
763    /**
764     * Return a warning box.
765     *
766     * This method produces HTML that requires CSS styles for the Codex MessageBox component
767     * that need to be supplied by mediawiki.codex.messagebox.styles or another suitable
768     * Codex style module.
769     *
770     * @since 1.31
771     * @since 1.34 $className optional parameter added
772     * @param string $html of contents of box
773     * @param-taint $html tainted
774     * @param string $className (optional) corresponding to box
775     * @return string of HTML representing a warning box.
776     */
777    public static function warningBox( $html, $className = '' ) {
778        return self::messageBox( $html, [
779            'cdx-message--warning', $className ] );
780    }
781
782    /**
783     * Return an error box.
784     *
785     * This method produces HTML that requires CSS styles for the Codex MessageBox component
786     * that need to be supplied by mediawiki.codex.messagebox.styles or another suitable
787     * Codex style module.
788     *
789     * @since 1.31
790     * @since 1.34 $className optional parameter added
791     * @param string $html of contents of error box
792     * @param-taint $html tainted
793     * @param string $heading (optional)
794     * @param string $className (optional) corresponding to box
795     * @return string of HTML representing an error box.
796     */
797    public static function errorBox( $html, $heading = '', $className = '' ) {
798        return self::messageBox( $html, [
799            'cdx-message--error', $className ], $heading );
800    }
801
802    /**
803     * Return a success box.
804     *
805     * This method produces HTML that requires CSS styles for the Codex MessageBox component
806     * that need to be supplied by mediawiki.codex.messagebox.styles or another suitable
807     * Codex style module.
808     *
809     * @since 1.31
810     * @since 1.34 $className optional parameter added
811     * @param string $html of contents of box
812     * @param-taint $html tainted
813     * @param string $className (optional) corresponding to box
814     * @return string of HTML representing a success box.
815     */
816    public static function successBox( $html, $className = '' ) {
817        return self::messageBox( $html, [
818            'cdx-message--success', $className ] );
819    }
820
821    /**
822     * Convenience function to produce a radio button (input element with type=radio)
823     *
824     * @param string $name Name attribute
825     * @param bool $checked Whether the radio button is checked or not
826     * @param array $attribs Array of additional attributes
827     * @return string Raw HTML
828     */
829    public static function radio( $name, $checked = false, array $attribs = [] ) {
830        $value = $attribs['value'] ?? 1;
831        unset( $attribs['value'] );
832        return self::element( 'input', [
833            ...$attribs,
834            'checked' => (bool)$checked,
835            'type' => 'radio',
836            'value' => $value,
837            'name' => $name,
838        ] );
839    }
840
841    /**
842     * Convenience function for generating a label for inputs.
843     *
844     * @param string $label Contents of the label
845     * @param string $id ID of the element being labeled
846     * @param array $attribs Additional attributes
847     * @return string Raw HTML
848     */
849    public static function label( $label, $id, array $attribs = [] ) {
850        $attribs += [
851            'for' => $id,
852        ];
853        return self::element( 'label', $attribs, $label );
854    }
855
856    /**
857     * Convenience function to produce an input element with type=hidden
858     *
859     * @param string $name Name attribute
860     * @param mixed $value Value attribute
861     * @param array $attribs Associative array of miscellaneous extra
862     *   attributes, passed to Html::element()
863     * @return string Raw HTML
864     */
865    public static function hidden( $name, $value, array $attribs = [] ) {
866        return self::element( 'input', [
867            ...$attribs,
868            'type' => 'hidden',
869            'value' => $value,
870            'name' => $name,
871        ] );
872    }
873
874    /**
875     * Convenience function to produce a <textarea> element.
876     *
877     * This supports leaving out the cols= and rows= which Xml requires and are
878     * required by HTML4/XHTML but not required by HTML5.
879     *
880     * @param string $name Name attribute
881     * @param string $value Value attribute
882     * @param array $attribs Associative array of miscellaneous extra
883     *   attributes, passed to Html::element()
884     * @return string Raw HTML
885     */
886    public static function textarea( $name, $value = '', array $attribs = [] ) {
887        $attribs['name'] = $name;
888
889        if ( str_starts_with( $value ?? '', "\n" ) ) {
890            // Workaround for T14130: browsers eat the initial newline
891            // assuming that it's just for show, but they do keep the later
892            // newlines, which we may want to preserve during editing.
893            // Prepending a single newline
894            $spacedValue = "\n" . $value;
895        } else {
896            $spacedValue = $value;
897        }
898        return self::element( 'textarea', $attribs, $spacedValue );
899    }
900
901    /**
902     * Helper for Html::namespaceSelector().
903     * @param array $params See Html::namespaceSelector()
904     * @return array
905     */
906    public static function namespaceSelectorOptions( array $params = [] ) {
907        if ( !isset( $params['exclude'] ) || !is_array( $params['exclude'] ) ) {
908            $params['exclude'] = [];
909        }
910
911        if ( $params['in-user-lang'] ?? false ) {
912            global $wgLang;
913            $lang = $wgLang;
914        } else {
915            $lang = MediaWikiServices::getInstance()->getContentLanguage();
916        }
917
918        $optionsOut = [];
919        if ( isset( $params['all'] ) ) {
920            // add an option that would let the user select all namespaces.
921            // Value is provided by user, the name shown is localized for the user.
922            $optionsOut[$params['all']] = wfMessage( 'namespacesall' )->text();
923        }
924        // Add all namespaces as options
925        $options = $lang->getFormattedNamespaces();
926        // Filter out namespaces below 0 and massage labels
927        foreach ( $options as $nsId => $nsName ) {
928            if ( $nsId < NS_MAIN || in_array( $nsId, $params['exclude'] ) ) {
929                continue;
930            }
931            if (
932                isset( $params['include'] ) &&
933                is_array( $params['include'] ) &&
934                !in_array( $nsId, $params['include'] )
935            ) {
936                continue;
937            }
938
939            if ( $nsId === NS_MAIN ) {
940                // For other namespaces use the namespace prefix as label, but for
941                // main we don't use "" but the user message describing it (e.g. "(Main)" or "(Article)")
942                $nsName = wfMessage( 'blanknamespace' )->text();
943            } elseif ( is_int( $nsId ) ) {
944                $converter = MediaWikiServices::getInstance()->getLanguageConverterFactory()
945                    ->getLanguageConverter( $lang );
946                $nsName = $converter->convertNamespace( $nsId );
947            }
948            $optionsOut[$nsId] = $nsName;
949        }
950
951        return $optionsOut;
952    }
953
954    /**
955     * Build a drop-down box for selecting a namespace
956     *
957     * @param array $params Params to set.
958     * - selected: [optional] Id of namespace which should be pre-selected
959     * - all: [optional] Value of item for "all namespaces". If null or unset,
960     *   no "<option>" is generated to select all namespaces.
961     * - label: text for label to add before the field.
962     * - exclude: [optional] Array of namespace ids to exclude.
963     * - disable: [optional] Array of namespace ids for which the option should
964     *   be disabled in the selector.
965     * @param array $selectAttribs HTML attributes for the generated select element.
966     * - id:   [optional], default: 'namespace'.
967     * - name: [optional], default: 'namespace'.
968     * @return string HTML code to select a namespace.
969     */
970    public static function namespaceSelector(
971        array $params = [],
972        array $selectAttribs = []
973    ) {
974        ksort( $selectAttribs );
975
976        // Is a namespace selected?
977        if ( isset( $params['selected'] ) ) {
978            // If string only contains digits, convert to clean int. Selected could also
979            // be "all" or "" etc. which needs to be left untouched.
980            if ( !is_int( $params['selected'] ) && ctype_digit( (string)$params['selected'] ) ) {
981                $params['selected'] = (int)$params['selected'];
982            }
983            // else: leaves it untouched for later processing
984        } else {
985            $params['selected'] = '';
986        }
987
988        if ( !isset( $params['disable'] ) || !is_array( $params['disable'] ) ) {
989            $params['disable'] = [];
990        }
991
992        // Associative array between option-values and option-labels
993        $options = self::namespaceSelectorOptions( $params );
994
995        // Convert $options to HTML
996        $optionsHtml = [];
997        foreach ( $options as $nsId => $nsName ) {
998            $optionsHtml[] = self::element(
999                'option',
1000                [
1001                    'disabled' => in_array( $nsId, $params['disable'] ),
1002                    'value' => $nsId,
1003                    'selected' => $nsId === $params['selected'],
1004                ],
1005                $nsName
1006            );
1007        }
1008
1009        $selectAttribs['id'] ??= 'namespace';
1010        $selectAttribs['name'] ??= 'namespace';
1011
1012        $label = '';
1013        if ( isset( $params['label'] ) ) {
1014            $label = self::element( 'label', [ 'for' => $selectAttribs['id'] ],
1015                $params['label']
1016            ) . "\u{00A0}";
1017        }
1018
1019        // Wrap options in a <select>
1020        return $label . self::rawElement( 'select', $selectAttribs,
1021            "\n" . implode( "\n", $optionsHtml ) . "\n"
1022        );
1023    }
1024
1025    /**
1026     * Constructs the opening html-tag with necessary doctypes depending on
1027     * global variables.
1028     *
1029     * @param array $attribs Associative array of miscellaneous extra
1030     *   attributes, passed to Html::element() of html tag.
1031     * @return string Raw HTML
1032     */
1033    public static function htmlHeader( array $attribs = [] ) {
1034        $ret = '';
1035        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
1036        $html5Version = $mainConfig->get( MainConfigNames::Html5Version );
1037        $mimeType = $mainConfig->get( MainConfigNames::MimeType );
1038        $xhtmlNamespaces = $mainConfig->get( MainConfigNames::XhtmlNamespaces );
1039
1040        $isXHTML = self::isXmlMimeType( $mimeType );
1041
1042        if ( $isXHTML ) { // XHTML5
1043            // XML MIME-typed markup should have an xml header.
1044            // However a DOCTYPE is not needed.
1045            $ret .= "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n";
1046
1047            // Add the standard xmlns
1048            $attribs['xmlns'] = 'http://www.w3.org/1999/xhtml';
1049
1050            // And support custom namespaces
1051            foreach ( $xhtmlNamespaces as $tag => $ns ) {
1052                $attribs["xmlns:$tag"] = $ns;
1053            }
1054        } else { // HTML5
1055            $ret .= "<!DOCTYPE html>\n";
1056        }
1057
1058        if ( $html5Version ) {
1059            $attribs['version'] = $html5Version;
1060        }
1061
1062        $ret .= self::openElement( 'html', $attribs );
1063
1064        return $ret;
1065    }
1066
1067    /**
1068     * Determines if the given MIME type is xml.
1069     *
1070     * @param string $mimetype
1071     * @return bool
1072     */
1073    public static function isXmlMimeType( $mimetype ) {
1074        # https://html.spec.whatwg.org/multipage/infrastructure.html#xml-mime-type
1075        # * text/xml
1076        # * application/xml
1077        # * Any MIME type with a subtype ending in +xml (this implicitly includes application/xhtml+xml)
1078        return (bool)preg_match( '!^(text|application)/xml$|^.+/.+\+xml$!', $mimetype );
1079    }
1080
1081    /**
1082     * Generate a srcset attribute value.
1083     *
1084     * Generates a srcset attribute value from an array mapping pixel densities
1085     * to URLs. A trailing 'x' in pixel density values is optional.
1086     *
1087     * @note srcset width and height values are not supported.
1088     *
1089     * @see https://html.spec.whatwg.org/#attr-img-srcset
1090     *
1091     * @par Example:
1092     * @code
1093     *     Html::srcSet( [
1094     *         '1x' => 'standard.jpg',
1095     *         '2x' => 'large.jpeg',
1096     *     ] );
1097     *     // gives 'standard.jpg 1x, large.jpg 2x'
1098     * @endcode
1099     *
1100     * @param string[] $urls
1101     * @return string
1102     */
1103    public static function srcSet( array $urls ) {
1104        $candidates = [];
1105        foreach ( $urls as $density => $url ) {
1106            // Cast density to float to strip 'x', then back to string to serve
1107            // as array index.
1108            $density = (string)(float)$density;
1109            $candidates[$density] = $url;
1110        }
1111
1112        // Remove duplicates that are the same as a smaller value
1113        ksort( $candidates, SORT_NUMERIC );
1114        $candidates = array_unique( $candidates );
1115
1116        // Append density info to the url
1117        foreach ( $candidates as $density => $url ) {
1118            $candidates[$density] = $url . ' ' . $density . 'x';
1119        }
1120
1121        return implode( ", ", $candidates );
1122    }
1123
1124    /**
1125     * Encode a variable of arbitrary type to JavaScript.
1126     * If the value is an HtmlJsCode object, pass through the object's value verbatim.
1127     *
1128     * @note Only use this function for generating JavaScript code. If generating output
1129     *       for a proper JSON parser, just call FormatJson::encode() directly.
1130     *
1131     * @since 1.41 (previously on {@link Xml})
1132     * @param mixed $value The value being encoded. Can be any type except a resource.
1133     * @param-taint $value escapes_html
1134     * @param bool $pretty If true, add non-significant whitespace to improve readability.
1135     * @return string|false String if successful; false upon failure
1136     * @return-taint none
1137     */
1138    public static function encodeJsVar( $value, $pretty = false ) {
1139        if ( $value instanceof HtmlJsCode ) {
1140            return $value->value;
1141        }
1142        return FormatJson::encode( $value, $pretty, FormatJson::UTF8_OK );
1143    }
1144
1145    /**
1146     * Create a call to a JavaScript function. The supplied arguments will be
1147     * encoded using Html::encodeJsVar().
1148     *
1149     * @since 1.41 (previously on {@link Xml} since 1.17)
1150     * @param string $name The name of the function to call, or a JavaScript expression
1151     *    which evaluates to a function object which is called.
1152     * @param-taint $name tainted
1153     * @param array $args The arguments to pass to the function.
1154     * @param-taint $args escapes_html
1155     * @param bool $pretty If true, add non-significant whitespace to improve readability.
1156     * @return string|false String if successful; false upon failure
1157     * @return-taint none
1158     */
1159    public static function encodeJsCall( $name, $args, $pretty = false ) {
1160        $encodedArgs = self::encodeJsList( $args, $pretty );
1161        if ( $encodedArgs === false ) {
1162            return false;
1163        }
1164        return "$name($encodedArgs);";
1165    }
1166
1167    /**
1168     * Encode a JavaScript comma-separated list. The supplied items will be encoded using
1169     * Html::encodeJsVar().
1170     *
1171     * @since 1.41.
1172     * @param array $args The elements of the list.
1173     * @param bool $pretty If true, add non-significant whitespace to improve readability.
1174     * @return false|string String if successful; false upon failure
1175     */
1176    public static function encodeJsList( $args, $pretty = false ) {
1177        foreach ( $args as &$arg ) {
1178            $arg = self::encodeJsVar( $arg, $pretty );
1179            if ( $arg === false ) {
1180                return false;
1181            }
1182        }
1183        if ( $pretty ) {
1184            return ' ' . implode( ', ', $args ) . ' ';
1185        } else {
1186            return implode( ',', $args );
1187        }
1188    }
1189
1190    /**
1191     * Build options for a drop-down box from a textual list.
1192     *
1193     * The result of this function can be passed to XmlSelect::addOptions()
1194     * (to render a plain `<select>` dropdown box) or to Html::listDropdownOptionsOoui()
1195     * and then OOUI\DropdownInputWidget() (to render a pretty one).
1196     *
1197     * @param string $list Correctly formatted text (newline delimited) to be
1198     *   used to generate the options.
1199     * @param array $params Extra parameters:
1200     *   - string $params['other'] If set, add an option with this as text and a value of 'other'
1201     * @return array Array keys are textual labels, values are internal values
1202     */
1203    public static function listDropdownOptions( $list, $params = [] ) {
1204        $options = [];
1205
1206        if ( isset( $params['other'] ) ) {
1207            $options[ $params['other'] ] = 'other';
1208        }
1209
1210        $optgroup = false;
1211        foreach ( explode( "\n", $list ) as $option ) {
1212            $value = trim( $option );
1213            if ( $value == '' ) {
1214                continue;
1215            }
1216            if ( str_starts_with( $value, '*' ) && !str_starts_with( $value, '**' ) ) {
1217                # A new group is starting...
1218                $value = trim( substr( $value, 1 ) );
1219                if ( $value !== '' &&
1220                    // Do not use the value for 'other' as option group - T251351
1221                    ( !isset( $params['other'] ) || $value !== $params['other'] )
1222                ) {
1223                    $optgroup = $value;
1224                } else {
1225                    $optgroup = false;
1226                }
1227            } elseif ( str_starts_with( $value, '**' ) ) {
1228                # groupmember
1229                $opt = trim( substr( $value, 2 ) );
1230                if ( $optgroup === false ) {
1231                    $options[$opt] = $opt;
1232                } else {
1233                    $options[$optgroup][$opt] = $opt;
1234                }
1235            } else {
1236                # groupless reason list
1237                $optgroup = false;
1238                $options[$option] = $option;
1239            }
1240        }
1241
1242        return $options;
1243    }
1244
1245    /**
1246     * Convert options for a drop-down box into a format accepted by OOUI\DropdownInputWidget etc.
1247     *
1248     * TODO Find a better home for this function.
1249     *
1250     * @param array $options Options, as returned e.g. by Html::listDropdownOptions()
1251     * @return array
1252     */
1253    public static function listDropdownOptionsOoui( $options ) {
1254        $optionsOoui = [];
1255
1256        foreach ( $options as $text => $value ) {
1257            if ( is_array( $value ) ) {
1258                $optionsOoui[] = [ 'optgroup' => (string)$text ];
1259                foreach ( $value as $text2 => $value2 ) {
1260                    $optionsOoui[] = [ 'data' => (string)$value2, 'label' => (string)$text2 ];
1261                }
1262            } else {
1263                $optionsOoui[] = [ 'data' => (string)$value, 'label' => (string)$text ];
1264            }
1265        }
1266
1267        return $optionsOoui;
1268    }
1269
1270    /**
1271     * Convert options for a drop-down box into a format accepted by OOUI\DropdownInputWidget etc.
1272     *
1273     * TODO Find a better home for this function.
1274     *
1275     * @param array $options Options, as returned e.g. by Html::listDropdownOptions()
1276     * @return array
1277     */
1278    public static function listDropdownOptionsCodex( $options ) {
1279        $optionsCodex = [];
1280
1281        foreach ( $options as $text => $value ) {
1282            if ( is_array( $value ) ) {
1283                $optionsCodex[] = [
1284                    'label' => (string)$text,
1285                    'items' => array_map( static function ( $text2, $value2 ) {
1286                        return [ 'label' => (string)$text2, 'value' => (string)$value2 ];
1287                    }, array_keys( $value ), $value )
1288                ];
1289            } else {
1290                $optionsCodex[] = [ 'label' => (string)$text, 'value' => (string)$value ];
1291            }
1292        }
1293        return $optionsCodex;
1294    }
1295}