Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.43% covered (success)
99.43%
174 / 175
96.55% covered (success)
96.55%
28 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Child
99.43% covered (success)
99.43%
174 / 175
96.55% covered (success)
96.55%
28 / 29
88
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseString
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
22
 parseFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 extractAttributes
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 climbToNamedParent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendSiblingText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getNodeName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNodeValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNodeContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTextContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 normalizeWhiteSpace
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 setNodeName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setNodeValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addNodeValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAsCData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isCData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAttribute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAttributes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAttributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeAttribute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 isChildrenFirst
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setChildrenFirst
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 preserveWhiteSpace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
24
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Dom;
16
17use RecursiveIteratorIterator;
18
19/**
20 * Dom child class
21 *
22 * @category   Pop
23 * @package    Pop\Dom
24 * @author     Nick Sagona, III <nick@popphp.org>
25 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29class Child extends AbstractNode
30{
31
32    /**
33     * Child element node name
34     * @var ?string
35     */
36    protected ?string $nodeName = null;
37
38    /**
39     * Child element node value
40     * @var ?string
41     */
42    protected ?string $nodeValue = null;
43
44    /**
45     * Child element node value CDATA flag
46     * @var bool
47     */
48    protected bool $cData = false;
49
50    /**
51     * Flag to render children before node value or not
52     * @var bool
53     */
54    protected bool $childrenFirst = false;
55
56    /**
57     * Child element attributes
58     * @var array
59     */
60    protected array $attributes = [];
61
62    /**
63     * Flag to preserve whitespace
64     * @var bool
65     */
66    protected bool $preserveWhiteSpace = true;
67
68    /**
69     * Constructor
70     *
71     * Instantiate the DOM element object
72     *
73     * @param  string  $name
74     * @param  ?string $value
75     * @param  array   $options
76     */
77    public function __construct(string $name, ?string $value = null, array $options = [])
78    {
79        $this->nodeName  = $name;
80        $this->nodeValue = $value;
81
82        if (isset($options['cData'])) {
83            $this->cData = (bool)$options['cData'];
84        }
85        if (isset($options['childrenFirst'])) {
86            $this->childrenFirst = (bool)$options['childrenFirst'];
87        }
88        if (isset($options['indent'])) {
89            $this->indent = (string)$options['indent'];
90        }
91        if (isset($options['attributes'])) {
92            $this->setAttributes($options['attributes']);
93        }
94        if (isset($options['whitespace'])) {
95            $this->preserveWhiteSpace($options['whitespace']);
96        }
97    }
98
99    /**
100     * Static factory method to create a child object
101     *
102     * @param  string  $name
103     * @param  ?string $value
104     * @param  array   $options
105     * @return Child
106     */
107    public static function create(string $name, ?string $value = null, array $options = []): Child
108    {
109        return new self($name, $value, $options);
110    }
111
112    /**
113     * Static method to parse an XML/HTML string
114     *
115     * @param  string $string
116     * @return Child|array|null
117     */
118    public static function parseString(string $string): Child|array|null
119    {
120        if (trim($string) === '') {
121            return null;
122        }
123
124        $doc = new \DOMDocument();
125        $doc->loadHTML($string);
126
127        $dit = new RecursiveIteratorIterator(
128            new DomIterator($doc),
129            RecursiveIteratorIterator::SELF_FIRST
130        );
131
132        $parent     = null;
133        $child      = null;
134        $lastDepth  = 0;
135        $endElement = null;
136        $partial    = ((stripos($string, '<html') === false) || (stripos($string, '<body') === false));
137
138        foreach($dit as $node) {
139            if (($node->nodeType == XML_ELEMENT_NODE) || ($node->nodeType == XML_TEXT_NODE)) {
140                $attribs = self::extractAttributes($node);
141
142                if ($parent === null) {
143                    $parent = new Child($node->nodeName);
144                } else {
145                    if (($node->nodeType == XML_TEXT_NODE) && ($child !== null)) {
146                        $nodeValue = trim($node->nodeValue);
147                        if (!empty($nodeValue)) {
148                            if (($endElement) && ($child->getParent() !== null) && ($node->previousSibling !== null)) {
149                                self::appendSiblingText($child, $node, $nodeValue);
150                            } else {
151                                $child->setNodeValue($nodeValue);
152                                $endElement = true;
153                            }
154                        }
155                    } else {
156                        // down
157                        if ($dit->getDepth() > $lastDepth) {
158                            if ($child !== null) {
159                                $parent = $child;
160                            }
161                            $child  = new Child($node->nodeName);
162                            $parent->addChild($child);
163                            $endElement = false;
164                        // up
165                        } else if ($dit->getDepth() < $lastDepth) {
166                            $parent = self::climbToNamedParent($parent, $node->parentNode->nodeName);
167                            $child  = new Child($node->nodeName);
168                            $parent->addChild($child);
169                            $endElement = false;
170                            // next (sibling)
171                        } else if ($dit->getDepth() == $lastDepth) {
172                            $child  = new Child($node->nodeName);
173                            $parent->addChild($child);
174                            $endElement = false;
175                        }
176                        if (!empty($attribs)) {
177                            $child->setAttributes($attribs);
178                        }
179                        $lastDepth = $dit->getDepth();
180                    }
181                }
182            }
183        }
184        if ($parent === null) {
185            return null;
186        }
187
188        while ($parent->getParent() !== null) {
189            $parent = $parent->getParent();
190        }
191
192        if ($partial) {
193            $parent = $parent->getChild(0);
194            if (strtolower($parent->getNodeName()) == 'body') {
195                $parent = $parent->getChildNodes();
196            }
197        }
198
199        return $parent;
200    }
201
202    /**
203     * Static method to parse an XML/HTML string from a file
204     *
205     * @param  string $file
206     * @throws Exception
207     * @return Child|array|null
208     */
209    public static function parseFile(string $file): Child|array|null
210    {
211        if (!file_exists($file)) {
212            throw new Exception('Error: That file does not exist.');
213        }
214        return self::parseString(file_get_contents($file));
215    }
216
217    /**
218     * Extract a DOM node's attributes as a name/value array
219     *
220     * @param  \DOMNode $node
221     * @return array
222     */
223    private static function extractAttributes(\DOMNode $node): array
224    {
225        $attribs = [];
226        if ($node instanceof \DOMElement) {
227            for ($i = 0; $i < $node->attributes->length; $i++) {
228                $name = $node->attributes->item($i)->name;
229                $attribs[$name] = $node->getAttribute($name);
230            }
231        }
232        return $attribs;
233    }
234
235    /**
236     * Climb the parent chain from $start until a Child node named $targetName is found
237     *
238     * @param  AbstractNode|null $start
239     * @param  string            $targetName
240     * @return AbstractNode|null
241     */
242    private static function climbToNamedParent(AbstractNode|null $start, string $targetName): AbstractNode|null
243    {
244        $node = $start;
245        while (($node instanceof Child) && ($node->getNodeName() != $targetName)) {
246            $node = $node->getParent();
247        }
248        return $node;
249    }
250
251    /**
252     * Reattach a stray text node to the appropriate ancestor of $child, based on the DOM node
253     * that precedes it as a sibling
254     *
255     * @param  Child    $child
256     * @param  \DOMNode $node
257     * @param  string   $nodeValue
258     * @return void
259     */
260    private static function appendSiblingText(Child $child, \DOMNode $node, string $nodeValue): void
261    {
262        $prev = $node->previousSibling->nodeName;
263        $par  = self::climbToNamedParent($child->getParent(), $prev);
264        $par  = ($par === null) ? $child->getParent() : $par->getParent();
265        $par->addChild(new Child('#text', $nodeValue));
266    }
267
268    /**
269     * Return the child node name
270     *
271     * @return string|null
272     */
273    public function getNodeName(): string|null
274    {
275        return $this->nodeName;
276    }
277
278    /**
279     * Return the child node value
280     *
281     * @return string|null
282     */
283    public function getNodeValue(): string|null
284    {
285        return $this->nodeValue;
286    }
287
288    /**
289     * Return the child node content, including tags, etc
290     *
291     * @param  bool $ignoreWhiteSpace
292     * @return string
293     */
294    public function getNodeContent(bool $ignoreWhiteSpace = false): string
295    {
296        $content = $this->render(0, null, true);
297        if ($ignoreWhiteSpace) {
298            $content = self::normalizeWhiteSpace($content);
299        }
300        return $content;
301    }
302
303    /**
304     * Return the child node content, including tags, etc
305     *
306     * @param  bool $ignoreWhiteSpace
307     * @return string
308     */
309    public function getTextContent(bool $ignoreWhiteSpace = false): string
310    {
311        $content = strip_tags($this->render(0, null, true));
312
313        if ($ignoreWhiteSpace) {
314            $content = self::normalizeWhiteSpace($content);
315        }
316        return $content;
317    }
318
319    /**
320     * Collapse whitespace and normalize spacing around sentence punctuation
321     *
322     * @param  string $content
323     * @return string
324     */
325    private static function normalizeWhiteSpace(string $content): string
326    {
327        $content = preg_replace('/\s+/', ' ', str_replace(["\n", "\r", "\t"], ["", "", ""], trim($content)));
328        $content = preg_replace('/\s*\.\s*/', '. ', $content);
329        $content = preg_replace('/\s*\?\s*/', '? ', $content);
330        $content = preg_replace('/\s*\!\s*/', '! ', $content);
331        $content = preg_replace('/\s*,\s*/', ', ', $content);
332        $content = preg_replace('/\s*\:\s*/', ': ', $content);
333        $content = preg_replace('/\s*\;\s*/', '; ', $content);
334        return $content;
335    }
336
337    /**
338     * Set the child node name
339     *
340     * @param  string $name
341     * @return Child
342     */
343    public function setNodeName(string $name): Child
344    {
345        $this->nodeName = $name;
346        return $this;
347    }
348
349    /**
350     * Set the child node value
351     *
352     * @param  mixed $value
353     * @return Child
354     */
355    public function setNodeValue(mixed $value = null): Child
356    {
357        $this->nodeValue = $value;
358        return $this;
359    }
360
361    /**
362     * Add to the child node value
363     *
364     * @param  string $value
365     * @return Child
366     */
367    public function addNodeValue(string $value): Child
368    {
369        $this->nodeValue .= $value;
370        return $this;
371    }
372
373    /**
374     * Set the child node value as CDATA
375     *
376     * @param  bool $cData
377     * @return Child
378     */
379    public function setAsCData(bool $cData = true): Child
380    {
381        $this->cData = $cData;
382        return $this;
383    }
384
385    /**
386     * Determine if the child node value is CDATA
387     *
388     * @return bool
389     */
390    public function isCData(): bool
391    {
392        return $this->cData;
393    }
394
395    /**
396     * Set an attribute for the child element object
397     *
398     * @param  string $name
399     * @param  mixed  $value
400     * @return Child
401     */
402    public function setAttribute(string $name, mixed $value = null): Child
403    {
404        $this->attributes[$name] = $value;
405        return $this;
406    }
407
408    /**
409     * Set an attribute or attributes for the child element object
410     *
411     * @param  array $attributes
412     * @return Child
413     */
414    public function setAttributes(array $attributes): Child
415    {
416        foreach ($attributes as $name => $value) {
417            $this->setAttribute($name, $value);
418        }
419        return $this;
420    }
421
422    /**
423     * Determine if the child object has an attribute
424     *
425     * @param  string $name
426     * @return bool
427     */
428    public function hasAttribute(string $name): bool
429    {
430        return isset($this->attributes[$name]);
431    }
432
433    /**
434     * Determine if the child object has attributes
435     *
436     * @return bool
437     */
438    public function hasAttributes(): bool
439    {
440        return (count($this->attributes) > 0);
441    }
442
443    /**
444     * Get the attribute of the child object
445     *
446     * @param  string $name
447     * @return ?string
448     */
449    public function getAttribute(string $name): ?string
450    {
451        return $this->attributes[$name] ?? null;
452    }
453
454    /**
455     * Get the attributes of the child object
456     *
457     * @return array
458     */
459    public function getAttributes(): array
460    {
461        return $this->attributes;
462    }
463
464    /**
465     * Remove an attribute from the child element object
466     *
467     * @param  string $name
468     * @return Child
469     */
470    public function removeAttribute(string $name): Child
471    {
472        if (isset($this->attributes[$name])) {
473            unset($this->attributes[$name]);
474        }
475        return $this;
476    }
477
478    /**
479     * Determine if child nodes render first, before the node value
480     *
481     * @return bool
482     */
483    public function isChildrenFirst(): bool
484    {
485        return $this->childrenFirst;
486    }
487
488    /**
489     * Set whether child nodes render first, before the node value
490     *
491     * @param  bool $first
492     * @return Child
493     */
494    public function setChildrenFirst(bool $first = true): Child
495    {
496        $this->childrenFirst = $first;
497        return $this;
498    }
499
500    /**
501     * Set whether to preserve whitespace
502     *
503     * @param  bool $preserve
504     * @return Child
505     */
506    public function preserveWhiteSpace(bool $preserve = true): Child
507    {
508        $this->preserveWhiteSpace = $preserve;
509        return $this;
510    }
511
512    /**
513     * Render the child and its child nodes.
514     *
515     * @param  int     $depth
516     * @param  ?string $indent
517     * @param  bool    $inner
518     * @return string|null
519     */
520    public function render(int $depth = 0, ?string $indent = null, bool $inner = false): string|null
521    {
522        // Initialize child object properties and variables.
523        $this->output = '';
524        $ownIndent    = $this->indent ?? str_repeat('    ', $depth);
525        $attribs      = '';
526        $attribAry    = [];
527        $nl           = $this->preserveWhiteSpace ? "\n" : '';
528        $leadIndent   = $this->preserveWhiteSpace ? "{$indent}{$ownIndent}" : '';
529
530        $nodeValue = $this->cData ? '<![CDATA[' . $this->nodeValue . ']]>' : $this->nodeValue;
531
532        // Format child attributes, if applicable.
533        if ($this->hasAttributes()) {
534            $attributes = $this->getAttributes();
535            foreach ($attributes as $key => $value) {
536                $attribAry[] = $key . "=\"" . htmlspecialchars((string)$value, ENT_QUOTES) . "\"";
537            }
538            $attribs = ' ' . implode(' ', $attribAry);
539        }
540
541        // Initialize the node.
542        if ($this->nodeName == '#text') {
543            $this->output .= $leadIndent . $nodeValue . $nl;
544        } else {
545            if (!$inner) {
546                $this->output .= $leadIndent . "<{$this->nodeName}{$attribs}";
547            }
548
549            if ($indent === null) {
550                $indent     = $ownIndent;
551                $origIndent = $ownIndent;
552            } else {
553                $origIndent = $indent . $ownIndent;
554            }
555
556            $closeIndent = $this->preserveWhiteSpace ? $origIndent : '';
557
558            // If current child element has child nodes, format and render.
559            if (count($this->childNodes) > 0) {
560                if (!$inner) {
561                    $this->output .= ">" . $nl;
562                }
563                $newDepth    = $depth + 1;
564                $valueIndent = $this->preserveWhiteSpace ? str_repeat('    ', $newDepth) . "{$indent}" : '';
565
566                // Render node value before the child nodes.
567                if (!$this->childrenFirst) {
568                    if ($nodeValue !== null) {
569                        $this->output .= $valueIndent . "{$nodeValue}\n";
570                    }
571                    foreach ($this->childNodes as $child) {
572                        $this->output .= $child->render($newDepth, $indent);
573                    }
574                    if (!$inner) {
575                        $this->output .= $closeIndent . "</{$this->nodeName}>" . $nl;
576                    }
577                // Else, render child nodes first, then node value.
578                } else {
579                    foreach ($this->childNodes as $child) {
580                        $this->output .= $child->render($newDepth, $indent);
581                    }
582                    if (!$inner) {
583                        if ($nodeValue !== null) {
584                            $this->output .= $valueIndent . "{$nodeValue}" . $nl . $closeIndent .
585                                "</{$this->nodeName}>" . $nl;
586                        } else {
587                            $this->output .= $closeIndent . "</{$this->nodeName}>" . $nl;
588                        }
589                    }
590                }
591            // Else, render the child node.
592            } else {
593                if (!$inner) {
594                    if (($nodeValue !== null) || ($this->nodeName == 'textarea')) {
595                        $this->output .= ">";
596                        $this->output .= "{$nodeValue}</{$this->nodeName}>" . $nl;
597                    } else {
598                        $this->output .= " />" . $nl;
599                    }
600                } else if (!empty($nodeValue)) {
601                    $this->output .= $nodeValue;
602                }
603            }
604        }
605
606        return $this->output;
607    }
608
609    /**
610     * Render Dom child object to string
611     *
612     * @return string
613     */
614    public function __toString(): string
615    {
616        return $this->render();
617    }
618
619}