Merge "Use correct module name for stats in executeActionWithErrorHandling()"
[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 Linker;
29 use MediaWiki\MediaWikiServices;
30 use Sanitizer;
31 use Title;
32 use TitleFormatter;
33
34 /**
35 * Class that generates HTML <a> links for pages.
36 *
37 * @since 1.28
38 */
39 class LinkRenderer {
40
41 /**
42 * Whether to force the pretty article path
43 *
44 * @var bool
45 */
46 private $forceArticlePath = false;
47
48 /**
49 * A PROTO_* constant or false
50 *
51 * @var string|bool|int
52 */
53 private $expandUrls = false;
54
55 /**
56 * Whether extra classes should be added
57 *
58 * @var bool
59 */
60 private $noClasses = false;
61
62 /**
63 * @var int
64 */
65 private $stubThreshold = 0;
66
67 /**
68 * @var TitleFormatter
69 */
70 private $titleFormatter;
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 */
82 public function __construct( TitleFormatter $titleFormatter ) {
83 $this->titleFormatter = $titleFormatter;
84 }
85
86 /**
87 * @param bool $force
88 */
89 public function setForceArticlePath( $force ) {
90 $this->forceArticlePath = $force;
91 }
92
93 /**
94 * @return bool
95 */
96 public function getForceArticlePath() {
97 return $this->forceArticlePath;
98 }
99
100 /**
101 * @param string|bool|int $expand A PROTO_* constant or false
102 */
103 public function setExpandURLs( $expand ) {
104 $this->expandUrls = $expand;
105 }
106
107 /**
108 * @return string|bool|int a PROTO_* constant or false
109 */
110 public function getExpandURLs() {
111 return $this->expandUrls;
112 }
113
114 /**
115 * @param bool $no
116 */
117 public function setNoClasses( $no ) {
118 $this->noClasses = $no;
119 }
120
121 /**
122 * @return bool
123 */
124 public function getNoClasses() {
125 return $this->noClasses;
126 }
127
128 /**
129 * @param int $threshold
130 */
131 public function setStubThreshold( $threshold ) {
132 $this->stubThreshold = $threshold;
133 }
134
135 /**
136 * @return int
137 */
138 public function getStubThreshold() {
139 return $this->stubThreshold;
140 }
141
142 /**
143 * @param bool $run
144 */
145 public function setRunLegacyBeginHook( $run ) {
146 $this->runLegacyBeginHook = $run;
147 }
148
149 /**
150 * @param LinkTarget $target
151 * @param string|HtmlArmor|null $text
152 * @param array $extraAttribs
153 * @param array $query
154 * @return string
155 */
156 public function makeLink(
157 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
158 ) {
159 $title = Title::newFromLinkTarget( $target );
160 if ( $title->isKnown() ) {
161 return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
162 } else {
163 return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
164 }
165 }
166
167 /**
168 * Get the options in the legacy format
169 *
170 * @param bool $isKnown Whether the link is known or broken
171 * @return array
172 */
173 private function getLegacyOptions( $isKnown ) {
174 $options = [ 'stubThreshold' => $this->stubThreshold ];
175 if ( $this->noClasses ) {
176 $options[] = 'noclasses';
177 }
178 if ( $this->forceArticlePath ) {
179 $options[] = 'forcearticlepath';
180 }
181 if ( $this->expandUrls === PROTO_HTTP ) {
182 $options[] = 'http';
183 } elseif ( $this->expandUrls === PROTO_HTTPS ) {
184 $options[] = 'https';
185 }
186
187 $options[] = $isKnown ? 'known' : 'broken';
188
189 return $options;
190 }
191
192 private function runBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query, $isKnown ) {
193 $ret = null;
194 if ( !Hooks::run( 'HtmlPageLinkRendererBegin',
195 [ $this, $target, &$text, &$extraAttribs, &$query, &$ret ] )
196 ) {
197 return $ret;
198 }
199
200 // Now run the legacy hook
201 return $this->runLegacyBeginHook( $target, $text, $extraAttribs, $query, $isKnown );
202 }
203
204 private function runLegacyBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query,
205 $isKnown
206 ) {
207 if ( !$this->runLegacyBeginHook || !Hooks::isRegistered( 'LinkBegin' ) ) {
208 // Disabled, or nothing registered
209 return null;
210 }
211
212 $realOptions = $options = $this->getLegacyOptions( $isKnown );
213 $ret = null;
214 $dummy = new DummyLinker();
215 $title = Title::newFromLinkTarget( $target );
216 if ( $text !== null ) {
217 $realHtml = $html = HtmlArmor::getHtml( $text );
218 } else {
219 $realHtml = $html = null;
220 }
221 if ( !Hooks::run( 'LinkBegin',
222 [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ] )
223 ) {
224 return $ret;
225 }
226
227 if ( $html !== null && $html !== $realHtml ) {
228 // &$html was modified, so re-armor it as $text
229 $text = new HtmlArmor( $html );
230 }
231
232 // Check if they changed any of the options, hopefully not!
233 if ( $options !== $realOptions ) {
234 $factory = MediaWikiServices::getInstance()->getLinkRendererFactory();
235 // They did, so create a separate instance and have that take over the rest
236 $newRenderer = $factory->createFromLegacyOptions( $options );
237 // Don't recurse the hook...
238 $newRenderer->setRunLegacyBeginHook( false );
239 if ( in_array( 'known', $options, true ) ) {
240 return $newRenderer->makeKnownLink( $title, $text, $extraAttribs, $query );
241 } elseif ( in_array( 'broken', $options, true ) ) {
242 return $newRenderer->makeBrokenLink( $title, $text, $extraAttribs, $query );
243 } else {
244 return $newRenderer->makeLink( $title, $text, $extraAttribs, $query );
245 }
246 }
247
248 return null;
249 }
250
251 /**
252 * @param LinkTarget $target
253 * @param string|HtmlArmor|null $text
254 * @param array $extraAttribs
255 * @param array $query
256 * @return string
257 */
258 public function makeKnownLink(
259 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
260 ) {
261 // Run begin hook
262 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, true );
263 if ( $ret !== null ) {
264 return $ret;
265 }
266 $target = $this->normalizeTarget( $target );
267 $url = $this->getLinkURL( $target, $query );
268 $attribs = [];
269 if ( !$this->noClasses ) {
270 $classes = [];
271 if ( $target->isExternal() ) {
272 $classes[] = 'extiw';
273 }
274 $title = Title::newFromLinkTarget( $target );
275 $colour = Linker::getLinkColour( $title, $this->stubThreshold );
276 if ( $colour !== '' ) {
277 $classes[] = $colour;
278 }
279 if ( $classes ) {
280 $attribs['class'] = implode( ' ', $classes );
281 }
282 }
283
284 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
285 if ( $prefixedText !== '' ) {
286 $attribs['title'] = $prefixedText;
287 }
288
289 $attribs = [
290 'href' => $url,
291 ] + $this->mergeAttribs( $attribs, $extraAttribs );
292
293 if ( $text === null ) {
294 $text = $this->getLinkText( $target );
295 }
296
297 return $this->buildAElement( $target, $text, $attribs, true );
298 }
299
300 /**
301 * @param LinkTarget $target
302 * @param string|HtmlArmor|null $text
303 * @param array $extraAttribs
304 * @param array $query
305 * @return string
306 */
307 public function makeBrokenLink(
308 LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
309 ) {
310 // Run legacy hook
311 $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, false );
312 if ( $ret !== null ) {
313 return $ret;
314 }
315
316 # We don't want to include fragments for broken links, because they
317 # generally make no sense.
318 if ( $target->hasFragment() ) {
319 $target = $target->createFragmentTarget( '' );
320 }
321 $target = $this->normalizeTarget( $target );
322
323 if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
324 $query['action'] = 'edit';
325 $query['redlink'] = '1';
326 }
327
328 $url = $this->getLinkURL( $target, $query );
329 $attribs = $this->noClasses ? [] : [ 'class' => 'new' ];
330 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
331 if ( $prefixedText !== '' ) {
332 // This ends up in parser cache!
333 $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
334 ->inContentLanguage()
335 ->text();
336 }
337
338 $attribs = [
339 'href' => $url,
340 ] + $this->mergeAttribs( $attribs, $extraAttribs );
341
342 if ( $text === null ) {
343 $text = $this->getLinkText( $target );
344 }
345
346 return $this->buildAElement( $target, $text, $attribs, false );
347 }
348
349 /**
350 * Builds the final <a> element
351 *
352 * @param LinkTarget $target
353 * @param string|HtmlArmor $text
354 * @param array $attribs
355 * @param bool $isKnown
356 * @return null|string
357 */
358 private function buildAElement( LinkTarget $target, $text, array $attribs, $isKnown ) {
359 $ret = null;
360 if ( !Hooks::run( 'HtmlPageLinkRendererEnd',
361 [ $this, $target, $isKnown, &$text, &$attribs, &$ret ] )
362 ) {
363 return $ret;
364 }
365
366 $html = HtmlArmor::getHtml( $text );
367
368 // Run legacy hook
369 if ( Hooks::isRegistered( 'LinkEnd' ) ) {
370 $dummy = new DummyLinker();
371 $title = Title::newFromLinkTarget( $target );
372 $options = $this->getLegacyOptions( $isKnown );
373 if ( !Hooks::run( 'LinkEnd',
374 [ $dummy, $title, $options, &$html, &$attribs, &$ret ] )
375 ) {
376 return $ret;
377 }
378 }
379
380 return Html::rawElement( 'a', $attribs, $html );
381 }
382
383 /**
384 * @param LinkTarget $target
385 * @return string non-escaped text
386 */
387 private function getLinkText( LinkTarget $target ) {
388 $prefixedText = $this->titleFormatter->getPrefixedText( $target );
389 // If the target is just a fragment, with no title, we return the fragment
390 // text. Otherwise, we return the title text itself.
391 if ( $prefixedText === '' && $target->hasFragment() ) {
392 return $target->getFragment();
393 }
394
395 return $prefixedText;
396 }
397
398 private function getLinkURL( LinkTarget $target, array $query = [] ) {
399 // TODO: Use a LinkTargetResolver service instead of Title
400 $title = Title::newFromLinkTarget( $target );
401 $proto = $this->expandUrls !== false
402 ? $this->expandUrls
403 : PROTO_RELATIVE;
404 if ( $this->forceArticlePath ) {
405 $realQuery = $query;
406 $query = [];
407 } else {
408 $realQuery = [];
409 }
410 $url = $title->getLinkURL( $query, false, $proto );
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 }