Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.94% covered (warning)
67.94%
142 / 209
41.38% covered (danger)
41.38%
12 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentHolder
67.94% covered (warning)
67.94%
142 / 209
41.38% covered (danger)
41.38%
12 / 29
266.29
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
 detectBodyOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createFromLegacyString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createFromParsoidPageBundle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createFromPageBundle
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
2.11
 createEmpty
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 isParsoidContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getAsHtmlString
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getAsDom
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setAsHtmlString
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
6.20
 prependHtmlString
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 prependDom
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 appendHtmlString
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 appendDom
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 setAsDom
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
5.06
 createFragment
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 addFragment
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getFragmentNames
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getBasePageBundle
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 convertHtmlToDom
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
5
 convertDomToHtml
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
5
 __clone
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 preferDom
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasContent
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 toJsonArray
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 newFromJsonArray
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 jsonClassHintFor
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeForObjectEquality
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4/**
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Parser;
10
11use MediaWiki\MediaWikiServices;
12use Wikimedia\Assert\Assert;
13use Wikimedia\JsonCodec\CodecService;
14use Wikimedia\JsonCodec\Hint;
15use Wikimedia\JsonCodec\JsonCodecable;
16use Wikimedia\JsonCodec\JsonCodecableWithServicesTrait;
17use Wikimedia\Parsoid\Config\SiteConfig;
18use Wikimedia\Parsoid\Core\BasePageBundle;
19use Wikimedia\Parsoid\Core\DOMCompat;
20use Wikimedia\Parsoid\Core\DomPageBundle;
21use Wikimedia\Parsoid\Core\HtmlPageBundle;
22use Wikimedia\Parsoid\DOM\Document;
23use Wikimedia\Parsoid\DOM\DocumentFragment;
24use Wikimedia\Parsoid\Ext\DOMUtils;
25use Wikimedia\Parsoid\Utils\ContentUtils;
26use Wikimedia\Parsoid\Utils\DOMDataUtils;
27
28/**
29 * @internal
30 * @unstable
31 * @since 1.45
32 *
33 * A ContentHolder holds a map of fragments that can be HTML string or DOM fragments.
34 * It should, as much as possible, be used in a consistent format and/or limit format switches, as conversions of
35 * all the fragments happen on all accessors, which has a performance impact.
36 *
37 * The canonical body fragment is body-only: getAsHtmlString( BODY_FRAGMENT )
38 * returns just the <body> contents, and the full document (with <head>) is
39 * generated when needed with
40 * PageBundleParserOutputConverter::htmlPageBundleFromParserOutput().
41 * Pre-MW-1.47 ParserCache entries may still contain a full HTML document
42 * in the BODY_FRAGMENT, and that wrapper is stripped in ::getAsHtmlString().
43 */
44class ContentHolder implements JsonCodecable {
45    use JsonCodecableWithServicesTrait;
46
47    public const BODY_FRAGMENT = "body";
48
49    private function __construct(
50        private Document $ownerDocument,
51        private ?BasePageBundle $pageBundle = null,
52        /**
53         * Contains the string representation of the fragments.
54         * $htmlMap[BODY_FRAGMENT] is body-only for new content, but may still be
55         * a full document for legacy ParserCache entries (tracked by
56         * $fullDocument and stripped lazily by getAsHtmlString()).
57         * @var array<string,string>
58         */
59        private array $htmlMap = [],
60        /**
61         * $domMap[BODY_FRAGMENT] does not contain the <body> tag
62         * @var array<string,DocumentFragment>
63         */
64        private array $domMap = [],
65        private bool $isParsoidContent = false,
66        private bool $domFormat = false,
67        private ?SiteConfig $siteConfig = null,
68        /**
69         * False if $htmlMap[BODY_FRAGMENT] currently holds a *full document*
70         * (with a <body> tag and possibly a <head>) rather than a body
71         * fragment.
72         * @see ::getAsHtmlString() (T393925)
73         */
74        private bool $bodyOnly = true,
75    ) {
76    }
77
78    /**
79     * Helper: does this HTML string carry a full document or a bare
80     * body fragment? Checks '<body', matching Parser::extractBody().
81     */
82    private static function detectBodyOnly( string $html ): bool {
83        return !str_contains( $html, '<body' );
84    }
85
86    /**
87     * Create a ContentHolder from a legacy body HTML string, typically
88     * returned by the legacy parser.
89     */
90    public static function createFromLegacyString( string $html ): ContentHolder {
91        $ch = new ContentHolder(
92            ownerDocument: DOMCompat::newDocument(),
93            htmlMap: [ self::BODY_FRAGMENT => $html ],
94            isParsoidContent: false,
95        );
96        return $ch;
97    }
98
99    /**
100     * Create a ContentHolder from a Parsoid HtmlPageBundle.
101     */
102    public static function createFromParsoidPageBundle(
103        HtmlPageBundle $pb,
104        ?SiteConfig $siteConfig = null
105    ): ContentHolder {
106        return static::createFromPageBundle(
107            $pb, isParsoidContent: true, siteConfig: $siteConfig,
108        );
109    }
110
111    /**
112     * Create a ContentHolder from an HtmlPageBundle; this doesn't have
113     * to be Parsoid content.
114     */
115    public static function createFromPageBundle(
116        HtmlPageBundle $pb,
117        bool $isParsoidContent,
118        ?SiteConfig $siteConfig = null
119    ): ContentHolder {
120        $htmlMap = [
121            self::BODY_FRAGMENT => $pb->html,
122        ] + $pb->fragments;
123        if ( !$isParsoidContent ) {
124            $ch = new ContentHolder(
125                ownerDocument: DOMCompat::newDocument(),
126                pageBundle: $pb->toBasePageBundle(),
127                htmlMap: $htmlMap,
128                isParsoidContent: false,
129                bodyOnly: self::detectBodyOnly( $pb->html ),
130            );
131        } else {
132            $siteConfig ??= MediaWikiServices::getInstance()->getParsoidSiteConfig();
133            $ch = new ContentHolder(
134                ownerDocument: ContentUtils::createAndLoadDocument(
135                    '', siteConfig: $siteConfig,
136                ),
137                pageBundle: $pb->toBasePageBundle(),
138                htmlMap: $htmlMap,
139                isParsoidContent: true,
140                siteConfig: $siteConfig,
141                bodyOnly: self::detectBodyOnly( $pb->html ),
142            );
143        }
144        return $ch;
145    }
146
147    /**
148     * Creates an empty ContentHolder that can be used as a placeholder.
149     *
150     * This does not contain any body content.
151     */
152    public static function createEmpty(): ContentHolder {
153        return new ContentHolder(
154            ownerDocument: DOMCompat::newDocument(),
155            isParsoidContent: false,
156        );
157    }
158
159    /**
160     * Returns true if this ContentHolder contains Parsoid-generated
161     * content.
162     */
163    public function isParsoidContent(): bool {
164        return $this->isParsoidContent;
165    }
166
167    /**
168     * Returns false if the designated fragment is not present in the
169     * ContentHolder.
170     */
171    public function has( string $fragmentName ): bool {
172        return isset( $this->htmlMap[$fragmentName] ) || isset( $this->domMap[$fragmentName] );
173    }
174
175    /**
176     * Returns the designated fragment as an HTML string version, or null if
177     * it is not present.
178     *
179     * @note If a conversion is needed, at present all fragments of the
180     * document are converted to HTML strings.
181     * @note The BODY_FRAGMENT will always return body-only content
182     * (T393925); use
183     * PageBundleParserOutputConverter::htmlPageBundleFromParserOutput()
184     * with `bodyOnly: false` to obtain a full document.
185     */
186    public function getAsHtmlString( string $fragmentName = self::BODY_FRAGMENT ): ?string {
187        if ( $this->domFormat ) {
188            $this->convertDomToHtml();
189        }
190        $html = $this->htmlMap[$fragmentName] ?? null;
191        if ( $fragmentName === self::BODY_FRAGMENT && $html !== null && !$this->bodyOnly ) {
192            // The body fragment currently holds a full document; strip the
193            // wrapper so callers see body-only content.
194            $html = Parser::extractBody( $html );
195        }
196        return $html;
197    }
198
199    /**
200     * Returns the designated fragment as a DOM DocumentFragment, or null if
201     * it is not present.
202     *
203     * @note If a conversion is needed, at present all fragments of the
204     * document are converted to DOM DocumentFragments.
205     */
206    public function getAsDom( string $fragmentName = self::BODY_FRAGMENT ): ?DocumentFragment {
207        if ( !$this->domFormat ) {
208            $this->convertHtmlToDom();
209        }
210        return $this->domMap[$fragmentName] ?? null;
211    }
212
213    /**
214     * Sets or removes a fragment, provided as an HTML string.
215     * @param string $fragmentName name of the fragment to set
216     * @param string|null $html string of the fragment to set, or null to
217     *  remove a fragment.
218     * @param-taint $html exec_html
219     * @return void
220     *
221     * @note The self::BODY_FRAGMENT should not contain the top-level <body>
222     * tag.
223     * @note All fragments may be converted to HTML strings as a side-effect.
224     */
225    public function setAsHtmlString( string $fragmentName = self::BODY_FRAGMENT, ?string $html = null ): void {
226        if ( $this->domFormat && $html !== null ) {
227            // Convert just this fragment if we can.
228            $this->setAsDom( $fragmentName, $this->createFragment( $html ) );
229            return;
230        }
231
232        // no need to convert the fragment that we're going to replace
233        unset( $this->domMap[ $fragmentName ] );
234
235        if ( $html === null ) {
236            unset( $this->htmlMap[$fragmentName] );
237            return;
238        }
239
240        Assert::invariant( !$this->domFormat, "should be in HTML format" );
241
242        if ( $fragmentName === self::BODY_FRAGMENT ) {
243            $this->bodyOnly = self::detectBodyOnly( $html );
244            // T393925: Eventually we'll deprecate calling this method
245            // with a full document.
246        }
247        $this->htmlMap[ $fragmentName ] = $html;
248    }
249
250    /**
251     * Helper method to prepend the given HTML string to the
252     * named content fragment.
253     */
254    public function prependHtmlString( string $html, string $fragmentName = self::BODY_FRAGMENT ): void {
255        if ( $this->domFormat ) {
256            $df = $this->createFragment( $html );
257            $this->prependDom( $df, $fragmentName );
258        } else {
259            $prev = $this->getAsHtmlString( $fragmentName ) ?? '';
260            $this->htmlMap[ $fragmentName ] = $html . $prev;
261        }
262    }
263
264    /**
265     * Helper method to prepend the given DocumentFragment to the
266     * named content fragment.
267     */
268    public function prependDom( DocumentFragment $df, string $fragmentName = self::BODY_FRAGMENT ) {
269        if ( !$this->domFormat ) {
270            // Not worth optimizing, because you almost certainly were in
271            // DOM format already in order to generate $df with the right
272            // owner. We'll still convert here for correctness in corner cases.
273            $this->convertHtmlToDom();
274        }
275        Assert::invariant( $df->ownerDocument === $this->ownerDocument,
276            "Fragment not owned by the ContentHolder document." );
277
278        $prev = $this->domMap[ $fragmentName ] ??
279              $this->ownerDocument->createDocumentFragment();
280        // @phan-suppress-next-line PhanParamTooManyInternal phan bug
281        $prev->prepend( $df );
282        $this->domMap[ $fragmentName ] = $prev;
283    }
284
285    /**
286     * Helper method to append the given HTML string to the
287     * named content fragment.
288     */
289    public function appendHtmlString( string $html, string $fragmentName = self::BODY_FRAGMENT ): void {
290        if ( $this->domFormat ) {
291            $df = $this->createFragment( $html );
292            $this->appendDom( $df, $fragmentName );
293        } else {
294            $prev = $this->getAsHtmlString( $fragmentName ) ?? '';
295            $this->htmlMap[ $fragmentName ] = $prev . $html;
296        }
297    }
298
299    /**
300     * Helper method to append the given DocumentFragment to the
301     * named content fragment.
302     */
303    public function appendDom( DocumentFragment $df, string $fragmentName = self::BODY_FRAGMENT ) {
304        if ( !$this->domFormat ) {
305            // Not worth optimizing, because you almost certainly were in
306            // DOM format already in order to generate $df with the right
307            // owner. We'll still convert here for correctness in corner cases.
308            $this->convertHtmlToDom();
309        }
310        Assert::invariant( $df->ownerDocument === $this->ownerDocument,
311            "Fragment not owned by the ContentHolder document." );
312
313        $prev = $this->domMap[ $fragmentName ] ??
314              $this->ownerDocument->createDocumentFragment();
315        // @phan-suppress-next-line PhanParamTooManyInternal phan bug
316        $prev->append( $df );
317        $this->domMap[ $fragmentName ] = $prev;
318    }
319
320    /**
321     * Sets or removes a fragment, provided as a DOM DocumentFragment.
322     * @param string $fragmentName name of the fragment to set
323     * @param DocumentFragment|null $fragment the fragment to set, or null to
324     *   remove a fragment.
325     * @return void
326     *
327     * @note The self::BODY_FRAGMENT should not contain the top-level <body>
328     * tag.
329     * @note All fragments may be converted to DOM DocumentFragments as a
330     * side-effect.
331     */
332    public function setAsDom( string $fragmentName = self::BODY_FRAGMENT, ?DocumentFragment $fragment = null ) {
333        // Note that it doesn't really make sense to special case setting
334        // a DocumentFragment when !$this->domFormat, because in order to
335        // create a DocumentFragment owned by the ContentHolder document
336        // we must have already done convertHtmlToDom().
337
338        // no need to convert the fragment that we're going to replace
339        unset( $this->htmlMap[ $fragmentName ] );
340
341        if ( $fragment === null ) {
342            unset( $this->domMap[ $fragmentName ] );
343            return;
344        }
345
346        if ( !$this->domFormat ) {
347            $this->convertHtmlToDom();
348        }
349
350        Assert::invariant( $fragment->ownerDocument === $this->ownerDocument,
351            "Fragment not owned by the ContentHolder document." );
352
353        $firstChild = $fragment->firstElementChild;
354        if ( $fragmentName === self::BODY_FRAGMENT ) {
355            if ( $firstChild ) {
356                Assert::invariant( DOMUtils::nodeName( $firstChild ) !== "body",
357                    "Body fragment should not contain a body tag" );
358            }
359            // A DOM body fragment never carries the <body> wrapper.
360            $this->bodyOnly = true;
361        }
362
363        $this->domMap[$fragmentName] = $fragment;
364    }
365
366    public function createFragment( ?string $html = null ): DocumentFragment {
367        if ( !$this->domFormat ) {
368            $this->convertHtmlToDom();
369        }
370        if ( $html === null ) {
371            return $this->ownerDocument->createDocumentFragment();
372        }
373        if ( $this->isParsoidContent() ) {
374            return ContentUtils::createAndLoadDocumentFragment( $this->ownerDocument, $html );
375        }
376        return DOMUtils::parseHTMLToFragment( $this->ownerDocument, $html );
377    }
378
379    public function addFragment( string $name = self::BODY_FRAGMENT, ?string $html = null ): DocumentFragment {
380        $frag = $this->createFragment( $html );
381        $this->setAsDom( $name, $frag );
382        return $frag;
383    }
384
385    /**
386     * Return all the fragment names.  You can pass `bodyFirst: true` in
387     * cases where the order of fragments matters, which will ensure that
388     * the first element is self::BODY_FRAGMENT (if it exists in this
389     * ContentHolder).
390     * @param bool $bodyFirst Pass true to get a repeatable sorted list
391     *  of fragments with the body fragment first.
392     * @return list<string>
393     */
394    public function getFragmentNames( bool $bodyFirst = false ): array {
395        $names = array_keys( $this->domFormat ? $this->domMap : $this->htmlMap );
396        // Just in case someone uses a numeric fragment name, make sure the
397        // result is a string.
398        $names = array_map( strval( ... ), $names );
399        if ( $bodyFirst ) {
400            // Sort names so that the fragment name order is reproducible
401            sort( $names );
402            // Now put BODY_FRAGMENT first, if it was present.
403            $idx = array_search( self::BODY_FRAGMENT, $names );
404            if ( $idx !== false ) {
405                array_splice( $names, 0, 0, array_splice( $names, $idx, 1 ) );
406            }
407        }
408        return $names;
409    }
410
411    /**
412     * Return the BasePageBundle of the ContentHolder.
413     * @internal
414     */
415    public function getBasePageBundle(): BasePageBundle {
416        if ( $this->isParsoidContent() && $this->domFormat ) {
417            // Ensure that data-parsoid and data-mw are serialized into
418            // the page bundle.
419            $this->convertDomToHtml();
420        }
421        if ( $this->pageBundle === null ) {
422            $this->pageBundle = new BasePageBundle();
423        }
424        return $this->pageBundle;
425    }
426
427    private function convertHtmlToDom() {
428        if ( $this->domFormat ) {
429            return;
430        }
431        if ( $this->isParsoidContent() ) {
432            $hasBody = $this->has( self::BODY_FRAGMENT );
433            $fragments = $this->htmlMap;
434            $html = $fragments[ self::BODY_FRAGMENT ] ?? '';
435            unset( $fragments[ self::BODY_FRAGMENT ] );
436            $dpb = DomPageBundle::fromHtmlPageBundle(
437                $this->pageBundle->withHtml( $html, $fragments )
438            );
439            $this->ownerDocument = $dpb->toDom(
440                siteConfig: $this->siteConfig,
441            );
442            $this->domMap = $dpb->fragments;
443            if ( $hasBody ) {
444                $frag = $this->ownerDocument->createDocumentFragment();
445                DOMUtils::migrateChildren( DOMCompat::getBody( $this->ownerDocument ), $frag );
446                $this->domMap[ self::BODY_FRAGMENT ] = $frag;
447            }
448        } else {
449            foreach ( $this->htmlMap as $name => $html ) {
450                $this->domMap[$name] = DOMUtils::parseHTMLToFragment(
451                    $this->ownerDocument, $html
452                );
453            }
454        }
455        $this->htmlMap = [];
456        $this->domFormat = true;
457    }
458
459    private function convertDomToHtml() {
460        if ( !$this->domFormat ) {
461            return;
462        }
463        if ( $this->isParsoidContent() ) {
464            $body = $this->domMap[ self::BODY_FRAGMENT ] ?? null;
465            unset( $this->domMap[ self::BODY_FRAGMENT ] );
466            if ( $body !== null ) {
467                DOMCompat::appendChild(
468                    DOMCompat::getBody( $this->ownerDocument ),
469                    $body
470                );
471            }
472            $pb = HtmlPageBundle::fromDomPageBundle(
473                DomPageBundle::fromLoadedDocument(
474                    $this->ownerDocument,
475                    // If isParsoidContent then siteConfig will be non-null.
476                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
477                    siteConfig: $this->siteConfig,
478                    options: [ 'pageBundle' => $this->pageBundle, ],
479                    fragments: $this->domMap,
480                ), [
481                    'body_only' => true,
482                ]
483            );
484            $this->pageBundle = $pb->toBasePageBundle();
485            $this->htmlMap = $pb->fragments;
486            $this->htmlMap[self::BODY_FRAGMENT] = $pb->html;
487            // A DOM round-trip serializes with body_only=true (and drops the
488            // <head>), so the body fragment is now body-only.
489            $this->bodyOnly = true;
490        } else {
491            foreach ( $this->domMap as $name => $df ) {
492                $this->htmlMap[ $name ] = ContentUtils::toXML( $df, [ 'innerXML' => true ] );
493            }
494        }
495        $this->domMap = [];
496        $this->domFormat = false;
497    }
498
499    public function __clone() {
500        $this->ownerDocument = DOMDataUtils::cloneDocument( $this->ownerDocument );
501        foreach ( $this->domMap as $name => &$fragment ) {
502            $fragment = $this->ownerDocument->importNode( $fragment, true );
503        }
504        if ( $this->pageBundle ) {
505            $this->pageBundle = clone $this->pageBundle;
506        }
507    }
508
509    /**
510     * Given a ContentHolderTransformStage that has two valid transform options, returns true if the state of the
511     * ContentHolder calls for a DOM transform, false if it calls for a text transform.
512     * Right now, this is strictly directed by whether the ContentHolder is in DOM format or in HTML format;
513     * this might change in the future if we maintain both formats in some cases.
514     */
515    public function preferDom(): bool {
516        return $this->domFormat;
517    }
518
519    public function hasContent(): bool {
520        return $this->domMap || $this->htmlMap ||
521            ( $this->pageBundle?->hasContent() );
522    }
523
524    // Implement JsonCodecable; make the ParsoidSiteConfig service available.
525
526    public function toJsonArray(): array {
527        $this->convertDomToHtml();
528        $data = [];
529        if ( $this->htmlMap ) {
530            $data['htmlMap'] = $this->htmlMap;
531        }
532        if ( $this->isParsoidContent ) {
533            $data['parsoid'] = $this->isParsoidContent;
534        }
535        if ( $this->pageBundle && $this->pageBundle?->hasContent() ) {
536            $data['pageBundle'] = $this->pageBundle;
537        }
538        return $data;
539    }
540
541    public static function newFromJsonArray(
542        array $json,
543        #[CodecService( 'ParsoidSiteConfig' )] SiteConfig $siteConfig,
544    ): ContentHolder {
545        $holder = self::createEmpty();
546        $holder->isParsoidContent = $json['parsoid'] ?? false;
547        if ( $holder->isParsoidContent ) {
548            $holder->siteConfig = $siteConfig;
549        }
550        $holder->convertDomToHtml(); // ensure in html form
551        $holder->pageBundle = $json['pageBundle'] ?? ( $holder->isParsoidContent ? new BasePageBundle() : null );
552        if ( isset( $json['htmlMap'] ) ) {
553            $holder->htmlMap = $json['htmlMap'];
554            // Recognize legacy cache entries whose stored body fragment is
555            // actually a full document (aka where bodyOnly=false)
556            $body = $json['htmlMap'][self::BODY_FRAGMENT] ?? '';
557            $holder->bodyOnly = self::detectBodyOnly( $body );
558        }
559        return $holder;
560    }
561
562    /** @inheritDoc */
563    public static function jsonClassHintFor( string $keyName ) {
564        return match ( $keyName ) {
565            'pageBundle' => Hint::build( BasePageBundle::class ),
566            default => null,
567        };
568    }
569
570    /** Helper for serialization compatibility testing. */
571    private static function normalizeForObjectEquality(): array {
572        return [ 'ownerDocument' => false, 'siteConfig' => false, 'bodyOnly' => false ];
573    }
574}