Display the file sha1 value in the file info page
[lhc/web/wiklou.git] / includes / linker / LinkRenderer.php
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 * @license GPL-2.0+
20 * @author Kunal Mehta <legoktm@member.fsf.org>
21 */
22 namespace MediaWiki\Linker;
23
24 use DummyLinker;
25 use Hooks;
26 use Html;
27 use HtmlArmor;
28 use LinkCache;
29 use Linker;
30 use MediaWiki\MediaWikiServices;
31 use MWNamespace;
32 use Sanitizer;
33 use Title;
34 use TitleFormatter;
35
36 /**
37 * Class that generates HTML <a> links for pages.
38 *
39 * @see https://www.mediawiki.org/wiki/Manual:LinkRenderer
40 * @since 1.28
41 */
42 class LinkRenderer {
43
44 /**
45 * Whether to force the pretty article path
46 *
47 * @var bool
48 */
49 private $forceArticlePath = false;
50
51 /**
52 * A PROTO_* constant or false
53 *
54 * @var string|bool|int
55 */
56 private $expandUrls = false;
57
58 /**
59 * @var int
60 */
61 private $stubThreshold = 0;
62
63 /**
64 * @var TitleFormatter
65 */
66 private $titleFormatter;
67
68 /**
69 * @var LinkCache
70 */
71 private $linkCache;
72
73 /**
74 * Whether to run the legacy Linker hooks
75 *
76 * @var bool
77 */
78 private $runLegacyBeginHook = true;
79
80 /**
81 * @param TitleFormatter $titleFormatter
82 * @param LinkCache $linkCache
83 */
84 public function __construct( TitleFormatter $titleFormatter, LinkCache $linkCache ) {
85 $this->titleFormatter = $titleFormatter;
86 $this->linkCache = $linkCache;
87 }
88
89 /**
90 * @param bool $force
91 */
92 public function setForceArticlePath( $force ) {
93 $this->forceArticlePath = $force;
94 }
95
96 /**
97 * @return bool
98 */
99 public function getForceArticlePath() {
100 return $this->forceArticlePath;
101 }
102
103 /**
104 * @param string|bool|int $expand A PROTO_* constant or false
105 */
106 public function setExpandURLs( $expand ) {
107 $this->expandUrls = $expand;
108 }
109
110 /**
111 * @return string|bool|int a PROTO_* constant or false
112 */
113 public function getExpandURLs() {
114 return $this->expandUrls;
115 }
116
117 /**
118 * @param int $threshold
119 */
120 public function setStubThreshold( $threshold ) {
121 $this->stubThreshold = $threshold;
122 }
123
124 /**
125 * @return int
126 */
127 public function getStubThreshold() {
128 return $this->stubThreshold;
129 }
130
131 /**
132 * @param bool $run
133 */
134 public function setRunLegacyBeginHook( $run ) {
135 $this->runLegacyBeginHook = $run;
136 }
137
138 /**
139 * @param LinkTarget $target
140 * @param string|HtmlArmor|null $text
141 * @param array $extraAttribs
142 * @param array $query
143 * @return string
144 */
145 public function makeLink(
146 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
147 ) {
148 $title = Title::newFromLinkTarget( $target );
149 if ( $title->isKnown() ) {
150 return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
151 } else {
152 return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
153 }
154 }
155
156 /**
157 * Get the options in the legacy format
158 *
159 * @param bool $isKnown Whether the link is known or broken
160 * @return array
161 */
162 private function getLegacyOptions( $isKnown ) {
163 $options = [ 'stubThreshold' => $this->stubThreshold ];
164 if ( $this->forceArticlePath ) {
165 $options[] = 'forcearticlepath';
166 }
167 if ( $this->expandUrls === PROTO_HTTP ) {
168 $options[] = 'http';
169 } elseif ( $this->expandUrls === PROTO_HTTPS ) {
170 $options[] = 'https';
171 }
172
173 $options[] = $isKnown ? 'known' : 'broken';
174
175 return $options;
176 }
177
178 private function runBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query, $isKnown ) {
179 $ret = null;
180 if ( !Hooks::run( 'HtmlPageLinkRendererBegin',
181 [ $this, $target, &$text, &$extraAttribs, &$query, &$ret ] )
182 ) {
183 return $ret;
184 }
185
186 // Now run the legacy hook
187 return $this->runLegacyBeginHook( $target, $text, $extraAttribs, $query, $isKnown );
188 }
189
190 private function runLegacyBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query,
191 $isKnown
192 ) {
193 if ( !$this->runLegacyBeginHook || !Hooks::isRegistered( 'LinkBegin' ) ) {
194 // Disabled, or nothing registered
195 return null;
196 }
197
198 $realOptions = $options = $this->getLegacyOptions( $isKnown );
199 $ret = null;
200 $dummy = new DummyLinker();
201 $title = Title::newFromLinkTarget( $target );
202 if ( $text !== null ) {
203 $realHtml = $html = HtmlArmor::getHtml( $text );
204 } else {
205 $realHtml = $html = null;
206 }
207 if ( !Hooks::run( 'LinkBegin',
208 [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ] )
209 ) {
210 return $ret;
211 }
212
213 if ( $html !== null && $html !== $realHtml ) {
214 // &$html was modified, so re-armor it as $text
215 $text = new HtmlArmor( $html );
216 }
217
218 // Check if they changed any of the options, hopefully not!
219 if ( $options !== $realOptions ) {
220 $factory = MediaWikiServices::getInstance()->getLinkRendererFactory();
221 // They did, so create a separate instance and have that take over the rest
222 $newRenderer = $factory->createFromLegacyOptions( $options );
223 // Don't recurse the hook...
224 $newRenderer->setRunLegacyBeginHook( false );
225 if ( in_array( 'known', $options, true ) ) {
226 return $newRenderer->makeKnownLink( $title, $text, $extraAttribs, $query );
227 } elseif ( in_array( 'broken', $options, true ) ) {
228 return $newRenderer->makeBrokenLink( $title, $text, $extraAttribs, $query );
229 } else {
230 return $newRenderer->makeLink( $title, $text, $extraAttribs, $query );
231 }
232 }
233
234 return null;
235 }
236
237 /**
238 * If you have already looked up the proper CSS classes using LinkRenderer::getLinkClasses()
239 * or some other method, use this to avoid looking it up again.
240 *
241 * @param LinkTarget $target
242 * @param string|HtmlArmor|null $text
243 * @param string $classes CSS classes to add
244 * @param array $extraAttribs
245 * @param array $query
246 * @return string
247 */
248 public function makePreloadedLink(
249 LinkTarget $target, $text = null, $classes, array $extraAttribs = [], array $query = []
250 ) {
251 // Run begin hook
252 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, true );
253 if ( $ret !== null ) {
254 return $ret;
255 }
256 $target = $this->normalizeTarget( $target );
257 $url = $this->getLinkURL( $target, $query );
258 $attribs = [ 'class' => $classes ];
259 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
260 if ( $prefixedText !== '' ) {
261 $attribs['title'] = $prefixedText;
262 }
263
264 $attribs = [
265 'href' => $url,
266 ] + $this->mergeAttribs( $attribs, $extraAttribs );
267
268 if ( $text === null ) {
269 $text = $this->getLinkText( $target );
270 }
271
272 return $this->buildAElement( $target, $text, $attribs, true );
273 }
274
275 /**
276 * @param LinkTarget $target
277 * @param string|HtmlArmor|null $text
278 * @param array $extraAttribs
279 * @param array $query
280 * @return string
281 */
282 public function makeKnownLink(
283 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
284 ) {
285 $classes = [];
286 if ( $target->isExternal() ) {
287 $classes[] = 'extiw';
288 }
289 $colour = $this->getLinkClasses( $target );
290 if ( $colour !== '' ) {
291 $classes[] = $colour;
292 }
293
294 return $this->makePreloadedLink(
295 $target,
296 $text,
297 $classes ? implode( ' ', $classes ) : '',
298 $extraAttribs,
299 $query
300 );
301 }
302
303 /**
304 * @param LinkTarget $target
305 * @param string|HtmlArmor|null $text
306 * @param array $extraAttribs
307 * @param array $query
308 * @return string
309 */
310 public function makeBrokenLink(
311 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
312 ) {
313 // Run legacy hook
314 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, false );
315 if ( $ret !== null ) {
316 return $ret;
317 }
318
319 # We don't want to include fragments for broken links, because they
320 # generally make no sense.
321 if ( $target->hasFragment() ) {
322 $target = $target->createFragmentTarget( '' );
323 }
324 $target = $this->normalizeTarget( $target );
325
326 if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
327 $query['action'] = 'edit';
328 $query['redlink'] = '1';
329 }
330
331 $url = $this->getLinkURL( $target, $query );
332 $attribs = [ 'class' => 'new' ];
333 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
334 if ( $prefixedText !== '' ) {
335 // This ends up in parser cache!
336 $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
337 ->inContentLanguage()
338 ->text();
339 }
340
341 $attribs = [
342 'href' => $url,
343 ] + $this->mergeAttribs( $attribs, $extraAttribs );
344
345 if ( $text === null ) {
346 $text = $this->getLinkText( $target );
347 }
348
349 return $this->buildAElement( $target, $text, $attribs, false );
350 }
351
352 /**
353 * Builds the final <a> element
354 *
355 * @param LinkTarget $target
356 * @param string|HtmlArmor $text
357 * @param array $attribs
358 * @param bool $isKnown
359 * @return null|string
360 */
361 private function buildAElement( LinkTarget $target, $text, array $attribs, $isKnown ) {
362 $ret = null;
363 if ( !Hooks::run( 'HtmlPageLinkRendererEnd',
364 [ $this, $target, $isKnown, &$text, &$attribs, &$ret ] )
365 ) {
366 return $ret;
367 }
368
369 $html = HtmlArmor::getHtml( $text );
370
371 // Run legacy hook
372 if ( Hooks::isRegistered( 'LinkEnd' ) ) {
373 $dummy = new DummyLinker();
374 $title = Title::newFromLinkTarget( $target );
375 $options = $this->getLegacyOptions( $isKnown );
376 if ( !Hooks::run( 'LinkEnd',
377 [ $dummy, $title, $options, &$html, &$attribs, &$ret ] )
378 ) {
379 return $ret;
380 }
381 }
382
383 return Html::rawElement( 'a', $attribs, $html );
384 }
385
386 /**
387 * @param LinkTarget $target
388 * @return string non-escaped text
389 */
390 private function getLinkText( LinkTarget $target ) {
391 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
392 // If the target is just a fragment, with no title, we return the fragment
393 // text. Otherwise, we return the title text itself.
394 if ( $prefixedText === '' && $target->hasFragment() ) {
395 return $target->getFragment();
396 }
397
398 return $prefixedText;
399 }
400
401 private function getLinkURL( LinkTarget $target, array $query = [] ) {
402 // TODO: Use a LinkTargetResolver service instead of Title
403 $title = Title::newFromLinkTarget( $target );
404 if ( $this->forceArticlePath ) {
405 $realQuery = $query;
406 $query = [];
407 } else {
408 $realQuery = [];
409 }
410 $url = $title->getLinkURL( $query, false, $this->expandUrls );
411
412 if ( $this->forceArticlePath && $realQuery ) {
413 $url = wfAppendQuery( $url, $realQuery );
414 }
415
416 return $url;
417 }
418
419 /**
420 * Normalizes the provided target
421 *
422 * @todo move the code from Linker actually here
423 * @param LinkTarget $target
424 * @return LinkTarget
425 */
426 private function normalizeTarget( LinkTarget $target ) {
427 return Linker::normaliseSpecialPage( $target );
428 }
429
430 /**
431 * Merges two sets of attributes
432 *
433 * @param array $defaults
434 * @param array $attribs
435 *
436 * @return array
437 */
438 private function mergeAttribs( $defaults, $attribs ) {
439 if ( !$attribs ) {
440 return $defaults;
441 }
442 # Merge the custom attribs with the default ones, and iterate
443 # over that, deleting all "false" attributes.
444 $ret = [];
445 $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
446 foreach ( $merged as $key => $val ) {
447 # A false value suppresses the attribute
448 if ( $val !== false ) {
449 $ret[$key] = $val;
450 }
451 }
452 return $ret;
453 }
454
455 /**
456 * Return the CSS classes of a known link
457 *
458 * @param LinkTarget $target
459 * @return string CSS class
460 */
461 public function getLinkClasses( LinkTarget $target ) {
462 // Make sure the target is in the cache
463 $id = $this->linkCache->addLinkObj( $target );
464 if ( $id == 0 ) {
465 // Doesn't exist
466 return '';
467 }
468
469 if ( $this->linkCache->getGoodLinkFieldObj( $target, 'redirect' ) ) {
470 # Page is a redirect
471 return 'mw-redirect';
472 } elseif ( $this->stubThreshold > 0 && MWNamespace::isContent( $target->getNamespace() )
473 && $this->linkCache->getGoodLinkFieldObj( $target, 'length' ) < $this->stubThreshold
474 ) {
475 # Page is a stub
476 return 'stub';
477 }
478
479 return '';
480 }
481 }