Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.83% covered (success)
97.83%
45 / 46
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Serializer
97.83% covered (success)
97.83%
45 / 46
83.33% covered (warning)
83.33%
5 / 6
23
0.00% covered (danger)
0.00%
0 / 1
 serialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeNode
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
8
 serializeElement
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
10
 serializeChildren
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 escapeText
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 escapeAttribute
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html;
6
7use Phpdftk\Html\Dom\Comment;
8use Phpdftk\Html\Dom\Document;
9use Phpdftk\Html\Dom\DocumentFragment;
10use Phpdftk\Html\Dom\DocumentType;
11use Phpdftk\Html\Dom\Element;
12use Phpdftk\Html\Dom\HTMLTemplateElement;
13use Phpdftk\Html\Dom\Node;
14use Phpdftk\Html\Dom\ShadowRoot;
15use Phpdftk\Html\Dom\Text;
16
17/**
18 * HTML5 fragment serializer per WHATWG §13.3. Re-emits a DOM as conformant
19 * HTML5 text; serialize→parse round-trips for any DOM the parser produced.
20 *
21 * Per Q11/contract: shadow roots marked serializable=true are emitted as
22 * <template shadowrootmode="open|closed">…</template>; otherwise omitted
23 * (the host element appears without its shadow content).
24 *
25 * Phase 1B.1 ships the structural traversal; the full character-escape
26 * tables (the named-entity reverse map plus the void-element and raw-text
27 * element registries) land alongside the tokenizer in Phase 1B.2.
28 */
29final class Serializer
30{
31    /** HTML void elements — emit as <foo> with no closing tag. */
32    private const array VOID_ELEMENTS = [
33        'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
34        'link', 'meta', 'source', 'track', 'wbr',
35    ];
36
37    /** Raw-text elements — children are serialised verbatim (no escaping). */
38    private const array RAW_TEXT_ELEMENTS = [
39        'script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext', 'noscript',
40    ];
41
42    public function serialize(Node $node): string
43    {
44        return $this->serializeNode($node, parentRawText: false);
45    }
46
47    private function serializeNode(Node $node, bool $parentRawText): string
48    {
49        return match (true) {
50            $node instanceof Document, $node instanceof DocumentFragment
51                => $this->serializeChildren($node, $parentRawText),
52            $node instanceof DocumentType => sprintf('<!DOCTYPE %s>', $node->name),
53            $node instanceof Text => $parentRawText ? $node->data : $this->escapeText($node->data),
54            $node instanceof Comment => '<!--' . $node->data . '-->',
55            $node instanceof Element => $this->serializeElement($node),
56            default => '',
57        };
58    }
59
60    private function serializeElement(Element $el): string
61    {
62        $tag = $el->localName;
63        $out = '<' . $tag;
64        foreach ($el->attributes() as $attr) {
65            $out .= ' ' . $attr->qualifiedName() . '="' . $this->escapeAttribute($attr->value) . '"';
66        }
67        if (in_array($tag, self::VOID_ELEMENTS, true)) {
68            return $out . '>';
69        }
70        $out .= '>';
71
72        $rawText = in_array($tag, self::RAW_TEXT_ELEMENTS, true);
73
74        // Per HTML §13.3: if the element has a serializable shadow root,
75        // emit it first as <template shadowrootmode>…</template>.
76        $shadow = $el->shadowRoot;
77        if ($shadow !== null && $shadow->serializable) {
78            $out .= sprintf(
79                '<template shadowrootmode="%s"%s%s shadowrootserializable="">',
80                $shadow->mode->name === 'Open' ? 'open' : 'closed',
81                $shadow->delegatesFocus ? ' shadowrootdelegatesfocus=""' : '',
82                $shadow->clonable ? ' shadowrootclonable=""' : '',
83            );
84            $out .= $this->serializeChildren($shadow, parentRawText: false);
85            $out .= '</template>';
86        }
87
88        // <template> children live in its `content` fragment, not directly.
89        if ($el instanceof HTMLTemplateElement && $el->content !== null) {
90            $out .= $this->serializeChildren($el->content, $rawText);
91        } else {
92            $out .= $this->serializeChildren($el, $rawText);
93        }
94        $out .= '</' . $tag . '>';
95        return $out;
96    }
97
98    private function serializeChildren(Node $node, bool $parentRawText): string
99    {
100        $out = '';
101        for ($n = $node->firstChild; $n !== null; $n = $n->nextSibling) {
102            $out .= $this->serializeNode($n, $parentRawText);
103        }
104        return $out;
105    }
106
107    private function escapeText(string $value): string
108    {
109        return strtr($value, [
110            '&' => '&amp;',
111            '<' => '&lt;',
112            '>' => '&gt;',
113            "\u{00A0}" => '&nbsp;',
114        ]);
115    }
116
117    private function escapeAttribute(string $value): string
118    {
119        return strtr($value, [
120            '&' => '&amp;',
121            '"' => '&quot;',
122            "\u{00A0}" => '&nbsp;',
123        ]);
124    }
125}