Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.29% covered (warning)
85.29%
87 / 102
28.57% covered (danger)
28.57%
2 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevisionRenderer
85.29% covered (warning)
85.29%
87 / 102
28.57% covered (danger)
28.57%
2 / 7
30.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
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
90.00% covered (success)
90.00%
36 / 40
0.00% covered (danger)
0.00%
0 / 1
11.12
 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
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
9
 splitSlotOutput
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
1<?php
2/**
3 * This file is part of MediaWiki.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Revision;
24
25use InvalidArgumentException;
26use MediaWiki\Content\Renderer\ContentRenderer;
27use MediaWiki\Html\Html;
28use MediaWiki\Parser\ParserOptions;
29use MediaWiki\Parser\ParserOutput;
30use MediaWiki\Permissions\Authority;
31use Psr\Log\LoggerInterface;
32use Psr\Log\NullLogger;
33use Wikimedia\Rdbms\ILoadBalancer;
34
35/**
36 * The RevisionRenderer service provides access to rendered output for revisions.
37 * It does so by acting as a factory for RenderedRevision instances, which in turn
38 * provide lazy access to ParserOutput objects.
39 *
40 * One key responsibility of RevisionRenderer is implementing the layout used to combine
41 * the output of multiple slots.
42 *
43 * @since 1.32
44 */
45class RevisionRenderer {
46
47    /** @var LoggerInterface */
48    private $saveParseLogger;
49
50    /** @var ILoadBalancer */
51    private $loadBalancer;
52
53    /** @var SlotRoleRegistry */
54    private $roleRegistry;
55
56    /** @var ContentRenderer */
57    private $contentRenderer;
58
59    /** @var string|false */
60    private $dbDomain;
61
62    /**
63     * @param ILoadBalancer $loadBalancer
64     * @param SlotRoleRegistry $roleRegistry
65     * @param ContentRenderer $contentRenderer
66     * @param string|false $dbDomain DB domain of the relevant wiki or false for the current one
67     */
68    public function __construct(
69        ILoadBalancer $loadBalancer,
70        SlotRoleRegistry $roleRegistry,
71        ContentRenderer $contentRenderer,
72        $dbDomain = false
73    ) {
74        $this->loadBalancer = $loadBalancer;
75        $this->roleRegistry = $roleRegistry;
76        $this->contentRenderer = $contentRenderer;
77        $this->dbDomain = $dbDomain;
78        $this->saveParseLogger = new NullLogger();
79    }
80
81    /**
82     * @param LoggerInterface $saveParseLogger
83     */
84    public function setLogger( LoggerInterface $saveParseLogger ) {
85        $this->saveParseLogger = $saveParseLogger;
86    }
87
88    // phpcs:disable Generic.Files.LineLength.TooLong
89    /**
90     * @param RevisionRecord $rev
91     * @param ParserOptions|null $options
92     * @param Authority|null $forPerformer User for privileged access. Default is unprivileged
93     *        (public) access, unless the 'audience' hint is set to something else RevisionRecord::RAW.
94     * @param array{use-master?:bool,audience?:int,known-revision-output?:ParserOutput,causeAction?:?string,previous-output?:?ParserOutput} $hints
95     *   Hints given as an associative array. Known keys:
96     *      - 'use-master' Use primary DB when rendering for the parser cache during save.
97     *        Default is to use a replica.
98     *      - 'audience' the audience to use for content access. Default is
99     *        RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER
100     *        if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks.
101     *      - 'known-revision-output' a combined ParserOutput for the revision, perhaps from
102     *        some cache. the caller is responsible for ensuring that the ParserOutput indeed
103     *        matched the $rev and $options. This mechanism is intended as a temporary stop-gap,
104     *        for the time until caches have been changed to store RenderedRevision states instead
105     *        of ParserOutput objects.
106     *      - 'previous-output' A previously-rendered ParserOutput for this page. This
107     *        can be used by Parsoid for selective updates.
108     *      - 'causeAction' the reason for rendering. This should be informative, for used for
109     *        logging and debugging.
110     *
111     * @return RenderedRevision|null The rendered revision, or null if the audience checks fails.
112     * @throws BadRevisionException
113     * @throws RevisionAccessException
114     */
115    // phpcs:enable Generic.Files.LineLength.TooLong
116    public function getRenderedRevision(
117        RevisionRecord $rev,
118        ?ParserOptions $options = null,
119        ?Authority $forPerformer = null,
120        array $hints = []
121    ) {
122        if ( $rev->getWikiId() !== $this->dbDomain ) {
123            throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() );
124        }
125
126        $audience = $hints['audience']
127            ?? ( $forPerformer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC );
128
129        if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forPerformer ) ) {
130            // Returning null here is awkward, but consistent with the signature of
131            // RevisionRecord::getContent().
132            return null;
133        }
134
135        if ( !$options ) {
136            $options = $forPerformer ?
137                ParserOptions::newFromUser( $forPerformer->getUser() ) :
138                ParserOptions::newFromAnon();
139        }
140
141        if ( isset( $hints['causeAction'] ) ) {
142            $options->setRenderReason( $hints['causeAction'] );
143        }
144
145        $usePrimary = $hints['use-master'] ?? false;
146
147        $dbIndex = $usePrimary
148            ? DB_PRIMARY // use latest values
149            : DB_REPLICA; // T154554
150
151        $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
152            return $this->getSpeculativeRevId( $dbIndex );
153        } );
154        $options->setSpeculativePageIdCallback( function () use ( $dbIndex ) {
155            return $this->getSpeculativePageId( $dbIndex );
156        } );
157
158        if ( !$rev->getId() && $rev->getTimestamp() ) {
159            // This is an unsaved revision with an already determined timestamp.
160            // Make the "current" time used during parsing match that of the revision.
161            // Any REVISION* parser variables will match up if the revision is saved.
162            $options->setTimestamp( $rev->getTimestamp() );
163        }
164
165        $previousOutput = $hints['previous-output'] ?? null;
166        $renderedRevision = new RenderedRevision(
167            $rev,
168            $options,
169            $this->contentRenderer,
170            function ( RenderedRevision $rrev, array $hints ) use ( $options, $previousOutput ) {
171                $h = [ 'previous-output' => $previousOutput ] + $hints;
172                return $this->combineSlotOutput( $rrev, $options, $h );
173            },
174            $audience,
175            $forPerformer
176        );
177
178        $renderedRevision->setSaveParseLogger( $this->saveParseLogger );
179
180        if ( isset( $hints['known-revision-output'] ) ) {
181            $renderedRevision->setRevisionParserOutput( $hints['known-revision-output'] );
182        }
183
184        return $renderedRevision;
185    }
186
187    private function getSpeculativeRevId( $dbIndex ) {
188        // Use a separate primary DB connection in order to see the latest data, by avoiding
189        // stale data from REPEATABLE-READ snapshots.
190        $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT;
191
192        $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags );
193
194        return 1 + (int)$db->newSelectQueryBuilder()
195            ->select( 'MAX(rev_id)' )
196            ->from( 'revision' )
197            ->caller( __METHOD__ )->fetchField();
198    }
199
200    private function getSpeculativePageId( $dbIndex ) {
201        // Use a separate primary DB connection in order to see the latest data, by avoiding
202        // stale data from REPEATABLE-READ snapshots.
203        $flags = ILoadBalancer::CONN_TRX_AUTOCOMMIT;
204
205        $db = $this->loadBalancer->getConnection( $dbIndex, [], $this->dbDomain, $flags );
206
207        return 1 + (int)$db->newSelectQueryBuilder()
208            ->select( 'MAX(page_id)' )
209            ->from( 'page' )
210            ->caller( __METHOD__ )->fetchField();
211    }
212
213    /**
214     * This implements the layout for combining the output of multiple slots.
215     *
216     * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout.
217     *
218     * @param RenderedRevision $rrev
219     * @param ParserOptions $options
220     * @param array $hints see RenderedRevision::getRevisionParserOutput()
221     *
222     * @return ParserOutput
223     * @throws SuppressedDataException
224     * @throws BadRevisionException
225     * @throws RevisionAccessException
226     */
227    private function combineSlotOutput( RenderedRevision $rrev, ParserOptions $options, array $hints = [] ) {
228        $revision = $rrev->getRevision();
229        $slots = $revision->getSlots()->getSlots();
230
231        $withHtml = $hints['generate-html'] ?? true;
232        $previousOutputs = $this->splitSlotOutput( $rrev, $options, $hints['previous-output'] ?? null );
233
234        // short circuit if there is only the main slot
235        // T351026 hack: if use-parsoid is set, only return main slot output for now
236        // T351113 will remove this hack.
237        if ( array_keys( $slots ) === [ SlotRecord::MAIN ] || $options->getUseParsoid() ) {
238            $h = [ 'previous-output' => $previousOutputs[SlotRecord::MAIN] ] + $hints;
239            return $rrev->getSlotParserOutput( SlotRecord::MAIN, $h );
240        }
241
242        // move main slot to front
243        if ( isset( $slots[SlotRecord::MAIN] ) ) {
244            $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
245        }
246
247        $combinedOutput = new ParserOutput( null );
248        $slotOutput = [];
249
250        $options = $rrev->getOptions();
251        $options->registerWatcher( [ $combinedOutput, 'recordOption' ] );
252
253        foreach ( $slots as $role => $slot ) {
254            $h = [ 'previous-output' => $previousOutputs[$role] ] + $hints;
255            $out = $rrev->getSlotParserOutput( $role, $h );
256            $slotOutput[$role] = $out;
257
258            // XXX: should the SlotRoleHandler be able to intervene here?
259            $combinedOutput->mergeInternalMetaDataFrom( $out );
260            $combinedOutput->mergeTrackingMetaDataFrom( $out );
261        }
262
263        if ( $withHtml ) {
264            $html = '';
265            $first = true;
266            /** @var ParserOutput $out */
267            foreach ( $slotOutput as $role => $out ) {
268                $roleHandler = $this->roleRegistry->getRoleHandler( $role );
269
270                // TODO: put more fancy layout logic here, see T200915.
271                $layout = $roleHandler->getOutputLayoutHints();
272                $display = $layout['display'] ?? 'section';
273
274                if ( $display === 'none' ) {
275                    continue;
276                }
277
278                if ( $first ) {
279                    // skip header for the first slot
280                    $first = false;
281                } else {
282                    // NOTE: this placeholder is hydrated by ParserOutput::getText().
283                    $headText = Html::element( 'mw:slotheader', [], $role );
284                    $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
285                }
286
287                // XXX: do we want to put a wrapper div around the output?
288                // Do we want to let $roleHandler do that?
289                $html .= $out->getRawText();
290                $combinedOutput->mergeHtmlMetaDataFrom( $out );
291            }
292
293            $combinedOutput->setRawText( $html );
294        }
295
296        $options->registerWatcher( null );
297        return $combinedOutput;
298    }
299
300    /**
301     * This reverses ::combineSlotOutput() in order to enable selective
302     * update of individual slots.
303     *
304     * @todo Currently this doesn't do much other than disable selective
305     * update if there is more than one slot.  But in the case where
306     * slot combination is reversible, this should reverse it and attempt
307     * to reconstruct the original split ParserOutputs from the merged
308     * ParserOutput.
309     *
310     * @param RenderedRevision $rrev
311     * @param ParserOptions $options
312     * @param ?ParserOutput $previousOutput A combined ParserOutput for a
313     *   previous parse, or null if none available.
314     * @return array<string,?ParserOutput> A mapping from role name to a
315     *   previous ParserOutput for that slot in the previous parse
316     */
317    private function splitSlotOutput( RenderedRevision $rrev, ParserOptions $options, ?ParserOutput $previousOutput ) {
318        // If there is no previous parse, then there is nothing to split.
319        $revision = $rrev->getRevision();
320        $revslots = $revision->getSlots();
321        if ( $previousOutput === null ) {
322            return array_fill_keys( $revslots->getSlotRoles(), null );
323        }
324
325        // short circuit if there is only the main slot
326        // T351026 hack: if use-parsoid is set, only return main slot output for now
327        // T351113 will remove this hack.
328        if ( $revslots->getSlotRoles() === [ SlotRecord::MAIN ] || $options->getUseParsoid() ) {
329            return [ SlotRecord::MAIN => $previousOutput ];
330        }
331
332        // @todo Currently slot combination is not reversible
333        return array_fill_keys( $revslots->getSlotRoles(), null );
334    }
335}