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