HtmlElement   A
last analyzed

Complexity

Total Complexity 37

Size/Duplication

Total Lines 345
Duplicated Lines 0 %

Test Coverage

Coverage 83.46%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 137
c 1
b 0
f 0
dl 0
loc 345
ccs 111
cts 133
cp 0.8346
rs 9.44
wmc 37

15 Methods

Rating   Name   Duplication   Size   Complexity  
A generateIndent() 0 3 2
B hp$0 ➔ cssFromArray() 0 32 8
C generateNodes() 0 78 16
cssFromArray() 0 32 ?
C generateHtml() 0 38 13
A generateId() 0 13 3
A hp$0 ➔ __construct() 0 3 1
renderAttributes() 0 41 ?
A hp$0 ➔ getIterator() 0 3 1
A hp$0 ➔ __toString() 0 3 1
C hp$0 ➔ renderAttributes() 0 41 16
A hp$0 ➔ renderHtml() 0 27 1
renderHtml() 0 27 ?
B hp$0 ➔ doParseNode() 0 24 9
doParseNode() 0 24 ?
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 $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
            }
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 1
        };
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
        $lastKey = \array_key_last($elementNodes);
355 1
        $previousNode = null !== $lastKey ? $elementNodes[$lastKey] : null;
356
357 1
        if ($previousNode instanceof Node\ElementNode) {
358 1
            if ($tagNode instanceof Node\ElementNode) {
359 1
                $tagHtml = $tagNode->html;
360
361 1
                if ($tagNode->parent === $previousNode->parent && ('' === $tagHtml && empty($previousNode->children))) {
362 1
                    $tagHtml = (string) $tagNode;
363
                }
364
            } else {
365 1
                $tagHtml = (string) $tagNode;
366
            }
367
368 1
            if (\str_contains($previousNode->html, $tagHtml)) {
369 1
                $tagNode->parent = &$previousNode;
370 1
                self::doParseNode($tagNode, $offset, $previousNode->children, $resolvedNodes);
371
            }
372
        }
373
374 1
        if (!isset($resolvedNodes[$offset])) {
375 1
            $elementNodes[$resolvedNodes[$offset] = $offset] = $tagNode;
376
        }
377
    }
378
}
379