Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.66% |
135 / 154 |
|
28.57% |
2 / 7 |
CRAP | |
0.00% |
0 / 1 |
| RevisionRenderer | |
87.66% |
135 / 154 |
|
28.57% |
2 / 7 |
37.30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getRenderedRevision | |
81.63% |
40 / 49 |
|
0.00% |
0 / 1 |
12.89 | |||
| getSpeculativeRevId | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| getSpeculativePageId | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| combineSlotOutput | |
98.48% |
65 / 66 |
|
0.00% |
0 / 1 |
14 | |||
| splitSlotOutput | |
90.00% |
18 / 20 |
|
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 | |
| 9 | namespace MediaWiki\Revision; |
| 10 | |
| 11 | use InvalidArgumentException; |
| 12 | use MediaWiki\Content\Renderer\ContentRenderer; |
| 13 | use MediaWiki\Html\Html; |
| 14 | use MediaWiki\Parser\ParserOptions; |
| 15 | use MediaWiki\Parser\ParserOutput; |
| 16 | use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter; |
| 17 | use MediaWiki\Permissions\Authority; |
| 18 | use Psr\Log\LoggerAwareInterface; |
| 19 | use Psr\Log\LoggerInterface; |
| 20 | use Psr\Log\NullLogger; |
| 21 | use Wikimedia\Assert\Assert; |
| 22 | use Wikimedia\Parsoid\Core\HtmlPageBundle; |
| 23 | use 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 | */ |
| 35 | class 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 | } |