1<?php 2/** 3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved. 4 * 5 * This source code is licensed under the GPL license found in the 6 * COPYING file in the root directory of this source tree. 7 * 8 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 9 * @author ComboStrap <support@combostrap.com> 10 * 11 */ 12 13namespace ComboStrap; 14 15use ComboStrap\Tag\BoxTag; 16use ComboStrap\TagAttribute\Align; 17use ComboStrap\TagAttribute\Animation; 18use ComboStrap\TagAttribute\BackgroundAttribute; 19use ComboStrap\TagAttribute\Boldness; 20use ComboStrap\TagAttribute\Hero; 21use ComboStrap\TagAttribute\Shadow; 22use ComboStrap\TagAttribute\StyleAttribute; 23use ComboStrap\TagAttribute\TextAlign; 24use ComboStrap\TagAttribute\Toggle; 25use ComboStrap\TagAttribute\Underline; 26use ComboStrap\TagAttribute\Vertical; 27use ComboStrap\Web\Url; 28use ComboStrap\Xml\XmlDocument; 29use ComboStrap\Xml\XmlElement; 30use dokuwiki\Extension\SyntaxPlugin; 31 32/** 33 * An utility: 34 * * to parse the jsx/component markup match 35 * * to enforce user security (ie `style` is not allowed) 36 * * to get from component attributes to html attributes 37 * 38 * 39 * This is the equivalent of an {@link XmlElement} 40 * but does not need any {@link XmlDocument} to be created 41 * 42 * You can: 43 * * declare component attribute after parsing 44 * * declare Html attribute during parsing 45 * * output the final HTML attributes at the end of the process with the function {@link TagAttributes::toHTMLAttributeString()} 46 * 47 * Component attributes have precedence on HTML attributes. 48 * 49 * @package ComboStrap 50 */ 51class TagAttributes 52{ 53 /** 54 * @var string the alt attribute value (known as the title for dokuwiki) 55 */ 56 const TITLE_KEY = 'title'; 57 58 59 const TYPE_KEY = "type"; 60 const ID_KEY = "id"; 61 62 /** 63 * If not strict, no error is reported 64 */ 65 const STRICT = "strict"; 66 67 /** 68 * The logical attributes that are not becoming HTML attributes 69 * (ie internal reserved words) 70 * 71 * TODO: They should be advertised by the syntax component 72 */ 73 const RESERVED_ATTRIBUTES = [ 74 self::SCRIPT_KEY, // no script attribute for security reason 75 TagAttributes::TYPE_KEY, // type is the component class 76 MediaMarkup::LINKING_KEY, // internal to image 77 IFetcherAbs::CACHE_KEY, // internal also 78 Tag\WebCodeTag::RENDERING_MODE_ATTRIBUTE, 79 Vertical::VERTICAL_ATTRIBUTE, 80 self::OPEN_TAG, 81 self::HTML_BEFORE, 82 self::HTML_AFTER, 83 Dimension::RATIO_ATTRIBUTE, 84 self::STRICT, 85 FetcherSvg::REQUESTED_PRESERVE_ATTRIBUTE, 86 \syntax_plugin_combo_link::CLICKABLE_ATTRIBUTE, 87 LinkMarkup::PREVIEW_ATTRIBUTE, 88 Skin::SKIN_ATTRIBUTE, 89 ColorRgb::PRIMARY_VALUE, 90 ColorRgb::SECONDARY_VALUE, 91 Dimension::ZOOM_ATTRIBUTE, 92 Tag\FollowTag::HANDLE_ATTRIBUTE, 93 \syntax_plugin_combo_menubar::BREAKPOINT_ATTRIBUTE, 94 ContainerTag::CONTAINER_ATTRIBUTE, 95 HeadingTag::HEADING_TEXT_ATTRIBUTE, 96 self::GENERATED_ID_KEY 97 ]; 98 99 /** 100 * The inline element 101 * We could pass the plugin object into tag attribute in place of the logical tag 102 * and check if the {@link SyntaxPlugin::getPType()} is normal 103 */ 104 const INLINE_LOGICAL_ELEMENTS = [ 105 FetcherSvg::CANONICAL, 106 FetcherRaster::CANONICAL, 107 \syntax_plugin_combo_media::TAG, 108 \syntax_plugin_combo_link::TAG, // link button for instance 109 ButtonTag::MARKUP_LONG 110 ]; 111 112 /** 113 * Container 114 * Heading is a block but not a container 115 */ 116 const CONTAINER_LOGICAL_ELEMENTS = [ 117 BoxTag::TAG, 118 CardTag::CARD_TAG, 119 BlockquoteTag::TAG, 120 ]; 121 122 const SCRIPT_KEY = "script"; 123 const TRANSFORM = "transform"; 124 125 const CANONICAL = "tag"; 126 127 const CLASS_KEY = "class"; 128 const WIKI_ID = "wiki-id"; 129 130 /** 131 * The open tag attributes 132 * permit to not close the tag in {@link TagAttributes::toHtmlEnterTag()} 133 * 134 * It's used for instance by the {@link \syntax_plugin_combo_tooltip} 135 * to advertise that it will add attribute and close it 136 */ 137 const OPEN_TAG = "open-tag"; 138 139 /** 140 * If an attribute has this value, 141 * it will not be added to the output (ie {@link TagAttributes::toHtmlEnterTag()}) 142 * Child element can unset attribute this way 143 * in order to write their own 144 * 145 * This is used by the {@link \syntax_plugin_combo_tooltip} 146 * to advertise that the title attribute should not be set 147 */ 148 const UN_SET = "unset"; 149 150 /** 151 * When wrapping an element 152 * A tag may get HTML before and after 153 * Uses for instance to wrap a svg in span 154 * when adding a {@link \syntax_plugin_combo_tooltip} 155 */ 156 const HTML_BEFORE = "htmlBefore"; 157 const HTML_AFTER = "htmlAfter"; 158 159 /** 160 * Attribute with multiple values 161 */ 162 const MULTIPLE_VALUES_ATTRIBUTES = [self::CLASS_KEY, self::REL, Align::ALIGN_ATTRIBUTE]; 163 164 /** 165 * Link relation attributes 166 * https://html.spec.whatwg.org/multipage/links.html#linkTypes 167 */ 168 const REL = "rel"; 169 170 /** 171 * The default id if no one is specified 172 */ 173 const GENERATED_ID_KEY = "generated_id"; 174 175 /** 176 * The attributes that may flow into an HTML output 177 * TODO: href comes from {@link \syntax_plugin_combo_brand}, it should be corrected to use {@link LinkMarkup} 178 */ 179 const HTML_ATTRIBUTES = [ 180 TagAttributes::CLASS_KEY, 181 StyleAttribute::STYLE_ATTRIBUTE, 182 TagAttributes::ID_KEY, 183 TagAttributes::TITLE_KEY, 184 "href", 185 "rel", // anchor 186 "name", // iframe 187 "frameborder", // iframe 188 "target" // a 189 ]; 190 191 /** 192 * Attribute that cannot be deleted 193 * TODO: This is because the request object and the response object are the same. We should add the request attribute in the {@link \TagAttributes} 194 */ 195 const PROTECTED_ATTRIBUTES = [ 196 TagAttributes::TYPE_KEY 197 ]; 198 const NAME_ATTRIBUTE = "name"; 199 200 /** 201 * The dokuwiki name attribute to store 202 * text node data 203 */ 204 public const DOKUWIKI_TEXT_NODE_ATTRIBUTE = "_data"; 205 206 /** 207 * A global static counter 208 * to {@link TagAttributes::generateAndSetId()} 209 */ 210 private static $counter = 0; 211 212 213 /** 214 * @var ArrayCaseInsensitive attribute that were set on a component 215 */ 216 private ArrayCaseInsensitive $componentAttributesCaseInsensitive; 217 218 /** 219 * @var array the style declaration array 220 */ 221 private array $styleDeclaration = array(); 222 223 /** 224 * @var bool - set when the transformation from component attribute to html attribute 225 * was done to avoid circular problem 226 */ 227 private $componentToHtmlAttributeProcessingWasDone = false; 228 229 /** 230 * @var array - output attribute are not the parsed attributes known as componentAttribute) 231 * They are created by the {@link TagAttributes::toHtmlArray()} processing mainly 232 */ 233 private $outputAttributes = array(); 234 235 /** 236 * @var array - the final html array 237 */ 238 private $finalHtmlArray = array(); 239 240 /** 241 * @var string the functional tag to which the attributes applies 242 * It's not an HTML tag (a div can have a flex display or a block and they don't carry this information) 243 * The tag gives also context for the attributes (ie an div has no natural width while an img has) 244 */ 245 private $logicalTag; 246 247 /** 248 * An html that should be added after the enter tag 249 * (used for instance to add metadata such as backgrounds, illustration image for cards ,... 250 * @var string 251 */ 252 private $htmlAfterEnterTag; 253 254 /** 255 * Use to make the difference between 256 * an HTTP call for a media (ie SVG) vs an HTTP call for a page (HTML) 257 */ 258 const TEXT_HTML_MIME = "text/html"; 259 private $mime = TagAttributes::TEXT_HTML_MIME; 260 261 /** 262 * @var bool - adding the default class for the logical tag 263 */ 264 private $defaultStyleClassShouldBeAdded = true; 265 private $knownTypes; 266 267 /** 268 * @var string - the inner Text (used for script or style tag mostly) 269 */ 270 private string $innerText; 271 272 273 /** 274 * ComponentAttributes constructor. 275 * Use the static create function to instantiate this object 276 * @param $tag - tag (the tag gives context for the attributes 277 * * an div has no natural width while an img has 278 * * this is not always the component name / syntax name (for instance the {@link \syntax_plugin_combo_codemarkdown} is another syntax 279 * for a {@link \syntax_plugin_combo_code} and have therefore the same logical name) 280 * @param array $componentAttributes 281 */ 282 private function __construct(array $componentAttributes = array(), $tag = null) 283 { 284 $this->logicalTag = $tag; 285 $this->componentAttributesCaseInsensitive = new ArrayCaseInsensitive($componentAttributes); 286 287 /** 288 * Delete null values 289 * Empty string, 0 may exist 290 */ 291 foreach ($componentAttributes as $key => $value) { 292 if (is_null($value)) { 293 unset($this->componentAttributesCaseInsensitive[$key]); 294 continue; 295 } 296 if ($key === StyleAttribute::STYLE_ATTRIBUTE) { 297 unset($this->componentAttributesCaseInsensitive[$key]); 298 LogUtility::warning("The style attribute cannot be set or used due to security. Uses the combostrap style attribute or set a class attibute instead."); 299 } 300 } 301 302 } 303 304 /** 305 * @param $match - the {@link SyntaxPlugin::handle()} match 306 * @param array $defaultAttributes - the default attributes values 307 * @param array $knownTypes - the known types 308 * @param bool $allowFirstBooleanAttributesAsType - if the first attribute is a boolean, make it a type 309 * @return TagAttributes 310 */ 311 public static function createFromTagMatch($match, array $defaultAttributes = [], array $knownTypes = [], bool $allowFirstBooleanAttributesAsType = false): TagAttributes 312 { 313 $inlineHtmlAttributes = PluginUtility::getTagAttributes($match, $knownTypes, $allowFirstBooleanAttributesAsType); 314 $tag = PluginUtility::getMarkupTag($match); 315 $mergedAttributes = PluginUtility::mergeAttributes($inlineHtmlAttributes, $defaultAttributes); 316 return (new TagAttributes($mergedAttributes, $tag)) 317 ->setKnownTypes($knownTypes); 318 } 319 320 321 public static function createEmpty($logicalTag = ""): TagAttributes 322 { 323 if ($logicalTag !== "") { 324 return new TagAttributes([], $logicalTag); 325 } else { 326 return new TagAttributes(); 327 } 328 } 329 330 /** 331 * @param array|null $callStackArray - an array of key value pair 332 * @param string|null $logicalTag - the logical tag for which this attribute will apply 333 * @return TagAttributes 334 */ 335 public static function createFromCallStackArray(?array $callStackArray, string $logicalTag = null): TagAttributes 336 { 337 if ($callStackArray === null) { 338 $callStackArray = []; 339 } 340 if (!is_array($callStackArray)) { 341 LogUtility::msg("The renderArray variable passed is not an array ($callStackArray)"); 342 $callStackArray = []; 343 } 344 /** 345 * Style is not allowed in a TagAttributes 346 * 347 * Because callstack is safe, 348 * style have been added by plugin 349 * For instance, the card had a `max-width style of 100%` to the image 350 * 351 * We capture it and add them afterwards 352 */ 353 if (isset($callStackArray[StyleAttribute::STYLE_ATTRIBUTE])) { 354 $style = $callStackArray[StyleAttribute::STYLE_ATTRIBUTE]; 355 unset($callStackArray[StyleAttribute::STYLE_ATTRIBUTE]); 356 } 357 358 $tagAttributes = new TagAttributes($callStackArray, $logicalTag); 359 360 /** 361 * Add the styles 362 */ 363 if (isset($style)) { 364 $stylingProperties = StyleAttribute::HtmlStyleValueToArray($style); 365 foreach ($stylingProperties as $styleKey => $styleValue) { 366 $tagAttributes->addStyleDeclarationIfNotSet($styleKey, $styleValue); 367 } 368 } 369 370 return $tagAttributes; 371 } 372 373 374 /** 375 * For CSS a unit is mandatory (not for HTML or SVG attributes) 376 * @param $value 377 * @return string return a CSS property with pixel as unit if the unit is not specified 378 * @throws ExceptionBadArgument 379 */ 380 public static function toQualifiedCssValue($value): string 381 { 382 return ConditionalLength::createFromString($value)->toCssLength(); 383 384 } 385 386 /** 387 * Function used to normalize the attribute name to the combostrap attribute name 388 * @param $name 389 * @return mixed|string 390 */ 391 public static function AttributeNameFromDokuwikiToCombo($name) 392 { 393 switch ($name) { 394 case "w": 395 return Dimension::WIDTH_KEY; 396 case "h": 397 return Dimension::HEIGHT_KEY; 398 default: 399 return $name; 400 } 401 } 402 403 /** 404 * Clone a tag attributes 405 * Tag Attributes are used for request and for response 406 * To avoid conflict, a function should clone it before 407 * calling the final method {@link TagAttributes::toHtmlArray()} 408 * or {@link TagAttributes::toHtmlEnterTag()} 409 * @param TagAttributes $tagAttributes 410 * @return TagAttributes 411 */ 412 public static function createFromTagAttributeString(TagAttributes $tagAttributes): TagAttributes 413 { 414 $newTagAttributes = new TagAttributes($tagAttributes->getComponentAttributes(), $tagAttributes->getLogicalTag()); 415 foreach ($tagAttributes->getStyleDeclarations() as $property => $value) { 416 $newTagAttributes->addStyleDeclarationIfNotSet($property, $value); 417 } 418 return $newTagAttributes; 419 } 420 421 public static function isEmptyValue($attributeValue): bool 422 { 423 return empty($attributeValue) && !is_bool($attributeValue); 424 } 425 426 public function addClassName($className): TagAttributes 427 { 428 429 $this->addComponentAttributeValue(self::CLASS_KEY, $className); 430 return $this; 431 432 } 433 434 /** 435 * @throws ExceptionNull 436 */ 437 public function getClass($default = null) 438 { 439 $value = $this->getValue(self::CLASS_KEY, $default); 440 if ($value !== null) { 441 return $value; 442 } 443 throw new ExceptionNull("No class was found"); 444 } 445 446 /** 447 * @return string 448 * @throws ExceptionNotFound 449 */ 450 public function getStyle(): string 451 { 452 453 if (sizeof($this->styleDeclaration) === 0) { 454 throw new ExceptionNotFound("No style"); 455 } 456 return Html::array2InlineStyle($this->styleDeclaration); 457 458 } 459 460 public function getStyleDeclarations(): array 461 { 462 return $this->styleDeclaration; 463 464 } 465 466 /** 467 * Add an attribute with its value if the value is not empty 468 * @param $attributeName 469 * @param $attributeValue 470 * @return TagAttributes 471 */ 472 public function addComponentAttributeValue($attributeName, $attributeValue): TagAttributes 473 { 474 475 if (TagAttributes::isEmptyValue($attributeValue)) { 476 LogUtility::msg("The value of the attribute ($attributeName) is empty. Use the nonEmpty function instead if it's the wanted behavior", LogUtility::LVL_MSG_WARNING, "support"); 477 } 478 479 $attLower = strtolower($attributeName); 480 $actual = null; 481 if ($this->hasComponentAttribute($attLower)) { 482 $actual = $this->componentAttributesCaseInsensitive[$attLower]; 483 } 484 485 /** 486 * Type of data: list (class) or atomic (id) 487 */ 488 if (in_array($attributeName, self::MULTIPLE_VALUES_ATTRIBUTES)) { 489 $this->componentAttributesCaseInsensitive[$attLower] = Html::mergeClassNames($attributeValue, $actual); 490 } else { 491 if (!empty($actual)) { 492 LogUtility::msg("The attribute ($attLower) stores an unique value and has already a value ($actual). to set another value ($attributeValue), use the `set` operation instead", LogUtility::LVL_MSG_ERROR, self::CANONICAL); 493 } 494 $this->componentAttributesCaseInsensitive[$attLower] = $attributeValue; 495 } 496 497 return $this; 498 499 } 500 501 public function setComponentAttributeValue($attributeName, $attributeValue): TagAttributes 502 { 503 $attLower = strtolower($attributeName); 504 $actualValue = $this->getValue($attributeName); 505 if ($actualValue === null || $actualValue !== TagAttributes::UN_SET) { 506 $this->componentAttributesCaseInsensitive[$attLower] = $attributeValue; 507 } 508 return $this; 509 } 510 511 public function addComponentAttributeValueIfNotEmpty($attributeName, $attributeValue) 512 { 513 if (!empty($attributeValue)) { 514 $this->addComponentAttributeValue($attributeName, $attributeValue); 515 } 516 } 517 518 public function hasComponentAttribute($attributeName): bool 519 { 520 $isset = isset($this->componentAttributesCaseInsensitive[$attributeName]); 521 if ($isset === false && $this->knownTypes === null) { 522 /** 523 * Edge effect 524 * if this is a boolean value and the first value, it may be stored in the type 525 */ 526 if (isset($this->componentAttributesCaseInsensitive[TagAttributes::TYPE_KEY])) { 527 if ($attributeName == $this->componentAttributesCaseInsensitive[TagAttributes::TYPE_KEY]) { 528 LogUtility::warning("Internal Error: The tag ({$this->getLogicalTag()}) has a boolean attribute ($attributeName) defined as a type. The possible types should be defined for this tag as it's deprecated."); 529 return true; 530 } 531 } 532 } 533 return $isset; 534 } 535 536 /** 537 * To an HTML array in the form 538 * class => 'value1 value2', 539 * att => 'value1 value 2' 540 * For historic reason, data passed between the handle and the render 541 * can still be in this format 542 */ 543 public function toHtmlArray(): array 544 { 545 if ($this->componentToHtmlAttributeProcessingWasDone) { 546 LogUtility::msg("This tag attribute ($this) was already finalized. You cannot finalized it twice", LogUtility::LVL_MSG_ERROR); 547 return $this->finalHtmlArray; 548 } 549 550 $this->componentToHtmlAttributeProcessingWasDone = true; 551 552 /** 553 * Width and height 554 */ 555 Dimension::processWidthAndHeight($this); 556 557 /** 558 * Process animation (onHover, onView) 559 */ 560 Hover::processOnHover($this); 561 Animation::processOnView($this); 562 563 564 /** 565 * Position and Stickiness 566 */ 567 Position::processStickiness($this); 568 Position::processPosition($this); 569 Display::processDisplay($this); 570 Vertical::processVertical($this); 571 Horizontal::processHorizontal($this); 572 573 /** 574 * Block processing 575 * 576 * Float, align, spacing 577 */ 578 FloatAttribute::processFloat($this); 579 Align::processAlignAttributes($this); 580 Spacing::processSpacingAttributes($this); 581 Hero::processHero($this); 582 Opacity::processOpacityAttribute($this); 583 BackgroundAttribute::processBackgroundAttributes($this); 584 Shadow::process($this); 585 586 /** 587 * Process text attributes 588 */ 589 LineSpacing::processLineSpacingAttributes($this); 590 TextAlign::processTextAlign($this); 591 Boldness::processBoldnessAttribute($this); 592 FontSize::processFontSizeAttribute($this); 593 TextColor::processTextColorAttribute($this); 594 Underline::processUnderlineAttribute($this); 595 596 /** 597 * Process the style attributes if any 598 */ 599 PluginUtility::processStyle($this); 600 Toggle::processToggle($this); 601 602 603 /** 604 * Skin Attribute 605 */ 606 Skin::processSkinAttribute($this); 607 608 /** 609 * Lang 610 */ 611 Lang::processLangAttribute($this); 612 613 /** 614 * Transform 615 */ 616 if ($this->hasComponentAttribute(self::TRANSFORM)) { 617 $transformValue = $this->getValueAndRemove(self::TRANSFORM); 618 $this->addStyleDeclarationIfNotSet("transform", $transformValue); 619 } 620 621 622 /** 623 * Tooltip 624 */ 625 Tooltip::processTooltip($this); 626 627 /** 628 * Add the type class used for CSS styling 629 */ 630 StyleAttribute::addStylingClass($this); 631 632 /** 633 * Add the style has html attribute 634 * before processing 635 */ 636 try { 637 $this->addOutputAttributeValueIfNotEmpty("style", $this->getStyle()); 638 } catch (ExceptionNotFound $e) { 639 // no style 640 } 641 642 /** 643 * Create a non-sorted temporary html attributes array 644 */ 645 $tempHtmlArray = $this->outputAttributes; 646 647 /** 648 * copy the unknown component attributes 649 */ 650 $originalArray = $this->componentAttributesCaseInsensitive->getOriginalArray(); 651 foreach ($originalArray as $key => $value) { 652 653 // Null Value, not needed 654 if (is_null($value)) { 655 continue; 656 } 657 658 // No overwrite 659 if (isset($tempHtmlArray[$key])) { 660 continue; 661 } 662 663 // We only add the common HTML attribute 664 if (in_array($key, self::HTML_ATTRIBUTES) || strpos($key, 'data-') === 0) { 665 $tempHtmlArray[$key] = $value; 666 } else { 667 668 if (!in_array($key, [ 669 TagAttributes::TYPE_KEY, 670 TagAttributes::GENERATED_ID_KEY, 671 TagAttributes::OPEN_TAG 672 ])) { 673 674 /** 675 * Note for developers: 676 * * If it must be in the HTML output, you should add it via the output attribute methods during processing. 677 * * Otherwise you need for now to get and delete it 678 */ 679 $message = "The component attribute ($key) is unknown or does not apply "; 680 if (isset($this->logicalTag)) { 681 $message = "$message for the component ({$this->logicalTag})."; 682 } 683 LogUtility::warning($message); 684 685 } 686 } 687 688 } 689 690 691 /** 692 * Sort by attribute 693 * https://datacadamia.com/web/html/attribute#order 694 * https://codeguide.co/#html-attribute-order 695 */ 696 $sortedArray = array(); 697 $once = "once"; 698 $multiple = "multiple"; 699 $orderPatterns = [ 700 "class" => $once, 701 "id" => $once, 702 "name" => $once, 703 "data-.*" => $multiple, 704 "src.*" => $multiple, 705 "for" => $once, 706 "type" => $once, 707 "href" => $once, 708 "value" => $once, 709 "title" => $once, 710 "alt" => $once, 711 "role" => $once, 712 "aria-*" => $multiple]; 713 foreach ($orderPatterns as $pattern => $type) { 714 foreach ($tempHtmlArray as $name => $value) { 715 $searchPattern = "^$pattern$"; 716 if (preg_match("/$searchPattern/", $name)) { 717 unset($tempHtmlArray[$name]); 718 if ($type === $once) { 719 $sortedArray[$name] = $value; 720 continue 2; 721 } else { 722 $multipleValues[$name] = $value; 723 } 724 } 725 } 726 if (!empty($multipleValues)) { 727 ksort($multipleValues); 728 $sortedArray = array_merge($sortedArray, $multipleValues); 729 $multipleValues = []; 730 } 731 } 732 foreach ($tempHtmlArray as $name => $value) { 733 734 if (!is_null($value)) { 735 /** 736 * 737 * Don't add a filter on the empty values 738 * 739 * The value of an HTML attribute may be empty 740 * Example the wiki id of the root namespace 741 * 742 * By default, {@link TagAttributes::addOutputAttributeValue()} 743 * will not accept any value, it must be implicitly said with the 744 * {@link TagAttributes::addOutputAttributeValue()} 745 * 746 */ 747 $sortedArray[$name] = $value; 748 } 749 750 } 751 $this->finalHtmlArray = $sortedArray; 752 753 /** 754 * To Html attribute encoding 755 */ 756 $this->finalHtmlArray = $this->encodeToHtmlValue($this->finalHtmlArray); 757 758 return $this->finalHtmlArray; 759 760 } 761 762 /** 763 * 764 * 765 * @param $key 766 * @param $value 767 * @return TagAttributes 768 */ 769 public function addOutputAttributeValue($key, $value): TagAttributes 770 { 771 772 if (blank($value)) { 773 LogUtility::error("The value of the output attribute is blank for the key ($key) - Tag ($this->logicalTag). Use the empty / boolean function if the value can be empty"); 774 } 775 776 $actualValue = $this->outputAttributes[$key] ?? null; 777 if ($actualValue === null) { 778 $this->outputAttributes[$key] = $value; 779 return $this; 780 } 781 782 if (!in_array($key, self::MULTIPLE_VALUES_ATTRIBUTES)) { 783 LogUtility::internalError("The output attribute ($key) was already set with the value ($actualValue), we have added the value ($value)"); 784 } 785 786 $this->outputAttributes[$key] = "$value $actualValue"; 787 return $this; 788 789 } 790 791 792 public function addOutputAttributeValueIfNotEmpty($key, $value) 793 { 794 if (!empty($value)) { 795 $this->addOutputAttributeValue($key, $value); 796 } 797 } 798 799 /** 800 * @param $attributeName 801 * @param null $default 802 * @return string|array|null a HTML value in the form 'value1 value2...' 803 */ 804 public function getValue($attributeName, $default = null) 805 { 806 $attributeName = strtolower($attributeName); 807 if ($this->hasComponentAttribute($attributeName)) { 808 return $this->componentAttributesCaseInsensitive[$attributeName]; 809 } else { 810 return $default; 811 } 812 } 813 814 815 /** 816 * Get the value and remove it from the attributes 817 * @param $attributeName 818 * @param $default 819 * @return string|array|null 820 * 821 * TODO: we should create a new response object and not deleting data from the request 822 */ 823 public function getValueAndRemove($attributeName, $default = null) 824 { 825 $attributeName = strtolower($attributeName); 826 $value = $default; 827 if ($this->hasComponentAttribute($attributeName)) { 828 $value = $this->getValue($attributeName); 829 830 if (!in_array($attributeName, self::PROTECTED_ATTRIBUTES)) { 831 /** 832 * Don't remove for instance the `type` 833 * because it may be used elsewhere 834 */ 835 unset($this->componentAttributesCaseInsensitive[$attributeName]); 836 } 837 838 } 839 return $value; 840 } 841 842 843 /** 844 * @return array - an array of key string and value of the component attributes 845 * This array is saved on the disk 846 */ 847 public function toCallStackArray(): array 848 { 849 850 $generatedId = $this->getValue(TagAttributes::GENERATED_ID_KEY); 851 if ($generatedId === null) { 852 853 $componentName = $this->logicalTag; 854 if ($componentName === null) { 855 $componentName = "unknown-component"; 856 } 857 $id = ExecutionContext::getActualOrCreateFromEnv() 858 ->getIdManager() 859 ->generateNewHtmlIdForComponent($componentName); 860 $this->addComponentAttributeValue(TagAttributes::GENERATED_ID_KEY, $id); 861 862 } 863 864 $array = array(); 865 $originalArray = $this->componentAttributesCaseInsensitive->getOriginalArray(); 866 foreach ($originalArray as $key => $value) { 867 /** 868 * Only null value are not passed 869 * width can be zero, wiki-id can be the empty string (ie root namespace) 870 * 871 * Value can be array, number, string 872 */ 873 if (!is_null($value)) { 874 $array[$key] = $value; 875 } 876 } 877 /** 878 * html attribute may also be in the callstack 879 */ 880 foreach ($this->outputAttributes as $key => $value) { 881 $array[$key] = $value; 882 } 883 try { 884 $array["style"] = $this->getStyle(); 885 } catch (ExceptionNotFound $e) { 886 // no style 887 } 888 889 if (isset($this->innerText)) { 890 $array[self::DOKUWIKI_TEXT_NODE_ATTRIBUTE] = $this->innerText; 891 } 892 return $array; 893 } 894 895 public 896 function getComponentAttributeValue($attributeName, $default = null) 897 { 898 $lowerAttribute = strtolower($attributeName); 899 $value = $default; 900 if ($this->hasComponentAttribute($lowerAttribute)) { 901 $value = $this->getValue($lowerAttribute); 902 } 903 return $value; 904 } 905 906 public 907 function addStyleDeclarationIfNotSet($property, $value) 908 { 909 ArrayUtility::addIfNotSet($this->styleDeclaration, $property, $value); 910 } 911 912 public 913 function setStyleDeclaration($property, $value): TagAttributes 914 { 915 $this->styleDeclaration[$property] = $value; 916 return $this; 917 } 918 919 920 public 921 function hasStyleDeclaration($styleDeclaration): bool 922 { 923 return isset($this->styleDeclaration[$styleDeclaration]); 924 } 925 926 public function getAndRemoveStyleDeclaration($styleDeclaration) 927 { 928 $styleValue = $this->styleDeclaration[$styleDeclaration]; 929 unset($this->styleDeclaration[$styleDeclaration]); 930 return $styleValue; 931 } 932 933 934 public 935 function toHTMLAttributeString(): string 936 { 937 938 $tagAttributeString = ""; 939 940 $htmlArray = $this->toHtmlArray(); 941 foreach ($htmlArray as $name => $value) { 942 943 /** 944 * Empty value are authorized 945 * null are just not set 946 */ 947 if (!is_null($value)) { 948 949 /** 950 * Unset attribute should not be added 951 */ 952 if ($value === TagAttributes::UN_SET) { 953 continue; 954 } 955 956 /** 957 * The condition is important 958 * because we may pass the javascript character `\n` in a `srcdoc` for javascript 959 * and the {@link StringUtility::toString()} will transform it as `\\n` 960 * making it unusable 961 */ 962 if (!is_string($value)) { 963 $stringValue = StringUtility::toString($value); 964 } else { 965 $stringValue = $value; 966 } 967 968 969 $tagAttributeString .= $name . '="' . $stringValue . '" '; 970 } 971 972 } 973 return trim($tagAttributeString); 974 975 976 } 977 978 public 979 function getComponentAttributes(): array 980 { 981 return $this->toCallStackArray(); 982 } 983 984 public 985 function removeComponentAttributeIfPresent($attributeName) 986 { 987 if ($this->hasComponentAttribute($attributeName)) { 988 unset($this->componentAttributesCaseInsensitive[$attributeName]); 989 } 990 991 } 992 993 public 994 function toHtmlEnterTag($htmlTag): string 995 { 996 997 $enterTag = "<" . trim($htmlTag); 998 $attributeString = $this->toHTMLAttributeString(); 999 if (!empty($attributeString)) { 1000 $enterTag .= " " . $attributeString; 1001 } 1002 /** 1003 * Is it an open tag ? 1004 */ 1005 if (!$this->getValue(self::OPEN_TAG, false)) { 1006 1007 $enterTag .= ">"; 1008 1009 /** 1010 * Do we have html after the tag is closed 1011 */ 1012 if (!empty($this->htmlAfterEnterTag)) { 1013 $enterTag .= DOKU_LF . $this->htmlAfterEnterTag; 1014 } 1015 1016 } 1017 1018 1019 return $enterTag; 1020 1021 } 1022 1023 public 1024 function toHtmlEmptyTag($htmlTag): string 1025 { 1026 1027 $enterTag = "<" . $htmlTag; 1028 $attributeString = $this->toHTMLAttributeString(); 1029 if (!empty($attributeString)) { 1030 $enterTag .= " " . $attributeString; 1031 } 1032 return $enterTag . "/>"; 1033 1034 } 1035 1036 public function getLogicalTag() 1037 { 1038 return $this->logicalTag; 1039 } 1040 1041 public 1042 function setLogicalTag($tag): TagAttributes 1043 { 1044 $this->logicalTag = $tag; 1045 return $this; 1046 } 1047 1048 /** 1049 * @param $attribute 1050 * @return mixed|null - the value deleted / null if it does not exist 1051 */ 1052 public function removeComponentAttribute($attribute) 1053 { 1054 $lowerAtt = strtolower($attribute); 1055 if (isset($this->componentAttributesCaseInsensitive[$lowerAtt])) { 1056 $value = $this->componentAttributesCaseInsensitive[$lowerAtt]; 1057 unset($this->componentAttributesCaseInsensitive[$lowerAtt]); 1058 return $value; 1059 } else { 1060 /** 1061 * Edge case, this is the first boolean attribute 1062 * and may has been categorized as the type 1063 */ 1064 if (!$this->getType() == $lowerAtt) { 1065 LogUtility::msg("Internal Error: The component attribute ($attribute) is not present. Use the ifPresent function, if you don't want this message"); 1066 } 1067 return null; 1068 1069 1070 } 1071 1072 } 1073 1074 /** 1075 * @param $html - an html that should be closed and added after the enter tag 1076 */ 1077 public 1078 function addHtmlAfterEnterTag($html) 1079 { 1080 $this->htmlAfterEnterTag = $html . $this->htmlAfterEnterTag; 1081 } 1082 1083 /** 1084 * The mime of the HTTP request 1085 * This is not the good place but yeah, 1086 * this class has become the context class 1087 * 1088 * Mime make the difference for a svg to know if it's required as external resource (ie SVG) 1089 * or as included in HTML page 1090 * @param $mime 1091 */ 1092 public 1093 function setMime($mime) 1094 { 1095 $this->mime = $mime; 1096 } 1097 1098 /** 1099 * @return string - the mime of the request 1100 */ 1101 public 1102 function getMime() 1103 { 1104 return $this->mime; 1105 } 1106 1107 public 1108 function getType() 1109 { 1110 return $this->getValue(self::TYPE_KEY); 1111 } 1112 1113 /** 1114 * @param $attributeName 1115 * @return ConditionalValue 1116 */ 1117 public 1118 function getConditionalValueAndRemove($attributeName) 1119 { 1120 $value = $this->getConditionalValueAndRemove($attributeName); 1121 return new ConditionalValue($value); 1122 1123 } 1124 1125 /** 1126 * @param $attributeName 1127 * @param null $default 1128 * @return null|string[] - an array of values 1129 * @throws ExceptionBadArgument 1130 */ 1131 public 1132 function getValuesAndRemove($attributeName, $default = null): array 1133 { 1134 1135 $trim = $this->getValues($attributeName, $default); 1136 $this->removeAttributeIfPresent($attributeName); 1137 return $trim; 1138 1139 1140 } 1141 1142 public 1143 function setType($type): TagAttributes 1144 { 1145 $this->setComponentAttributeValue(TagAttributes::TYPE_KEY, $type); 1146 return $this; 1147 } 1148 1149 /** 1150 * Merging will add the values, no replace or overwrite 1151 * @param $callStackArray 1152 */ 1153 public 1154 function mergeWithCallStackArray($callStackArray) 1155 { 1156 foreach ($callStackArray as $key => $value) { 1157 1158 if ($this->hasComponentAttribute($key)) { 1159 $isMultipleAttributeValue = in_array($key, self::MULTIPLE_VALUES_ATTRIBUTES); 1160 if ($isMultipleAttributeValue) { 1161 $this->addComponentAttributeValue($key, $value); 1162 } 1163 } else { 1164 $this->setComponentAttributeValue($key, $value); 1165 } 1166 } 1167 1168 } 1169 1170 /** 1171 * @param $string 1172 * @return TagAttributes 1173 */ 1174 public 1175 function removeAttributeIfPresent($string): TagAttributes 1176 { 1177 $this->removeComponentAttributeIfPresent($string); 1178 $this->removeOutputAttributeIfPresent($string); 1179 return $this; 1180 1181 } 1182 1183 public function removeOutputAttributeIfPresent($string) 1184 { 1185 $lowerAtt = strtolower($string); 1186 if (isset($this->outputAttributes[$lowerAtt])) { 1187 unset($this->outputAttributes[$lowerAtt]); 1188 } 1189 } 1190 1191 public 1192 function getValueAndRemoveIfPresent($attribute, $default = null) 1193 { 1194 $value = $this->getValue($attribute, $default); 1195 $this->removeAttributeIfPresent($attribute); 1196 return $value; 1197 } 1198 1199 public 1200 function generateAndSetId() 1201 { 1202 self::$counter += 1; 1203 $id = self::$counter; 1204 $logicalTag = $this->getLogicalTag(); 1205 if (!empty($logicalTag)) { 1206 $id = $this->logicalTag . $id; 1207 } 1208 $this->setComponentAttributeValue("id", $id); 1209 return $id; 1210 } 1211 1212 /** 1213 * 1214 * @param $markiTag 1215 * @return string - the marki tag made of logical attribute 1216 * There is no processing to transform it to an HTML tag 1217 */ 1218 public 1219 function toMarkiEnterTag($markiTag) 1220 { 1221 $enterTag = "<" . $markiTag; 1222 1223 $attributeString = ""; 1224 foreach ($this->getComponentAttributes() as $key => $value) { 1225 $attributeString .= "$key=\"$value\" "; 1226 } 1227 $attributeString = trim($attributeString); 1228 1229 if (!empty($attributeString)) { 1230 $enterTag .= " " . $attributeString; 1231 } 1232 $enterTag .= ">"; 1233 return $enterTag; 1234 1235 } 1236 1237 /** 1238 * @param string $key add an html attribute with the empty string 1239 */ 1240 public 1241 function addBooleanOutputAttributeValue(string $key): TagAttributes 1242 { 1243 1244 $this->outputAttributes[$key] = null; 1245 return $this; 1246 1247 } 1248 1249 public 1250 function addEmptyComponentAttributeValue($attribute) 1251 { 1252 $this->componentAttributesCaseInsensitive[$attribute] = ""; 1253 } 1254 1255 /** 1256 * @param $attribute 1257 * @param null $default 1258 * @return mixed 1259 */ 1260 public 1261 function getBooleanValueAndRemoveIfPresent($attribute, $default = null) 1262 { 1263 $value = $this->getBooleanValue($attribute, $default); 1264 $this->removeAttributeIfPresent($attribute); 1265 return $value; 1266 } 1267 1268 public 1269 function getBooleanValue($attribute, $default = null) 1270 { 1271 $value = $this->getValue($attribute); 1272 if ($value !== null) { 1273 return DataType::toBoolean($value); 1274 } 1275 return $default; 1276 } 1277 1278 public function hasAttribute($attribute): bool 1279 { 1280 $hasAttribute = $this->hasComponentAttribute($attribute); 1281 if ($hasAttribute === true) { 1282 return true; 1283 } else { 1284 return $this->hasHtmlAttribute($attribute); 1285 } 1286 } 1287 1288 function hasHtmlAttribute($attribute): bool 1289 { 1290 return isset($this->outputAttributes[$attribute]); 1291 } 1292 1293 /** 1294 * @throws ExceptionNotFound 1295 */ 1296 function getOutputAttribute($attribute) 1297 { 1298 $value = $this->outputAttributes[$attribute] ?? null; 1299 if ($value === null) { 1300 throw new ExceptionNotFound("No output attribute with the key ($attribute)"); 1301 } 1302 return $value; 1303 } 1304 1305 /** 1306 * Encoding should happen always to the target format output. 1307 * ie HTML 1308 * 1309 * If it's user or not data. 1310 * 1311 * Sanitizing is completely useless. We follow the same principal than SQL parameters 1312 * 1313 * We follows the rule 2 to encode the unknown value 1314 * We encode the component attribute to the target output (ie HTML) 1315 * 1316 * @param array $arrayToEscape 1317 * @param null $subKey 1318 * 1319 * 1320 * 1321 * 1322 * https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-2-attribute-encode-before-inserting-untrusted-data-into-html-common-attributes 1323 * 1324 * @return array 1325 */ 1326 private 1327 function encodeToHtmlValue(array $arrayToEscape, $subKey = null): array 1328 { 1329 1330 $returnedArray = []; 1331 foreach ($arrayToEscape as $name => $value) { 1332 1333 $encodedName = Html::encode($name); 1334 1335 /** 1336 * Boolean does not need to be encoded 1337 */ 1338 if (is_bool($value)) { 1339 if ($subKey == null) { 1340 $returnedArray[$encodedName] = $value; 1341 } else { 1342 $returnedArray[$subKey][$encodedName] = $value; 1343 } 1344 continue; 1345 } 1346 1347 /** 1348 * 1349 * Browser bug in a srcset 1350 * 1351 * In the HTML attribute srcset (not in the img src), if we set, 1352 * ``` 1353 * http://nico.lan/_media/docs/metadata/metadata_manager.png?w=355&h=176&tseed=1636624852&tok=af396a 355w 1354 * ``` 1355 * the request is encoded ***by the browser**** one more time and the server gets: 1356 * * `&&h = 176` 1357 * * php create therefore the property 1358 * * `&h = 176` 1359 * * and note `h = 176` 1360 */ 1361 $encodeValue = true; 1362 if ($encodedName === "srcset" && !PluginUtility::isTest()) { 1363 /** 1364 * Our test xhtml processor does not support non ampersand encoded character 1365 */ 1366 $encodeValue = false; 1367 } 1368 if ($encodeValue) { 1369 $value = Html::encode($value); 1370 } 1371 if ($subKey == null) { 1372 $returnedArray[$encodedName] = $value; 1373 } else { 1374 $returnedArray[$subKey][$encodedName] = $value; 1375 } 1376 1377 } 1378 return $returnedArray; 1379 1380 } 1381 1382 public function __toString() 1383 { 1384 return "TagAttributes"; 1385 } 1386 1387 /** 1388 * @throws ExceptionCompile 1389 */ 1390 public function getValueAsInteger(string $WIDTH_KEY, ?int $default = null): ?int 1391 { 1392 $value = $this->getValue($WIDTH_KEY, $default); 1393 if ($value === null) { 1394 return null; 1395 } 1396 return DataType::toInteger($value); 1397 } 1398 1399 public function hasClass(string $string): bool 1400 { 1401 return strpos($this->getClass(), $string) !== false; 1402 } 1403 1404 public function getDefaultStyleClassShouldBeAdded(): bool 1405 { 1406 return $this->defaultStyleClassShouldBeAdded; 1407 } 1408 1409 public function setDefaultStyleClassShouldBeAdded(bool $bool): TagAttributes 1410 { 1411 $this->defaultStyleClassShouldBeAdded = $bool; 1412 return $this; 1413 } 1414 1415 public function getDefaultGeneratedId() 1416 { 1417 return $this->getValue(TagAttributes::GENERATED_ID_KEY); 1418 } 1419 1420 public function setKnownTypes(?array $knownTypes): TagAttributes 1421 { 1422 $this->knownTypes = $knownTypes; 1423 return $this; 1424 } 1425 1426 public function removeType(): TagAttributes 1427 { 1428 $this->removeAttributeIfPresent(self::TYPE_KEY); 1429 return $this; 1430 } 1431 1432 /** 1433 * @param $attributeName 1434 * @param array|null $default 1435 * @return string[] 1436 * @throws ExceptionBadArgument 1437 */ 1438 public function getValues($attributeName, ?array $default = null): ?array 1439 { 1440 /** 1441 * Replace all suite of space that have more than 2 characters 1442 */ 1443 $value = $this->getValue($attributeName); 1444 if ($value === null) { 1445 return $default; 1446 } 1447 if (!is_string($value)) { 1448 throw new ExceptionBadArgument("The attribute ($attributeName) does not contain a string, we can't return multiple values"); 1449 } 1450 $value = preg_replace("/\s{2,}/", " ", trim($value)); 1451 return explode(" ", $value); 1452 1453 } 1454 1455 public function getComponentAttributeValueAndRemoveIfPresent(string $attribute, $default = null) 1456 { 1457 $value = $this->getComponentAttributeValue($attribute, $default); 1458 $this->removeComponentAttributeIfPresent($attribute); 1459 return $value; 1460 } 1461 1462 public function toUrl(): Url 1463 { 1464 $url = Url::createEmpty(); 1465 foreach ($this->componentAttributesCaseInsensitive as $key => $value) { 1466 $url->addQueryParameter($key, $value); 1467 } 1468 return $url; 1469 } 1470 1471 public function hasComponentAttributeAndRemove(string $key): bool 1472 { 1473 $hasAttribute = $this->hasComponentAttribute($key); 1474 if ($hasAttribute) { 1475 $this->removeComponentAttribute($key); 1476 } 1477 return $hasAttribute; 1478 } 1479 1480 /** 1481 * @param string $text - the text node content 1482 * @return $this 1483 */ 1484 public function setInnerText(string $text): TagAttributes 1485 { 1486 $this->innerText = $text; 1487 return $this; 1488 } 1489 1490 /** 1491 * @throws ExceptionNotFound 1492 */ 1493 public function getInnerText(): string 1494 { 1495 if (!isset($this->innerText)) { 1496 throw new ExceptionNotFound("No inner text is set"); 1497 } 1498 return $this->innerText; 1499 } 1500 1501 1502 public function setId(string $id): TagAttributes 1503 { 1504 return $this->setComponentAttributeValue("id", $id); 1505 } 1506 1507 /** 1508 * @throws ExceptionNotFound 1509 */ 1510 public function getId() 1511 { 1512 $id = $this->getValue(TagAttributes::ID_KEY); 1513 if ($id === null) { 1514 throw new ExceptionNotFound("no id"); 1515 } 1516 return $id; 1517 1518 } 1519 1520 1521} 1522