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