Merge "Add LinkRenderer (rewrite of Linker::link())"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 24 May 2016 03:29:32 +0000 (03:29 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 24 May 2016 03:29:32 +0000 (03:29 +0000)
13 files changed:
autoload.php
docs/hooks.txt
includes/Linker.php
includes/MediaWikiServices.php
includes/ServiceWiring.php
includes/libs/HtmlArmor.php [new file with mode: 0644]
includes/linker/LinkRenderer.php [new file with mode: 0644]
includes/linker/LinkRendererFactory.php [new file with mode: 0644]
tests/parser/parserTest.inc
tests/phpunit/includes/MediaWikiServicesTest.php
tests/phpunit/includes/libs/HtmlArmorTest.php [new file with mode: 0644]
tests/phpunit/includes/linker/LinkRendererFactoryTest.php [new file with mode: 0644]
tests/phpunit/includes/linker/LinkRendererTest.php [new file with mode: 0644]

index c68b703..f635bc1 100644 (file)
@@ -557,6 +557,7 @@ $wgAutoloadLocalClasses = [
        'HistoryPager' => __DIR__ . '/includes/actions/HistoryAction.php',
        'Hooks' => __DIR__ . '/includes/Hooks.php',
        'Html' => __DIR__ . '/includes/Html.php',
+       'HtmlArmor' => __DIR__ . '/includes/libs/HtmlArmor.php',
        'HtmlFormatter' => __DIR__ . '/includes/HtmlFormatter.php',
        'Http' => __DIR__ . '/includes/HttpFunctions.php',
        'HttpError' => __DIR__ . '/includes/exception/HttpError.php',
@@ -834,6 +835,8 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Interwiki\\InterwikiLookup' => __DIR__ . '/includes/interwiki/InterwikiLookup.php',
        'MediaWiki\\Languages\\Data\\Names' => __DIR__ . '/languages/data/Names.php',
        'MediaWiki\\Languages\\Data\\ZhConversion' => __DIR__ . '/languages/data/ZhConversion.php',
+       'MediaWiki\\Linker\\LinkRenderer' => __DIR__ . '/includes/linker/LinkRenderer.php',
+       'MediaWiki\\Linker\\LinkRendererFactory' => __DIR__ . '/includes/linker/LinkRendererFactory.php',
        'MediaWiki\\Linker\\LinkTarget' => __DIR__ . '/includes/linker/LinkTarget.php',
        'MediaWiki\\Logger\\LegacyLogger' => __DIR__ . '/includes/debug/logger/LegacyLogger.php',
        'MediaWiki\\Logger\\LegacySpi' => __DIR__ . '/includes/debug/logger/LegacySpi.php',
index 9d2b3c2..c91354d 100644 (file)
@@ -1783,7 +1783,8 @@ $title: The page's Title.
 $out: The output page.
 $cssClassName: CSS class name of the language selector.
 
-'LinkBegin': Used when generating internal and interwiki links in
+'LinkBegin': DEPRECATED! Use HtmlPageLinkRendererBegin instead.
+Used when generating internal and interwiki links in
 Linker::link(), before processing starts.  Return false to skip default
 processing and return $ret. See documentation for Linker::link() for details on
 the expected meanings of parameters.
@@ -1800,7 +1801,8 @@ $target: the Title that the link is pointing to
 &$options: array of options.  Can include 'known', 'broken', 'noclasses'.
 &$ret: the value to return if your hook returns false.
 
-'LinkEnd': Used when generating internal and interwiki links in Linker::link(),
+'LinkEnd': DEPRECATED! Use HtmlPageLinkRendererEnd hook instead
+Used when generating internal and interwiki links in Linker::link(),
 just before the function returns a value.  If you return true, an <a> element
 with HTML attributes $attribs and contents $html will be returned.  If you
 return false, $ret will be returned.
@@ -1835,6 +1837,35 @@ $file: the File object or false if broken link
 &$attribs: the attributes to be applied
 &$ret: the value to return if your hook returns false
 
+'LinkRendererBegin':
+Used when generating internal and interwiki links in
+LinkRenderer, before processing starts.  Return false to skip default
+processing and return $ret.
+$linkRenderer: the LinkRenderer object
+$target: the LinkTarget that the link is pointing to
+&$html: the contents that the <a> tag should have (raw HTML); null means
+  "default".
+&$customAttribs: the HTML attributes that the <a> tag should have, in
+  associative array form, with keys and values unescaped.  Should be merged
+  with default values, with a value of false meaning to suppress the
+  attribute.
+&$query: the query string to add to the generated URL (the bit after the "?"),
+  in associative array form, with keys and values unescaped.
+&$ret: the value to return if your hook returns false.
+
+'LinkRendererEnd':
+Used when generating internal and interwiki links in LinkRenderer,
+just before the function returns a value.  If you return true, an <a> element
+with HTML attributes $attribs and contents $html will be returned.  If you
+return false, $ret will be returned.
+$linkRenderer: the LinkRenderer object
+$target: the LinkTarget object that the link is pointing to
+$isKnown: boolean indicating whether the page is known or not
+&$html: the final (raw HTML) contents of the <a> tag, after processing.
+&$attribs: the final HTML attributes of the <a> tag, after processing, in
+  associative array form.
+&$ret: the value to return if your hook returns false.
+
 'LinksUpdate': At the beginning of LinksUpdate::doUpdate() just before the
 actual update.
 &$linksUpdate: the LinksUpdate object
index f4131ed..990ad15 100644 (file)
@@ -20,6 +20,7 @@
  * @file
  */
 use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
 
 /**
  * Some internal bits split of from Skin.php. These functions are used
@@ -210,55 +211,33 @@ class Linker {
                        wfDeprecated( __METHOD__ . ' with parameter $query as string (should be array)', '1.20' );
                        $query = wfCgiToArray( $query );
                }
-               $options = (array)$options;
-
-               $dummy = new DummyLinker; // dummy linker instance for bc on the hooks
 
-               $ret = null;
-               if ( !Hooks::run( 'LinkBegin',
-                       [ $dummy, $target, &$html, &$customAttribs, &$query, &$options, &$ret ] )
-               ) {
-                       return $ret;
-               }
-
-               # Normalize the Title if it's a special page
-               $target = self::normaliseSpecialPage( $target );
-
-               # If we don't know whether the page exists, let's find out.
-               if ( !in_array( 'known', $options, true ) && !in_array( 'broken', $options, true ) ) {
-                       if ( $target->isKnown() ) {
-                               $options[] = 'known';
-                       } else {
-                               $options[] = 'broken';
+               $services = MediaWikiServices::getInstance();
+               $options = (array)$options;
+               if ( $options ) {
+                       // Custom options, create new LinkRenderer
+                       if ( !isset( $options['stubThreshold'] ) ) {
+                               global $wgUser;
+                               $options['stubThreshold'] = $wgUser->getStubThreshold();
                        }
+                       $linkRenderer = $services->getLinkRendererFactory()
+                               ->createFromLegacyOptions( $options );
+               } else {
+                       $linkRenderer = $services->getLinkRenderer();
                }
 
-               $oldquery = [];
-               if ( in_array( "forcearticlepath", $options, true ) && $query ) {
-                       $oldquery = $query;
-                       $query = [];
-               }
-
-               # Note: we want the href attribute first, for prettiness.
-               $attribs = [ 'href' => self::linkUrl( $target, $query, $options ) ];
-               if ( in_array( 'forcearticlepath', $options, true ) && $oldquery ) {
-                       $attribs['href'] = wfAppendQuery( $attribs['href'], $oldquery );
-               }
-
-               $attribs = array_merge(
-                       $attribs,
-                       self::linkAttribs( $target, $customAttribs, $options )
-               );
-               if ( is_null( $html ) ) {
-                       $html = self::linkText( $target );
+               if ( $html !== null ) {
+                       $text = new HtmlArmor( $html );
+               } else {
+                       $text = $html; // null
                }
-
-               $ret = null;
-               if ( Hooks::run( 'LinkEnd', [ $dummy, $target, $options, &$html, &$attribs, &$ret ] ) ) {
-                       $ret = Html::rawElement( 'a', $attribs, $html );
+               if ( in_array( 'known', $options, true ) ) {
+                       return $linkRenderer->makeKnownLink( $target, $text, $customAttribs, $query );
+               } elseif ( in_array( 'broken', $options, true ) ) {
+                       return $linkRenderer->makeBrokenLink( $target, $text, $customAttribs, $query );
+               } else {
+                       return $linkRenderer->makeLink( $target, $text, $customAttribs, $query );
                }
-
-               return $ret;
        }
 
        /**
@@ -274,130 +253,6 @@ class Linker {
                return self::link( $target, $html, $customAttribs, $query, $options );
        }
 
-       /**
-        * Returns the Url used to link to a Title
-        *
-        * @param LinkTarget $target
-        * @param array $query Query parameters
-        * @param array $options
-        * @return string
-        */
-       private static function linkUrl( LinkTarget $target, $query, $options ) {
-               # We don't want to include fragments for broken links, because they
-               # generally make no sense.
-               if ( in_array( 'broken', $options, true ) && $target->hasFragment() ) {
-                       $target = $target->createFragmentTarget( '' );
-               }
-
-               # If it's a broken link, add the appropriate query pieces, unless
-               # there's already an action specified, or unless 'edit' makes no sense
-               # (i.e., for a nonexistent special page).
-               if ( in_array( 'broken', $options, true ) && empty( $query['action'] )
-                       && $target->getNamespace() !== NS_SPECIAL ) {
-                       $query['action'] = 'edit';
-                       $query['redlink'] = '1';
-               }
-
-               if ( in_array( 'http', $options, true ) ) {
-                       $proto = PROTO_HTTP;
-               } elseif ( in_array( 'https', $options, true ) ) {
-                       $proto = PROTO_HTTPS;
-               } else {
-                       $proto = PROTO_RELATIVE;
-               }
-
-               $title = Title::newFromLinkTarget( $target );
-               $ret = $title->getLinkURL( $query, false, $proto );
-               return $ret;
-       }
-
-       /**
-        * Returns the array of attributes used when linking to the Title $target
-        *
-        * @param Title $target
-        * @param array $attribs
-        * @param array $options
-        *
-        * @return array
-        */
-       private static function linkAttribs( $target, $attribs, $options ) {
-               global $wgUser;
-               $defaults = [];
-
-               if ( !in_array( 'noclasses', $options, true ) ) {
-                       # Now build the classes.
-                       $classes = [];
-
-                       if ( in_array( 'broken', $options, true ) ) {
-                               $classes[] = 'new';
-                       }
-
-                       if ( $target->isExternal() ) {
-                               $classes[] = 'extiw';
-                       }
-
-                       if ( !in_array( 'broken', $options, true ) ) { # Avoid useless calls to LinkCache (see r50387)
-                               $colour = self::getLinkColour(
-                                       $target,
-                                       isset( $options['stubThreshold'] ) ? $options['stubThreshold'] : $wgUser->getStubThreshold()
-                               );
-                               if ( $colour !== '' ) {
-                                       $classes[] = $colour; # mw-redirect or stub
-                               }
-                       }
-                       if ( $classes != [] ) {
-                               $defaults['class'] = implode( ' ', $classes );
-                       }
-               }
-
-               # Get a default title attribute.
-               if ( $target->getPrefixedText() == '' ) {
-                       # A link like [[#Foo]].  This used to mean an empty title
-                       # attribute, but that's silly.  Just don't output a title.
-               } elseif ( in_array( 'known', $options, true ) ) {
-                       $defaults['title'] = $target->getPrefixedText();
-               } else {
-                       // This ends up in parser cache!
-                       $defaults['title'] = wfMessage( 'red-link-title', $target->getPrefixedText() )
-                               ->inContentLanguage()
-                               ->text();
-               }
-
-               # Finally, merge the custom attribs with the default ones, and iterate
-               # over that, deleting all "false" attributes.
-               $ret = [];
-               $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
-               foreach ( $merged as $key => $val ) {
-                       # A false value suppresses the attribute, and we don't want the
-                       # href attribute to be overridden.
-                       if ( $key != 'href' && $val !== false ) {
-                               $ret[$key] = $val;
-                       }
-               }
-               return $ret;
-       }
-
-       /**
-        * Default text of the links to the Title $target
-        *
-        * @param Title $target
-        *
-        * @return string
-        */
-       private static function linkText( $target ) {
-               if ( !$target instanceof Title ) {
-                       wfWarn( __METHOD__ . ': Requires $target to be a Title object.' );
-                       return '';
-               }
-               // If the target is just a fragment, with no title, we return the fragment
-               // text.  Otherwise, we return the title text itself.
-               if ( $target->getPrefixedText() === '' && $target->hasFragment() ) {
-                       return htmlspecialchars( $target->getFragment() );
-               }
-
-               return htmlspecialchars( $target->getPrefixedText() );
-       }
-
        /**
         * Make appropriate markup for a link to the current article. This is
         * currently rendered as the bold link text. The calling sequence is the
index 1b8a759..6613db1 100644 (file)
@@ -11,6 +11,8 @@ use LBFactory;
 use LinkCache;
 use Liuggio\StatsdClient\Factory\StatsdDataFactory;
 use LoadBalancer;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
 use MediaWiki\Services\SalvageableService;
 use MediaWiki\Services\ServiceContainer;
 use MWException;
@@ -516,6 +518,25 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'LinkCache' );
        }
 
+       /**
+        * @since 1.28
+        * @return LinkRendererFactory
+        */
+       public function getLinkRendererFactory() {
+               return $this->getService( 'LinkRendererFactory' );
+       }
+
+       /**
+        * LinkRenderer instance that can be used
+        * if no custom options are needed
+        *
+        * @since 1.28
+        * @return LinkRenderer
+        */
+       public function getLinkRenderer() {
+               return $this->getService( 'LinkRenderer' );
+       }
+
        /**
         * @since 1.28
         * @return TitleFormatter
index 6bdacf0..2250935 100644 (file)
@@ -38,6 +38,8 @@
  */
 
 use MediaWiki\Interwiki\ClassicInterwikiLookup;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
 use MediaWiki\MediaWikiServices;
 
 return [
@@ -159,6 +161,18 @@ return [
                );
        },
 
+       'LinkRendererFactory' => function( MediaWikiServices $services ) {
+               return new LinkRendererFactory(
+                       $services->getTitleFormatter()
+               );
+       },
+
+       'LinkRenderer' => function( MediaWikiServices $services ) {
+               global $wgUser;
+
+               return $services->getLinkRendererFactory()->createForUser( $wgUser );
+       },
+
        'GenderCache' => function( MediaWikiServices $services ) {
                return new GenderCache();
        },
diff --git a/includes/libs/HtmlArmor.php b/includes/libs/HtmlArmor.php
new file mode 100644 (file)
index 0000000..511e1c9
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+
+/**
+ * Marks HTML that shouldn't be escaped
+ *
+ * @since 1.28
+ */
+class HtmlArmor {
+
+       /**
+        * @var string
+        */
+       private $value;
+
+       /**
+        * @param string $value
+        */
+       public function __construct( $value ) {
+               $this->value = $value;
+       }
+
+       /**
+        * Provide a string or HtmlArmor object
+        * and get safe HTML back
+        *
+        * @param string|HtmlArmor $input
+        * @return string safe for usage in HTML
+        */
+       public static function getHtml( $input ) {
+               if ( $input instanceof HtmlArmor ) {
+                       return $input->value;
+               } else {
+                       return htmlspecialchars( $input );
+               }
+       }
+}
diff --git a/includes/linker/LinkRenderer.php b/includes/linker/LinkRenderer.php
new file mode 100644 (file)
index 0000000..0261365
--- /dev/null
@@ -0,0 +1,451 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+namespace MediaWiki\Linker;
+
+use DummyLinker;
+use Hooks;
+use Html;
+use HtmlArmor;
+use Linker;
+use MediaWiki\MediaWikiServices;
+use Sanitizer;
+use Title;
+use TitleFormatter;
+
+/**
+ * Class that generates HTML <a> links for pages.
+ *
+ * @since 1.28
+ */
+class LinkRenderer {
+
+       /**
+        * Whether to force the pretty article path
+        *
+        * @var bool
+        */
+       private $forceArticlePath = false;
+
+       /**
+        * A PROTO_* constant or false
+        *
+        * @var string|bool|int
+        */
+       private $expandUrls = false;
+
+       /**
+        * Whether extra classes should be added
+        *
+        * @var bool
+        */
+       private $noClasses = false;
+
+       /**
+        * @var int
+        */
+       private $stubThreshold = 0;
+
+       /**
+        * @var TitleFormatter
+        */
+       private $titleFormatter;
+
+       /**
+        * Whether to run the legacy Linker hooks
+        *
+        * @var bool
+        */
+       private $runLegacyBeginHook = true;
+
+       /**
+        * @param TitleFormatter $titleFormatter
+        */
+       public function __construct( TitleFormatter $titleFormatter ) {
+               $this->titleFormatter = $titleFormatter;
+       }
+
+       /**
+        * @param bool $force
+        */
+       public function setForceArticlePath( $force ) {
+               $this->forceArticlePath = $force;
+       }
+
+       /**
+        * @return bool
+        */
+       public function getForceArticlePath() {
+               return $this->forceArticlePath;
+       }
+
+       /**
+        * @param string|bool|int $expand A PROTO_* constant or false
+        */
+       public function setExpandURLs( $expand ) {
+               $this->expandUrls = $expand;
+       }
+
+       /**
+        * @return string|bool|int a PROTO_* constant or false
+        */
+       public function getExpandURLs() {
+               return $this->expandUrls;
+       }
+
+       /**
+        * @param bool $no
+        */
+       public function setNoClasses( $no ) {
+               $this->noClasses = $no;
+       }
+
+       /**
+        * @return bool
+        */
+       public function getNoClasses() {
+               return $this->noClasses;
+       }
+
+       /**
+        * @param int $threshold
+        */
+       public function setStubThreshold( $threshold ) {
+               $this->stubThreshold = $threshold;
+       }
+
+       /**
+        * @return int
+        */
+       public function getStubThreshold() {
+               return $this->stubThreshold;
+       }
+
+       /**
+        * @param bool $run
+        */
+       public function setRunLegacyBeginHook( $run ) {
+               $this->runLegacyBeginHook = $run;
+       }
+
+       /**
+        * @param LinkTarget $target
+        * @param string|HtmlArmor|null $text
+        * @param array $extraAttribs
+        * @param array $query
+        * @return string
+        */
+       public function makeLink(
+               LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+       ) {
+               $title = Title::newFromLinkTarget( $target );
+               if ( $title->isKnown() ) {
+                       return $this->makeKnownLink( $target, $text, $extraAttribs, $query );
+               } else {
+                       return $this->makeBrokenLink( $target, $text, $extraAttribs, $query );
+               }
+       }
+
+       /**
+        * Get the options in the legacy format
+        *
+        * @param bool $isKnown Whether the link is known or broken
+        * @return array
+        */
+       private function getLegacyOptions( $isKnown ) {
+               $options = [ 'stubThreshold' => $this->stubThreshold ];
+               if ( $this->noClasses ) {
+                       $options[] = 'noclasses';
+               }
+               if ( $this->forceArticlePath ) {
+                       $options[] = 'forcearticlepath';
+               }
+               if ( $this->expandUrls === PROTO_HTTP ) {
+                       $options[] = 'http';
+               } elseif ( $this->expandUrls === PROTO_HTTPS ) {
+                       $options[] = 'https';
+               }
+
+               $options[] = $isKnown ? 'known' : 'broken';
+
+               return $options;
+       }
+
+       private function runBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query, $isKnown ) {
+               $ret = null;
+               if ( !Hooks::run( 'HtmlPageLinkRendererBegin',
+                       [ $this, $target, &$text, &$extraAttribs, &$query, &$ret ] )
+               ) {
+                       return $ret;
+               }
+
+               // Now run the legacy hook
+               return $this->runLegacyBeginHook( $target, $text, $extraAttribs, $query, $isKnown );
+       }
+
+       private function runLegacyBeginHook( LinkTarget $target, &$text, &$extraAttribs, &$query,
+               $isKnown
+       ) {
+               if ( !$this->runLegacyBeginHook || !Hooks::isRegistered( 'LinkBegin' ) ) {
+                       // Disabled, or nothing registered
+                       return null;
+               }
+
+               $realOptions = $options = $this->getLegacyOptions( $isKnown );
+               $ret = null;
+               $dummy = new DummyLinker();
+               $title = Title::newFromLinkTarget( $target );
+               $realHtml = $html = HtmlArmor::getHtml( $text );
+               if ( !Hooks::run( 'LinkBegin',
+                       [ $dummy, $title, &$html, &$extraAttribs, &$query, &$options, &$ret ] )
+               ) {
+                       return $ret;
+               }
+
+               if ( $html !== null && $html !== $realHtml ) {
+                       // &$html was modified, so re-armor it as $text
+                       $text = new HtmlArmor( $html );
+               }
+
+               // Check if they changed any of the options, hopefully not!
+               if ( $options !== $realOptions ) {
+                       $factory = MediaWikiServices::getInstance()->getLinkRendererFactory();
+                       // They did, so create a separate instance and have that take over the rest
+                       $newRenderer = $factory->createFromLegacyOptions( $options );
+                       // Don't recurse the hook...
+                       $newRenderer->setRunLegacyBeginHook( false );
+                       if ( in_array( 'known', $options, true ) ) {
+                               return $newRenderer->makeKnownLink( $title, $text, $extraAttribs, $query );
+                       } elseif ( in_array( 'broken', $options, true ) ) {
+                               return $newRenderer->makeBrokenLink( $title, $text, $extraAttribs, $query );
+                       } else {
+                               return $newRenderer->makeLink( $title, $text, $extraAttribs, $query );
+                       }
+               }
+
+               return null;
+       }
+
+       /**
+        * @param LinkTarget $target
+        * @param string|HtmlArmor|null $text
+        * @param array $extraAttribs
+        * @param array $query
+        * @return string
+        */
+       public function makeKnownLink(
+               LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+       ) {
+               // Run begin hook
+               $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, true );
+               if ( $ret !== null ) {
+                       return $ret;
+               }
+               $target = $this->normalizeTarget( $target );
+               $url = $this->getLinkURL( $target, $query );
+               $attribs = [];
+               if ( !$this->noClasses ) {
+                       $classes = [];
+                       if ( $target->isExternal() ) {
+                               $classes[] = 'extiw';
+                       }
+                       $title = Title::newFromLinkTarget( $target );
+                       $colour = Linker::getLinkColour( $title, $this->stubThreshold );
+                       if ( $colour !== '' ) {
+                               $classes[] = $colour;
+                       }
+                       if ( $classes ) {
+                               $attribs['class'] = implode( ' ', $classes );
+                       }
+               }
+
+               $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+               if ( $prefixedText !== '' ) {
+                       $attribs['title'] = $prefixedText;
+               }
+
+               $attribs = [
+                       'href' => $url,
+               ] + $this->mergeAttribs( $attribs, $extraAttribs );
+
+               if ( $text === null ) {
+                       $text = $this->getLinkText( $target );
+               }
+
+               return $this->buildAElement( $target, $text, $attribs, true );
+       }
+
+       /**
+        * @param LinkTarget $target
+        * @param string|HtmlArmor|null $text
+        * @param array $extraAttribs
+        * @param array $query
+        * @return string
+        */
+       public function makeBrokenLink(
+               LinkTarget $target, $text = null, array $extraAttribs = [], array $query = []
+       ) {
+               // Run legacy hook
+               $ret = $this->runBeginHook( $target, $text, $extraAttribs, $query, false );
+               if ( $ret !== null ) {
+                       return $ret;
+               }
+
+               # We don't want to include fragments for broken links, because they
+               # generally make no sense.
+               if ( $target->hasFragment() ) {
+                       $target = $target->createFragmentTarget( '' );
+               }
+               $target = $this->normalizeTarget( $target );
+
+               if ( !isset( $query['action'] ) && $target->getNamespace() !== NS_SPECIAL ) {
+                       $query['action'] = 'edit';
+                       $query['redlink'] = '1';
+               }
+
+               $url = $this->getLinkURL( $target, $query );
+               $attribs = $this->noClasses ? [] : [ 'class' => 'new' ];
+               $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+               if ( $prefixedText !== '' ) {
+                       // This ends up in parser cache!
+                       $attribs['title'] = wfMessage( 'red-link-title', $prefixedText )
+                               ->inContentLanguage()
+                               ->text();
+               }
+
+               $attribs = [
+                       'href' => $url,
+               ] + $this->mergeAttribs( $attribs, $extraAttribs );
+
+               if ( $text === null ) {
+                       $text = $this->getLinkText( $target );
+               }
+
+               return $this->buildAElement( $target, $text, $attribs, false );
+       }
+
+       /**
+        * Builds the final <a> element
+        *
+        * @param LinkTarget $target
+        * @param string|HtmlArmor $text
+        * @param array $attribs
+        * @param bool $isKnown
+        * @return null|string
+        */
+       private function buildAElement( LinkTarget $target, $text, array $attribs, $isKnown ) {
+               $ret = null;
+               if ( !Hooks::run( 'HtmlPageLinkRendererEnd',
+                       [ $this, $target, $isKnown, &$text, &$attribs, &$ret ] )
+               ) {
+                       return $ret;
+               }
+
+               $html = HtmlArmor::getHtml( $text );
+
+               // Run legacy hook
+               if ( Hooks::isRegistered( 'LinkEnd' ) ) {
+                       $dummy = new DummyLinker();
+                       $title = Title::newFromLinkTarget( $target );
+                       $options = $this->getLegacyOptions( $isKnown );
+                       if ( !Hooks::run( 'LinkEnd',
+                               [ $dummy, $title, $options, &$html, &$attribs, &$ret ] )
+                       ) {
+                               return $ret;
+                       }
+               }
+
+               return Html::rawElement( 'a', $attribs, $html );
+       }
+
+       /**
+        * @param LinkTarget $target
+        * @return string non-escaped text
+        */
+       private function getLinkText( LinkTarget $target ) {
+               $prefixedText = $this->titleFormatter->getPrefixedText( $target );
+               // If the target is just a fragment, with no title, we return the fragment
+               // text.  Otherwise, we return the title text itself.
+               if ( $prefixedText === '' && $target->hasFragment() ) {
+                       return $target->getFragment();
+               }
+
+               return $prefixedText;
+       }
+
+       private function getLinkURL( LinkTarget $target, array $query = [] ) {
+               // TODO: Use a LinkTargetResolver service instead of Title
+               $title = Title::newFromLinkTarget( $target );
+               $proto = $this->expandUrls !== false
+                       ? $this->expandUrls
+                       : PROTO_RELATIVE;
+               if ( $this->forceArticlePath ) {
+                       $realQuery = $query;
+                       $query = [];
+               } else {
+                       $realQuery = [];
+               }
+               $url = $title->getLinkURL( $query, false, $proto );
+
+               if ( $this->forceArticlePath && $realQuery ) {
+                       $url = wfAppendQuery( $url, $realQuery );
+               }
+
+               return $url;
+       }
+
+       /**
+        * Normalizes the provided target
+        *
+        * @todo move the code from Linker actually here
+        * @param LinkTarget $target
+        * @return LinkTarget
+        */
+       private function normalizeTarget( LinkTarget $target ) {
+               return Linker::normaliseSpecialPage( $target );
+       }
+
+       /**
+        * Merges two sets of attributes
+        *
+        * @param array $defaults
+        * @param array $attribs
+        *
+        * @return array
+        */
+       private function mergeAttribs( $defaults, $attribs ) {
+               if ( !$attribs ) {
+                       return $defaults;
+               }
+               # Merge the custom attribs with the default ones, and iterate
+               # over that, deleting all "false" attributes.
+               $ret = [];
+               $merged = Sanitizer::mergeAttributes( $defaults, $attribs );
+               foreach ( $merged as $key => $val ) {
+                       # A false value suppresses the attribute
+                       if ( $val !== false ) {
+                               $ret[$key] = $val;
+                       }
+               }
+               return $ret;
+       }
+
+}
diff --git a/includes/linker/LinkRendererFactory.php b/includes/linker/LinkRendererFactory.php
new file mode 100644 (file)
index 0000000..3a30772
--- /dev/null
@@ -0,0 +1,92 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @license GPL-2.0+
+ * @author Kunal Mehta <legoktm@member.fsf.org>
+ */
+namespace MediaWiki\Linker;
+
+use TitleFormatter;
+use User;
+
+/**
+ * Factory to create LinkRender objects
+ * @since 1.28
+ */
+class LinkRendererFactory {
+
+       /**
+        * @var TitleFormatter
+        */
+       private $titleFormatter;
+
+       /**
+        * @param TitleFormatter $titleFormatter
+        */
+       public function __construct( TitleFormatter $titleFormatter ) {
+               $this->titleFormatter = $titleFormatter;
+       }
+
+       /**
+        * @return LinkRenderer
+        */
+       public function create() {
+               return new LinkRenderer( $this->titleFormatter );
+       }
+
+       /**
+        * @param User $user
+        * @return LinkRenderer
+        */
+       public function createForUser( User $user ) {
+               $linkRenderer = $this->create();
+               $linkRenderer->setStubThreshold( $user->getStubThreshold() );
+
+               return $linkRenderer;
+       }
+
+       /**
+        * @param array $options
+        * @return LinkRenderer
+        */
+       public function createFromLegacyOptions( array $options ) {
+               $linkRenderer = $this->create();
+
+               if ( in_array( 'noclasses', $options, true ) ) {
+                       $linkRenderer->setNoClasses( true );
+               }
+
+               if ( in_array( 'forcearticlepath', $options, true ) ) {
+                       $linkRenderer->setForceArticlePath( true );
+               }
+
+               if ( in_array( 'http', $options, true ) ) {
+                       $linkRenderer->setExpandURLs( PROTO_HTTP );
+               } elseif ( in_array( 'https', $options, true ) ) {
+                       $linkRenderer->setExpandURLs( PROTO_HTTPS );
+               }
+
+               if ( isset( $options['stubThreshold'] ) ) {
+                       $linkRenderer->setStubThreshold(
+                               $options['stubThreshold']
+                       );
+               }
+
+               return $linkRenderer;
+       }
+}
index 1be2d62..c2bb78d 100644 (file)
@@ -342,7 +342,8 @@ class ParserTest {
                $services->resetServiceForTesting( 'TitleFormatter' );
                $services->resetServiceForTesting( 'TitleParser' );
                $services->resetServiceForTesting( '_MediaWikiTitleCodec' );
-
+               $services->resetServiceForTesting( 'LinkRenderer' );
+               $services->resetServiceForTesting( 'LinkRendererFactory' );
        }
 
        public function setupRecorder( $options ) {
index 0e646ea..0e13721 100644 (file)
@@ -1,6 +1,8 @@
 <?php
 use Liuggio\StatsdClient\Factory\StatsdDataFactory;
 use MediaWiki\Interwiki\InterwikiLookup;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Services\DestructibleService;
 use MediaWiki\Services\SalvageableService;
@@ -315,6 +317,8 @@ class MediaWikiServicesTest extends MediaWikiTestCase {
                        'WatchedItemStore' => [ 'WatchedItemStore', WatchedItemStore::class ],
                        'GenderCache' => [ 'GenderCache', GenderCache::class ],
                        'LinkCache' => [ 'LinkCache', LinkCache::class ],
+                       'LinkRenderer' => [ 'LinkRenderer', LinkRenderer::class ],
+                       'LinkRendererFactory' => [ 'LinkRendererFactory', LinkRendererFactory::class ],
                        '_MediaWikiTitleCodec' => [ '_MediaWikiTitleCodec', MediaWikiTitleCodec::class ],
                        'TitleFormatter' => [ 'TitleFormatter', TitleFormatter::class ],
                        'TitleParser' => [ 'TitleParser', TitleParser::class ],
diff --git a/tests/phpunit/includes/libs/HtmlArmorTest.php b/tests/phpunit/includes/libs/HtmlArmorTest.php
new file mode 100644 (file)
index 0000000..5f176e0
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @covers HtmlArmor
+ */
+class HtmlArmorTest extends PHPUnit_Framework_TestCase {
+
+       public static function provideHtmlArmor() {
+               return [
+                       [
+                               'foobar',
+                               'foobar',
+                       ],
+                       [
+                               '<script>alert("evil!");</script>',
+                               '&lt;script&gt;alert(&quot;evil!&quot;);&lt;/script&gt;',
+                       ],
+                       [
+                               new HtmlArmor( '<script>alert("evil!");</script>' ),
+                               '<script>alert("evil!");</script>',
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideHtmlArmor
+        */
+       public function testHtmlArmor( $input, $expected ) {
+               $this->assertEquals(
+                       $expected,
+                       HtmlArmor::getHtml( $input )
+               );
+       }
+}
diff --git a/tests/phpunit/includes/linker/LinkRendererFactoryTest.php b/tests/phpunit/includes/linker/LinkRendererFactoryTest.php
new file mode 100644 (file)
index 0000000..bd3103b
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\Linker\LinkRendererFactory;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers LinkRendererFactory
+ */
+class LinkRendererFactoryTest extends MediaWikiLangTestCase {
+
+       /**
+        * @var TitleFormatter
+        */
+       private $titleFormatter;
+
+       public function setUp() {
+               parent::setUp();
+               $this->titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+       }
+
+       public static function provideCreateFromLegacyOptions() {
+               return [
+                       [
+                               [ 'noclasses' ],
+                               'getNoClasses',
+                               true
+                       ],
+                       [
+                               [ 'forcearticlepath' ],
+                               'getForceArticlePath',
+                               true
+                       ],
+                       [
+                               [ 'http' ],
+                               'getExpandURLs',
+                               PROTO_HTTP
+                       ],
+                       [
+                               [ 'https' ],
+                               'getExpandURLs',
+                               PROTO_HTTPS
+                       ],
+                       [
+                               [ 'stubThreshold' => 150 ],
+                               'getStubThreshold',
+                               150
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideCreateFromLegacyOptions
+        */
+       public function testCreateFromLegacyOptions( $options, $func, $val ) {
+               $factory = new LinkRendererFactory( $this->titleFormatter );
+               $linkRenderer = $factory->createFromLegacyOptions(
+                       $options
+               );
+               $this->assertInstanceOf( LinkRenderer::class, $linkRenderer );
+               $this->assertEquals( $val, $linkRenderer->$func(), $func );
+       }
+
+       public function testCreate() {
+               $factory = new LinkRendererFactory( $this->titleFormatter );
+               $this->assertInstanceOf( LinkRenderer::class, $factory->create() );
+       }
+
+       public function testCreateForUser() {
+               $user = $this->getMock( User::class, [ 'getStubThreshold' ] );
+               $user->expects( $this->once() )
+                       ->method( 'getStubThreshold' )
+                       ->willReturn( 15 );
+               $factory = new LinkRendererFactory( $this->titleFormatter );
+               $linkRenderer = $factory->createForUser( $user );
+               $this->assertInstanceOf( LinkRenderer::class, $linkRenderer );
+               $this->assertEquals( 15, $linkRenderer->getStubThreshold() );
+       }
+}
diff --git a/tests/phpunit/includes/linker/LinkRendererTest.php b/tests/phpunit/includes/linker/LinkRendererTest.php
new file mode 100644 (file)
index 0000000..a74db20
--- /dev/null
@@ -0,0 +1,134 @@
+<?php
+
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @covers LinkRenderer
+ */
+class LinkRendererTest extends MediaWikiLangTestCase {
+
+       /**
+        * @var TitleFormatter
+        */
+       private $titleFormatter;
+
+       public function setUp() {
+               parent::setUp();
+               $this->setMwGlobals( [
+                       'wgArticlePath' => '/wiki/$1',
+                       'wgServer' => '//example.org',
+                       'wgCanonicalServer' => 'http://example.org',
+                       'wgScriptPath' => '/w',
+                       'wgScript' => '/w/index.php',
+               ] );
+               $this->titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
+       }
+
+       public function testMergeAttribs() {
+               $target = new TitleValue( NS_SPECIAL, 'Blankpage' );
+               $linkRenderer = new LinkRenderer( $this->titleFormatter );
+               $link = $linkRenderer->makeBrokenLink( $target, null, [
+                       // Appended to class
+                       'class' => 'foobar',
+                       // Suppresses href attribute
+                       'href' => false,
+                       // Extra attribute
+                       'bar' => 'baz'
+               ] );
+               $this->assertEquals(
+                       '<a href="/wiki/Special:BlankPage" class="new foobar" '
+                       . 'title="Special:BlankPage (page does not exist)" bar="baz">'
+                       . 'Special:BlankPage</a>',
+                       $link
+               );
+       }
+
+       public function testMakeKnownLink() {
+               $target = new TitleValue( NS_MAIN, 'Foobar' );
+               $linkRenderer = new LinkRenderer( $this->titleFormatter );
+
+               // Query added
+               $this->assertEquals(
+                       '<a href="/w/index.php?title=Foobar&amp;foo=bar" '. 'title="Foobar">Foobar</a>',
+                       $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
+               );
+
+               // forcearticlepath
+               $linkRenderer->setForceArticlePath( true );
+               $this->assertEquals(
+                       '<a href="/wiki/Foobar?foo=bar" title="Foobar">Foobar</a>',
+                       $linkRenderer->makeKnownLink( $target, null, [], [ 'foo' => 'bar' ] )
+               );
+
+               // expand = HTTPS
+               $linkRenderer->setForceArticlePath( false );
+               $linkRenderer->setExpandURLs( PROTO_HTTPS );
+               $this->assertEquals(
+                       '<a href="https://example.org/wiki/Foobar" title="Foobar">Foobar</a>',
+                       $linkRenderer->makeKnownLink( $target )
+               );
+       }
+
+       public function testMakeBrokenLink() {
+               $target = new TitleValue( NS_MAIN, 'Foobar' );
+               $special = new TitleValue( NS_SPECIAL, 'Foobar' );
+               $linkRenderer = new LinkRenderer( $this->titleFormatter );
+
+               // action=edit&redlink=1 added
+               $this->assertEquals(
+                       '<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" '
+                       . 'class="new" title="Foobar (page does not exist)">Foobar</a>',
+                       $linkRenderer->makeBrokenLink( $target )
+               );
+
+               // action=edit&redlink=1 not added due to action query parameter
+               $this->assertEquals(
+                       '<a href="/w/index.php?title=Foobar&amp;action=foobar" class="new" '
+                       . 'title="Foobar (page does not exist)">Foobar</a>',
+                       $linkRenderer->makeBrokenLink( $target, null, [], [ 'action' => 'foobar' ] )
+               );
+
+               // action=edit&redlink=1 not added due to NS_SPECIAL
+               $this->assertEquals(
+                       '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+                       . '(page does not exist)">Special:Foobar</a>',
+                       $linkRenderer->makeBrokenLink( $special )
+               );
+
+               // fragment stripped
+               $this->assertEquals(
+                       '<a href="/w/index.php?title=Foobar&amp;action=edit&amp;redlink=1" '
+                       . 'class="new" title="Foobar (page does not exist)">Foobar</a>',
+                       $linkRenderer->makeBrokenLink( $target->createFragmentTarget( 'foobar' ) )
+               );
+       }
+
+       public function testMakeLink() {
+               $linkRenderer = new LinkRenderer( $this->titleFormatter );
+               $foobar = new TitleValue( NS_SPECIAL, 'Foobar' );
+               $blankpage = new TitleValue( NS_SPECIAL, 'Blankpage' );
+               $this->assertEquals(
+                       '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+                       . '(page does not exist)">foo</a>',
+                       $linkRenderer->makeLink( $foobar, 'foo' )
+               );
+
+               $this->assertEquals(
+                       '<a href="/wiki/Special:BlankPage" title="Special:BlankPage">blank</a>',
+                       $linkRenderer->makeLink( $blankpage, 'blank' )
+               );
+
+               $this->assertEquals(
+                       '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+                       . '(page does not exist)">&lt;script&gt;evil()&lt;/script&gt;</a>',
+                       $linkRenderer->makeLink( $foobar, '<script>evil()</script>' )
+               );
+
+               $this->assertEquals(
+                       '<a href="/wiki/Special:Foobar" class="new" title="Special:Foobar '
+                       . '(page does not exist)"><script>evil()</script></a>',
+                       $linkRenderer->makeLink( $foobar, new HtmlArmor( '<script>evil()</script>' ) )
+               );
+       }
+}