Test Setup Failed
Pull Request — latest (#3)
by Mark
32:07
created

HtmlElement::__toString()   B

Complexity

Conditions 8
Paths 16

Size

Total Lines 25
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 17
c 1
b 0
f 1
dl 0
loc 25
rs 8.4444
cc 8
nc 16
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file was originally part of the league/commonmark package.
7
 *
8
 * (c) Colin O'Dell <[email protected]>
9
 *
10
 * Original code based on the CommonMark JS reference parser (https://bitly.com/commonmark-js)
11
 *  - (c) John MacFarlane
12
 *
13
 * For the full copyright and license information, please view the LICENSE
14
 * file that was distributed with this source code.
15
 */
16
17
namespace UnicornFail\Emoji\Util;
18
19
class HtmlElement implements \Stringable
20
{
21
    public const CSS_IDENTIFIER_FILTERS = [
22
        ' ' => '-',
23
        '_' => '-',
24
        '/' => '-',
25
        '[' => '-',
26
        ']' => '',
27
    ];
28
29
    /** @var string */
30
    protected $tagName;
31
32
    /** @var array<string, string|bool> */
33
    protected $attributes = [];
34
35
    /** @var HtmlElement|HtmlElement[]|string */
36
    protected $contents;
37
38
    /** @var bool */
39
    protected $selfClosing = false;
40
41
    /**
42
     * @param string                                $tagName     Name of the HTML tag
43
     * @param array<string, mixed>                  $attributes  Array of attributes (values should be unescaped)
44
     * @param HtmlElement|HtmlElement[]|string|null $contents    Inner contents, pre-escaped if needed
45
     * @param bool                                  $selfClosing Whether the tag is self-closing
46
     */
47
    public function __construct(string $tagName, array $attributes = [], $contents = '', bool $selfClosing = false)
48
    {
49
        $this->tagName     = $tagName;
50
        $this->selfClosing = $selfClosing;
51
52
        /**
53
         * @var string $name
54
         * @var string|bool $value
55
         */
56
        foreach ($attributes as $name => $value) {
57
            $this->setAttribute($name, $value);
58
        }
59
60
        $this->setContents($contents ?? '');
61
    }
62
63
    /**
64
     * Prepares a string for use as a CSS identifier (element, class, or ID name).
65
     *
66
     * Link below shows the syntax for valid CSS identifiers (including element
67
     * names, classes, and IDs in selectors).
68
     *
69
     * @see http://www.w3.org/TR/CSS21/syndata.html#characters
70
     *
71
     * @param string   $identifier
72
     *   The identifier to clean.
73
     * @param string[] $filter
74
     *   An array of string replacements to use on the identifier.
75
     *
76
     * @return string
77
     *   The cleaned identifier.
78
     *
79
     * Note: shamelessly copied from
80
     * https://github.com/drupal/core-utility/blob/6807795c25836ccdb3f50d4396c4427705b7b6ad/Html.php
81
     */
82
    public static function cleanCssIdentifier(string $identifier, array $filter = self::CSS_IDENTIFIER_FILTERS): string
83
    {
84
        // We could also use strtr() here but its much slower than str_replace(). In
85
        // order to keep '__' to stay '__' we first replace it with a different
86
        // placeholder after checking that it is not defined as a filter.
87
        $doubleUnderscoreReplacements = 0;
88
        if (! isset($filter['__'])) {
89
            $identifier = \str_replace('__', '##', $identifier, $doubleUnderscoreReplacements);
90
        }
91
92
        $identifier = \str_replace(\array_keys($filter), \array_values($filter), $identifier);
93
        // Replace temporary placeholder '##' with '__' only if the original
94
        // $identifier contained '__'.
95
        if ($doubleUnderscoreReplacements > 0) {
96
            $identifier = \str_replace('##', '__', $identifier);
97
        }
98
99
        // Valid characters in a CSS identifier are:
100
        // - the hyphen (U+002D)
101
        // - a-z (U+0030 - U+0039)
102
        // - A-Z (U+0041 - U+005A)
103
        // - the underscore (U+005F)
104
        // - 0-9 (U+0061 - U+007A)
105
        // - ISO 10646 characters U+00A1 and higher
106
        // We strip out any character not in the above list.
107
        $identifier = (string) \preg_replace('/[^\x{002D}\x{0030}-\x{0039}\x{0041}-\x{005A}\x{005F}\x{0061}-\x{007A}\x{00A1}-\x{FFFF}]/u', '', $identifier);
108
109
        // Identifiers cannot start with a digit, two hyphens, or a hyphen followed by a digit.
110
        $identifier = (string) \preg_replace(['/^[0-9]/', '/^(-[0-9])|^(--)/'], ['_', '__'], $identifier);
111
112
        return $identifier;
113
    }
114
115
    public function addClass(string ...$classes): self
116
    {
117
        // Merge classes into existing classes.
118
        $existing = \explode(' ', (string) ($this->attributes['class'] ?? ''));
119
        $classes  = \array_merge($existing, $classes);
120
121
        // Split any strings that may have multiple classes in them.
122
        $classes = \array_map(static function (string $class) {
123
            return \explode(' ', $class);
124
        }, $classes);
125
126
        // Flatten classes back into a single level array.
127
        /** @var string[] $classes */
128
        $classes = \array_reduce(
129
            $classes,
130
            /**
131
             * @param string[] $a
132
             * @param string[] $v
133
             *
134
             * @return string[]
135
             */
136
            static function (array $a, array $v): array {
137
                return \array_merge($a, $v);
138
            },
139
            []
140
        );
141
142
        // Filter out empty items and ensure classes are unique.
143
        $classes = \array_filter(\array_unique($classes));
144
145
        // Normalize the classes.
146
        $classes = \array_map('\UnicornFail\Emoji\Util\HtmlElement::cleanCssIdentifier', \array_map('trim', $classes));
147
148
        // Convert the array of classes back into a string.
149
        $classes = \trim(\implode(' ', $classes));
150
151
        $this->attributes['class'] = $classes;
152
153
        return $this;
154
    }
155
156
    public function getTagName(): string
157
    {
158
        return $this->tagName;
159
    }
160
161
    /**
162
     * @return array<string, string|bool>
163
     */
164
    public function getAllAttributes(): array
165
    {
166
        return $this->attributes;
167
    }
168
169
    /**
170
     * @return string|bool|null
171
     */
172
    public function getAttribute(string $key)
173
    {
174
        if (! isset($this->attributes[$key])) {
175
            return null;
176
        }
177
178
        return $this->attributes[$key];
179
    }
180
181
    /**
182
     * @param string|string[]|bool $value
183
     */
184
    public function setAttribute(string $key, $value): self
185
    {
186
        if ($key === 'class') {
187
            $this->attributes['class'] = '';
188
            if (! \is_array($value)) {
189
                $value = [$value];
190
            }
191
192
            /** @var string[] $value */
193
            return $this->addClass(...$value);
194
        }
195
196
        if (\is_array($value)) {
197
            $this->attributes[$key] = \implode(' ', \array_unique($value));
198
        } else {
199
            $this->attributes[$key] = $value;
200
        }
201
202
        return $this;
203
    }
204
205
    /**
206
     * @return HtmlElement|HtmlElement[]|string
207
     */
208
    public function getContents(bool $asString = true)
209
    {
210
        if (! $asString) {
211
            return $this->contents;
212
        }
213
214
        return $this->getContentsAsString();
215
    }
216
217
    /**
218
     * Sets the inner contents of the tag (must be pre-escaped if needed)
219
     *
220
     * @param HtmlElement|HtmlElement[]|string|null $contents
221
     *
222
     * @return $this
223
     */
224
    public function setContents($contents): self
225
    {
226
        $this->contents = $contents ?? '';
227
228
        return $this;
229
    }
230
231
    public function __toString(): string
232
    {
233
        $result = '<' . $this->tagName;
234
235
        foreach ($this->attributes as $key => $value) {
236
            if ($value === true) {
237
                $result .= ' ' . $key;
238
            } elseif ($value === false) {
239
                continue;
240
            } else {
241
                $result .= ' ' . $key . '="' . Xml::escape($value) . '"';
242
            }
243
        }
244
245
        if ($this->contents !== '') {
246
            $result .= '>' . $this->getContentsAsString() . '</' . $this->tagName . '>';
247
        } elseif ($this->selfClosing && $this->tagName === 'input') {
248
            $result .= '>';
249
        } elseif ($this->selfClosing) {
250
            $result .= ' />';
251
        } else {
252
            $result .= '></' . $this->tagName . '>';
253
        }
254
255
        return $result;
256
    }
257
258
    private function getContentsAsString(): string
259
    {
260
        if (\is_string($this->contents)) {
261
            return $this->contents;
262
        }
263
264
        if (\is_array($this->contents)) {
265
            return \implode('', $this->contents);
266
        }
267
268
        return (string) $this->contents;
269
    }
270
}
271