Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.66% covered (warning)
87.66%
135 / 154
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionRenderer
87.66% covered (warning)
87.66%
135 / 154
28.57% covered (danger)
28.57%
2 / 7
37.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRenderedRevision
81.63% covered (warning)
81.63%
40 / 49
0.00% covered (danger)
0.00%
0 / 1
12.89
 getSpeculativeRevId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getSpeculativePageId
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 combineSlotOutput
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
14
 splitSlotOutput
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
5.03
1<?php
2/**
3 * This file is part of MediaWiki.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Revision;
10
11use InvalidArgumentException;
12use MediaWiki\Content\Renderer\ContentRenderer;
13use MediaWiki\Html\Html;
14use MediaWiki\Parser\ParserOptions;
15use MediaWiki\Parser\ParserOutput;
16use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter;
17use MediaWiki\Permissions\Authority;
18use Psr\Log\LoggerAwareInterface;
19use Psr\Log\LoggerInterface;
20use Psr\Log\NullLogger;
21use Wikimedia\Assert\Assert;
22use Wikimedia\Parsoid\Core\HtmlPageBundle;
23use Wikimedia\Rdbms\ILoadBalancer;
24
25/**
26 * The RevisionRenderer service provides access to rendered output for revisions.
27 * It does so by acting as a factory for RenderedRevision instances, which in turn
28 * provide lazy access to ParserOutput objects.
29 *
30 * One key responsibility of RevisionRenderer is implementing the layout used to combine
31 * the output of multiple slots.
32 *
33 * @since 1.32
34 */
35class RevisionRenderer implements LoggerAwareInterface {
36
37    /** @var LoggerInterface */
38    private $saveParseLogger;
39
40    /** @var ILoadBalancer */
41    private $loadBalancer;
42
43    /** @var SlotRoleRegistry */
44    private $roleRegistry;
45
46    /** @var ContentRenderer */
47    private $contentRenderer;
48
49    private PageBundleParserOutputConverter $pageBundleParserOutputConverter;
50
51    /** @var string|false */
52    private $dbDomain;
53
54    /**
55     * @param ILoadBalancer $loadBalancer
56     * @param SlotRoleRegistry $roleRegistry
57     * @param ContentRenderer $contentRenderer
58     * @param PageBundleParserOutputConverter $pageBundleParserOutputConverter
59     * @param string|false $dbDomain DB domain of the relevant wiki or false for the current one
60     */
61    public function __construct(
62        ILoadBalancer $loadBalancer,
63        SlotRoleRegistry $roleRegistry,
64        ContentRenderer $contentRenderer,
65        PageBundleParserOutputConverter $pageBundleParserOutputConverter,
66        $dbDomain = false
67    ) {
68        $this->loadBalancer = $loadBalancer;
69        $this->roleRegistry = $roleRegistry;
70        $this->contentRenderer = $contentRenderer;
71        $this->pageBundleParserOutputConverter = $pageBundleParserOutputConverter;
72        $this->dbDomain = $dbDomain;
73        $this->saveParseLogger = new NullLogger();
74    }
75
76    /** @inheritDoc */
77    public function setLogger( LoggerInterface $saveParseLogger ): void {
78        $this->saveParseLogger = $saveParseLogger;
79    }
80
81    /**
82     * @param RevisionRecord $rev
83     * @param ParserOptions|null $options
84     * @param Authority|null $forPerformer User for privileged access. Default is unprivileged
85     *   (public) access, unless the 'audience' hint is set to something else RevisionRecord::RAW.
86     * @phpcs:ignore Generic.Files.LineLength.TooLong
87     * @param array{use-master?:bool,audience?:int,known-revision-output?:ParserOutput,known-revision-options?:ParserOptions,causeAction?:?string,previous-output?:?ParserOutput} $hints
88     *   Hints given as an associative array. Known keys:
89     *   - 'use-master' Use primary DB when rendering for the parser cache during save.
90     *     Default is to use a replica.
91     *   - 'audience' the audience to use for content access. Default is
92     *     RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER
93     *     if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks.
94     *   - 'known-revision-output' a combined ParserOutput for the revision, perhaps from
95     *     some cache. the caller is responsible for ensuring that the ParserOutput indeed
96     *     matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
97     *     for the time until caches have been changed to store RenderedRevision states instead
98     *     of ParserOutput objects.
99     *   - 'known-revision-options' ParserOptions object associated with the known-revision-output
100     *   - 'previous-output' A previously-rendered ParserOutput for this page. This
101     *     can be used by Parsoid for selective updates.
102     *   - 'causeAction' the reason for rendering. This should be informative, for used for
103     *     logging and debugging.
104     *
105     * @return RenderedRevision|null The rendered revision, or null if the audience checks fails.
106     * @throws BadRevisionException
107     * @throws RevisionAccessException
108     */
109    public function getRenderedRevision(
110        RevisionRecord $rev,
111        ?ParserOptions $options = null,
112        ?Authority $forPerformer = null,
113        array $hints = []
114    ) {
115        if ( $rev->getWikiId() !== $this->dbDomain ) {
116            throw new InvalidArgumentException(
117                "Mismatching wiki ID rev={$rev->getWikiId()}, this={$this->dbDomain}"
118            );
119        }
120
121        $audience = $hints['audience']
122            ?? ( $forPerformer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC );
123
124        if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forPerformer ) ) {
125            // Returning null here is awkward, but consistent with the signature of
126            // RevisionRecord::getContent().
127            return null;
128        }
129
130        if ( !$options ) {
131            $options = $forPerformer ?
132                ParserOptions::newFromUser( $forPerformer->getUser() ) :
133                ParserOptions::newFromAnon();
134
135            // If we've asked for canonical options and are passing Parsoid content,
136            // ensure our options reflect that.  RefreshLinksJob sets a
137            // 'known-revision-output' with Parsoid content when UseParsoidLinksUpdate
138            // may differ from the canonical choice.
139            if ( isset( $hints['known-revision-output'] ) ) {
140                $options->setUseParsoid(
141                    $hints['known-revision-output']->getContentHolder()->isParsoidContent()
142                );
143            }
144        }
145
146        if ( isset( $hints['causeAction'] ) ) {
147            $options->setRenderReason( $hints['causeAction'] );
148        }
149
150        $usePrimary = $hints['use-master'] ?? false;
151
152        $dbIndex = $usePrimary
153            ? DB_PRIMARY // use latest values
154            : DB_REPLICA; // T154554
155
156        $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
157            return $this->getSpeculativeRevId( $dbIndex );
158        } );
159        $options->setSpeculativePageIdCallback( function () use ( $dbIndex ) {
160            return $this->getSpeculativePageId( $dbIndex );
161        } );
162
163        if ( !$rev->getId() && $rev->getTimestamp() ) {
164            // This is an unsaved revision with an already determined timestamp.
165            // Make the "current" time used during parsing match that of the revision.
166            // Any REVISION* parser variables will match up if the revision is saved.
167            $options->setTimestamp( $rev->getTimestamp() );
168        }
169
170        $previousOutput = $hints['previous-output'] ?? null;
171        $renderedRevision = new RenderedRevision(
172            $rev,
173            $options,
174            $this->contentRenderer,
175            function ( RenderedRevision $rrev, array $hints ) use ( $options, $previousOutput ) {
176                $h = [ 'previous-output' => $previousOutput ] + $hints;
177                return $this->combineSlotOutput( $rrev, $options, $h );
178            },
179            $audience,
180            $forPerformer
181        );
182
183        $renderedRevision->setSaveParseLogger( $this->saveParseLogger );
184
185        if ( isset( $hints['known-revision-output'] ) ) {
186            $renderedRevision->setRevisionParserOutput(
187                $hints['known-revision-output'],
188                // $hints['known-revision-options'] should ideally have been
189                // used as $options when calling getRenderedRevision
190                null
191            );
192        }
193
194        return $renderedRevision;
195    }
196
197    private function getSpeculativeRevId( int $dbIndex ): int {
198        // Use a separate primary DB connection in order to see the latest data, by avoiding
199        // stale data from REPEATABLE-READ snapshots.
200        $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT;
201
202        $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags );
203
204        return 1 + (int)$db->newSelectQueryBuilder()
205            ->select( 'MAX(rev_id)' )
206            ->from( 'revision' )
207            ->caller( __METHOD__ )->fetchField();
208    }
209
210    private function getSpeculativePageId( int $dbIndex ): int {
211        // Use a separate primary DB connection in order to see the latest data, by avoiding
212        // stale data from REPEATABLE-READ snapshots.
213        $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT;
214
215        $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags );
216
217        return 1 + (int)$db->newSelectQueryBuilder()
218            ->select( 'MAX(page_id)' )
219            ->from( 'page' )
220            ->caller( __METHOD__ )->fetchField();
221    }
222
223    /**
224     * This implements the layout for combining the output of multiple slots.
225     *
226     * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout.
227     *
228     * @param RenderedRevision $rrev
229     * @param ParserOptions $options
230     * @param array $hints see RenderedRevision::getRevisionParserOutput()
231     *
232     * @return ParserOutput
233     * @throws SuppressedDataException
234     * @throws BadRevisionException
235     * @throws RevisionAccessException
236     */
237    private function combineSlotOutput( RenderedRevision $rrev, ParserOptions $options, array $hints = [] ) {
238        $revision = $rrev->getRevision();
239        $slots = $revision->getSlots()->getSlots();
240
241        $withHtml = $hints['generate-html'] ?? true;
242        $previousOutputs = $this->splitSlotOutput( $rrev, $options, $hints['previous-output'] ?? null );
243
244        // short circuit if there is only the main slot
245        if ( array_keys( $slots ) === [ SlotRecord::MAIN ] ) {
246            $h = [ 'previous-output' => $previousOutputs[SlotRecord::MAIN] ] + $hints;
247            return $rrev->getSlotParserOutput( SlotRecord::MAIN, $h );
248        }
249
250        // move main slot to front
251        if ( isset( $slots[SlotRecord::MAIN] ) ) {
252            $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
253        }
254
255        if ( $options->getUseParsoid() ) {
256            $combinedOutput = null;
257            $oldWatcher = false;
258            $options = $rrev->getOptions();
259
260            foreach ( $slots as $role => $slot ) {
261                $h = [ 'previous-output' => $previousOutputs[$role] ] + $hints;
262                $out = $rrev->getSlotParserOutput( $role, $h );
263
264                if ( $combinedOutput === null ) {
265                    $combinedOutput = clone $out;
266                    $oldWatcher = $options->registerWatcher( $combinedOutput->recordOption( ... ) );
267                    if ( $withHtml ) {
268                        // The isset above when moving the main slot to the front
269                        // implies that we should record the first role for splitting
270                        $combinedOutput->setExtensionData( 'core:slots:first', $role );
271                    }
272                } else {
273                    if ( $withHtml ) {
274                        Assert::invariant(
275                            !$out->getContentHolder()->isParsoidContent(),
276                            "T438406: Can't combine Parsoid output until we figure " .
277                                "out what to do with the PageBundle."
278                        );
279                        $fragmentName = "slot-$role";
280                        $combinedOutput->getContentHolder()->setAsHtmlString(
281                            $fragmentName,
282                            $out->getContentHolderText(),
283                        );
284                        $combinedOutput->appendExtensionData( 'core:slots', $role );
285                    }
286                    $out->collectMetadata( $combinedOutput );
287                }
288            }
289
290            if ( $oldWatcher !== false ) {
291                $options->registerWatcher( $oldWatcher );
292            }
293            return $combinedOutput;
294        }
295
296        $combinedOutput = new ParserOutput( null );
297        $slotOutput = [];
298
299        $options = $rrev->getOptions();
300        $options->registerWatcher( $combinedOutput->recordOption( ... ) );
301
302        foreach ( $slots as $role => $slot ) {
303            $h = [ 'previous-output' => $previousOutputs[$role] ] + $hints;
304            $out = $rrev->getSlotParserOutput( $role, $h );
305            $slotOutput[$role] = $out;
306
307            // XXX: should the SlotRoleHandler be able to intervene here?
308            // XXX: this should probably just use ParserOutput::collectMetadata
309            $combinedOutput->mergeInternalMetaDataFrom( $out );
310            $combinedOutput->mergeTrackingMetaDataFrom( $out );
311        }
312
313        if ( $withHtml ) {
314            $html = '';
315            $first = true;
316            /** @var ParserOutput $out */
317            foreach ( $slotOutput as $role => $out ) {
318                $roleHandler = $this->roleRegistry->getRoleHandler( $role );
319
320                // TODO: put more fancy layout logic here, see T200915.
321                $layout = $roleHandler->getOutputLayoutHints();
322                $display = $layout['display'] ?? 'section';
323
324                if ( $display === 'none' ) {
325                    continue;
326                }
327
328                if ( $first ) {
329                    // skip header for the first slot
330                    $first = false;
331                } else {
332                    // NOTE: this placeholder is hydrated by HydrateHeaderPlaceholders.
333                    $headText = Html::element( 'mw:slotheader', [], $role );
334                    $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
335                    $combinedOutput->appendExtensionData( 'core:slots', $role );
336                }
337
338                // XXX: do we want to put a wrapper div around the output?
339                // Do we want to let $roleHandler do that?
340                $html .= $out->getContentHolderText();
341                $combinedOutput->mergeHtmlMetaDataFrom( $out );
342            }
343
344            $combinedOutput->setContentHolderText( $html );
345        }
346
347        $options->registerWatcher( null );
348        return $combinedOutput;
349    }
350
351    /**
352     * This reverses ::combineSlotOutput() in order to enable selective
353     * update of individual slots.
354     *
355     * @todo Currently, for legacy output, slot combination is not reversible.
356     * For Parsoid output, the original split ParserOutputs are reconstructed
357     * from the combined ParserOutput.  However, the munged metadata on the
358     * combined ParserOutput isn't yet untangled.
359     *
360     * @param RenderedRevision $rrev
361     * @param ParserOptions $options
362     * @param ?ParserOutput $previousOutput A combined ParserOutput for a
363     *   previous parse, or null if none available.
364     * @return array<string,?ParserOutput> A mapping from role name to a
365     *   previous ParserOutput for that slot in the previous parse
366     */
367    private function splitSlotOutput(
368        RenderedRevision $rrev, ParserOptions $options, ?ParserOutput $previousOutput
369    ) {
370        // If there is no previous parse, then there is nothing to split.
371        $revision = $rrev->getRevision();
372        $revslots = $revision->getSlots();
373        if ( $previousOutput === null ) {
374            return array_fill_keys( $revslots->getSlotRoles(), null );
375        }
376
377        // short circuit if there is only the main slot
378        if ( $revslots->getSlotRoles() === [ SlotRecord::MAIN ] ) {
379            return [ SlotRecord::MAIN => $previousOutput ];
380        }
381
382        if ( !$options->getUseParsoid() ) {
383            // @todo Currently slot combination is not reversible
384            return array_fill_keys( $revslots->getSlotRoles(), null );
385        }
386
387        // FIXME: While the below splits out the html, it's unclear what to do about
388        // the metadata that is collected along the way
389
390        // Extension data was only set when the ParserOutput was combined $withHtml
391        $first = $previousOutput->getExtensionData( 'core:slots:first' ) ?? SlotRecord::MAIN;
392        $slotOutput = [ $first => $previousOutput ];
393        $contentHolder = $previousOutput->getContentHolder();
394
395        foreach ( $previousOutput->getExtensionData( 'core:slots' ) ?? [] as $role => $value ) {
396            $fragmentName = "slot-$role";
397            $html = $contentHolder->getAsHtmlString( $fragmentName ) ?? '';
398            $contentHolder->setAsHtmlString( $fragmentName, null );
399
400            $slotOutput[$role] = $this->pageBundleParserOutputConverter->parserOutputFromPageBundle(
401                HtmlPageBundle::newEmpty( $html ),
402                // T438406: We've asserted this when combining
403                isParsoidContent: false,
404            );
405        }
406
407        return $slotOutput;
408    }
409}