Completed
Branch master (3f14ba)
by
unknown
24:06
created

LinkRenderer::getLinkClasses()   B

Complexity

Conditions 6
Paths 4

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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