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&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