Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.24% covered (success)
93.24%
138 / 148
55.00% covered (warning)
55.00%
11 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
LinkRenderer
93.24% covered (success)
93.24%
138 / 148
55.00% covered (warning)
55.00%
11 / 20
56.97
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
 setForceArticlePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getForceArticlePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setExpandURLs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExpandURLs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isForComment
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeLink
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 runBeginHook
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 makePreloadedLink
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 makeKnownLink
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 makeBrokenLink
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
7
 makeRedirectHeader
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 buildAElement
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getLinkText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getLinkURL
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 normalizeTarget
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 mergeAttribs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getLinkClasses
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 castToTitle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 castToLinkTarget
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
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.
7 *
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.
12 *
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
17 *
18 * @file
19 * @author Kunal Mehta <legoktm@debian.org>
20 */
21namespace MediaWiki\Linker;
22
23use HtmlArmor;
24use Language;
25use MediaWiki\Cache\LinkCache;
26use MediaWiki\Config\ServiceOptions;
27use MediaWiki\HookContainer\HookContainer;
28use MediaWiki\HookContainer\HookRunner;
29use MediaWiki\Html\Html;
30use MediaWiki\Page\PageReference;
31use MediaWiki\Parser\Sanitizer;
32use MediaWiki\SpecialPage\SpecialPageFactory;
33use MediaWiki\Title\Title;
34use MediaWiki\Title\TitleFormatter;
35use MediaWiki\Title\TitleValue;
36use Wikimedia\Assert\Assert;
37
38/**
39 * Class that generates HTML for internal links.
40 * See the Linker class for other kinds of links.
41 *
42 * @see https://www.mediawiki.org/wiki/Manual:LinkRenderer
43 * @since 1.28
44 */
45class LinkRenderer {
46
47    public const CONSTRUCTOR_OPTIONS = [
48        'renderForComment',
49    ];
50
51    /**
52     * Whether to force the pretty article path
53     *
54     * @var bool
55     */
56    private $forceArticlePath = false;
57
58    /**
59     * A PROTO_* constant or false
60     *
61     * @var string|bool|int
62     */
63    private $expandUrls = false;
64
65    /**
66     * Whether links are being rendered for comments.
67     *
68     * @var bool
69     */
70    private $comment = false;
71
72    /**
73     * @var TitleFormatter
74     */
75    private $titleFormatter;
76
77    /**
78     * @var LinkCache
79     */
80    private $linkCache;
81
82    /** @var HookRunner */
83    private $hookRunner;
84
85    /**
86     * @var SpecialPageFactory
87     */
88    private $specialPageFactory;
89
90    /**
91     * @internal For use by LinkRendererFactory
92     *
93     * @param TitleFormatter $titleFormatter
94     * @param LinkCache $linkCache
95     * @param SpecialPageFactory $specialPageFactory
96     * @param HookContainer $hookContainer
97     * @param ServiceOptions $options
98     */
99    public function __construct(
100        TitleFormatter $titleFormatter,
101        LinkCache $linkCache,
102        SpecialPageFactory $specialPageFactory,
103        HookContainer $hookContainer,
104        ServiceOptions $options
105    ) {
106        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
107        $this->comment = $options->get( 'renderForComment' );
108
109        $this->titleFormatter = $titleFormatter;
110        $this->linkCache = $linkCache;
111        $this->specialPageFactory = $specialPageFactory;
112        $this->hookRunner = new HookRunner( $hookContainer );
113    }
114
115    /**
116     * Whether to force the link to use the article path ($wgArticlePath) even if
117     * a query string is present, resulting in URLs like /wiki/Main_Page?action=foobar.
118     *
119     * @param bool $force
120     */
121    public function setForceArticlePath( $force ) {
122        $this->forceArticlePath = $force;
123    }
124
125    /**
126     * @return bool
127     * @see setForceArticlePath()
128     */
129    public function getForceArticlePath() {
130        return $this->forceArticlePath;
131    }
132
133    /**
134     * Whether/how to expand URLs.
135     *
136     * @param string|bool|int $expand A PROTO_* constant or false for no expansion
137     * @see UrlUtils::expand()
138     */
139    public function setExpandURLs( $expand ) {
140        $this->expandUrls = $expand;
141    }
142
143    /**
144     * @return string|bool|int a PROTO_* constant or false for no expansion
145     * @see setExpandURLs()
146     */
147    public function getExpandURLs() {
148        return $this->expandUrls;
149    }
150
151    /**
152     * True when the links will be rendered in an edit summary or log comment.
153     *
154     * @return bool
155     */
156    public function isForComment(): bool {
157        // This option only exists to power a hack in Wikibase's onHtmlPageLinkRendererEnd hook.
158        return $this->comment;
159    }
160
161    /**
162     * Render a wikilink.
163     * Will call makeKnownLink() or makeBrokenLink() as appropriate.
164     *
165     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
166     * @param-taint $target none
167     * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
168     * @param-taint $text escapes_html
169     * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
170     * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
171     * when hovering!' ]
172     * @param-taint $extraAttribs none
173     * @param array $query Parameters you would like to add to the URL. For example, if you would
174     * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
175     * @param-taint $query none
176     * @return string HTML
177     * @return-taint escaped
178     */
179    public function makeLink(
180        $target, $text = null, array $extraAttribs = [], array $query = []
181    ) {
182        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
183        if ( $this->castToTitle( $target )->isKnown() ) {
184            return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
185        } else {
186            return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
187        }
188    }
189
190    private function runBeginHook( $target, &$text, &$extraAttribs, &$query ) {
191        $ret = null;
192        if ( !$this->hookRunner->onHtmlPageLinkRendererBegin(
193            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
194            $this, $this->castToTitle( $target ), $text, $extraAttribs, $query, $ret )
195        ) {
196            return $ret;
197        }
198    }
199
200    /**
201     * Make a link that's styled as if the target page exists (a "blue link"), with a specified
202     * class attribute.
203     *
204     * Usually you should use makeLink() or makeKnownLink() instead, which will select the CSS
205     * classes automatically. Use this method if the exact styling doesn't matter and you want
206     * to ensure no extra DB lookup happens, e.g. for links generated by the skin.
207     *
208     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
209     * @param-taint $target none
210     * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
211     * @param-taint $text escapes_html
212     * @param string $classes CSS classes to add
213     * @param-taint $classes none
214     * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
215     * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
216     * when hovering!' ]
217     * @param-taint $extraAttribs none
218     * @param array $query Parameters you would like to add to the URL. For example, if you would
219     * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
220     * @param-taint $query none
221     * @return string
222     * @return-taint escaped
223     */
224    public function makePreloadedLink(
225        $target, $text = null, $classes = '', array $extraAttribs = [], array $query = []
226    ) {
227        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
228
229        // Run begin hook
230        $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query );
231        if ( $ret !== null ) {
232            return $ret;
233        }
234        $target = $this->normalizeTarget( $target );
235        $url = $this->getLinkURL( $target, $query );
236        $attribs = [ 'class' => $classes ];
237        $prefixedText = $this->titleFormatter->getPrefixedText( $target );
238        if ( $prefixedText !== '' ) {
239            $attribs['title'] = $prefixedText;
240        }
241
242        $attribs = [
243            'href' => $url,
244        ] + $this->mergeAttribs( $attribs, $extraAttribs );
245
246        $text ??= $this->getLinkText( $target );
247
248        return $this->buildAElement( $target, $text, $attribs, true );
249    }
250
251    /**
252     * Make a link that's styled as if the target page exists (usually a "blue link", although the
253     * styling might depend on e.g. whether the target is a redirect).
254     *
255     * This will result in a DB lookup if the title wasn't cached yet. If you want to avoid that,
256     * and don't care about matching the exact styling of links within page content, you can use
257     * makePreloadedLink() instead.
258     *
259     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
260     * @param-taint $target none
261     * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
262     * @param-taint $text escapes_html
263     * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
264     * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
265     * when hovering!' ]
266     * @param-taint $extraAttribs none
267     * @param array $query Parameters you would like to add to the URL. For example, if you would
268     * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
269     * @param-taint $query none
270     * @return string HTML
271     * @return-taint escaped
272     */
273    public function makeKnownLink(
274        $target, $text = null, array $extraAttribs = [], array $query = []
275    ) {
276        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
277        if ( $target instanceof LinkTarget ) {
278            $isExternal = $target->isExternal();
279        } else {
280            // $target instanceof PageReference
281            // treat all PageReferences as local for now
282            $isExternal = false;
283        }
284        $classes = [];
285        if ( $isExternal ) {
286            $classes[] = 'extiw';
287        }
288        $colour = $this->getLinkClasses( $target );
289        if ( $colour !== '' ) {
290            $classes[] = $colour;
291        }
292
293        return $this->makePreloadedLink(
294            $target,
295            $text,
296            implode( ' ', $classes ),
297            $extraAttribs,
298            $query
299        );
300    }
301
302    /**
303     * Make a link that's styled as if the target page doesn't exist (a "red link").
304     *
305     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
306     * @param-taint $target none
307     * @param string|HtmlArmor|null $text Text that the user can click on to visit the link.
308     * @param-taint $text escapes_html
309     * @param array $extraAttribs Attributes you would like to add to the <a> tag. For example, if
310     * you would like to add title="Text when hovering!", you would set this to [ 'title' => 'Text
311     * when hovering!' ]
312     * @param-taint $extraAttribs none
313     * @param array $query Parameters you would like to add to the URL. For example, if you would
314     * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
315     * @param-taint $query none
316     * @return string
317     * @return-taint escaped
318     */
319    public function makeBrokenLink(
320        $target, $text = null, array $extraAttribs = [], array $query = []
321    ) {
322        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
323        // Run legacy hook
324        $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query );
325        if ( $ret !== null ) {
326            return $ret;
327        }
328
329        if ( $target instanceof LinkTarget ) {
330            # We don't want to include fragments for broken links, because they
331            # generally make no sense.
332            if ( $target->hasFragment() ) {
333                $target = $target->createFragmentTarget( '' );
334            }
335        }
336        $target = $this->normalizeTarget( $target );
337
338        if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
339            $query['action'] = 'edit';
340            $query['redlink'] = '1';
341        }
342
343        $url = $this->getLinkURL( $target, $query );
344        $attribs = [ 'class' => 'new' ];
345        $prefixedText = $this->titleFormatter->getPrefixedText( $target );
346        if ( $prefixedText !== '' ) {
347            // This ends up in parser cache!
348            $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
349                ->inContentLanguage()
350                ->text();
351        }
352
353        $attribs = [
354            'href' => $url,
355        ] + $this->mergeAttribs( $attribs, $extraAttribs );
356
357        $text ??= $this->getLinkText( $target );
358
359        return $this->buildAElement( $target, $text, $attribs, false );
360    }
361
362    /**
363     * Return the HTML for the top of a redirect page
364     *
365     * Chances are you should just be using the ParserOutput from
366     * WikitextContent::getParserOutput (which will have this header added
367     * automatically) instead of calling this for redirects.
368     *
369     * If creating your own redirect-alike, please use return value of
370     * this method to set the 'core:redirect-header' extension data field
371     * in your ParserOutput, rather than concatenating HTML directly.
372     * See WikitextContentHandler::fillParserOutput().
373     *
374     * @since 1.41
375     * @param Language $lang
376     * @param Title $target Destination to redirect
377     * @param bool $forceKnown Should the image be shown as a bluelink regardless of existence?
378     * @return string Containing HTML with redirect link
379     */
380    public function makeRedirectHeader( Language $lang, Title $target, bool $forceKnown = false ) {
381        $html = '<ul class="redirectText">';
382        if ( $forceKnown ) {
383            $link = $this->makeKnownLink(
384                $target,
385                $target->getFullText(),
386                [],
387                // Make sure wiki page redirects are not followed
388                $target->isRedirect() ? [ 'redirect' => 'no' ] : []
389            );
390        } else {
391            $link = $this->makeLink(
392                $target,
393                $target->getFullText(),
394                [],
395                // Make sure wiki page redirects are not followed
396                $target->isRedirect() ? [ 'redirect' => 'no' ] : []
397            );
398        }
399
400        $redirectToText = wfMessage( 'redirectto' )->inLanguage( $lang )->escaped();
401
402        return Html::rawElement(
403            'div', [ 'class' => 'redirectMsg' ],
404            Html::rawElement( 'p', [], $redirectToText ) .
405            Html::rawElement( 'ul', [ 'class' => 'redirectText' ],
406                Html::rawElement( 'li', [], $link ) )
407        );
408    }
409
410    /**
411     * Builds the final <a> element
412     *
413     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
414     * @param-taint $target none
415     * @param string|HtmlArmor $text
416     * @param-taint $text escapes_html
417     * @param array $attribs
418     * @param-taint $attribs none
419     * @param bool $isKnown
420     * @param-taint $isKnown none
421     * @return null|string
422     * @return-taint escaped
423     */
424    private function buildAElement( $target, $text, array $attribs, $isKnown ) {
425        $ret = null;
426        if ( !$this->hookRunner->onHtmlPageLinkRendererEnd(
427            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
428            $this, $this->castToLinkTarget( $target ), $isKnown, $text, $attribs, $ret )
429        ) {
430            return $ret;
431        }
432
433        return Html::rawElement( 'a', $attribs, HtmlArmor::getHtml( $text ) );
434    }
435
436    /**
437     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
438     * @return string
439     */
440    private function getLinkText( $target ) {
441        $prefixedText = $this->titleFormatter->getPrefixedText( $target );
442        // If the target is just a fragment, with no title, we return the fragment
443        // text.  Otherwise, we return the title text itself.
444        if ( $prefixedText === '' && $target instanceof LinkTarget && $target->hasFragment() ) {
445            return $target->getFragment();
446        }
447
448        return $prefixedText;
449    }
450
451    /**
452     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
453     * @param array $query Parameters you would like to add to the URL. For example, if you would
454     * like to add ?redirect=no&debug=1, you would set this to [ 'redirect' => 'no', 'debug' => '1' ]
455     * @return string non-escaped text
456     */
457    private function getLinkURL( $target, $query = [] ) {
458        if ( $this->forceArticlePath ) {
459            $realQuery = $query;
460            $query = [];
461        } else {
462            $realQuery = [];
463        }
464        $url = $this->castToTitle( $target )->getLinkURL( $query, false, $this->expandUrls );
465
466        if ( $this->forceArticlePath && $realQuery ) {
467            $url = wfAppendQuery( $url, $realQuery );
468        }
469
470        return $url;
471    }
472
473    /**
474     * Normalizes the provided target
475     *
476     * @internal For use by Linker::getImageLinkMTOParams()
477     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
478     * @return LinkTarget
479     */
480    public function normalizeTarget( $target ) {
481        $target = $this->castToLinkTarget( $target );
482        if ( $target->getNamespace() === NS_SPECIAL && !$target->isExternal() ) {
483            [ $name, $subpage ] = $this->specialPageFactory->resolveAlias(
484                $target->getDBkey()
485            );
486            if ( $name ) {
487                return new TitleValue(
488                    NS_SPECIAL,
489                    $this->specialPageFactory->getLocalNameFor( $name, $subpage ),
490                    $target->getFragment()
491                );
492            }
493        }
494
495        return $target;
496    }
497
498    /**
499     * Merges two sets of attributes
500     *
501     * @param array $defaults
502     * @param array $attribs
503     *
504     * @return array
505     */
506    private function mergeAttribs( $defaults, $attribs ) {
507        if ( !$attribs ) {
508            return $defaults;
509        }
510        # Merge the custom attribs with the default ones, and iterate
511        # over that, deleting all "false" attributes.
512        $ret = [];
513        $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
514        foreach ( $merged as $key => $val ) {
515            # A false value suppresses the attribute
516            if ( $val !== false ) {
517                $ret[$key] = $val;
518            }
519        }
520        return $ret;
521    }
522
523    /**
524     * Returns CSS classes to add to a known link.
525     *
526     * Note that most CSS classes set on wikilinks are actually handled elsewhere (e.g. in
527     * makeKnownLink() or in LinkHolderArray).
528     *
529     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
530     * @return string CSS class
531     */
532    public function getLinkClasses( $target ) {
533        Assert::parameterType( [ LinkTarget::class, PageReference::class ], $target, '$target' );
534        $target = $this->castToLinkTarget( $target );
535        // Don't call LinkCache if the target is "non-proper"
536        if ( $target->isExternal() || $target->getText() === '' || $target->getNamespace() < 0 ) {
537            return '';
538        }
539        // Make sure the target is in the cache
540        $id = $this->linkCache->addLinkObj( $target );
541        if ( $id == 0 ) {
542            // Doesn't exist
543            return '';
544        }
545
546        if ( $this->linkCache->getGoodLinkFieldObj( $target, 'redirect' ) ) {
547            # Page is a redirect
548            return 'mw-redirect';
549        }
550
551        return '';
552    }
553
554    /**
555     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
556     * @return Title
557     */
558    private function castToTitle( $target ): Title {
559        if ( $target instanceof LinkTarget ) {
560            return Title::newFromLinkTarget( $target );
561        }
562        // $target instanceof PageReference
563        return Title::newFromPageReference( $target );
564    }
565
566    /**
567     * @param LinkTarget|PageReference $target Page that will be visited when the user clicks on the link.
568     * @return LinkTarget
569     */
570    private function castToLinkTarget( $target ): LinkTarget {
571        if ( $target instanceof PageReference ) {
572            return Title::newFromPageReference( $target );
573        }
574        // $target instanceof LinkTarget
575        return $target;
576    }
577}