Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / linker / LinkRenderer.php
blob2be522cfabfc5401d62b02901ace0409a1cab845
1 <?php
2 /**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
18 * @file
19 * @author Kunal Mehta <legoktm@debian.org>
21 namespace MediaWiki\Linker;
23 use HtmlArmor;
24 use MediaWiki\Cache\LinkCache;
25 use MediaWiki\Config\ServiceOptions;
26 use MediaWiki\HookContainer\HookContainer;
27 use MediaWiki\HookContainer\HookRunner;
28 use MediaWiki\Html\Html;
29 use MediaWiki\Language\Language;
30 use MediaWiki\Message\Message;
31 use MediaWiki\Page\PageReference;
32 use MediaWiki\Parser\Parser;
33 use MediaWiki\Parser\Sanitizer;
34 use MediaWiki\SpecialPage\SpecialPageFactory;
35 use MediaWiki\Title\Title;
36 use MediaWiki\Title\TitleFormatter;
37 use MediaWiki\Title\TitleValue;
38 use Wikimedia\Assert\Assert;
40 /**
41 * Class that generates HTML for internal links.
42 * See the Linker class for other kinds of links.
44 * @see https://www.mediawiki.org/wiki/Manual:LinkRenderer
45 * @since 1.28
47 class LinkRenderer {
49 public const CONSTRUCTOR_OPTIONS = [
50 'renderForComment',
53 /**
54 * Whether to force the pretty article path
56 * @var bool
58 private $forceArticlePath = false;
60 /**
61 * A PROTO_* constant or false
63 * @var string|bool|int
65 private $expandUrls = false;
67 /**
68 * Whether links are being rendered for comments.
70 * @var bool
72 private $comment = false;
74 /**
75 * @var TitleFormatter
77 private $titleFormatter;
79 /**
80 * @var LinkCache
82 private $linkCache;
84 /** @var HookRunner */
85 private $hookRunner;
87 /**
88 * @var SpecialPageFactory
90 private $specialPageFactory;
92 /**
93 * @internal For use by LinkRendererFactory
95 * @param TitleFormatter $titleFormatter
96 * @param LinkCache $linkCache
97 * @param SpecialPageFactory $specialPageFactory
98 * @param HookContainer $hookContainer
99 * @param ServiceOptions $options
101 public function __construct(
102 TitleFormatter $titleFormatter,
103 LinkCache $linkCache,
104 SpecialPageFactory $specialPageFactory,
105 HookContainer $hookContainer,
106 ServiceOptions $options
108 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
109 $this->comment = $options->get( 'renderForComment' );
111 $this->titleFormatter = $titleFormatter;
112 $this->linkCache = $linkCache;
113 $this->specialPageFactory = $specialPageFactory;
114 $this->hookRunner = new HookRunner( $hookContainer );
118 * Whether to force the link to use the article path ($wgArticlePath) even if
119 * a query string is present, resulting in URLs like /wiki/Main_Page?action=foobar.
121 * @param bool $force
123 public function setForceArticlePath( $force ) {
124 $this->forceArticlePath = $force;
128 * @return bool
129 * @see setForceArticlePath()
131 public function getForceArticlePath() {
132 return $this->forceArticlePath;
136 * Whether/how to expand URLs.
138 * @param string|bool|int $expand A PROTO_* constant or false for no expansion
139 * @see UrlUtils::expand()
141 public function setExpandURLs( $expand ) {
142 $this->expandUrls = $expand;
146 * @return string|bool|int a PROTO_* constant or false for no expansion
147 * @see setExpandURLs()
149 public function getExpandURLs() {
150 return $this->expandUrls;
154 * True when the links will be rendered in an edit summary or log comment.
156 * @return bool
158 public function isForComment(): bool {
159 // This option only exists to power a hack in Wikibase's onHtmlPageLinkRendererEnd hook.
160 return $this->comment;
164 * Render a wikilink.
165 * Will call makeKnownLink() or makeBrokenLink() as appropriate.
167 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
168 * @param-taint $target none
169 * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
170 * @param-taint $text escapes_html
171 * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
172 * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
173 * when hovering!' ]
174 * @param-taint $extraAttribs none
175 * @param array $query Parameters you would like to add to the URL. For example, if you would
176 * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
177 * @param-taint $query none
178 * @return string HTML
179 * @return-taint escaped
181 public function makeLink(
182 $target, $text = null, array $extraAttribs = [], array $query = []
184 Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
185 if ( $this->castToTitle( $target )->isKnown() ) {
186 return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
187 } else {
188 return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
192 private function runBeginHook( $target, &$text, &$extraAttribs, &$query ) {
193 $ret = null;
194 if ( !$this->hookRunner->onHtmlPageLinkRendererBegin(
195 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
196 $this, $this->castToTitle( $target ), $text, $extraAttribs, $query, $ret )
198 return $ret;
203 * Make a link that's styled as if the target page exists (a "blue link"), with a specified
204 * class attribute.
206 * Usually you should use makeLink() or makeKnownLink() instead, which will select the CSS
207 * classes automatically. Use this method if the exact styling doesn't matter and you want
208 * to ensure no extra DB lookup happens, e.g. for links generated by the skin.
210 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
211 * @param-taint $target none
212 * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
213 * @param-taint $text escapes_html
214 * @param string $classes CSS classes to add
215 * @param-taint $classes none
216 * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
217 * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
218 * when hovering!' ]
219 * @param-taint $extraAttribs none
220 * @param array $query Parameters you would like to add to the URL. For example, if you would
221 * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
222 * @param-taint $query none
223 * @return string
224 * @return-taint escaped
226 public function makePreloadedLink(
227 $target, $text = null, $classes = '', array $extraAttribs = [], array $query = []
229 Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
231 // Run begin hook
232 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query );
233 if ( $ret !== null ) {
234 return $ret;
236 $target = $this->normalizeTarget( $target );
237 $url = $this->getLinkURL( $target, $query );
238 $attribs = [ 'class' => $classes ];
239 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
240 if ( $prefixedText !== '' ) {
241 $attribs['title'] = $prefixedText;
244 $attribs = [
245 'href' => $url,
246 ] + $this->mergeAttribs( $attribs, $extraAttribs );
248 $text ??= $this->getLinkText( $target );
250 return $this->buildAElement( $target, $text, $attribs, true );
254 * Make a link that's styled as if the target page exists (usually a "blue link", although the
255 * styling might depend on e.g. whether the target is a redirect).
257 * This will result in a DB lookup if the title wasn't cached yet. If you want to avoid that,
258 * and don't care about matching the exact styling of links within page content, you can use
259 * makePreloadedLink() instead.
261 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
262 * @param-taint $target none
263 * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
264 * @param-taint $text escapes_html
265 * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
266 * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
267 * when hovering!' ]
268 * @param-taint $extraAttribs none
269 * @param array $query Parameters you would like to add to the URL. For example, if you would
270 * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
271 * @param-taint $query none
272 * @return string HTML
273 * @return-taint escaped
275 public function makeKnownLink(
276 $target, $text = null, array $extraAttribs = [], array $query = []
278 Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
279 if ( $target instanceof LinkTarget ) {
280 $isExternal = $target->isExternal();
281 } else {
282 // $target instanceof PageReference
283 // treat all PageReferences as local for now
284 $isExternal = false;
286 $classes = [];
287 if ( $isExternal ) {
288 $classes[] = 'extiw';
290 $colour = $this->getLinkClasses( $target );
291 if ( $colour !== '' ) {
292 $classes[] = $colour;
295 return $this->makePreloadedLink(
296 $target,
297 $text,
298 implode( ' ', $classes ),
299 $extraAttribs,
300 $query
305 * Make a link that's styled as if the target page doesn't exist (a "red link").
307 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
308 * @param-taint $target none
309 * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
310 * @param-taint $text escapes_html
311 * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
312 * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
313 * when hovering!' ]
314 * @param-taint $extraAttribs none
315 * @param array $query Parameters you would like to add to the URL. For example, if you would
316 * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
317 * @param-taint $query none
318 * @return string
319 * @return-taint escaped
321 public function makeBrokenLink(
322 $target, $text = null, array $extraAttribs = [], array $query = []
324 Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
325 // Run legacy hook
326 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query );
327 if ( $ret !== null ) {
328 return $ret;
331 if ( $target instanceof LinkTarget ) {
332 # We don't want to include fragments for broken links, because they
333 # generally make no sense.
334 if ( $target->hasFragment() ) {
335 $target = $target->createFragmentTarget( '' );
338 $target = $this->normalizeTarget( $target );
340 if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
341 $query['action'] = 'edit';
342 $query['redlink'] = '1';
345 $url = $this->getLinkURL( $target, $query );
346 $attribs = [ 'class' => 'new' ];
347 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
348 if ( $prefixedText !== '' ) {
349 // This ends up in parser cache!
350 $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
351 ->inContentLanguage()
352 ->text();
355 $attribs = [
356 'href' => $url,
357 ] + $this->mergeAttribs( $attribs, $extraAttribs );
359 $text ??= $this->getLinkText( $target );
361 return $this->buildAElement( $target, $text, $attribs, false );
365 * Make an external link
367 * @since 1.43
368 * @param string $url URL to link to
369 * @param-taint $url escapes_html
370 * @param string|HtmlArmor|Message $text Text of link; will be escaped if
371 * a string.
372 * @param-taint $text escapes_html
373 * @param LinkTarget|PageReference $title Where the link is being rendered, used for title specific link attributes
374 * @param-taint $title none
375 * @param string $linktype Type of external link. Gets added to the classes
376 * @param-taint $linktype escapes_html
377 * @param array $attribs Array of extra attributes to <a>
378 * @param-taint $attribs escapes_html
379 * @return string
381 public function makeExternalLink(
382 string $url, $text, $title, $linktype = '', $attribs = []
384 $class = 'external';
385 if ( $linktype ) {
386 $class .= " $linktype";
388 if ( isset( $attribs['class'] ) && $attribs['class'] ) {
389 $class .= " {$attribs['class']}";
391 $attribs['class'] = $class;
393 if ( $text instanceof Message ) {
394 $text = $text->escaped();
395 } else {
396 $text = HtmlArmor::getHtml( $text );
397 // Tell phan that $text is non-null after ::getHtml()
398 '@phan-var string $text';
401 $newRel = Parser::getExternalLinkRel( $url, $title );
402 if ( !isset( $attribs['rel'] ) || $attribs['rel'] === '' ) {
403 $attribs['rel'] = $newRel;
404 } elseif ( $newRel !== null ) {
405 // Merge the rel attributes.
406 $newRels = explode( ' ', $newRel );
407 $oldRels = explode( ' ', $attribs['rel'] );
408 $combined = array_unique( array_merge( $newRels, $oldRels ) );
409 $attribs['rel'] = implode( ' ', $combined );
411 $link = '';
412 $success = $this->hookRunner->onLinkerMakeExternalLink(
413 $url, $text, $link, $attribs, $linktype );
414 if ( !$success ) {
415 wfDebug( "Hook LinkerMakeExternalLink changed the output of link "
416 . "with url {$url} and text {$text} to {$link}" );
417 return $link;
419 $attribs['href'] = $url;
420 return Html::rawElement( 'a', $attribs, $text );
424 * Return the HTML for the top of a redirect page
426 * Chances are you should just be using the ParserOutput from
427 * WikitextContent::getParserOutput (which will have this header added
428 * automatically) instead of calling this for redirects.
430 * If creating your own redirect-alike, please use return value of
431 * this method to set the 'core:redirect-header' extension data field
432 * in your ParserOutput, rather than concatenating HTML directly.
433 * See WikitextContentHandler::fillParserOutput().
435 * @since 1.41
436 * @param Language $lang
437 * @param Title $target Destination to redirect
438 * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence?
439 * @return string Containing HTML with redirect link
441 public function makeRedirectHeader( Language $lang, Title $target, bool $forceKnown = false ) {
442 $html = '<ul class="redirectText">';
443 if ( $forceKnown ) {
444 $link = $this->makeKnownLink(
445 $target,
446 $target->getFullText(),
448 // Make sure wiki page redirects are not followed
449 $target->isRedirect() ? [ 'redirect' => 'no' ] : []
451 } else {
452 $link = $this->makeLink(
453 $target,
454 $target->getFullText(),
456 // Make sure wiki page redirects are not followed
457 $target->isRedirect() ? [ 'redirect' => 'no' ] : []
461 $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->escaped();
463 return Html::rawElement(
464 'div', [ 'class' => 'redirectMsg' ],
465 Html::rawElement( 'p', [], $redirectToText ) .
466 Html::rawElement( 'ul', [ 'class' => 'redirectText' ],
467 Html::rawElement( 'li', [], $link ) )
472 * Builds the final <a> element
474 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
475 * @param-taint $target none
476 * @param string|HtmlArmor $text
477 * @param-taint $text escapes_html
478 * @param array $attribs
479 * @param-taint $attribs none
480 * @param bool $isKnown
481 * @param-taint $isKnown none
482 * @return null|string
483 * @return-taint escaped
485 private function buildAElement( $target, $text, array $attribs, $isKnown ) {
486 $ret = null;
487 if ( !$this->hookRunner->onHtmlPageLinkRendererEnd(
488 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
489 $this, $this->castToLinkTarget( $target ), $isKnown, $text, $attribs, $ret )
491 return $ret;
494 return Html::rawElement( 'a', $attribs, HtmlArmor::getHtml( $text ) );
498 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
499 * @return string
501 private function getLinkText( $target ) {
502 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
503 // If the target is just a fragment, with no title, we return the fragment
504 // text. Otherwise, we return the title text itself.
505 if ( $prefixedText === '' && $target instanceof LinkTarget && $target->hasFragment() ) {
506 return $target->getFragment();
509 return $prefixedText;
513 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
514 * @param array $query Parameters you would like to add to the URL. For example, if you would
515 * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
516 * @return string non-escaped text
518 private function getLinkURL( $target, $query = [] ) {
519 if ( $this->forceArticlePath ) {
520 $realQuery = $query;
521 $query = [];
522 } else {
523 $realQuery = [];
525 $url = $this->castToTitle( $target )->getLinkURL( $query, false, $this->expandUrls );
527 if ( $this->forceArticlePath && $realQuery ) {
528 $url = wfAppendQuery( $url, $realQuery );
531 return $url;
535 * Normalizes the provided target
537 * @internal For use by Linker::getImageLinkMTOParams()
538 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
539 * @return LinkTarget
541 public function normalizeTarget( $target ) {
542 $target = $this->castToLinkTarget( $target );
543 if ( $target->getNamespace() === NS_SPECIAL && !$target->isExternal() ) {
544 [ $name, $subpage ] = $this->specialPageFactory->resolveAlias(
545 $target->getDBkey()
547 if ( $name ) {
548 return new TitleValue(
549 NS_SPECIAL,
550 $this->specialPageFactory->getLocalNameFor( $name, $subpage ),
551 $target->getFragment()
556 return $target;
560 * Merges two sets of attributes
562 * @param array $defaults
563 * @param array $attribs
565 * @return array
567 private function mergeAttribs( $defaults, $attribs ) {
568 if ( !$attribs ) {
569 return $defaults;
571 # Merge the custom attribs with the default ones, and iterate
572 # over that, deleting all "false" attributes.
573 $ret = [];
574 $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
575 foreach ( $merged as $key => $val ) {
576 # A false value suppresses the attribute
577 if ( $val !== false ) {
578 $ret[$key] = $val;
581 return $ret;
585 * Returns CSS classes to add to a known link.
587 * Note that most CSS classes set on wikilinks are actually handled elsewhere (e.g. in
588 * makeKnownLink() or in LinkHolderArray).
590 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
591 * @return string CSS class
593 public function getLinkClasses( $target ) {
594 Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
595 $target = $this->castToLinkTarget( $target );
596 // Don't call LinkCache if the target is "non-proper"
597 if ( $target->isExternal() || $target->getText() === '' || $target->getNamespace() < 0 ) {
598 return '';
600 // Make sure the target is in the cache
601 $id = $this->linkCache->addLinkObj( $target );
602 if ( $id == 0 ) {
603 // Doesn't exist
604 return '';
607 if ( $this->linkCache->getGoodLinkFieldObj( $target, 'redirect' ) ) {
608 # Page is a redirect
609 return 'mw-redirect';
612 return '';
616 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
617 * @return Title
619 private function castToTitle( $target ): Title {
620 if ( $target instanceof LinkTarget ) {
621 return Title::newFromLinkTarget( $target );
623 // $target instanceof PageReference
624 return Title::newFromPageReference( $target );
628 * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
629 * @return LinkTarget
631 private function castToLinkTarget( $target ): LinkTarget {
632 if ( $target instanceof PageReference ) {
633 return Title::newFromPageReference( $target );
635 // $target instanceof LinkTarget
636 return $target;