Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.71% covered (warning)
89.71%
61 / 68
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
89.71% covered (warning)
89.71%
61 / 68
50.00% covered (danger)
50.00%
3 / 6
38.49
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%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 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        $tokenizer = new Tokenizer($html);
41        $builder = new TreeBuilder($this->options);
42        $doc = $builder->build($tokenizer);
43        $this->mirrorSelectedContent($doc);
44        return $doc;
45    }
46
47    /**
48     * Post-parse pass for the customizable-select `<selectedcontent>` element:
49     * mirror the selected option's children into each `<selectedcontent>`
50     * descendant of every `<select>`. Per the customizable-select draft, this
51     * happens at runtime — but parsing-time test fixtures (html5lib-tests
52     * webkit02 #45–#48) bake the mirror into their expected output, so we
53     * perform it once after the tree is built.
54     */
55    private function mirrorSelectedContent(Document $doc): void
56    {
57        $stack = [$doc];
58        $selects = [];
59        while ($stack !== []) {
60            $node = array_pop($stack);
61            foreach ($node->childNodes() as $child) {
62                if ($child instanceof Element) {
63                    if ($child->localName === 'select'
64                        && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
65                    ) {
66                        $selects[] = $child;
67                    }
68                    $stack[] = $child;
69                }
70            }
71        }
72        foreach ($selects as $select) {
73            $selectedContent = $this->findFirstDescendant($select, 'selectedcontent');
74            if ($selectedContent === null) {
75                continue;
76            }
77            $option = $this->findSelectedOption($select);
78            if ($option === null) {
79                continue;
80            }
81            foreach ($option->childNodes() as $child) {
82                $selectedContent->appendChild($child->cloneNode(true));
83            }
84        }
85    }
86
87    private function findFirstDescendant(Element $root, string $localName): ?Element
88    {
89        $stack = [$root];
90        while ($stack !== []) {
91            $node = array_pop($stack);
92            foreach ($node->childNodes() as $child) {
93                if ($child instanceof Element) {
94                    if ($child->localName === $localName
95                        && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
96                    ) {
97                        return $child;
98                    }
99                    $stack[] = $child;
100                }
101            }
102        }
103        return null;
104    }
105
106    private function findSelectedOption(Element $select): ?Element
107    {
108        $firstOption = null;
109        $stack = [$select];
110        while ($stack !== []) {
111            $node = array_pop($stack);
112            foreach ($node->childNodes() as $child) {
113                if (!$child instanceof Element) {
114                    continue;
115                }
116                if ($child->localName === 'option'
117                    && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
118                ) {
119                    if ($child->hasAttribute('selected')) {
120                        return $child;
121                    }
122                    $firstOption ??= $child;
123                }
124                // Don't recurse into nested selects.
125                if ($child->localName === 'select'
126                    && $child->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS
127                    && $child !== $select
128                ) {
129                    continue;
130                }
131                $stack[] = $child;
132            }
133        }
134        return $firstOption;
135    }
136
137    /**
138     * Parse an HTML fragment in the context of a host element per WHATWG
139     * §13.4. The context element determines the initial tokenizer state
140     * (e.g. RCDATA for <title>/<textarea>, RAWTEXT for <style>/<script>,
141     * PLAINTEXT for <plaintext>) and the initial insertion mode (via the
142     * "reset insertion mode appropriately" walk with the context as the
143     * implicit bottom of the stack).
144     */
145    public function parseFragment(string $html, Element $context): DocumentFragment
146    {
147        // Step 1: new document, inherit mode from context's owner.
148        $doc = new \Phpdftk\Html\Dom\Document();
149        $doc->mode = $context->ownerDocument->mode;
150
151        // Step 2: tokenizer with context-aware initial state.
152        $tokenizer = new Tokenizer($html);
153        if ($context->namespaceURI === \Phpdftk\Html\Dom\Document::HTML_NS) {
154            $tokenizer->state = match ($context->localName) {
155                'title', 'textarea' => \Phpdftk\Html\Tokenizer\TokenizerState::Rcdata,
156                'style', 'xmp', 'iframe', 'noembed', 'noframes' => \Phpdftk\Html\Tokenizer\TokenizerState::Rawtext,
157                'script' => \Phpdftk\Html\Tokenizer\TokenizerState::ScriptData,
158                'noscript' => $this->options->scriptingEnabled
159                    ? \Phpdftk\Html\Tokenizer\TokenizerState::Rawtext
160                    : \Phpdftk\Html\Tokenizer\TokenizerState::Data,
161                'plaintext' => \Phpdftk\Html\Tokenizer\TokenizerState::Plaintext,
162                default => \Phpdftk\Html\Tokenizer\TokenizerState::Data,
163            };
164        }
165
166        // Step 3-9 delegated to TreeBuilder::buildFragment, which configures
167        // the initial state (html root, form pointer, template stack, reset
168        // insertion mode based on context) and runs the parse.
169        $builder = new TreeBuilder($this->options, $doc);
170        return $builder->buildFragment($tokenizer, $context);
171    }
172}