Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.03% covered (success)
91.03%
71 / 78
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
91.03% covered (success)
91.03%
71 / 78
57.14% covered (warning)
57.14%
4 / 7
40.10
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
 parseDocument
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 looksLikeXhtml
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 mirrorSelectedContent
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
 findFirstDescendant
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 findSelectedOption
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
10.02
 parseFragment
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
12.00
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Html;
6
7use Phpdftk\Html\Dom\Document;
8use Phpdftk\Html\Dom\DocumentFragment;
9use Phpdftk\Html\Dom\Element;
10use Phpdftk\Html\Tokenizer\Tokenizer;
11use Phpdftk\Html\TreeConstruction\TreeBuilder;
12
13/**
14 * WHATWG HTML5 parser entry point. Hand-rolls the tokenizer (§13.2.5) and
15 * tree-construction state machine (§13.2.6) — no `libxml`, no DOM extension.
16 *
17 * The public surface is intentionally tiny: parseDocument() for a full HTML
18 * document and parseFragment() for innerHTML-style operations and HTML
19 * embedded in SVG <foreignObject>.
20 *
21 * Implementation is staged across Phase 1B sub-phases:
22 *  - 1B.1: public DOM types and parser shell (this file).
23 *  - 1B.2: tokenizer state machine.
24 *  - 1B.3: tree-construction insertion modes.
25 *  - 1B.4: declarative-shadow-DOM tree-construction integration.
26 *  - 1B.5: html5lib-tests integration to 100%.
27 */
28final class Parser
29{
30    public function __construct(public readonly ParserOptions $options = new ParserOptions()) {}
31
32    /**
33     * Parse a complete HTML document.
34     *
35     * @param string $html The HTML source.
36     * @param string|null $encoding Optional override for encoding sniffing.
37     */
38    public function parseDocument(string $html, ?string $encoding = null): Document
39    {
40        // HTML §13.2.3 — the tokenizer works in UTF-8, so a document in a
41        // legacy encoding has to be transcoded FIRST. Skipping this did
42        // not merely mislabel such a document, it mis-read it: every
43        // non-ASCII byte became U+FFFD, so `<meta charset=windows-1251>`
44        // rendered replacement boxes where its text should be.
45        $html = EncodingSniffer::toUtf8($html, $encoding);
46        $tokenizer = new Tokenizer($html);
47        $builder = new TreeBuilder($this->options);
48        $builder->xhtmlSelfClosing = self::looksLikeXhtml($html);
49        $doc = $builder->build($tokenizer);
50        $this->mirrorSelectedContent($doc);
51        return $doc;
52    }
53
54    /**
55     * Is this source XHTML rather than `text/html`?
56     *
57     * XHTML is XML, so `<div/>` is an empty element; the HTML tokenizer
58     * instead treats the trailing slash as a parse error and opens a div
59     * that swallows the rest of the document. Sniffing lets a single
60     * `parseDocument()` entry point serve both without the caller having
61     * to know the MIME type.
62     *
63     * The signals are the ones only an XHTML document carries: an XML
64     * declaration, or an XHTML `<!DOCTYPE>` public identifier. The
65     * `xmlns` attribute alone is NOT enough — it is legal (and common)
66     * in `text/html`, where the HTML rules still apply.
67     */
68    private static function looksLikeXhtml(string $html): bool
69    {
70        $prefix = substr($html, 0, 1024);
71        // Skip a UTF-8 BOM and leading whitespace before the prolog test.
72        $trimmed = ltrim($prefix, "\xEF\xBB\xBF \t\r\n\f");
73        if (str_starts_with($trimmed, '<?xml')) {
74            return true;
75        }
76        return (bool) preg_match(
77            '/<!DOCTYPE\s+html\s+PUBLIC\s+"-\/\/W3C\/\/DTD\s+XHTML/i',
78            $prefix,
79        );
80    }
81
82    /**
83     * Post-parse pass for the customizable-select `<selectedcontent>` element:
84     * mirror the selected option's children into each `<selectedcontent>`
85     * descendant of every `<select>`. Per the customizable-select draft, this
86     * happens at runtime — but parsing-time test fixtures (html5lib-tests
87     * webkit02 #45–#48) bake the mirror into their expected output, so we
88     * perform it once after the tree is built.
89     */
90    private function mirrorSelectedContent(Document $doc): void
91    {
92        $stack = [$doc];
93        $selects = [];
94        while ($stack !== []) {
95            $node = array_pop($stack);
96            foreach ($node->childNodes() as $child) {
97                if ($child instanceof Element) {
98                    if ($child->localName === 'select'
99                        && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
100                    ) {
101                        $selects[] = $child;
102                    }
103                    $stack[] = $child;
104                }
105            }
106        }
107        foreach ($selects as $select) {
108            $selectedContent = $this->findFirstDescendant($select, 'selectedcontent');
109            if ($selectedContent === null) {
110                continue;
111            }
112            $option = $this->findSelectedOption($select);
113            if ($option === null) {
114                continue;
115            }
116            foreach ($option->childNodes() as $child) {
117                $selectedContent->appendChild($child->cloneNode(true));
118            }
119        }
120    }
121
122    private function findFirstDescendant(Element $root, string $localName): ?Element
123    {
124        $stack = [$root];
125        while ($stack !== []) {
126            $node = array_pop($stack);
127            foreach ($node->childNodes() as $child) {
128                if ($child instanceof Element) {
129                    if ($child->localName === $localName
130                        && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
131                    ) {
132                        return $child;
133                    }
134                    $stack[] = $child;
135                }
136            }
137        }
138        return null;
139    }
140
141    private function findSelectedOption(Element $select): ?Element
142    {
143        $firstOption = null;
144        $stack = [$select];
145        while ($stack !== []) {
146            $node = array_pop($stack);
147            foreach ($node->childNodes() as $child) {
148                if (!$child instanceof Element) {
149                    continue;
150                }
151                if ($child->localName === 'option'
152                    && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
153                ) {
154                    if ($child->hasAttribute('selected')) {
155                        return $child;
156                    }
157                    $firstOption ??= $child;
158                }
159                // Don't recurse into nested selects.
160                if ($child->localName === 'select'
161                    && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
162                    && $child !== $select
163                ) {
164                    continue;
165                }
166                $stack[] = $child;
167            }
168        }
169        return $firstOption;
170    }
171
172    /**
173     * Parse an HTML fragment in the context of a host element per WHATWG
174     * §13.4. The context element determines the initial tokenizer state
175     * (e.g. RCDATA for <title>/<textarea>, RAWTEXT for <style>/<script>,
176     * PLAINTEXT for <plaintext>) and the initial insertion mode (via the
177     * "reset insertion mode appropriately" walk with the context as the
178     * implicit bottom of the stack).
179     */
180    public function parseFragment(string $html, Element $context): DocumentFragment
181    {
182        // Step 1: new document, inherit mode from context's owner.
183        $doc = new \Phpdftk\Html\Dom\Document();
184        $doc->mode = $context->ownerDocument->mode;
185
186        // Step 2: tokenizer with context-aware initial state.
187        $tokenizer = new Tokenizer($html);
188        if ($context->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS) {
189            $tokenizer->state = match ($context->localName) {
190                'title', 'textarea' => \Phpdftk\Html\Tokenizer\TokenizerState::Rcdata,
191                'style', 'xmp', 'iframe', 'noembed', 'noframes' => \Phpdftk\Html\Tokenizer\TokenizerState::Rawtext,
192                'script' => \Phpdftk\Html\Tokenizer\TokenizerState::ScriptData,
193                'noscript' => $this->options->scriptingEnabled
194                    ? \Phpdftk\Html\Tokenizer\TokenizerState::Rawtext
195                    : \Phpdftk\Html\Tokenizer\TokenizerState::Data,
196                'plaintext' => \Phpdftk\Html\Tokenizer\TokenizerState::Plaintext,
197                default => \Phpdftk\Html\Tokenizer\TokenizerState::Data,
198            };
199        }
200
201        // Step 3-9 delegated to TreeBuilder::buildFragment, which configures
202        // the initial state (html root, form pointer, template stack, reset
203        // insertion mode based on context) and runs the parse.
204        $builder = new TreeBuilder($this->options, $doc);
205        return $builder->buildFragment($tokenizer, $context);
206    }
207}