Merge "Rewrite pref cleanup script"
[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 MWNamespace;
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 * 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 if ( $this->forceArticlePath ) {
404 $realQuery = $query;
405 $query = [];
406 } else {
407 $realQuery = [];
408 }
409 $url = $title->getLinkURL( $query, false, $this->expandUrls );
410
411 if ( $this->forceArticlePath && $realQuery ) {
412 $url = wfAppendQuery( $url, $realQuery );
413 }
414
415 return $url;
416 }
417
418 /**
419 * Normalizes the provided target
420 *
421 * @todo move the code from Linker actually here
422 * @param LinkTarget $target
423 * @return LinkTarget
424 */
425 private function normalizeTarget( LinkTarget $target ) {
426 return Linker::normaliseSpecialPage( $target );
427 }
428
429 /**
430 * Merges two sets of attributes
431 *
432 * @param array $defaults
433 * @param array $attribs
434 *
435 * @return array
436 */
437 private function mergeAttribs( $defaults, $attribs ) {
438 if ( !$attribs ) {
439 return $defaults;
440 }
441 # Merge the custom attribs with the default ones, and iterate
442 # over that, deleting all "false" attributes.
443 $ret = [];
444 $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
445 foreach ( $merged as $key => $val ) {
446 # A false value suppresses the attribute
447 if ( $val !== false ) {
448 $ret[$key] = $val;
449 }
450 }
451 return $ret;
452 }
453
454 /**
455 * Return the CSS classes of a known link
456 *
457 * @param LinkTarget $target
458 * @return string CSS class
459 */
460 public function getLinkClasses( LinkTarget $target ) {
461 // Make sure the target is in the cache
462 $id = $this->linkCache->addLinkObj( $target );
463 if ( $id == 0 ) {
464 // Doesn't exist
465 return '';
466 }
467
468 if ( $this->linkCache->getGoodLinkFieldObj( $target, 'redirect' ) ) {
469 # Page is a redirect
470 return 'mw-redirect';
471 } elseif ( $this->stubThreshold > 0 && MWNamespace::isContent( $target->getNamespace() )
472 && $this->linkCache->getGoodLinkFieldObj( $target, 'length' ) < $this->stubThreshold
473 ) {
474 # Page is a stub
475 return 'stub';
476 }
477
478 return '';
479 }
480 }