1<?php 2 3/** 4 * This file is part of the Nette Framework (https://nette.org) 5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com) 6 */ 7 8declare(strict_types=1); 9 10namespace Nette\Utils; 11 12use Nette; 13use Nette\HtmlStringable; 14use function is_array, is_float, is_object, is_string; 15 16 17/** 18 * HTML helper. 19 * 20 * @property string|null $accept 21 * @property string|null $accesskey 22 * @property string|null $action 23 * @property string|null $align 24 * @property string|null $allow 25 * @property string|null $alt 26 * @property bool|null $async 27 * @property string|null $autocapitalize 28 * @property string|null $autocomplete 29 * @property bool|null $autofocus 30 * @property bool|null $autoplay 31 * @property string|null $charset 32 * @property bool|null $checked 33 * @property string|null $cite 34 * @property string|null $class 35 * @property int|null $cols 36 * @property int|null $colspan 37 * @property string|null $content 38 * @property bool|null $contenteditable 39 * @property bool|null $controls 40 * @property string|null $coords 41 * @property string|null $crossorigin 42 * @property string|null $data 43 * @property string|null $datetime 44 * @property string|null $decoding 45 * @property bool|null $default 46 * @property bool|null $defer 47 * @property string|null $dir 48 * @property string|null $dirname 49 * @property bool|null $disabled 50 * @property bool|null $download 51 * @property string|null $draggable 52 * @property string|null $dropzone 53 * @property string|null $enctype 54 * @property string|null $for 55 * @property string|null $form 56 * @property string|null $formaction 57 * @property string|null $formenctype 58 * @property string|null $formmethod 59 * @property bool|null $formnovalidate 60 * @property string|null $formtarget 61 * @property string|null $headers 62 * @property int|null $height 63 * @property bool|null $hidden 64 * @property float|null $high 65 * @property string|null $href 66 * @property string|null $hreflang 67 * @property string|null $id 68 * @property string|null $integrity 69 * @property string|null $inputmode 70 * @property bool|null $ismap 71 * @property string|null $itemprop 72 * @property string|null $kind 73 * @property string|null $label 74 * @property string|null $lang 75 * @property string|null $list 76 * @property bool|null $loop 77 * @property float|null $low 78 * @property float|null $max 79 * @property int|null $maxlength 80 * @property int|null $minlength 81 * @property string|null $media 82 * @property string|null $method 83 * @property float|null $min 84 * @property bool|null $multiple 85 * @property bool|null $muted 86 * @property string|null $name 87 * @property bool|null $novalidate 88 * @property bool|null $open 89 * @property float|null $optimum 90 * @property string|null $pattern 91 * @property string|null $ping 92 * @property string|null $placeholder 93 * @property string|null $poster 94 * @property string|null $preload 95 * @property string|null $radiogroup 96 * @property bool|null $readonly 97 * @property string|null $rel 98 * @property bool|null $required 99 * @property bool|null $reversed 100 * @property int|null $rows 101 * @property int|null $rowspan 102 * @property string|null $sandbox 103 * @property string|null $scope 104 * @property bool|null $selected 105 * @property string|null $shape 106 * @property int|null $size 107 * @property string|null $sizes 108 * @property string|null $slot 109 * @property int|null $span 110 * @property string|null $spellcheck 111 * @property string|null $src 112 * @property string|null $srcdoc 113 * @property string|null $srclang 114 * @property string|null $srcset 115 * @property int|null $start 116 * @property float|null $step 117 * @property string|null $style 118 * @property int|null $tabindex 119 * @property string|null $target 120 * @property string|null $title 121 * @property string|null $translate 122 * @property string|null $type 123 * @property string|null $usemap 124 * @property string|null $value 125 * @property int|null $width 126 * @property string|null $wrap 127 * 128 * @method self accept(?string $val) 129 * @method self accesskey(?string $val, bool $state = null) 130 * @method self action(?string $val) 131 * @method self align(?string $val) 132 * @method self allow(?string $val, bool $state = null) 133 * @method self alt(?string $val) 134 * @method self async(?bool $val) 135 * @method self autocapitalize(?string $val) 136 * @method self autocomplete(?string $val) 137 * @method self autofocus(?bool $val) 138 * @method self autoplay(?bool $val) 139 * @method self charset(?string $val) 140 * @method self checked(?bool $val) 141 * @method self cite(?string $val) 142 * @method self class(?string $val, bool $state = null) 143 * @method self cols(?int $val) 144 * @method self colspan(?int $val) 145 * @method self content(?string $val) 146 * @method self contenteditable(?bool $val) 147 * @method self controls(?bool $val) 148 * @method self coords(?string $val) 149 * @method self crossorigin(?string $val) 150 * @method self datetime(?string $val) 151 * @method self decoding(?string $val) 152 * @method self default(?bool $val) 153 * @method self defer(?bool $val) 154 * @method self dir(?string $val) 155 * @method self dirname(?string $val) 156 * @method self disabled(?bool $val) 157 * @method self download(?bool $val) 158 * @method self draggable(?string $val) 159 * @method self dropzone(?string $val) 160 * @method self enctype(?string $val) 161 * @method self for(?string $val) 162 * @method self form(?string $val) 163 * @method self formaction(?string $val) 164 * @method self formenctype(?string $val) 165 * @method self formmethod(?string $val) 166 * @method self formnovalidate(?bool $val) 167 * @method self formtarget(?string $val) 168 * @method self headers(?string $val, bool $state = null) 169 * @method self height(?int $val) 170 * @method self hidden(?bool $val) 171 * @method self high(?float $val) 172 * @method self hreflang(?string $val) 173 * @method self id(?string $val) 174 * @method self integrity(?string $val) 175 * @method self inputmode(?string $val) 176 * @method self ismap(?bool $val) 177 * @method self itemprop(?string $val) 178 * @method self kind(?string $val) 179 * @method self label(?string $val) 180 * @method self lang(?string $val) 181 * @method self list(?string $val) 182 * @method self loop(?bool $val) 183 * @method self low(?float $val) 184 * @method self max(?float $val) 185 * @method self maxlength(?int $val) 186 * @method self minlength(?int $val) 187 * @method self media(?string $val) 188 * @method self method(?string $val) 189 * @method self min(?float $val) 190 * @method self multiple(?bool $val) 191 * @method self muted(?bool $val) 192 * @method self name(?string $val) 193 * @method self novalidate(?bool $val) 194 * @method self open(?bool $val) 195 * @method self optimum(?float $val) 196 * @method self pattern(?string $val) 197 * @method self ping(?string $val, bool $state = null) 198 * @method self placeholder(?string $val) 199 * @method self poster(?string $val) 200 * @method self preload(?string $val) 201 * @method self radiogroup(?string $val) 202 * @method self readonly(?bool $val) 203 * @method self rel(?string $val) 204 * @method self required(?bool $val) 205 * @method self reversed(?bool $val) 206 * @method self rows(?int $val) 207 * @method self rowspan(?int $val) 208 * @method self sandbox(?string $val, bool $state = null) 209 * @method self scope(?string $val) 210 * @method self selected(?bool $val) 211 * @method self shape(?string $val) 212 * @method self size(?int $val) 213 * @method self sizes(?string $val) 214 * @method self slot(?string $val) 215 * @method self span(?int $val) 216 * @method self spellcheck(?string $val) 217 * @method self src(?string $val) 218 * @method self srcdoc(?string $val) 219 * @method self srclang(?string $val) 220 * @method self srcset(?string $val) 221 * @method self start(?int $val) 222 * @method self step(?float $val) 223 * @method self style(?string $property, string $val = null) 224 * @method self tabindex(?int $val) 225 * @method self target(?string $val) 226 * @method self title(?string $val) 227 * @method self translate(?string $val) 228 * @method self type(?string $val) 229 * @method self usemap(?string $val) 230 * @method self value(?string $val) 231 * @method self width(?int $val) 232 * @method self wrap(?string $val) 233 */ 234class Html implements \ArrayAccess, \Countable, \IteratorAggregate, HtmlStringable 235{ 236 use Nette\SmartObject; 237 238 /** @var array<string, mixed> element's attributes */ 239 public $attrs = []; 240 241 /** void elements */ 242 public static $emptyElements = [ 243 'img' => 1, 'hr' => 1, 'br' => 1, 'input' => 1, 'meta' => 1, 'area' => 1, 'embed' => 1, 'keygen' => 1, 244 'source' => 1, 'base' => 1, 'col' => 1, 'link' => 1, 'param' => 1, 'basefont' => 1, 'frame' => 1, 245 'isindex' => 1, 'wbr' => 1, 'command' => 1, 'track' => 1, 246 ]; 247 248 /** @var array<int, HtmlStringable|string> nodes */ 249 protected $children = []; 250 251 /** element's name */ 252 private string $name = ''; 253 254 private bool $isEmpty = false; 255 256 257 /** 258 * Constructs new HTML element. 259 * @param array|string $attrs element's attributes or plain text content 260 */ 261 public static function el(?string $name = null, array|string|null $attrs = null): static 262 { 263 $el = new static; 264 $parts = explode(' ', (string) $name, 2); 265 $el->setName($parts[0]); 266 267 if (is_array($attrs)) { 268 $el->attrs = $attrs; 269 270 } elseif ($attrs !== null) { 271 $el->setText($attrs); 272 } 273 274 if (isset($parts[1])) { 275 foreach (Strings::matchAll($parts[1] . ' ', '#([a-z0-9:-]+)(?:=(["\'])?(.*?)(?(2)\2|\s))?#i') as $m) { 276 $el->attrs[$m[1]] = $m[3] ?? true; 277 } 278 } 279 280 return $el; 281 } 282 283 284 /** 285 * Returns an object representing HTML text. 286 */ 287 public static function fromHtml(string $html): static 288 { 289 return (new static)->setHtml($html); 290 } 291 292 293 /** 294 * Returns an object representing plain text. 295 */ 296 public static function fromText(string $text): static 297 { 298 return (new static)->setText($text); 299 } 300 301 302 /** 303 * Converts to HTML. 304 */ 305 final public function toHtml(): string 306 { 307 return $this->render(); 308 } 309 310 311 /** 312 * Converts to plain text. 313 */ 314 final public function toText(): string 315 { 316 return $this->getText(); 317 } 318 319 320 /** 321 * Converts given HTML code to plain text. 322 */ 323 public static function htmlToText(string $html): string 324 { 325 return html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); 326 } 327 328 329 /** 330 * Changes element's name. 331 */ 332 final public function setName(string $name, ?bool $isEmpty = null): static 333 { 334 $this->name = $name; 335 $this->isEmpty = $isEmpty ?? isset(static::$emptyElements[$name]); 336 return $this; 337 } 338 339 340 /** 341 * Returns element's name. 342 */ 343 final public function getName(): string 344 { 345 return $this->name; 346 } 347 348 349 /** 350 * Is element empty? 351 */ 352 final public function isEmpty(): bool 353 { 354 return $this->isEmpty; 355 } 356 357 358 /** 359 * Sets multiple attributes. 360 */ 361 public function addAttributes(array $attrs): static 362 { 363 $this->attrs = array_merge($this->attrs, $attrs); 364 return $this; 365 } 366 367 368 /** 369 * Appends value to element's attribute. 370 */ 371 public function appendAttribute(string $name, mixed $value, mixed $option = true): static 372 { 373 if (is_array($value)) { 374 $prev = isset($this->attrs[$name]) ? (array) $this->attrs[$name] : []; 375 $this->attrs[$name] = $value + $prev; 376 377 } elseif ((string) $value === '') { 378 $tmp = &$this->attrs[$name]; // appending empty value? -> ignore, but ensure it exists 379 380 } elseif (!isset($this->attrs[$name]) || is_array($this->attrs[$name])) { // needs array 381 $this->attrs[$name][$value] = $option; 382 383 } else { 384 $this->attrs[$name] = [$this->attrs[$name] => true, $value => $option]; 385 } 386 387 return $this; 388 } 389 390 391 /** 392 * Sets element's attribute. 393 */ 394 public function setAttribute(string $name, mixed $value): static 395 { 396 $this->attrs[$name] = $value; 397 return $this; 398 } 399 400 401 /** 402 * Returns element's attribute. 403 */ 404 public function getAttribute(string $name): mixed 405 { 406 return $this->attrs[$name] ?? null; 407 } 408 409 410 /** 411 * Unsets element's attribute. 412 */ 413 public function removeAttribute(string $name): static 414 { 415 unset($this->attrs[$name]); 416 return $this; 417 } 418 419 420 /** 421 * Unsets element's attributes. 422 */ 423 public function removeAttributes(array $attributes): static 424 { 425 foreach ($attributes as $name) { 426 unset($this->attrs[$name]); 427 } 428 429 return $this; 430 } 431 432 433 /** 434 * Overloaded setter for element's attribute. 435 */ 436 final public function __set(string $name, mixed $value): void 437 { 438 $this->attrs[$name] = $value; 439 } 440 441 442 /** 443 * Overloaded getter for element's attribute. 444 */ 445 final public function &__get(string $name): mixed 446 { 447 return $this->attrs[$name]; 448 } 449 450 451 /** 452 * Overloaded tester for element's attribute. 453 */ 454 final public function __isset(string $name): bool 455 { 456 return isset($this->attrs[$name]); 457 } 458 459 460 /** 461 * Overloaded unsetter for element's attribute. 462 */ 463 final public function __unset(string $name): void 464 { 465 unset($this->attrs[$name]); 466 } 467 468 469 /** 470 * Overloaded setter for element's attribute. 471 */ 472 final public function __call(string $m, array $args): mixed 473 { 474 $p = substr($m, 0, 3); 475 if ($p === 'get' || $p === 'set' || $p === 'add') { 476 $m = substr($m, 3); 477 $m[0] = $m[0] | "\x20"; 478 if ($p === 'get') { 479 return $this->attrs[$m] ?? null; 480 481 } elseif ($p === 'add') { 482 $args[] = true; 483 } 484 } 485 486 if (count($args) === 0) { // invalid 487 488 } elseif (count($args) === 1) { // set 489 $this->attrs[$m] = $args[0]; 490 491 } else { // add 492 $this->appendAttribute($m, $args[0], $args[1]); 493 } 494 495 return $this; 496 } 497 498 499 /** 500 * Special setter for element's attribute. 501 */ 502 final public function href(string $path, array $query = []): static 503 { 504 if ($query) { 505 $query = http_build_query($query, '', '&'); 506 if ($query !== '') { 507 $path .= '?' . $query; 508 } 509 } 510 511 $this->attrs['href'] = $path; 512 return $this; 513 } 514 515 516 /** 517 * Setter for data-* attributes. Booleans are converted to 'true' resp. 'false'. 518 */ 519 public function data(string $name, mixed $value = null): static 520 { 521 if (func_num_args() === 1) { 522 $this->attrs['data'] = $name; 523 } else { 524 $this->attrs["data-$name"] = is_bool($value) 525 ? json_encode($value) 526 : $value; 527 } 528 529 return $this; 530 } 531 532 533 /** 534 * Sets element's HTML content. 535 */ 536 final public function setHtml(mixed $html): static 537 { 538 $this->children = [(string) $html]; 539 return $this; 540 } 541 542 543 /** 544 * Returns element's HTML content. 545 */ 546 final public function getHtml(): string 547 { 548 return implode('', $this->children); 549 } 550 551 552 /** 553 * Sets element's textual content. 554 */ 555 final public function setText(mixed $text): static 556 { 557 if (!$text instanceof HtmlStringable) { 558 $text = htmlspecialchars((string) $text, ENT_NOQUOTES, 'UTF-8'); 559 } 560 561 $this->children = [(string) $text]; 562 return $this; 563 } 564 565 566 /** 567 * Returns element's textual content. 568 */ 569 final public function getText(): string 570 { 571 return self::htmlToText($this->getHtml()); 572 } 573 574 575 /** 576 * Adds new element's child. 577 */ 578 final public function addHtml(mixed $child): static 579 { 580 return $this->insert(null, $child); 581 } 582 583 584 /** 585 * Appends plain-text string to element content. 586 */ 587 public function addText(mixed $text): static 588 { 589 if (!$text instanceof HtmlStringable) { 590 $text = htmlspecialchars((string) $text, ENT_NOQUOTES, 'UTF-8'); 591 } 592 593 return $this->insert(null, $text); 594 } 595 596 597 /** 598 * Creates and adds a new Html child. 599 */ 600 final public function create(string $name, array|string|null $attrs = null): static 601 { 602 $this->insert(null, $child = static::el($name, $attrs)); 603 return $child; 604 } 605 606 607 /** 608 * Inserts child node. 609 */ 610 public function insert(?int $index, HtmlStringable|string $child, bool $replace = false): static 611 { 612 $child = $child instanceof self ? $child : (string) $child; 613 if ($index === null) { // append 614 $this->children[] = $child; 615 616 } else { // insert or replace 617 array_splice($this->children, $index, $replace ? 1 : 0, [$child]); 618 } 619 620 return $this; 621 } 622 623 624 /** 625 * Inserts (replaces) child node (\ArrayAccess implementation). 626 * @param int|null $index position or null for appending 627 * @param Html|string $child Html node or raw HTML string 628 */ 629 final public function offsetSet($index, $child): void 630 { 631 $this->insert($index, $child, true); 632 } 633 634 635 /** 636 * Returns child node (\ArrayAccess implementation). 637 * @param int $index 638 */ 639 final public function offsetGet($index): HtmlStringable|string 640 { 641 return $this->children[$index]; 642 } 643 644 645 /** 646 * Exists child node? (\ArrayAccess implementation). 647 * @param int $index 648 */ 649 final public function offsetExists($index): bool 650 { 651 return isset($this->children[$index]); 652 } 653 654 655 /** 656 * Removes child node (\ArrayAccess implementation). 657 * @param int $index 658 */ 659 public function offsetUnset($index): void 660 { 661 if (isset($this->children[$index])) { 662 array_splice($this->children, $index, 1); 663 } 664 } 665 666 667 /** 668 * Returns children count. 669 */ 670 final public function count(): int 671 { 672 return count($this->children); 673 } 674 675 676 /** 677 * Removes all children. 678 */ 679 public function removeChildren(): void 680 { 681 $this->children = []; 682 } 683 684 685 /** 686 * Iterates over elements. 687 * @return \ArrayIterator<int, HtmlStringable|string> 688 */ 689 final public function getIterator(): \ArrayIterator 690 { 691 return new \ArrayIterator($this->children); 692 } 693 694 695 /** 696 * Returns all children. 697 */ 698 final public function getChildren(): array 699 { 700 return $this->children; 701 } 702 703 704 /** 705 * Renders element's start tag, content and end tag. 706 */ 707 final public function render(?int $indent = null): string 708 { 709 $s = $this->startTag(); 710 711 if (!$this->isEmpty) { 712 // add content 713 if ($indent !== null) { 714 $indent++; 715 } 716 717 foreach ($this->children as $child) { 718 if ($child instanceof self) { 719 $s .= $child->render($indent); 720 } else { 721 $s .= $child; 722 } 723 } 724 725 // add end tag 726 $s .= $this->endTag(); 727 } 728 729 if ($indent !== null) { 730 return "\n" . str_repeat("\t", $indent - 1) . $s . "\n" . str_repeat("\t", max(0, $indent - 2)); 731 } 732 733 return $s; 734 } 735 736 737 final public function __toString(): string 738 { 739 return $this->render(); 740 } 741 742 743 /** 744 * Returns element's start tag. 745 */ 746 final public function startTag(): string 747 { 748 return $this->name 749 ? '<' . $this->name . $this->attributes() . '>' 750 : ''; 751 } 752 753 754 /** 755 * Returns element's end tag. 756 */ 757 final public function endTag(): string 758 { 759 return $this->name && !$this->isEmpty ? '</' . $this->name . '>' : ''; 760 } 761 762 763 /** 764 * Returns element's attributes. 765 * @internal 766 */ 767 final public function attributes(): string 768 { 769 if (!is_array($this->attrs)) { 770 return ''; 771 } 772 773 $s = ''; 774 $attrs = $this->attrs; 775 foreach ($attrs as $key => $value) { 776 if ($value === null || $value === false) { 777 continue; 778 779 } elseif ($value === true) { 780 $s .= ' ' . $key; 781 782 continue; 783 784 } elseif (is_array($value)) { 785 if (strncmp($key, 'data-', 5) === 0) { 786 $value = Json::encode($value); 787 788 } else { 789 $tmp = null; 790 foreach ($value as $k => $v) { 791 if ($v != null) { // intentionally ==, skip nulls & empty string 792 // composite 'style' vs. 'others' 793 $tmp[] = $v === true 794 ? $k 795 : (is_string($k) ? $k . ':' . $v : $v); 796 } 797 } 798 799 if ($tmp === null) { 800 continue; 801 } 802 803 $value = implode($key === 'style' || !strncmp($key, 'on', 2) ? ';' : ' ', $tmp); 804 } 805 } elseif (is_float($value)) { 806 $value = rtrim(rtrim(number_format($value, 10, '.', ''), '0'), '.'); 807 808 } else { 809 $value = (string) $value; 810 } 811 812 $q = str_contains($value, '"') ? "'" : '"'; 813 $s .= ' ' . $key . '=' . $q 814 . str_replace( 815 ['&', $q, '<'], 816 ['&', $q === '"' ? '"' : ''', '<'], 817 $value, 818 ) 819 . (str_contains($value, '`') && strpbrk($value, ' <>"\'') === false ? ' ' : '') 820 . $q; 821 } 822 823 $s = str_replace('@', '@', $s); 824 return $s; 825 } 826 827 828 /** 829 * Clones all children too. 830 */ 831 public function __clone() 832 { 833 foreach ($this->children as $key => $value) { 834 if (is_object($value)) { 835 $this->children[$key] = clone $value; 836 } 837 } 838 } 839} 840