Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.80% covered (danger)
47.80%
76 / 159
37.50% covered (danger)
37.50%
6 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
DomPageBundle
47.80% covered (danger)
47.80%
76 / 159
37.50% covered (danger)
37.50%
6 / 16
113.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 newEmpty
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 fromHtmlPageBundle
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 toDom
63.64% covered (warning)
63.64%
14 / 22
0.00% covered (danger)
0.00%
0 / 1
4.77
 toSingleDocument
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 fromSingleDocument
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fromLoadedDocument
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
2
 isSingleDocument
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toSingleDocumentHtml
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 toInlineAttributeDocument
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 toInlineAttributeHtml
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 encodeForHeadElement
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 decodeFromHeadElement
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 headElementHint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toJsonArray
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 newFromJsonArray
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Core;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\JsonCodec\Hint;
8use Wikimedia\JsonCodec\JsonCodec;
9use Wikimedia\Parsoid\Config\SiteConfig;
10use Wikimedia\Parsoid\DOM\Document;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\Mocks\MockSiteConfig;
13use Wikimedia\Parsoid\Utils\DOMDataUtils;
14use Wikimedia\Parsoid\Utils\DOMUtils;
15use Wikimedia\Parsoid\Utils\PHPUtils;
16use Wikimedia\Parsoid\Wt2Html\XHtmlSerializer;
17
18/**
19 * A page bundle stores an HTML DOM with separated data-parsoid and
20 * data-mw content.  The data-parsoid and data-mw content is indexed
21 * by the id attributes on individual nodes.  This content needs to
22 * be loaded before the data-parsoid and/or data-mw information can be
23 * used.
24 *
25 * Note that the parsoid/mw properties of the page bundle are in "serialized
26 * array" form; that is, they are flat arrays appropriate for json-encoding
27 * and do not contain DataParsoid or DataMw objects.
28 *
29 * See HtmlPageBundle for a similar structure used where the HTML DOM has been
30 * serialized into a string.
31 */
32class DomPageBundle extends BasePageBundle {
33    private bool $invalid = false;
34
35    public function __construct(
36        /** The document, as a DOM. */
37        public Document $doc,
38        ?array $parsoid = null, ?array $mw = null,
39        ?array $counters = null,
40        ?string $version = null, ?array $headers = null,
41        ?string $contentmodel = null,
42        /** @var array<string,DocumentFragment> Additional named DocumentFragments. */
43        public array $fragments = [],
44    ) {
45        parent::__construct(
46            parsoid: $parsoid,
47            mw: $mw,
48            counters: $counters,
49            version: $version,
50            headers: $headers,
51            contentmodel: $contentmodel,
52        );
53        Assert::invariant(
54            !self::isSingleDocument( $doc ),
55            'single document should be unpacked before DomPageBundle created'
56        );
57    }
58
59    public static function newEmpty(
60        Document $doc,
61        ?string $version = null,
62        ?array $headers = null,
63        ?string $contentmodel = null,
64    ): self {
65        return new DomPageBundle(
66            $doc,
67            [
68                'ids' => [],
69            ],
70            [
71                'ids' => [],
72            ],
73            [
74                'nodedata' => -1,
75                'annotation' => -1,
76                'transclusion' => -1,
77            ],
78            $version,
79            $headers,
80            $contentmodel
81        );
82    }
83
84    /**
85     * Create a DomPageBundle from a HtmlPageBundle.
86     *
87     * This simply parses the HTML string from the HtmlPageBundle, preserving
88     * the metadata.
89     */
90    public static function fromHtmlPageBundle( HtmlPageBundle $pb ): DomPageBundle {
91        $doc = DOMUtils::parseHTML( $pb->html );
92        $fragments = array_map(
93            static fn ( $html )=>DOMUtils::parseHTMLToFragment( $doc, $html ),
94            $pb->fragments
95        );
96        return new DomPageBundle(
97            $doc,
98            $pb->parsoid,
99            $pb->mw,
100            $pb->counters,
101            $pb->version,
102            $pb->headers,
103            $pb->contentmodel,
104            $fragments,
105        );
106    }
107
108    /**
109     * Return a DOM from the contents of this page bundle.
110     *
111     * @note Although technically the Document and DocumentFragments
112     * held by the DomPageBundle are the same as the Document and
113     * DocumentFragments returned from this method, the former are not
114     * directly usable (parsoid/mw attributes are not loaded or present
115     * in inline attributes) while the latter are.  It is recommended
116     * that you treat the Document/DocumentFragment held by the DomPageBundle
117     * and the Document/DocumentFragment returned by this method as separate
118     * objects and consider the DomPageBundle "used up" and invalid once
119     * ::toDom() is called.
120     *
121     * @param bool $load
122     * If `$load` is true (the default), the returned DOM will be prepared
123     * and loaded using `$options`.
124     *
125     * If `$load` is false, any data-parsoid or data-mw information from this
126     * page bundle will be converted to inline attributes in the DOM.  This
127     * process is less efficient than preparing and loading the document
128     * directly from the DOM and should be avoided if possible.
129     * @param ?array $options Additional options to
130     *  DOMDataUtils::prepareAndLoadDoc, used when $load is true.
131     * @param ?array<string,DocumentFragment> &$fragments Additional fragments
132     *  present in the page bundle, which will also be loaded as necessary.
133     *  This is an output parameter.
134     * @param ?SiteConfig $siteConfig used to initialize the document codec
135     */
136    public function toDom(
137        bool $load = true, ?array $options = null,
138        ?array &$fragments = null,
139        ?SiteConfig $siteConfig = null
140    ): Document {
141        Assert::invariant( !$this->invalid, "invalidated" );
142        $doc = $this->doc;
143        $options ??= [];
144        if ( $load ) {
145            $fragments = [];
146            foreach ( $this->fragments as $name => $f ) {
147                $fragments[$name] = $f;
148            }
149            $options['loadFromPageBundle'] = $this;
150            $options['fragments'] = $this->fragments;
151            $siteConfig ??= $options['siteConfig'] ?? null;
152            if ( $siteConfig === null ) {
153                PHPUtils::deprecated( __METHOD__ . ' without SiteConfig', '0.24' );
154                $siteConfig = new MockSiteConfig( [] );
155            }
156            DOMDataUtils::prepareAndLoadDoc( $doc, $siteConfig, $options );
157        } else {
158            PHPUtils::deprecated( __METHOD__ . ' with $load=false', '0.23' );
159            $doc = $this->toInlineAttributeDocument(
160                siteConfig: $siteConfig ?? new MockSiteConfig( [] ),
161                options: $options,
162                fragments: $fragments
163            );
164        }
165        $this->invalid = true;
166        return $doc;
167    }
168
169    /**
170     * Create a "PageBundle as single Document" by embedding page bundle
171     * information into a <script> element in the <head> of the DOM.
172     *
173     * @see ::fromSingleDocument()
174     */
175    public function toSingleDocument(): Document {
176        Assert::invariant( !$this->invalid, "invalidated" );
177        $script = DOMUtils::appendToHead( $this->doc, 'script', [
178            'id' => 'mw-pagebundle',
179            'type' => 'application/x-mw-pagebundle',
180        ] );
181        $script->appendChild( $this->doc->createTextNode( $this->encodeForHeadElement() ) );
182        $doc = $this->doc;
183        // Invalidate this DomPageBundle to prevent us from using it again.
184        $this->invalid = true;
185        return $doc;
186    }
187
188    /**
189     * Return a DomPageBundle from a "PageBundle as single Document"
190     * representation, where some page bundle information has been embedded
191     * as a <script> element into the <head> of the DOM.
192     *
193     * @see ::toSingleDocument()
194     *
195     * @param Document $doc doc
196     * @param array $options Optional content version/headers/contentmodel
197     * @return DomPageBundle
198     */
199    public static function fromSingleDocument( Document $doc, array $options = [] ): DomPageBundle {
200        $dpScriptElt = DOMCompat::getElementById( $doc, 'mw-pagebundle' );
201        Assert::invariant( $dpScriptElt !== null, "no page bundle found" );
202        $dpScriptElt->parentNode->removeChild( $dpScriptElt );
203        return self::decodeFromHeadElement( $doc, $dpScriptElt->textContent, $options );
204    }
205
206    /**
207     * Create a new DomPageBundle from a "prepared and loaded" document.
208     *
209     * If a `pageBundle` key is present in the options, the
210     * version/headers/contentmodel will be initialized from that
211     * page bundle.
212     *
213     * @param Document $doc Should be "prepared and loaded"
214     * @param SiteConfig $siteConfig
215     * @param array $options store options
216     * @param array<string,DocumentFragment> $fragments
217     * @return DomPageBundle
218     */
219    public static function fromLoadedDocument(
220        Document $doc, SiteConfig $siteConfig,
221        array $options = [], array $fragments = [],
222    ): DomPageBundle {
223        $metadata = $options['pageBundle'] ?? null;
224        $dpb = self::newEmpty(
225            $doc,
226            $metadata->version ?? $options['contentversion'] ?? null,
227            $metadata->headers ?? $options['headers'] ?? null,
228            $metadata->contentmodel ?? $options['contentmodel'] ?? null,
229        );
230        // FIXME: Should we init $dpb->counters here using databag?
231        $options = [
232            'storeInPageBundle' => $dpb,
233            'outputContentVersion' => $dpb->version,
234            'idIndex' => DOMDataUtils::usedIdIndex( $siteConfig, $doc, $fragments ),
235        ] + $options;
236        DOMDataUtils::visitAndStoreDataAttribs(
237            DOMCompat::getBody( $doc ), $options
238        );
239        foreach ( $fragments as $name => $f ) {
240            DOMDataUtils::visitAndStoreDataAttribs(
241                $f, $options
242            );
243            $dpb->fragments[$name] = $f;
244        }
245        return $dpb;
246    }
247
248    /**
249     * Return true iff the given Document has page bundle information embedded
250     * as a <script id="mw-pagebundle"> element in its <head>.
251     */
252    public static function isSingleDocument( Document $doc ): bool {
253        return DOMCompat::getElementById( $doc, 'mw-pagebundle' ) !== null;
254    }
255
256    /**
257     * Convert this DomPageBundle to "single document" form, where page bundle
258     * information is embedded in the <head> of the document.
259     * @param array $options XHtmlSerializer options
260     * @return string an HTML string
261     */
262    public function toSingleDocumentHtml( array $options = [] ): string {
263        Assert::invariant( !$this->invalid, "invalidated" );
264        $doc = $this->toSingleDocument();
265        return XHtmlSerializer::serialize( $doc, $options )['html'];
266    }
267
268    /**
269     * Convert this DomPageBundle to "inline attribute" form, where page bundle
270     * information is represented as inline JSON-valued attributes.
271     * @param SiteConfig $siteConfig
272     * @param array $options XHtmlSerializer options
273     * @param array<string,DocumentFragment>|null &$fragments Additional fragments from the
274     *  page bundle which will also be converted to "inline attribute" form.
275     *  This is an output parameter.
276     * @return Document a standalone document with page bundle information
277     *  represented as inline JSON-valued attributes.
278     */
279    public function toInlineAttributeDocument(
280        SiteConfig $siteConfig,
281        array $options = [],
282        ?array &$fragments = null,
283    ): Document {
284        Assert::invariant( !$this->invalid, "invalidated" );
285        $doc = $this->toDom( siteConfig: $siteConfig, fragments: $fragments );
286        $options = [
287            'idIndex' => DOMDataUtils::usedIdIndex( $siteConfig, $doc, $fragments ),
288            'fragments' => array_values( $fragments )
289        ] + $options;
290        DOMDataUtils::storeAndUnprepareDoc( $doc, $options );
291        return $doc;
292    }
293
294    /**
295     * Convert this DomPageBundle to "inline attribute" form, where page bundle
296     * information is represented as inline JSON-valued attributes.
297     * @param SiteConfig $siteConfig
298     * @param array $options XHtmlSerializer options
299     * @param array<string,string>|null &$fragments Additional fragments from the
300     *  page bundle which will also be serialized to HTML strings.
301     *  This is an output parameter.
302     * @return string an HTML string
303     */
304    public function toInlineAttributeHtml(
305        SiteConfig $siteConfig,
306        array $options = [],
307        ?array &$fragments = null,
308    ): string {
309        $doc = $this->toInlineAttributeDocument(
310            siteConfig: $siteConfig, options: $options, fragments: $fragments
311        );
312        foreach ( $fragments as $name => $f ) {
313            // @phan-suppress-next-line PhanTypeMismatchArgument phan confused
314            $fragments[$name] = XHtmlSerializer::serialize( $f, $options )['html'];
315        }
316        if ( $options['body_only'] ?? false ) {
317            $node = DOMCompat::getBody( $doc );
318            $options['innerXML'] = true;
319        } else {
320            $node = $doc;
321        }
322        return XHtmlSerializer::serialize( $node, $options )['html'];
323    }
324
325    /**
326     * Encode some page bundle properties for emitting as a <script> element
327     * in the <head> of a document.
328     */
329    private function encodeForHeadElement(): string {
330        // Note that $this->parsoid and $this->mw are already serialized arrays
331        // so a naive jsonEncode is sufficient.  We use a JsonCodec to ensure
332        // that objects stay objects and arrays stay arrays, though.
333        $json = [ 'parsoid' => $this->parsoid ?? [], 'mw' => $this->mw ?? [], 'counters' => $this->counters ?? [] ];
334        if ( $this->fragments ) {
335            // Preserve fragments in the <head>
336            $json['fragments'] = array_map(
337                static fn ( $f ) => XHtmlSerializer::serialize( $f, [] )['html'],
338                $this->fragments
339            );
340        }
341        $codec = new JsonCodec();
342        return $codec->toJsonString( $json, self::headElementHint() );
343    }
344
345    /**
346     * Decode some page bundle properties from the contents of the <script>
347     * element embedded in a document.
348     */
349    private static function decodeFromHeadElement( Document $doc, string $s, array $options = [] ): DomPageBundle {
350        // Note that only 'parsoid' and 'mw' are encoded, so these will be
351        // the only fields set in the decoded DomPageBundle
352        // Even though 'parsoid' and 'mw' are encoded, use a JsonCodec so
353        // that objects stay objects and arrays stay arrays.
354        $codec = new JsonCodec();
355        $decoded = $codec->newFromJsonString( $s, self::headElementHint() );
356        $fragments = array_map(
357            static fn ( $html ) => DOMUtils::parseHTMLToFragment( $doc, $html ),
358            $decoded['fragments'] ?? []
359        );
360        // Backward compatibility with Parsoid < 0.23
361        if ( !isset( $decoded['counters'] ) ) {
362            $decoded['counters'] = [
363                'nodedata' => $decoded['parsoid']['counter'] ?? -1,
364                'annotation' => -1,
365                'transclusion' => -1,
366            ];
367        }
368        if ( isset( $decoded['parsoid']['counter'] ) ) {
369            unset( $decoded['parsoid']['counter'] );
370        }
371        return new DomPageBundle(
372            $doc,
373            $decoded['parsoid'] ?? null,
374            $decoded['mw'] ?? null,
375            $decoded['counters'] ?? null,
376            $options['contentversion'] ?? null,
377            $options['headers'] ?? null,
378            $options['contentmodel'] ?? null,
379            $fragments,
380        );
381    }
382
383    private static function headElementHint(): Hint {
384        // @phan-suppress-next-line PhanUndeclaredClassReference array
385        return Hint::build( 'array', Hint::LIST, Hint::LIST, Hint::LIST );
386    }
387
388    // JsonCodecable -------------
389
390    /** @inheritDoc */
391    public function toJsonArray(): array {
392        Assert::invariant( !$this->invalid, "invalidated" );
393        return HtmlPageBundle::fromDomPageBundle( $this )->toJsonArray();
394    }
395
396    /** @inheritDoc */
397    public static function newFromJsonArray( array $json ): DomPageBundle {
398        $pb = HtmlPageBundle::newFromJsonArray( $json );
399        return self::fromHtmlPageBundle( $pb );
400    }
401}