Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.31% covered (success)
90.31%
205 / 227
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageBundleParserOutputConverter
90.31% covered (success)
90.31%
205 / 227
40.00% covered (danger)
40.00%
4 / 10
45.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parserOutputFromPageBundle
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 pageBundleFromParserOutput
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 htmlPageBundleFromParserOutput
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 basePageBundleFromParserOutput
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
5.12
 hasPageBundle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMetadataMap
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 addMetadataToDocument
95.92% covered (success)
95.92%
94 / 98
0.00% covered (danger)
0.00%
0 / 1
21
 updateBodyClasslist
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 appendToHead
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Parser\Parsoid;
5
6use MediaWiki\Language\Language;
7use MediaWiki\Language\LanguageCode;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Page\PageReference;
10use MediaWiki\Parser\ContentHolder;
11use MediaWiki\Parser\ParserOutput;
12use MediaWiki\SpecialPage\SpecialPage;
13use MediaWiki\Title\Title;
14use MediaWiki\Utils\MWTimestamp;
15use Wikimedia\Assert\Assert;
16use Wikimedia\Bcp47Code\Bcp47CodeValue;
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\Core\LinkTarget as ParsoidLinkTarget;
23use Wikimedia\Parsoid\DOM\Document;
24use Wikimedia\Parsoid\DOM\Element;
25use Wikimedia\Parsoid\Ext\DOMUtils;
26use Wikimedia\Parsoid\Parsoid;
27use Wikimedia\Timestamp\TimestampFormat;
28
29/**
30 * Provides methods for conversion between HtmlPageBundle and ParserOutput
31 *
32 * ParserOutput typically contains only the article content HTML (ie,
33 * without `<body>` tags), while a HtmlPageBundle can contain an entire
34 * document including `<html>` wrapper, metadata in the `<head>`, and
35 * article content inside a `<body>` tag.
36 *
37 * @since 1.40
38 * @internal
39 */
40final class PageBundleParserOutputConverter {
41    /**
42     * @var string Key used to store parsoid page bundle data in ParserOutput
43     * @deprecated since 1.45; use ParserOutput::PARSOID_PAGE_BUNDLE_KEY
44     */
45    public const PARSOID_PAGE_BUNDLE_KEY = ParserOutput::PARSOID_PAGE_BUNDLE_KEY;
46
47    /**
48     * We do not want instances of this class to be created
49     * @return void
50     */
51    private function __construct() {
52    }
53
54    /**
55     * Creates a ParserOutput object containing the relevant data from
56     * the given HtmlPageBundle object.
57     *
58     * We need to inject data-parsoid and other properties into the
59     * parser output object for caching, so we can use it for VE edits
60     * and transformations.
61     *
62     * @param HtmlPageBundle $pageBundle
63     * @param ?ParserOutput $originalParserOutput Any non-parsoid metadata
64     *  from $originalParserOutput will be copied into the new ParserOutput object.
65     * @param ParsoidLinkTarget|PageReference|null $title The given title will
66     *  be copied into the new ParserOutput object.
67     * @param ?SiteConfig $siteConfig
68     *
69     * @return ParserOutput
70     */
71    public static function parserOutputFromPageBundle(
72        HtmlPageBundle $pageBundle,
73        ?ParserOutput $originalParserOutput = null,
74        // phpcs:ignore MediaWiki.Usage.NullableType.ExplicitNullableTypes
75        ParsoidLinkTarget|PageReference|null $title = null,
76        ?SiteConfig $siteConfig = null,
77    ): ParserOutput {
78        $siteConfig ??= MediaWikiServices::getInstance()->getParsoidSiteConfig();
79        $parserOutput = new ParserOutput();
80        $parserOutput->setContentHolder(
81            ContentHolder::createFromParsoidPageBundle( $pageBundle, $siteConfig )
82        );
83        if ( $originalParserOutput ) {
84            // Merging metadata from the original parser output will also
85            // potentially transfer fragments from
86            // $originalParserOutput->getContentHolder() to
87            // $parserOutput->getContentHolder()
88            $originalParserOutput->collectMetadata( $parserOutput );
89        }
90        if ( $title !== null ) {
91            $parserOutput->setTitle( $title );
92        }
93        if ( isset( $pageBundle->headers['content-language'] ) ) {
94            $lang = LanguageCode::normalizeNonstandardCodeAndWarn(
95                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
96                $pageBundle->headers['content-language']
97            );
98            $parserOutput->setLanguage( $lang );
99        }
100        if ( isset( $pageBundle->headers['x-mediawiki-render-id'] ) ) {
101            $parserOutput->setRenderId(
102                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
103                $pageBundle->headers['x-mediawiki-render-id']
104            );
105        }
106        return $parserOutput;
107    }
108
109    /**
110     * Returns a Parsoid HtmlPageBundle equivalent to the given ParserOutput.
111     * @param ParserOutput $parserOutput
112     *
113     * @return HtmlPageBundle
114     * @deprecated Use ::htmlPageBundleFromParserOutput
115     */
116    public static function pageBundleFromParserOutput( ParserOutput $parserOutput ): HtmlPageBundle {
117        wfDeprecated( __METHOD__, '1.46' );
118        return self::htmlPageBundleFromParserOutput(
119            $parserOutput,
120            MediaWikiServices::getInstance()->getParsoidSiteConfig(),
121            true,
122        );
123    }
124
125    /**
126     * Returns a Parsoid HtmlPageBundle equivalent to the given ParserOutput.
127     * @param ParserOutput $parserOutput
128     * @param SiteConfig $siteConfig ParsoidSiteConfig service
129     * @param bool $bodyOnly If false, returns a full document with
130     *  metadata in the <head>.  If true, the `html` section of
131     *  the PageBundle returns the inner HTML of the <body> element
132     *  only.
133     * @return HtmlPageBundle
134     */
135    public static function htmlPageBundleFromParserOutput(
136        ParserOutput $parserOutput,
137        SiteConfig $siteConfig,
138        bool $bodyOnly = false,
139    ): HtmlPageBundle {
140        $bpb = self::basePageBundleFromParserOutput( $parserOutput );
141        $html = $parserOutput->getContentHolderText();
142        if ( !$bodyOnly ) {
143            $document = DOMCompat::newDocument();
144            self::addMetadataToDocument( $parserOutput, $siteConfig, $bpb, $document );
145            // Add selected header information from page bundle to the <head>
146            foreach ( [ 'content-language', 'vary', 'x-mediawiki-render-id' ] as $h ) {
147                if ( isset( $bpb->headers[$h] ) ) {
148                    self::appendToHead( $document, 'meta', [
149                        'http-equiv' => $h,
150                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
151                        'content' => $bpb->headers[$h],
152                    ] );
153                }
154            }
155            // We don't want to parse the content holder to DOM just to
156            // reserialize as HTML, so serialize just the full document
157            // wrapper, and then use string concatenation to slip the
158            // body HTML before the `</body>` tag.
159            $fulldoc = HtmlPageBundle::fromDomPageBundle(
160                DomPageBundle::newEmpty( $document ), [
161                    'addDoctype' => true,
162                ] )->html;
163            $posEnd = strrpos( $fulldoc, '</body>' );
164            Assert::invariant(
165                $posEnd !== false, "should have <body>"
166            );
167            $html = substr( $fulldoc, 0, $posEnd ) .
168                  $html .
169                  substr( $fulldoc, $posEnd );
170        }
171        $pb = $bpb->withHtml( $html );
172        // NOTE that the fragments from the ContentHolder are missing
173        // from this page bundle.  It is assumed that the fragments
174        // are referenced from other parts of the ParserOutput; aka that
175        // they are loaded/saved as part of ParserOutput::$mIndicators
176        return $pb;
177    }
178
179    private static function basePageBundleFromParserOutput( ParserOutput $parserOutput ): BasePageBundle {
180        $contentHolder = $parserOutput->getContentHolder();
181        $basePageBundle = $contentHolder->isParsoidContent() ?
182            $contentHolder->getBasePageBundle() :
183            new BasePageBundle(
184                parsoid: [ 'ids' => [] ],
185                headers: [],
186                // It would be nice to have this be "null", but
187                // ParsoidFormatHelper chokes on that: T325137.
188                version: '0.0.0',
189            );
190
191        $lang = $parserOutput->getLanguage();
192        if ( $lang ) {
193            $basePageBundle->headers ??= [];
194            $basePageBundle->headers['content-language'] = $lang->toBcp47Code();
195        }
196
197        $renderid = $parserOutput->getRenderId();
198        if ( $renderid !== null ) {
199            $basePageBundle->headers ??= [];
200            $basePageBundle->headers['x-mediawiki-render-id'] = $renderid;
201        }
202        return $basePageBundle;
203    }
204
205    public static function hasPageBundle( ParserOutput $parserOutput ): bool {
206        return $parserOutput->getContentHolder()->isParsoidContent();
207    }
208
209    private static function getMetadataMap( string $key ): ?array {
210        static $map = null;
211        $map ??= [
212            'ns' => [
213                'property' => 'mw:pageNamespace',
214                'content' => '%d',
215            ],
216            'id' => [
217                'property' => 'mw:pageId',
218                'content' => '%d',
219            ],
220
221            // DO NOT ADD rev_user, rev_userid, and rev_comment (See T125266)
222            // 'rev_revid' is used to set the overall subject of the document, we don't
223            // need to add a specific <meta> or <link> element for it.
224
225            'rev_parentid' => [
226                'rel' => 'dc:replaces',
227                'resource' => 'mwr:revision/%d',
228            ],
229            'rev_timestamp' => [
230                'property' => 'dc:modified',
231                'content' => static fn ( $m ) =>
232                    # Convert from TS_MW ("mediawiki timestamp") format
233                    MWTimestamp::fromMW( $m['rev_timestamp'] )->
234                        getTimestamp( TimestampFormat::ISO_8601 ),
235            ],
236            'rev_sha1' => [
237                'property' => 'mw:revisionSHA1',
238                'content' => '%s',
239            ]
240        ];
241        return $map[$key] ?? null;
242    }
243
244    /**
245     * Add information to the document <head> corresponding to metadata
246     * stored in the ParserOutput.
247     */
248    private static function addMetadataToDocument(
249        ParserOutput $parserOutput, SiteConfig $siteConfig,
250        BasePageBundle $pb, Document $document
251    ): void {
252        // This method is a direct port/translation of the AddMetaData
253        // DOM pipeline stage in Parsoid. The intention is for the Parsoid
254        // stage to be eventually removed entirely in favor of this
255        // implementation in core (T393925) so new feature development
256        // (metadata additions to <head>) should happen here, not in Parsoid.
257        Assert::invariant(
258            DOMCompat::getHead( $document )?->firstChild === null,
259            "head should be empty"
260        );
261        // Set the charset in the <head> first.
262        // This also adds the <head> element if it was missing.
263        self::appendToHead( $document, 'meta', [ 'charset' => 'utf-8' ] );
264
265        // add mw: and mwr: RDFa prefixes
266        $prefixes = [
267            'dc: http://purl.org/dc/terms/',
268            'mw: http://mediawiki.org/rdf/'
269        ];
270        $document->documentElement->setAttribute( 'prefix', implode( ' ', $prefixes ) );
271
272        $mwrPrefix = SpecialPage::getTitleFor( 'Redirect', '' )->getFullURL();
273        ( DOMCompat::getHead( $document ) )->setAttribute( 'prefix', 'mwr: ' . $mwrPrefix );
274
275        // add <head> content based on page meta data:
276        $revProps = [];
277        $title = $parserOutput->getTitle();
278        $revId = $parserOutput->getCacheRevisionId();
279        $revRecord = null;
280        if ( $revId ) {
281            $revLookup = MediaWikiServices::getInstance()->getRevisionLookup();
282            $revRecord = $revLookup->getRevisionById( $revId );
283        }
284        if ( $revRecord !== null ) {
285            $revProps += [
286                'rev_parentid' => $revRecord->getParentId(),
287                'rev_revid' => $revRecord->getId(),
288                'rev_sha1' => $revRecord->getSha1(),
289                'rev_timestamp' => $revRecord->getTimestamp(),
290            ];
291            // If both Revision ID and Title as provided; revision overrides
292            // (never output contradictory title and revision information)
293            $title = $revRecord->getPageAsLinkTarget();
294        }
295        if ( $title !== null ) {
296            $title = Title::newFromLinkTarget( $title );
297            if ( $title !== null ) {
298                $revProps['ns'] = $title->getNamespace();
299            }
300            if ( $title?->canExist() ) {
301                $revProps['id'] = $title->getId();
302            }
303        }
304        $revProps['rev_revid'] ??= $parserOutput->getCacheRevisionId();
305        $revProps['rev_timestamp'] ??= $parserOutput->getRevisionTimestamp();
306        foreach ( $revProps as $key => $value ) {
307            // generate proper attributes for the <meta> or <link> tag
308            if ( $value === null || $value === '' || self::getMetadataMap( $key ) === null ) {
309                continue;
310            }
311
312            $attrs = [];
313            foreach ( self::getMetadataMap( $key ) as $k => $v ) {
314                // evaluate a function, or perform sprintf-style formatting, or
315                // use string directly, depending on value in metadataMap
316                if ( $v instanceof \Closure ) {
317                    $v = $v( $revProps );
318                } elseif ( str_contains( $v, '%' ) ) {
319                    $v = sprintf( $v, $value );
320                }
321                $attrs[$k] = $v;
322            }
323
324            // <link> is used if there's a resource or href attribute.
325            self::appendToHead( $document,
326                isset( $attrs['resource'] ) || isset( $attrs['href'] ) ? 'link' : 'meta',
327                $attrs
328            );
329        }
330
331        if ( $revProps['rev_revid'] ) {
332            $document->documentElement->setAttribute(
333                'about', $mwrPrefix . 'revision/' . $revProps['rev_revid']
334            );
335        }
336
337        // Normalize before comparison
338        if ( $title?->isSameLinkAs( Title::newMainPage() ) ) {
339            self::appendToHead( $document, 'meta', [
340                'property' => 'isMainPage',
341                'content' => 'true' /* HTML attribute values should be strings */
342            ] );
343        }
344
345        if ( $parserOutput->getContentHolder()->isParsoidContent() ) {
346            // Set the parsoid content-type strings
347            $htmlVersion = $pb->version ??
348                $parserOutput->getExtensionData( 'core:html-version' ) ??
349                Parsoid::defaultHTMLVersion();
350            // FIXME: Should we be using http-equiv for this?
351            self::appendToHead( $document, 'meta', [
352                'property' => 'mw:htmlVersion',
353                'content' => $htmlVersion,
354            ] );
355            // Temporary backward compatibility for clients
356            // This could be skipped if we support a version downgrade path
357            // with a major version bump.
358            self::appendToHead( $document, 'meta', [
359                'property' => 'mw:html:version',
360                'content' => $htmlVersion,
361            ] );
362
363            // Add base href pointing to the wiki root
364            $baseUri = $parserOutput->getExtensionData( 'core:base-uri' )
365                     ?? $siteConfig->baseURI();
366            self::appendToHead( $document, 'base', [
367                'href' => $baseUri
368            ] );
369        }
370
371        if ( $title !== null ) {
372            self::appendToHead( $document, 'link', [
373                'rel' => 'dc:isVersionOf',
374                'href' => $title->getFullURL(),
375            ] );
376            // T324431: Note that this is *not* the displaytitle, and that
377            // the title element contents are plaintext *not* HTML
378            DOMCompat::setTitle( $document, $title->getPrefixedText() );
379        }
380
381        // Ensure there's a <body>
382        if ( DOMCompat::getBody( $document ) === null ) {
383            DOMCompat::append(
384                $document->documentElement,
385                $document->createElement( 'body' )
386            );
387        }
388
389        // Set properties of <body>
390        $lang = $parserOutput->getLanguage();
391        if ( $lang !== null ) {
392            $lang = MediaWikiServices::getInstance()->getLanguageFactory()
393                ->getLanguage( $lang );
394        }
395        self::updateBodyClasslist(
396            DOMCompat::getBody( $document ), $lang, $parserOutput
397        );
398
399        $siteConfig->exportMetadataToHeadBcp47(
400            $document, $parserOutput,
401            ( $title ?? Title::newMainPage() )->getPrefixedText(),
402            $lang ?? new Bcp47CodeValue( 'en' )
403        );
404    }
405
406    private static function updateBodyClasslist(
407        Element $body, ?Language $lang, ParserOutput $parserOutput
408    ) {
409        $bodyCL = DOMCompat::getClassList( $body );
410        if ( $lang !== null ) {
411            $dir = $lang->getDir();
412            $bodyCL->add( 'mw-content-' . $dir );
413            $bodyCL->add( 'sitedir-' . $dir );
414            $bodyCL->add( $dir );
415            $body->setAttribute( 'lang', $lang->toBcp47Code() );
416            $body->setAttribute( 'dir', $dir );
417        }
418
419        // Set 'mw-body-content' directly on the body.
420        // This is the designated successor for #bodyContent in core skins.
421        $bodyCL->add( 'mw-body-content' );
422        // Also, add the 'mediawiki' class.
423        // Some MediaWiki:Common.css seem to target this selector.
424        $bodyCL->add( 'mediawiki' );
425        // Set 'mw-parser-output' directly on the body.
426        // Templates target this class as part of the TemplateStyles RFC
427        // FIXME: This isn't expected to be found on the same element as the
428        // body class above, since some css targets it as a descendant.
429        // In visual diff'ing, we migrate the body contents to a wrapper div
430        // with this class to reduce visual differences.  Consider getting
431        // rid of it.
432        $bodyCL->add( 'mw-parser-output' );
433
434        if ( $parserOutput->getContentHolder()->isParsoidContent() ) {
435            // Set 'parsoid-body' to add the desired layout styling from Vector.
436            $bodyCL->add( 'parsoid-body' );
437            // Set the parsoid version on the body, for consistency with
438            // the wrapper div.  Make this match the extension data in
439            // case the content is coming from cache and was generated
440            // with a different version.
441            $body->setAttribute(
442                'data-mw-parsoid-version',
443                $parserOutput->getExtensionData( 'core:parsoid-version' ) ??
444                    Parsoid::version()
445            );
446            $body->setAttribute(
447                'data-mw-html-version',
448                $parserOutput->getExtensionData( 'core:html-version' ) ??
449                    Parsoid::defaultHTMLVersion()
450            );
451        }
452    }
453
454    /**
455     * Create an element in the document head with the given attrs.
456     * Creates the head element in the document if needed.
457     *
458     * @param Document $document
459     * @param string $tagName
460     * @param array $attrs
461     * @return Element The newly-appended Element
462     */
463    private static function appendToHead(
464        Document $document, string $tagName, array $attrs = []
465    ): Element {
466        $elt = $document->createElement( $tagName );
467        DOMUtils::addAttributes( $elt, $attrs );
468        $head = DOMCompat::getHead( $document );
469        if ( !$head ) {
470            if ( !$document->documentElement ) {
471                $document->appendChild( $document->createElement( 'html' ) );
472            }
473            $head = $document->createElement( 'head' );
474            $document->documentElement->insertBefore(
475                $head, DOMCompat::getBody( $document )
476            );
477        }
478        $head->appendChild( $elt );
479        return $elt;
480    }
481}