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