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 
13 namespace ComboStrap;
14 
15 use ComboStrap\Tag\BoxTag;
16 use ComboStrap\TagAttribute\Align;
17 use ComboStrap\TagAttribute\Animation;
18 use ComboStrap\TagAttribute\BackgroundAttribute;
19 use ComboStrap\TagAttribute\Boldness;
20 use ComboStrap\TagAttribute\Hero;
21 use ComboStrap\TagAttribute\Shadow;
22 use ComboStrap\TagAttribute\StyleAttribute;
23 use ComboStrap\TagAttribute\TextAlign;
24 use ComboStrap\TagAttribute\Toggle;
25 use ComboStrap\TagAttribute\Underline;
26 use ComboStrap\TagAttribute\Vertical;
27 use ComboStrap\Web\Url;
28 use ComboStrap\Xml\XmlDocument;
29 use ComboStrap\Xml\XmlElement;
30 use 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  */
51 class 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&amp;h=176&amp;tseed=1636624852&amp;tok=af396a 355w
1354              * ```
1355              * the request is encoded ***by the browser**** one more time and the server gets:
1356              *   * `&amp;&amp;h  =   176`
1357              *   * php create therefore the property
1358              *      * `&amp;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