diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -394,6 +394,8 @@ 'PhutilRemarkupHeaderBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHeaderBlockRule.php', 'PhutilRemarkupHighlightRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHighlightRule.php', 'PhutilRemarkupHorizontalRuleBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php', + 'PhutilRemarkupHyperlinkEngineExtension' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php', + 'PhutilRemarkupHyperlinkRef' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRef.php', 'PhutilRemarkupHyperlinkRule' => 'markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php', 'PhutilRemarkupInlineBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInlineBlockRule.php', 'PhutilRemarkupInterpreterBlockRule' => 'markup/engine/remarkup/blockrule/PhutilRemarkupInterpreterBlockRule.php', @@ -1055,6 +1057,8 @@ 'PhutilRemarkupHeaderBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupHighlightRule' => 'PhutilRemarkupRule', 'PhutilRemarkupHorizontalRuleBlockRule' => 'PhutilRemarkupBlockRule', + 'PhutilRemarkupHyperlinkEngineExtension' => 'Phobject', + 'PhutilRemarkupHyperlinkRef' => 'Phobject', 'PhutilRemarkupHyperlinkRule' => 'PhutilRemarkupRule', 'PhutilRemarkupInlineBlockRule' => 'PhutilRemarkupBlockRule', 'PhutilRemarkupInterpreterBlockRule' => 'PhutilRemarkupBlockRule', diff --git a/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php new file mode 100644 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php @@ -0,0 +1,30 @@ +getPhobjectClassConstant('LINKENGINEKEY', 32); + } + + final public static function getAllLinkEngines() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getHyperlinkEngineKey') + ->execute(); + } + + final public function setEngine(PhutilRemarkupEngine $engine) { + $this->engine = $engine; + return $this; + } + + final public function getEngine() { + return $this->engine; + } + + abstract public function processHyperlinks(array $hyperlinks); + +} diff --git a/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRef.php b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRef.php new file mode 100644 --- /dev/null +++ b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRef.php @@ -0,0 +1,38 @@ +token = $map['token']; + $this->uri = $map['uri']; + $this->embed = ($map['mode'] === '{'); + } + + public function getToken() { + return $this->token; + } + + public function getURI() { + return $this->uri; + } + + public function isEmbed() { + return $this->embed; + } + + public function setResult($result) { + $this->result = $result; + return $this; + } + + public function getResult() { + return $this->result; + } + +} diff --git a/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php --- a/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php +++ b/src/markup/engine/remarkup/markuprule/PhutilRemarkupHyperlinkRule.php @@ -2,6 +2,8 @@ final class PhutilRemarkupHyperlinkRule extends PhutilRemarkupRule { + const KEY_HYPERLINKS = 'hyperlinks'; + public function getPriority() { return 400.0; } @@ -13,7 +15,13 @@ // don't appear in normal text or normal URLs. $text = preg_replace_callback( '@<(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)>@', - array($this, 'markupHyperlink'), + array($this, 'markupHyperlinkAngle'), + $text); + + // We match "{uri}", but do not link it by default. + $text = preg_replace_callback( + '@{(\w{3,}://[^\s'.PhutilRemarkupBlockStorage::MAGIC_BYTE.']+?)}@', + array($this, 'markupHyperlinkCurly'), $text); // Anything else we match "ungreedily", which means we'll look for @@ -31,42 +39,49 @@ return $text; } - protected function markupHyperlink(array $matches) { + public function markupHyperlinkAngle(array $matches) { + return $this->markupHyperlink('<', $matches); + } + + public function markupHyperlinkCurly(array $matches) { + return $this->markupHyperlink('{', $matches); + } + + protected function markupHyperlink($mode, array $matches) { + $raw_uri = $matches[1]; + try { - $uri = new PhutilURI($matches[1]); + $uri = new PhutilURI($raw_uri); } catch (Exception $ex) { return $matches[0]; } - $protocol = $uri->getProtocol(); + $engine = $this->getEngine(); - $protocols = $this->getEngine()->getConfig( - 'uri.allowed-protocols', - array()); + $token = $engine->storeText($raw_uri); - if (!idx($protocols, $protocol)) { - // If this URI doesn't use a whitelisted protocol, don't link it. This - // is primarily intended to prevent javascript:// silliness. - return $this->getEngine()->storeText($matches[1]); - } + $list_key = self::KEY_HYPERLINKS; + $link_list = $engine->getTextMetadata($list_key, array()); - return $this->storeRenderedHyperlink($matches[1]); - } + $link_list[] = array( + 'token' => $token, + 'uri' => $raw_uri, + 'mode' => $mode, + ); - protected function storeRenderedHyperlink($link) { - return $this->getEngine()->storeText($this->renderHyperlink($link)); - } + $engine->setTextMetadata($list_key, $link_list); - protected function renderHyperlink($link) { - $engine = $this->getEngine(); + return $token; + } - if ($engine->isTextMode()) { - return $link; + protected function renderHyperlink($link, $is_embed) { + // If the URI is "{uri}" and no handler picked it up, we just render it + // as plain text. + if ($is_embed) { + return $this->renderRawLink($link, $is_embed); } - if ($engine->getState('toc')) { - return $link; - } + $engine = $this->getEngine(); $same_window = $engine->getConfig('uri.same-window', false); if ($same_window) { @@ -86,6 +101,14 @@ $link); } + private function renderRawLink($link, $is_embed) { + if ($is_embed) { + return '{'.$link.'}'; + } else { + return $link; + } + } + protected function markupHyperlinkUngreedy($matches) { $match = $matches[1]; $tail = null; @@ -116,7 +139,96 @@ return $matches[0]; } - return hsprintf('%s%s', $this->markupHyperlink(array(null, $match)), $tail); + $link = $this->markupHyperlink(null, array(null, $match)); + + return hsprintf('%s%s', $link, $tail); + } + + public function didMarkupText() { + $engine = $this->getEngine(); + + $protocols = $engine->getConfig('uri.allowed-protocols', array()); + $is_toc = $engine->getState('toc'); + $is_text = $engine->isTextMode(); + $is_mail = $engine->isHTMLMailMode(); + + $list_key = self::KEY_HYPERLINKS; + $raw_list = $engine->getTextMetadata($list_key, array()); + + $links = array(); + foreach ($raw_list as $key => $link) { + $token = $link['token']; + $raw_uri = $link['uri']; + $mode = $link['mode']; + + $is_embed = ($mode === '{'); + $is_literal = ($mode === '<'); + + // If we're rendering in a "Table of Contents" or a plain text mode, + // we're going to render the raw URI without modifications. + if ($is_toc || $is_text) { + $result = $this->renderRawLink($raw_uri, $is_embed); + $engine->overwriteStoredText($token, $result); + continue; + } + + // If this URI doesn't use a whitelisted protocol, don't link it. This + // is primarily intended to prevent "javascript://" silliness. + $uri = new PhutilURI($raw_uri); + $protocol = $uri->getProtocol(); + $valid_protocol = idx($protocols, $protocol); + if (!$valid_protocol) { + $result = $this->renderRawLink($raw_uri, $is_embed); + $engine->overwriteStoredText($token, $result); + continue; + } + + // If the URI is written as "", we'll render it literally even if + // some handler would otherwise deal with it. + // If we're rendering for HTML mail, we also render literally. + if ($is_literal || $is_mail) { + $result = $this->renderHyperlink($raw_uri, $is_embed); + $engine->overwriteStoredText($token, $result); + continue; + } + + // Otherwise, this link is a valid resource which extensions are allowed + // to handle. + $links[$key] = $link; + } + + if (!$links) { + return; + } + + foreach ($links as $key => $link) { + $links[$key] = new PhutilRemarkupHyperlinkRef($link); + } + + $extensions = PhutilRemarkupHyperlinkEngineExtension::getAllLinkEngines(); + foreach ($extensions as $extension) { + $extension = id(clone $extension) + ->setEngine($engine) + ->processHyperlinks($links); + + foreach ($links as $key => $link) { + $result = $link->getResult(); + if ($result !== null) { + $engine->overwriteStoredText($link->getToken(), $result); + unset($links[$key]); + } + } + + if (!$links) { + break; + } + } + + // Render any remaining links in a normal way. + foreach ($links as $link) { + $result = $this->renderHyperlink($link->getURI(), $link->isEmbed()); + $engine->overwriteStoredText($link->getToken(), $result); + } } }