Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.61% covered (success)
91.61%
131 / 143
81.25% covered (warning)
81.25%
13 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Element
91.61% covered (success)
91.61%
131 / 143
81.25% covered (warning)
81.25%
13 / 16
69.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __invoke
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 append
81.25% covered (warning)
81.25%
26 / 32
0.00% covered (danger)
0.00%
0 / 1
17.69
 attr
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAttr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 class
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 style
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 data
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 script
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 toHtml
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 indentHtml
93.33% covered (success)
93.33%
70 / 75
0.00% covered (danger)
0.00%
0 / 1
31.28
 __toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toPrettyHtml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 tap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 when
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Epic64\Elem;
6
7use Closure;
8use Dom\Element as DomElement;
9use Dom\Node;
10use Epic64\Elem\Elements\RawHtml;
11use Epic64\Elem\Elements\Text;
12use InvalidArgumentException;
13
14/**
15 * Base class for all HTML elements.
16 * Provides a safe, type-checked wrapper around Dom\Element.
17 * Uses a shared HTMLDocument for efficient memory usage.
18 *
19 * @phpstan-type Child Node|Element|RawHtml|Text|string|null
20 */
21class Element
22{
23    /** @var array<string, true> Void elements that cannot have children */
24    private const array VOID_ELEMENTS = [
25        'area' => true, 'base' => true, 'br' => true, 'col' => true,
26        'embed' => true, 'hr' => true, 'img' => true, 'input' => true,
27        'link' => true, 'meta' => true, 'param' => true, 'source' => true,
28        'track' => true, 'wbr' => true,
29    ];
30
31    /** @var array<string, true> Tags that preserve whitespace */
32    private const array PRESERVE_WHITESPACE = [
33        'pre' => true, 'code' => true, 'textarea' => true, 'script' => true,
34    ];
35
36    protected DomElement $element;
37
38    protected ?DomElement $pendingScript = null;
39
40    public function __construct(string $tagName, ?string $text = null)
41    {
42        $this->element = ElementFactory::createElement($tagName, $text);
43    }
44
45    /**
46     * Add children to the element.
47     *
48     * @param Child|iterable<Child>|Closure(static): (Child|iterable<Child>) ...$children
49     * @return $this
50     */
51    public function __invoke(mixed ...$children): static
52    {
53        $this->append(...$children);
54        return $this;
55    }
56
57    /**
58     * Add children to the element.
59     *
60     * @param Child|iterable<Child>|Closure(static): (Child|iterable<Child>) ...$children
61     * @return $this
62     */
63    public function append(mixed ...$children): static
64    {
65        $dom = ElementFactory::dom();
66        foreach ($children as $child) {
67            // Skip null values (allows ternary expressions like: $condition ? element() : null)
68            if ($child === null) {
69                continue;
70            }
71            // Handle Closure - invoke it and process the result
72            if ($child instanceof Closure) {
73                $result = $child($this);
74                if ($result !== null) {
75                    $this($result);
76                }
77                continue;
78            }
79            // Handle Element instances first
80            if ($child instanceof Element) {
81                // Check if from same document, import if needed
82                if ($child->element->ownerDocument !== $dom) {
83                    $imported = ElementFactory::importNode($child->element, true);
84                    $this->element->appendChild($imported);
85                } else {
86                    $this->element->appendChild($child->element);
87                }
88                // Handle pending script for void elements
89                if ($child->pendingScript !== null) {
90                    $this->element->appendChild($child->pendingScript);
91                    $child->pendingScript = null;
92                }
93            } elseif ($child instanceof RawHtml) {
94                // Raw HTML: insert marker that gets replaced during serialization (no parsing!)
95                if ($child->html !== '') {
96                    $this->element->appendChild(ElementFactory::createRawMarker($child->html));
97                }
98            } elseif ($child instanceof Text) {
99                // Text is escaped and inserted as a text node
100                if ($child->content !== '') {
101                    $this->element->appendChild(ElementFactory::createTextNode($child->content));
102                }
103            } elseif ($child instanceof Node) {
104                // External node might need import
105                if ($child->ownerDocument !== $dom) {
106                    $child = ElementFactory::importNode($child, true);
107                }
108                $this->element->appendChild($child);
109            } elseif (is_iterable($child)) {
110                // Handle iterables (arrays, Collections, Laravel collections, generators, etc.)
111                $this(...$child);
112            } elseif (is_string($child)) {
113                $this->element->appendChild(ElementFactory::createTextNode($child));
114            }
115        }
116        return $this;
117    }
118
119    /**
120     * Set an attribute on the element.
121     */
122    public function attr(string $name, string $value): static
123    {
124        $this->element->setAttribute($name, $value);
125        return $this;
126    }
127
128    /**
129     * Get an attribute value.
130     */
131    public function getAttr(string $name): string
132    {
133        return $this->element->getAttribute($name) ?? '';
134    }
135
136    /**
137     * Set the id attribute.
138     */
139    public function id(string $id): static
140    {
141        $this->element->id = $id;
142        return $this;
143    }
144
145    /**
146     * Add one or more CSS classes.
147     */
148    public function class(string ...$classes): static
149    {
150        $classes = array_filter($classes, fn($c) => $c !== '');
151        if ($classes !== []) {
152            $this->element->classList->add(...$classes);
153        }
154
155
156        return $this;
157    }
158
159    /**
160     * Set inline styles.
161     */
162    public function style(string $style): static
163    {
164        return $this->attr('style', $style);
165    }
166
167    /**
168     * Set a data-* attribute.
169     */
170    public function data(string $name, string $value): static
171    {
172        return $this->attr("data-$name", $value);
173    }
174
175    /**
176     * Add an inline script that receives this element as `el`.
177     * Automatically wraps the code with getElementById lookup.
178     * For void elements (input, img, etc), appends script as next sibling.
179     *
180     * Usage:
181     *   form(id: 'my-form')->script(<<<JS
182     *       el.addEventListener('submit', (e) => { ... });
183     *   JS)
184     */
185    public function script(string $code): static
186    {
187        $id = $this->element->getAttribute('id');
188        if (empty($id)) {
189            throw new InvalidArgumentException('Element must have an id to use script()');
190        }
191
192        $wrappedCode = "{ const el = document.getElementById('$id'); $code }";
193        $script = ElementFactory::createElement('script', $wrappedCode);
194
195        // Void elements can't have children, so we store the script to be rendered after
196        if (isset(self::VOID_ELEMENTS[strtolower($this->element->tagName)])) {
197            // Insert after this element
198            if ($this->element->parentNode) {
199                $this->element->parentNode->insertBefore($script, $this->element->nextSibling);
200            } else {
201                // No parent yet - store for later
202                $this->pendingScript = $script;
203            }
204        } else {
205            $this->element->appendChild($script);
206        }
207        return $this;
208    }
209
210    public function toHtml(bool $pretty = false): string
211    {
212        if ($pretty) {
213            $html = ElementFactory::saveHTML($this->element);
214            return $this->indentHtml($html);
215        }
216
217        return ElementFactory::saveHTML($this->element);
218    }
219
220    /**
221     * Indent HTML string with proper formatting.
222     */
223    private function indentHtml(string $html): string
224    {
225        $html = trim($html);
226        if ($html === '') {
227            return '';
228        }
229
230        // Pre-computed indent strings for common depths (0-20)
231        static $indentCache = [
232            '', '    ', '        ', '            ', '                ',
233            '                    ', '                        ', '                            ',
234            '                                ', '                                    ',
235        ];
236
237        $output = [];
238        $indent = 0;
239        $insidePreformatted = 0;
240
241        // Split by tags while keeping tags
242        $tokens = preg_split('/(<[^>]+>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
243
244        if ($tokens === false) {
245            return $html;
246        }
247
248        // Collapse simple elements (open tag + text + close tag) into single tokens
249        $collapsed = [];
250        $count = count($tokens);
251        for ($i = 0; $i < $count; $i++) {
252            $token = $tokens[$i];
253            // Check for pattern: opening tag, text content, closing tag (with no nested tags)
254            if (
255                $i + 2 < $count
256                && $token[0] === '<'
257                && $token[1] !== '/'
258                && ($tokens[$i + 1][0] ?? '<') !== '<'
259                && ($tokens[$i + 2][0] ?? '') === '<'
260                && ($tokens[$i + 2][1] ?? '') === '/'
261            ) {
262                // Extract tag name from opening tag
263                $spacePos = strpos($token, ' ');
264                $tagEnd = $spacePos !== false ? $spacePos : strlen($token) - 1;
265                $tagName = strtolower(substr($token, 1, $tagEnd - 1));
266
267                // Only collapse if not void/preformatted and closing tag matches
268                if (!isset(self::VOID_ELEMENTS[$tagName]) && !isset(self::PRESERVE_WHITESPACE[$tagName])) {
269                    $closeTag = $tokens[$i + 2];
270                    $closeTagName = strtolower(substr($closeTag, 2, -1));
271                    if ($tagName === $closeTagName) {
272                        $collapsed[] = $token . trim($tokens[$i + 1]) . $closeTag;
273                        $i += 2;
274                        continue;
275                    }
276                }
277            }
278            $collapsed[] = $token;
279        }
280
281        foreach ($collapsed as $token) {
282            // Only trim if not inside preformatted content
283            if ($insidePreformatted === 0) {
284                $token = trim($token);
285                if ($token === '') {
286                    continue;
287                }
288            } elseif ($token[0] !== '<') {
289                // Inside preformatted: skip empty text tokens but preserve non-empty ones as-is
290                if (trim($token) === '') {
291                    continue;
292                }
293            }
294
295            $indentStr = $indentCache[$indent] ?? str_repeat('    ', $indent);
296
297            // Check if token starts with <
298            if ($token[0] === '<') {
299                // Check if it's a closing tag
300                if ($token[1] === '/') {
301                    // Extract tag name
302                    $tagName = strtolower(substr($token, 2, strpos($token, '>') - 2));
303                    $wasInsidePreformatted = $insidePreformatted > 0;
304
305                    if (isset(self::PRESERVE_WHITESPACE[$tagName])) {
306                        $insidePreformatted = max(0, $insidePreformatted - 1);
307                    }
308
309                    $indent = max(0, $indent - 1);
310                    $indentStr = $indentCache[$indent] ?? str_repeat('    ', $indent);
311
312                    if ($wasInsidePreformatted) {
313                        $output[] = $token;
314                        if ($insidePreformatted === 0) {
315                            $output[] = "\n";
316                        }
317                    } else {
318                        $output[] = $indentStr . $token . "\n";
319                    }
320                } else {
321                    // Opening tag or self-closing - check if it's a collapsed element
322                    if (str_contains($token, '</')) {
323                        // Collapsed element like <tag>text</tag>
324                        $output[] = $indentStr . $token . "\n";
325                        continue;
326                    }
327
328                    // Extract tag name
329                    $spacePos = strpos($token, ' ');
330                    $tagEnd = $spacePos !== false ? $spacePos : strlen($token) - 1;
331                    $tagName = strtolower(substr($token, 1, $tagEnd - 1));
332                    $isSelfClosing = isset(self::VOID_ELEMENTS[$tagName]) || $token[-2] === '/';
333
334                    if ($insidePreformatted > 0) {
335                        $output[] = $token;
336                    } else {
337                        $output[] = $indentStr . $token . "\n";
338                    }
339
340                    if (!$isSelfClosing) {
341                        $indent++;
342                        if (isset(self::PRESERVE_WHITESPACE[$tagName])) {
343                            $insidePreformatted++;
344                        }
345                    }
346                }
347            } else {
348                // Text content
349                if ($insidePreformatted > 0) {
350                    $output[] = $token;
351                } else {
352                    $output[] = $indentStr . $token . "\n";
353                }
354            }
355        }
356
357        return rtrim(implode('', $output));
358    }
359
360    public function __toString(): string
361    {
362        return $this->toHtml(pretty: false); // pretty is 30-40% slower.
363    }
364
365    /**
366     * Output pretty-printed HTML.
367     */
368    public function toPrettyHtml(): string
369    {
370        return $this->toHtml(true);
371    }
372
373    /**
374     * Tap into the element for imperative modifications.
375     *
376     * The callback receives the element and can perform any operations on it.
377     * Returns the element for continued chaining.
378     *
379     * Usage:
380     *   div(class: 'card')->tap(function($el) {
381     *       if ($someCondition) {
382     *           $el->class('highlighted');
383     *       }
384     *       $el->data('loaded', 'true');
385     *   })
386     *
387     * @param callable(static): mixed $callback
388     * @return $this
389     */
390    public function tap(callable $callback): static
391    {
392        $callback($this);
393        return $this;
394    }
395
396    /**
397     * Conditionally tap into the element.
398     *
399     * Only executes the callback if the condition is true.
400     * Returns the element for continued chaining regardless.
401     *
402     * Usage:
403     *   div(class: 'card')
404     *       ->when($isAdmin, fn($el) => $el->class('admin'))
405     *       ->when($isActive, fn($el) => $el->class('active'))
406     *
407     * @param callable(static): mixed $callback
408     * @return $this
409     */
410    public function when(bool $condition, callable $callback): static
411    {
412        if ($condition) {
413            $callback($this);
414        }
415        return $this;
416    }
417}