Passed
Push — master ( fbdae9...66097f )
by Divine Niiquaye
10:37
created

HtmlElement.php$0 ➔ renderAttributes()   C

Complexity

Conditions 16

Size

Total Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 23.4579

Importance

Changes 0
Metric Value
cc 16
c 0
b 0
f 0
dl 0
loc 41
ccs 18
cts 26
cp 0.6923
crap 23.4579
rs 5.5666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Biurad opensource projects.
7
 *
8
 * PHP version 7.2 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Biurad\UI\Html;
19
20
use Biurad\UI\Exceptions\RenderException;
21
use Biurad\UI\Interfaces\HtmlInterface;
22
23
/**
24
 * This class provides a set of static methods for creating php html.
25
 *
26
 * @see Biurad\UI\Html\createElement
27
 * @experimental in 1.0
28
 *
29
 * @author Divine Niiquaye Ibok <[email protected]>
30
 */
31
final class HtmlElement
32
{
33
    public const HTML_ARRAY_ENCODE = \JSON_UNESCAPED_UNICODE | \JSON_HEX_QUOT | \JSON_HEX_TAG | \JSON_HEX_AMP | \JSON_HEX_APOS;
34
35
    public const HTML_ATTR_ENCODE = \ENT_QUOTES | \ENT_SUBSTITUTE | \ENT_HTML5;
36
37
    public const HTML_STRING_ENCODE = \ENT_NOQUOTES | \ENT_SUBSTITUTE;
38
39
    public const ATTR_REGEX = '~
40
        \s*(?<prop>[\w:-]+) ## the HTML attribute name
41
        (?:
42
            \=(?|
43
                \"(?P<value>.*?)\"| ## the double quoted HTML attribute value
44
                \'(?P<value>.*?)\'| ## the single quoted HTML attribute value
45
                (?P<value>.*?)(\s+) ## the non quoted HTML attribute value
46
            )?
47
        )?
48
    ~xsi';
49
50
    public const HTML_REGEX = '~
51
        <(?P<tag>\s*[\w\!\-]+) ##  beginning of HTML tag
52
        \s*(?P<attr>[^>]+)?\> ## the HTML attribute in tag
53
        #(?:
54
            #(?P<value>[^\<]+) ## the HTML value
55
            #?(?P<end>\<\s*\/\w+\>) ##  end of HTML tag
56
        #)?
57
    ~xsi';
58
59
    /** @var array<string,int> */
60
    private static array $generateIdCounter = [];
61
62
    /**
63
     * Returns an autogenerated sequential ID.
64
     */
65
    public static function generateId(string $prefix = 'i'): string
66
    {
67
        if (isset(self::$generateIdCounter[$prefix])) {
68
            $counter = ++self::$generateIdCounter[$prefix];
69
        } else {
70
            self::$generateIdCounter = [$prefix => ($counter = 0)];
71
        }
72
73
        if ($counter > 0) {
74
            $prefix .= $counter;
75
        }
76
77
        return $prefix;
78
    }
79
80
    /**
81
     * Create an indent string spaced level.
82
     */
83 1
    public static function generateIndent(?int $indentLevel): string
84
    {
85 1
        return null !== $indentLevel ? "\n" . \str_repeat(' ', $indentLevel) : '';
86
    }
87
88
    /**
89
     * Generates a list of html elements node from html string.
90
     *
91
     * @return array<int,Node\AbstractNode>
92
     */
93 1
    public static function generateNodes(string $html): array
94
    {
95 1
        $matchedNodes = $elementNodes = $resolvedNodes = [];
96 1
        $html = \preg_replace(['/\n\s*/', '/\s+<\//', '/\"\s+\>/', '/\"\s+\s+/'], [' ', '</', '">', '" '], $html) ?? $html;
97
98 1
        if (!\preg_match_all(self::HTML_REGEX, $html, $matches, \PREG_SET_ORDER | \PREG_OFFSET_CAPTURE)) {
99 1
            throw new RenderException('Unable to render provided html into element nodes.');
100
        }
101
102 1
        foreach ($matches as $match) {
103 1
            $attributes = [];
104 1
            [$tagOpen, $tagOffset] = $match['tag'];
105
106 1
            if ('!--' === $tagOpen) {
107 1
                $matchedNodes[] = new Node\CommentNode($match[0][0]);
108
109 1
                continue;
110
            }
111
112 1
            if ('' !== $tagAttr = $match['attr'][0] ?? '') {
113 1
                \preg_match_all(self::ATTR_REGEX, $tagAttr, $attrMatches, \PREG_SET_ORDER);
114
115 1
                if (!empty($attrMatches)) {
116 1
                    foreach ($attrMatches as $attrMatch) {
117 1
                        $attributes[] = new Node\AttributeNode($attrMatch['prop'], $attrMatch['value'] ?? null, ' ', isset($attrMatch[3]));
118
                    }
119
                }
120
            }
121
122 1
            if (!\str_ends_with($tag = $match[0][0], '/>')) {
123 1
                $htmlPos = $recursive = 0;
124 1
                $tagContent = \substr($html, $tagOffset + \strlen($tag) - 1);
125
126 1
                while ($htmlPos < \strlen($tagContent)) {
127 1
                    if (\preg_match('/^<\s*' . $tagOpen . '\s*.*?>/', $tagHtml = \substr($tagContent, $htmlPos))) {
128
                        ++$recursive;
129
                    }
130
131 1
                    if (\preg_match('/^<\s*\/\s*' . $tagOpen . '\s*>/', $tagHtml)) {
132 1
                        if ($recursive > 0) {
133
                            --$recursive;
134
135
                            continue;
136
                        }
137
138 1
                        $matchedNodes[] = new Node\ElementNode($tagOpen, \substr($tagContent, 0, $htmlPos), $attributes);
139 1
                        $tagContent = null;
140
141 1
                        break;
142
                    }
143
144 1
                    ++$htmlPos;
145
                }
146
147 1
                if (null === $tagContent) {
148 1
                    continue;
149
                }
150
151 1
                unset($tagContent, $htmlPos);
152
            } else {
153 1
                $attributes[] = new Node\AttributeNode('/', null, ' ' === $match[0][0][-3] ? ' ' : '');
154
            }
155
156 1
            $matchedNodes[] = new Node\SelfCloseNode($tagOpen, $attributes);
157
        }
158
159 1
        foreach ($matchedNodes as $offset => $tagNode) {
160 1
            if (isset($matchedNodes[$offset + 1])) {
161 1
                $tagNode->next = &$matchedNodes[$offset + 1];
162
            }
163
164
            // Add $tagNode to an orderly stack
165 1
            self::doParseNode($tagNode, $offset, $elementNodes, $resolvedNodes);
166
        }
167
168 1
        unset($matchedNodes, $resolvedNodes);
169
170 1
        return $elementNodes;
171
    }
172
173
    /**
174
     * Parses node generated by the generateNodes() method.
175
     *
176
     * @param array<int,Node\AbstractNode> $elementNodes
177
     * @param callable|null                $resolveNode  Takes two parameters, (Node\AbstractNode &$tagNode, ?int $IndentLevel)
178
     */
179 1
    public static function generateHtml(array $elementNodes, callable $resolveNode = null, array $options = []): string
180
    {
181 1
        $content = '';
182
183 1
        if (null !== ($indentLevel = $options['indentLevel'] ?? null) && $indentLevel < 2) {
184
            throw new \InvalidArgumentException(\sprintf('Indent level for formatting cannot be less than two: "%s" provided.', $indentLevel));
185
        }
186
187 1
        $indent = $options['indent'] ?? $indentLevel;
188
189 1
        foreach ($elementNodes as $tagNode) {
190 1
            $tagIndent = self::generateIndent($indent ? $indent - $indentLevel : $indent);
191
192 1
            if ($tagNode instanceof Node\ElementNode) {
193 1
                $tagHtml = '';
194
195 1
                if (null !== $indent && !empty($tagNode->html)) {
196 1
                    if (empty($tagNode->children)) {
197 1
                        $tagHtml .= self::generateIndent($indent);
198
                    }
199
200 1
                    $indent += $indentLevel;
201
                }
202
203 1
                $tagHtml .= $h = (!empty($tagNode->children) ? self::generateHtml($tagNode->children, $resolveNode, \compact('indentLevel', 'indent')) : $tagNode->html);
204
205 1
                if ($h && $indent) {
206 1
                    $tagHtml .= self::generateIndent(($indent -= $indentLevel) - $indentLevel);
207
                }
208
209 1
                $tagNode->html = $tagHtml;
210
            }
211
212 1
            $tagNode->indentLevel = $indent;
213 1
            $content .= null !== $resolveNode ? $resolveNode($tagNode, $tagIndent, $indentLevel) : ($tagIndent . (string) $tagNode);
214
        }
215
216 1
        return $content;
217
    }
218
219
    /**
220
     * Cast html string into object.
221
     *
222
     * @param string $html
223
     *
224
     * @return HtmlInterface&\IteratorAggregate
225
     */
226 1
    public static function renderHtml(string $html): HtmlInterface
227
    {
228 1
        return new class ($html) implements HtmlInterface, \IteratorAggregate {
229
            /** @var string */
230
            private $content;
231
232
            public function __construct(string $content)
233
            {
234 1
                $this->content = $content;
235 1
            }
236
237
            /**
238
             * {@inheritdoc}
239
             */
240
            public function __toString(): string
241
            {
242 1
                return $this->content;
243
            }
244
245
            /**
246
             * {@inheritdoc}
247
             *
248
             * @return \ArrayIterator<int,Node\AbstractNode>
249
             */
250
            public function getIterator(): \Traversable
251
            {
252
                return new \ArrayIterator(HtmlElement::generateNodes($this->content));
253
            }
254
        };
255
    }
256
257
    /**
258
     * Renders the HTML tag attributes.
259
     *
260
     * Attributes with boolean values are rendered without a value. `['class' => true]` as: `class`,
261
     * array values are json encoded, while null values are ignored.
262
     *
263
     * Additionally, attributes prefixed with a "@" symbol are specially rendered when receiving an array value.
264
     * For example, `'@data' => ['id' => 1, 'name' => 'biurad']` is rendered as `data-id="1" data-name="biurad"`
265
     *
266
     * @param array<string,mixed> $attributes
267
     */
268 1
    public static function renderAttributes(array $attributes): string
269
    {
270 1
        $attribute = '';
271
272 1
        foreach ($attributes as $name => $value) {
273 1
            if (null === $value || [] === $value || false === $value) {
274
                continue;
275
            }
276
277 1
            if (\is_int($name)) {
278
                $attribute .= ' ' . \htmlspecialchars($value, self::HTML_STRING_ENCODE);
279 1
            } elseif (true === $value) {
280
                $attribute .= ' ' . $name;
281 1
            } elseif (\is_array($value)) {
282 1
                if ('class' === $name) {
283
                    $attribute .= ' ' . $name . '="' . \htmlspecialchars(\implode(' ', $value), self::HTML_ATTR_ENCODE) . '"';
284 1
                } elseif ('@' === $name[0] || \in_array($name, ['data', 'data-ng', 'ng', 'aria'], true)) {
285
                    $name = \ltrim($name . '-', '@');
286
287
                    foreach ($value as $n => $v) {
288
                        $attribute .= ' ' . $name . $n . '=';
289
                        $attribute .= \is_array($v) ? '\'' . \json_encode($v, self::HTML_ARRAY_ENCODE) . '\'' : '"' . \htmlspecialchars($v, self::HTML_ATTR_ENCODE) . '"';
290
                    }
291 1
                } elseif ('style' === $name) {
292 1
                    $style = '';
293 1
                    $attribute .= ' ' . $name . '="';
294
295 1
                    foreach ($value as $selector => $styling) {
296 1
                        $style .= $selector . ': ' . \rtrim($styling, ';') . ';';
297
                    }
298
299 1
                    $attribute .= \htmlspecialchars($style, self::HTML_ATTR_ENCODE) . '"';
300
                } else {
301 1
                    $attribute .= ' ' . $name . '=\'' . \json_encode($value, self::HTML_ARRAY_ENCODE) . '\'';
302
                }
303
            } else {
304 1
                $attribute .= ' ' . ('' === $value ? $name : $name . '="' . \htmlspecialchars($value, self::HTML_ATTR_ENCODE) . '"');
305
            }
306
        }
307
308 1
        return $attribute;
309
    }
310
311
    /**
312
     * Converts a CSS style array into a string representation.
313
     *
314
     * @param array<string,string> $style The CSS style array. (e.g. `['div' => ['width' => '100px', 'height' => '200px']]`)
315
     *
316
     * @return string|null The CSS style string. If the CSS style is empty, a null will be returned.
317
     */
318 1
    public static function cssFromArray(array $style): ?string
319
    {
320 1
        $result = '';
321
322 1
        foreach ($style as $name => $value) {
323 1
            if (\is_array($value)) {
324 1
                if (\str_starts_with($name, '@')) {
325 1
                    $result .= $name . ' { ' . self::cssFromArray($value) . ' } ';
326 1
                } elseif (!empty($value)) {
327 1
                    $result .= $name . ' { ';
328
329 1
                    foreach ($value as $selector => $styling) {
330 1
                        if (\is_array($styling)) {
331
                            $result .= self::cssFromArray($value);
332
333
                            continue;
334
                        }
335
336 1
                        $result .= $selector . ': ' . \rtrim($styling, ';') . '; ';
337
                    }
338
339 1
                    $result .= '} ';
340
                }
341
342 1
                continue;
343
            }
344
345 1
            $result .= $name . ' { ' . \rtrim($value, ';') . '; } ';
346
        }
347
348
        // Return null if empty to avoid rendering the "style" attribute.
349 1
        return '' === $result ? null : \rtrim($result);
350
    }
351
352 1
    private static function doParseNode(Node\AbstractNode $tagNode, int $offset, array &$elementNodes, array &$resolvedNodes): void
353
    {
354 1
        $previousNode = $elementNodes[\array_key_last($elementNodes)] ?? null;
355
356 1
        if ($previousNode instanceof Node\ElementNode) {
357 1
            if ($tagNode instanceof Node\ElementNode) {
358 1
                $tagHtml = $tagNode->html;
359
360 1
                if ($tagNode->parent === $previousNode->parent && ('' === $tagHtml && empty($previousNode->children))) {
361 1
                    $tagHtml = (string) $tagNode;
362
                }
363
            } else {
364 1
                $tagHtml = (string) $tagNode;
365
            }
366
367 1
            if (\str_contains($previousNode->html, $tagHtml)) {
368 1
                $tagNode->parent = &$previousNode;
369 1
                self::doParseNode($tagNode, $offset, $previousNode->children, $resolvedNodes);
370
            }
371
        }
372
373 1
        if (!isset($resolvedNodes[$offset])) {
374 1
            $elementNodes[$resolvedNodes[$offset] = $offset] = $tagNode;
375
        }
376 1
    }
377
}
378