LinkRenderer.php 12 KB

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