10 * Tag name for this instance.
12 * @var string HTML tag name
19 * @var array HTML attributes
21 protected $attributes = [];
26 * @var array CSS classes
28 protected $classes = [];
33 * @var array Content text and elements
35 protected $content = [];
40 * @var GroupElement|null Group element is in
42 protected $elementGroup = null;
47 * @var boolean Whether to serialize tag/element/widget state for client-side use.
49 protected $infusable = false;
56 * @param string $tag HTML tag name
58 public function __construct( $tag = 'div' ) {
63 * Check for CSS class.
65 * @param string $class CSS class name
68 public function hasClass( $class ) {
69 return in_array( $class, $this->classes );
75 * @param array $classes List of classes to add
78 public function addClasses( array $classes ) {
79 $this->classes = array_merge( $this->classes, $classes );
86 * @param array $classes List of classes to remove
89 public function removeClasses( array $classes ) {
90 $this->classes = array_diff( $this->classes, $classes );
97 * @param array $classes List of classes to add
98 * @param bool $toggle Add classes
101 public function toggleClasses( array $classes, $toggle = null ) {
102 if ( $toggle === null ) {
103 $this->classes = array_diff(
104 array_merge( $this->classes, $classes ),
105 array_intersect( $this->classes, $classes )
107 } elseif ( $toggle ) {
108 $this->classes = array_merge( $this->classes, $classes );
110 $this->classes = array_diff( $this->classes, $classes );
115 public function getTag() {
120 * Get HTML attribute value.
122 * @param string $key HTML attribute name
123 * @return string|null
125 public function getAttribute( $key ) {
126 return isset( $this->attributes[$key] ) ? $this->attributes[$key] : null;
130 * Add HTML attributes.
132 * @param array $attributes List of attribute key/value pairs to add
135 public function setAttributes( array $attributes ) {
136 foreach ( $attributes as $key => $value ) {
137 $this->attributes[$key] = $value;
143 * Set value of input element ('value' attribute for most, element content for textarea).
145 * @param string $value Value to set
148 public function setValue( $value ) {
149 if ( strtolower( $this->tag ) === 'textarea' ) {
150 $this->clearContent();
151 $this->appendContent( $value );
153 $this->setAttributes( [ 'value' => $value ] );
159 * Remove HTML attributes.
161 * @param array $keys List of attribute keys to remove
164 public function removeAttributes( array $keys ) {
165 foreach ( $keys as $key ) {
166 unset( $this->attributes[$key] );
172 * Add content to the end.
174 * Accepts either variadic arguments (the $content argument can be repeated any number of times)
175 * or an array of arguments.
177 * For example, these uses are valid:
178 * * $tag->appendContent( [ $element1, $element2 ] );
179 * * $tag->appendContent( $element1, $element2 );
180 * This, however, is not acceptable
181 * * $tag->appendContent( [ $element1, $element2 ], $element3 );
183 * @param string|Tag|HtmlSnippet $content Content to append. Strings will be HTML-escaped
184 * for output, use a HtmlSnippet instance to prevent that.
187 public function appendContent( /* $content... */ ) {
188 $contents = func_get_args();
189 if ( is_array( $contents[ 0 ] ) ) {
190 $this->content = array_merge( $this->content, $contents[ 0 ] );
192 $this->content = array_merge( $this->content, $contents );
198 * Add content to the beginning.
200 * Accepts either variadic arguments (the $content argument can be repeated any number of times)
201 * or an array of arguments.
203 * For example, these uses are valid:
204 * * $tag->prependContent( [ $element1, $element2 ] );
205 * * $tag->prependContent( $element1, $element2 );
206 * This, however, is not acceptable
207 * * $tag->prependContent( [ $element1, $element2 ], $element3 );
209 * @param string|Tag|HtmlSnippet $content Content to prepend. Strings will be HTML-escaped
210 * for output, use a HtmlSnippet instance to prevent that.
213 public function prependContent( /* $content... */ ) {
214 $contents = func_get_args();
215 if ( is_array( $contents[ 0 ] ) ) {
216 array_splice( $this->content, 0, 0, $contents[ 0 ] );
218 array_splice( $this->content, 0, 0, $contents );
224 * Remove all content.
228 public function clearContent() {
234 * Get group element is in.
236 * @return GroupElement|null Group element, null if none
238 public function getElementGroup() {
239 return $this->elementGroup;
243 * Set group element is in.
245 * @param GroupElement|null $group Group element, null if none
248 public function setElementGroup( $group ) {
249 $this->elementGroup = $group;
254 * Enable widget for client-side infusion.
256 * @param bool $infusable True to allow tag/element/widget to be referenced client-side.
259 public function setInfusable( $infusable ) {
260 $this->infusable = $infusable;
265 * Get client-side infusability.
267 * @return bool If this tag/element/widget can be referenced client-side.
269 public function isInfusable() {
270 return $this->infusable;
273 private static $elementId = 0;
276 * Generate a unique ID for element
280 public static function generateElementId() {
282 return 'ooui-' . self::$elementId;
286 * Ensure that this given Tag is infusable and has a unique `id`
290 public function ensureInfusableId() {
291 $this->setInfusable( true );
292 if ( $this->getAttribute( 'id' ) === null ) {
293 $this->setAttributes( [ 'id' => self::generateElementId() ] );
299 * Return an augmented `attributes` array, including synthetic attributes
300 * which are created from other properties (like the `classes` array)
301 * but which shouldn't be retained in the user-visible `attributes`.
302 * @return array An attributes array.
304 protected function getGeneratedAttributes() {
305 // Copy attributes, add `class` attribute from `$this->classes` array.
306 $attributesArray = $this->attributes;
307 if ( $this->classes ) {
308 $attributesArray['class'] = implode( ' ', array_unique( $this->classes ) );
310 if ( $this->infusable ) {
311 // Indicate that this is "just" a tag (not a widget)
312 $attributesArray['data-ooui'] = json_encode( [ '_' => 'Tag' ] );
314 return $attributesArray;
318 * Check whether the string $haystack begins with the string $needle.
320 * @param string $haystack
321 * @param string $needle
322 * @return bool True if $haystack begins with $needle, false otherwise.
324 private static function stringStartsWith( $haystack, $needle ) {
325 return strncmp( $haystack, $needle, strlen( $needle ) ) === 0;
329 * Check whether user-supplied URL is safe, that is, whether outputting it will not result in XSS
330 * vulnerability. (Note that URLs must be HTML-escaped regardless of this check.)
332 * The point is to disallow 'javascript:' URLs (there are no good reasons to ever use them
333 * anyway), but there's no good way to blacklist them because of very lax parsing in browsers.
338 * - it starts with a whitelisted protocol, followed by a colon (absolute URL), or
339 * - it starts with two slashes `//` (protocol-relative URL), or
340 * - it starts with a single slash `/`, or dot and slash `./` (relative URL), or
341 * - it starts with a question mark `?` (replace query part in current URL), or
342 * - it starts with a pound sign `#` (replace fragment part in current URL)
344 * Plain relative URLs (like `index.php`) are not allowed, since it's pretty much impossible to
345 * distinguish them from malformed absolute ones (again, very lax rules for parsing protocols).
347 * @param string $url URL
348 * @return bool [description]
350 public static function isSafeUrl( $url ) {
351 // Keep this function in sync with OO.ui.isSafeUrl
352 $protocolWhitelist = [
353 // Sourced from MediaWiki's $wgUrlProtocols
354 'bitcoin', 'ftp', 'ftps', 'geo', 'git', 'gopher', 'http', 'https', 'irc', 'ircs',
355 'magnet', 'mailto', 'mms', 'news', 'nntp', 'redis', 'sftp', 'sip', 'sips', 'sms', 'ssh',
356 'svn', 'tel', 'telnet', 'urn', 'worldwind', 'xmpp',
363 foreach ( $protocolWhitelist as $protocol ) {
364 if ( self::stringStartsWith( $url, $protocol . ':' ) ) {
369 // This matches '//' too
370 if ( self::stringStartsWith( $url, '/' ) || self::stringStartsWith( $url, './' ) ) {
373 if ( self::stringStartsWith( $url, '?' ) || self::stringStartsWith( $url, '#' ) ) {
381 * Render element into HTML.
382 * @return string HTML serialization
385 public function toString() {
386 // List of void elements from HTML5, section 8.1.2 as of 2016-09-19
387 static $voidElements = [
406 foreach ( $this->getGeneratedAttributes() as $key => $value ) {
407 if ( !preg_match( '/^[0-9a-zA-Z-]+$/', $key ) ) {
408 throw new Exception( 'Attribute name must consist of only ASCII letters, numbers and dash' );
411 // Note that this is not a complete list of HTML attributes that need this validation.
412 // We only check for the ones that are generated by built-in OOjs UI PHP elements.
413 if ( $key === 'href' || $key === 'action' ) {
414 if ( !self::isSafeUrl( $value ) ) {
415 // We can't tell for sure whether this URL is safe (although it might be). The only safe
416 // URLs that we can't check for is relative ones, so just prefix with './'. This should
417 // change nothing for relative URLs, and it will neutralize sneaky 'javascript:' URLs.
418 $value = './' . $value;
422 // Use single-quotes around the attribute value in HTML, because
423 // some of the values might be JSON strings
424 // 1. Encode both single and double quotes (and other special chars)
425 $value = htmlspecialchars( $value, ENT_QUOTES );
426 // 2. Decode double quotes, for readability.
427 $value = str_replace( '"', '"', $value );
428 // 3. Wrap attribute value in single quotes in the HTML.
429 $attributes .= ' ' . $key . "='" . $value . "'";
434 foreach ( $this->content as $part ) {
435 if ( is_string( $part ) ) {
436 $content .= htmlspecialchars( $part );
437 } elseif ( $part instanceof Tag || $part instanceof HtmlSnippet ) {
438 $content .= (string)$part;
442 if ( !preg_match( '/^[0-9a-zA-Z]+$/', $this->tag ) ) {
443 throw new Exception( 'Tag name must consist of only ASCII letters and numbers' );
447 if ( !$content && in_array( $this->tag, $voidElements ) ) {
448 return '<' . $this->tag . $attributes . ' />';
450 return '<' . $this->tag . $attributes . '>' . $content . '</' . $this->tag . '>';
455 * Magic method implementation.
457 * PHP doesn't allow __toString to throw exceptions and will trigger a fatal error if it does.
458 * This is a wrapper around the real toString() to convert them to errors instead.
462 public function __toString() {
464 return $this->toString();
465 } catch ( Exception $ex ) {
466 trigger_error( (string)$ex, E_USER_ERROR );