diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php index 87555f1d64..64f3a03924 100644 --- a/src/infrastructure/markup/PhabricatorMarkupEngine.php +++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php @@ -1,597 +1,598 @@ addObject($comment, $field); * } * * Now, call @{method:process} to perform the actual cache/rendering * step. This is a heavyweight call which does batched data access and * transforms the markup into output. * * $engine->process(); * * Finally, do something with the results: * * $results = array(); * foreach ($comments as $comment) { * $results[] = $engine->getOutput($comment, $field); * } * * If you have a single object to render, you can use the convenience method * @{method:renderOneObject}. * * @task markup Markup Pipeline * @task engine Engine Construction */ final class PhabricatorMarkupEngine { private $objects = array(); private $viewer; private $version = 8; /* -( Markup Pipeline )---------------------------------------------------- */ /** * Convenience method for pushing a single object through the markup * pipeline. * * @param PhabricatorMarkupInterface The object to render. * @param string The field to render. * @param PhabricatorUser User viewing the markup. * @return string Marked up output. * @task markup */ public static function renderOneObject( PhabricatorMarkupInterface $object, $field, PhabricatorUser $viewer) { return id(new PhabricatorMarkupEngine()) ->setViewer($viewer) ->addObject($object, $field) ->process() ->getOutput($object, $field); } /** * Queue an object for markup generation when @{method:process} is * called. You can retrieve the output later with @{method:getOutput}. * * @param PhabricatorMarkupInterface The object to render. * @param string The field to render. * @return this * @task markup */ public function addObject(PhabricatorMarkupInterface $object, $field) { $key = $this->getMarkupFieldKey($object, $field); $this->objects[$key] = array( 'object' => $object, 'field' => $field, ); return $this; } /** * Process objects queued with @{method:addObject}. You can then retrieve * the output with @{method:getOutput}. * * @return this * @task markup */ public function process() { $keys = array(); foreach ($this->objects as $key => $info) { if (!isset($info['markup'])) { $keys[] = $key; } } if (!$keys) { return; } $objects = array_select_keys($this->objects, $keys); // Build all the markup engines. We need an engine for each field whether // we have a cache or not, since we still need to postprocess the cache. $engines = array(); foreach ($objects as $key => $info) { $engines[$key] = $info['object']->newMarkupEngine($info['field']); $engines[$key]->setConfig('viewer', $this->viewer); } // Load or build the preprocessor caches. $blocks = $this->loadPreprocessorCaches($engines, $objects); $blocks = mpull($blocks, 'getCacheData'); $this->engineCaches = $blocks; // Finalize the output. foreach ($objects as $key => $info) { $engine = $engines[$key]; $field = $info['field']; $object = $info['object']; $output = $engine->postprocessText($blocks[$key]); $output = $object->didMarkupText($field, $output, $engine); $this->objects[$key]['output'] = $output; } return $this; } /** * Get the output of markup processing for a field queued with * @{method:addObject}. Before you can call this method, you must call * @{method:process}. * * @param PhabricatorMarkupInterface The object to retrieve. * @param string The field to retrieve. * @return string Processed output. * @task markup */ public function getOutput(PhabricatorMarkupInterface $object, $field) { $key = $this->getMarkupFieldKey($object, $field); $this->requireKeyProcessed($key); return $this->objects[$key]['output']; } /** * Retrieve engine metadata for a given field. * * @param PhabricatorMarkupInterface The object to retrieve. * @param string The field to retrieve. * @param string The engine metadata field to retrieve. * @param wild Optional default value. * @task markup */ public function getEngineMetadata( PhabricatorMarkupInterface $object, $field, $metadata_key, $default = null) { $key = $this->getMarkupFieldKey($object, $field); $this->requireKeyProcessed($key); return idx($this->engineCaches[$key]['metadata'], $metadata_key, $default); } /** * @task markup */ private function requireKeyProcessed($key) { if (empty($this->objects[$key])) { throw new Exception( "Call addObject() before using results (key = '{$key}')."); } if (!isset($this->objects[$key]['output'])) { throw new Exception( "Call process() before using results."); } } /** * @task markup */ private function getMarkupFieldKey( PhabricatorMarkupInterface $object, $field) { static $custom; if ($custom === null) { $custom = array_merge( self::loadCustomInlineRules(), self::loadCustomBlockRules()); $custom = mpull($custom, 'getRuleVersion', null); ksort($custom); $custom = PhabricatorHash::digestForIndex(serialize($custom)); } return $object->getMarkupFieldKey($field).'@'.$this->version.'@'.$custom; } /** * @task markup */ private function loadPreprocessorCaches(array $engines, array $objects) { $blocks = array(); $use_cache = array(); foreach ($objects as $key => $info) { if ($info['object']->shouldUseMarkupCache($info['field'])) { $use_cache[$key] = true; } } if ($use_cache) { try { $blocks = id(new PhabricatorMarkupCache())->loadAllWhere( 'cacheKey IN (%Ls)', array_keys($use_cache)); $blocks = mpull($blocks, null, 'getCacheKey'); } catch (Exception $ex) { phlog($ex); } } foreach ($objects as $key => $info) { // False check in case MySQL doesn't support unicode characters // in the string (T1191), resulting in unserialize returning false. if (isset($blocks[$key]) && $blocks[$key]->getCacheData() !== false) { // If we already have a preprocessing cache, we don't need to rebuild // it. continue; } $text = $info['object']->getMarkupText($info['field']); $data = $engines[$key]->preprocessText($text); // NOTE: This is just debugging information to help sort out cache issues. // If one machine is misconfigured and poisoning caches you can use this // field to hunt it down. $metadata = array( 'host' => php_uname('n'), ); $blocks[$key] = id(new PhabricatorMarkupCache()) ->setCacheKey($key) ->setCacheData($data) ->setMetadata($metadata); if (isset($use_cache[$key])) { // This is just filling a cache and always safe, even on a read pathway. $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $blocks[$key]->replace(); unset($unguarded); } } return $blocks; } /** * Set the viewing user. Used to implement object permissions. * * @param PhabricatorUser The viewing user. * @return this * @task markup */ public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } /* -( Engine Construction )------------------------------------------------ */ /** * @task engine */ public static function newManiphestMarkupEngine() { return self::newMarkupEngine(array( )); } /** * @task engine */ public static function newPhrictionMarkupEngine() { return self::newMarkupEngine(array( 'header.generate-toc' => true, )); } /** * @task engine */ public static function newPhameMarkupEngine() { return self::newMarkupEngine(array( 'macros' => false, )); } /** * @task engine */ public static function newFeedMarkupEngine() { return self::newMarkupEngine( array( 'macros' => false, 'youtube' => false, )); } /** * @task engine */ public static function newDifferentialMarkupEngine(array $options = array()) { return self::newMarkupEngine(array( 'differential.diff' => idx($options, 'differential.diff'), )); } /** * @task engine */ public static function newDiffusionMarkupEngine(array $options = array()) { return self::newMarkupEngine(array( 'header.generate-toc' => true, )); } /** * @task engine */ public static function getEngine($ruleset = 'default') { static $engines = array(); if (isset($engines[$ruleset])) { return $engines[$ruleset]; } $engine = null; switch ($ruleset) { case 'default': $engine = self::newMarkupEngine(array()); break; case 'nolinebreaks': $engine = self::newMarkupEngine(array()); $engine->setConfig('preserve-linebreaks', false); break; case 'diviner': $engine = self::newMarkupEngine(array()); $engine->setConfig('preserve-linebreaks', false); // $engine->setConfig('diviner.renderer', new DivinerDefaultRenderer()); $engine->setConfig('header.generate-toc', true); break; case 'extract': // Engine used for reference/edge extraction. Turn off anything which // is slow and doesn't change reference extraction. $engine = self::newMarkupEngine(array()); $engine->setConfig('pygments.enabled', false); break; default: throw new Exception("Unknown engine ruleset: {$ruleset}!"); } $engines[$ruleset] = $engine; return $engine; } /** * @task engine */ private static function getMarkupEngineDefaultConfiguration() { return array( 'pygments' => PhabricatorEnv::getEnvConfig('pygments.enabled'), 'youtube' => PhabricatorEnv::getEnvConfig( 'remarkup.enable-embedded-youtube'), 'differential.diff' => null, 'header.generate-toc' => false, 'macros' => true, 'uri.allowed-protocols' => PhabricatorEnv::getEnvConfig( 'uri.allowed-protocols'), 'syntax-highlighter.engine' => PhabricatorEnv::getEnvConfig( 'syntax-highlighter.engine'), 'preserve-linebreaks' => true, ); } /** * @task engine */ public static function newMarkupEngine(array $options) { $options += self::getMarkupEngineDefaultConfiguration(); $engine = new PhutilRemarkupEngine(); $engine->setConfig('preserve-linebreaks', $options['preserve-linebreaks']); $engine->setConfig('pygments.enabled', $options['pygments']); $engine->setConfig( 'uri.allowed-protocols', $options['uri.allowed-protocols']); $engine->setConfig('differential.diff', $options['differential.diff']); $engine->setConfig('header.generate-toc', $options['header.generate-toc']); $engine->setConfig( 'syntax-highlighter.engine', $options['syntax-highlighter.engine']); $rules = array(); $rules[] = new PhutilRemarkupRuleEscapeRemarkup(); $rules[] = new PhutilRemarkupRuleMonospace(); $rules[] = new PhutilRemarkupRuleDocumentLink(); if ($options['youtube']) { $rules[] = new PhabricatorRemarkupRuleYoutube(); } $applications = PhabricatorApplication::getAllInstalledApplications(); foreach ($applications as $application) { foreach ($application->getRemarkupRules() as $rule) { $rules[] = $rule; } } $rules[] = new PhutilRemarkupRuleHyperlink(); if ($options['macros']) { $rules[] = new PhabricatorRemarkupRuleImageMacro(); $rules[] = new PhabricatorRemarkupRuleMeme(); } $rules[] = new PhutilRemarkupRuleBold(); $rules[] = new PhutilRemarkupRuleItalic(); $rules[] = new PhutilRemarkupRuleDel(); $rules[] = new PhutilRemarkupRuleUnderline(); foreach (self::loadCustomInlineRules() as $rule) { $rules[] = $rule; } $blocks = array(); $blocks[] = new PhutilRemarkupEngineRemarkupQuotesBlockRule(); + $blocks[] = new PhutilRemarkupEngineRemarkupReplyBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupLiteralBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupHeaderBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupHorizontalRuleBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupListBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupCodeBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupNoteBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupTableBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupSimpleTableBlockRule(); $blocks[] = new PhutilRemarkupEngineRemarkupInterpreterRule(); $blocks[] = new PhutilRemarkupEngineRemarkupDefaultBlockRule(); foreach (self::loadCustomBlockRules() as $rule) { $blocks[] = $rule; } foreach ($blocks as $block) { $block->setMarkupRules($rules); } $engine->setBlockRules($blocks); return $engine; } public static function extractPHIDsFromMentions( PhabricatorUser $viewer, array $content_blocks) { $mentions = array(); $engine = self::newDifferentialMarkupEngine(); $engine->setConfig('viewer', $viewer); foreach ($content_blocks as $content_block) { $engine->markupText($content_block); $phids = $engine->getTextMetadata( PhabricatorRemarkupRuleMention::KEY_MENTIONED, array()); $mentions += $phids; } return $mentions; } public static function extractFilePHIDsFromEmbeddedFiles( PhabricatorUser $viewer, array $content_blocks) { $files = array(); $engine = self::newDifferentialMarkupEngine(); $engine->setConfig('viewer', $viewer); foreach ($content_blocks as $content_block) { $engine->markupText($content_block); $ids = $engine->getTextMetadata( PhabricatorRemarkupRuleEmbedFile::KEY_EMBED_FILE_PHIDS, array()); $files += $ids; } return $files; } /** * Produce a corpus summary, in a way that shortens the underlying text * without truncating it somewhere awkward. * * TODO: We could do a better job of this. * * @param string Remarkup corpus to summarize. * @return string Summarized corpus. */ public static function summarize($corpus) { // Major goals here are: // - Don't split in the middle of a character (utf-8). // - Don't split in the middle of, e.g., **bold** text, since // we end up with hanging '**' in the summary. // - Try not to pick an image macro, header, embedded file, etc. // - Hopefully don't return too much text. We don't explicitly limit // this right now. $blocks = preg_split("/\n *\n\s*/", trim($corpus)); $best = null; foreach ($blocks as $block) { // This is a test for normal spaces in the block, i.e. a heuristic to // distinguish standard paragraphs from things like image macros. It may // not work well for non-latin text. We prefer to summarize with a // paragraph of normal words over an image macro, if possible. $has_space = preg_match('/\w\s\w/', $block); // This is a test to find embedded images and headers. We prefer to // summarize with a normal paragraph over a header or an embedded object, // if possible. $has_embed = preg_match('/^[{=]/', $block); if ($has_space && !$has_embed) { // This seems like a good summary, so return it. return $block; } if (!$best) { // This is the first block we found; if everything is garbage just // use the first block. $best = $block; } } return $best; } private static function loadCustomInlineRules() { return id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorRemarkupCustomInlineRule') ->loadObjects(); } private static function loadCustomBlockRules() { return id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorRemarkupCustomBlockRule') ->loadObjects(); } } diff --git a/webroot/rsrc/css/core/remarkup.css b/webroot/rsrc/css/core/remarkup.css index f8b3209f9c..7a4c439ea8 100644 --- a/webroot/rsrc/css/core/remarkup.css +++ b/webroot/rsrc/css/core/remarkup.css @@ -1,434 +1,438 @@ /** * @provides phabricator-remarkup-css */ .phabricator-remarkup { line-height: 1.45em; } .phabricator-remarkup p { margin: 0 0 12px; } .phabricator-remarkup p:last-child { margin-bottom: 0; } .phabricator-remarkup .remarkup-code-block { margin: 12px; white-space: pre; font-family: "Monaco", monospace; font-size: 10px; } .phabricator-remarkup .remarkup-code-header { padding: 6px 8px; background: {$lightyellow}; color: rgba(0,0,0,.75); font-weight: bold; display: inline-block; border-top: 1px solid {$yellow}; border-left: 1px solid {$yellow}; border-right: 1px solid {$yellow}; margin-bottom: -1px; } .phabricator-remarkup .remarkup-code-block pre { background: {$lightyellow}; border: 1px solid {$yellow}; display: block; color: #000000; overflow: auto; padding: 8px; font-family: "Monaco", monospace; } .phabricator-remarkup pre.remarkup-counterexample { border: 1px solid {$red}; background-color: {$lightred}; } .phabricator-remarkup tt { color: #333333; background: #ebebeb; padding: 0 4px; white-space: pre-wrap; } /* NOTE: You can currently produce this with [[link | `name`]]. Restore the link color. */ .phabricator-remarkup a tt { color: #18559d; } .phabricator-remarkup ul { list-style: disc; margin: 12px 0 12px 30px; } .phabricator-remarkup ol { list-style: decimal; margin: 12px 0 12px 30px; } .phabricator-remarkup .remarkup-list-with-checkmarks { list-style: none; margin-left: 18px; } .phabricator-remarkup .remarkup-list-with-checkmarks input { margin-right: 2px; } .phabricator-remarkup .remarkup-list-with-checkmarks .remarkup-checked-item { color: {$greytext}; } .phabricator-remarkup ul ol, .phabricator-remarkup ul ul, .phabricator-remarkup ol ol, .phabricator-remarkup ol ul { margin: 4px 0 4px 24px; } .phabricator-remarkup li.phantom-item, .phabricator-remarkup li.phantom-item { list-style-type: none; } .phabricator-remarkup h1:first-child, .phabricator-remarkup h2:first-child, .phabricator-remarkup h3:first-child, .phabricator-remarkup h4:first-child, .phabricator-remarkup h5:first-child, .phabricator-remarkup h6:first-child { margin-top: 0; } .phabricator-remarkup h1:last-child, .phabricator-remarkup h2:last-child, .phabricator-remarkup h3:last-child, .phabricator-remarkup h4:last-child, .phabricator-remarkup h5:last-child, .phabricator-remarkup h6:last-child { margin-bottom: 0; } .phabricator-remarkup h1 { font-size: 1.625em; line-height: 1.625em; margin: 8px 0; } .phabricator-remarkup h2 { font-size: 1.5em; line-height: 1.5em; margin: 8px 0; } .phabricator-remarkup h3 { font-size: 1.375em; line-height: 1.375em; margin: 8px 0; } .phabricator-remarkup h4 { font-size: 1.25em; line-height: 1.25em; margin: 8px 0; } .phabricator-remarkup h5 { font-size: 1.125em; line-height: 1.125em; margin: 4px 0; } .phabricator-remarkup h6 { font-size: 1em; line-height: 1em; margin: 4px 0; } .phabricator-remarkup blockquote { border-left: 3px solid {$lightbluetext}; color: {$bluetext}; font-style: italic; margin: 4px 20px 12px 0; padding: 8px 12px; background-color: #F8F9FC; } .phabricator-remarkup blockquote em { font-style: normal; } +.phabricator-remarkup blockquote div.remarkup-reply-head { + font-weight: bold; +} + .phabricator-remarkup img.remarkup-proxy-image { max-width: 640px; max-height: 640px; } .phabricator-remarkup audio { display: block; margin: 16px auto; min-width: 240px; width: 50%; } .phabricator-remarkup-mention-exists { font-weight: bold; background: #e6f3ff; } .phabricator-remarkup-mention-disabled { font-weight: bold; background: #dddddd; } .phui-remarkup-preview .phabricator-remarkup-mention-unknown, .aphront-panel-preview .phabricator-remarkup-mention-unknown { font-weight: bold; background: #ffaaaa; } .phabricator-remarkup .remarkup-note { margin: 16px 0; padding: 12px; border-left: 3px solid {$blue}; background: {$lightblue}; } .phabricator-remarkup .remarkup-warning { margin: 16px 0; padding: 12px; border-left: 3px solid {$yellow}; background: {$lightyellow}; } .phabricator-remarkup .remarkup-important { margin: 16px 0; padding: 12px; border-left: 3px solid {$red}; background: {$lightred}; } .phabricator-remarkup .remarkup-note-word { font-weight: bold; color: {$darkbluetext}; } .phabricator-remarkup-toc { float: right; border: 1px solid {$lightblueborder}; background: {$lightgreybackground}; padding: 8px; width: 180px; margin: 0 0 4px 8px; } .phabricator-remarkup-toc-header { font-size: 12px; line-height: 12px; color: {$darkbluetext}; font-weight: bold; margin-bottom: 4px; } .phabricator-remarkup-toc ul { padding: 0; margin: 0; list-style: none; overflow: hidden; } .phabricator-remarkup-toc ul ul { margin: 0 0 0 10px; } .phabricator-remarkup-toc ul li { padding: 0; margin: 0; font-size: 11px; } .phabricator-remarkup-embed-layout-right { text-align: right; } .phabricator-remarkup-embed-layout-center { text-align: center; } .phabricator-remarkup-embed-layout-inline { display: inline; } .phabricator-remarkup-embed-float-right { float: right; margin: .5em 1em 0; } .phabricator-remarkup-embed-layout-link { padding-left: 20px; background: url(/rsrc/image/icon/fatcow/page_white_put.png) 0 0 no-repeat; } .phabricator-remarkup-embed-float-left { float: left; margin: .5em 1em 0; } .phabricator-remarkup-embed-image { display: inline-block; border: 3px solid white; box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.20); } .phabricator-remarkup-embed-image-full { display: inline-block; } .phabricator-remarkup table.remarkup-table { border-collapse: separate; border-spacing: 1px; background: #d3d3d3; margin: 12px 0; } .phabricator-remarkup table.remarkup-table th { font-weight: bold; padding: 3px 6px; background: #e3e3e3; } .phabricator-remarkup table.remarkup-table td { background: #ffffff; padding: 3px 6px; } .remarkup-assist-textarea { border-left-color: {$blueborder}; border-right-color: {$blueborder}; border-bottom-color: {$blueborder}; border-top-color: {$lightblueborder}; border-radius: 0; /* Prevent Safari and Chrome users from dragging the textarea any wider, because the top bar won't resize along with it. */ resize: vertical; } .remarkup-assist-textarea:focus { border: 1px solid rgba(82, 168, 236, 0.8); } .remarkup-assist-bar { height: 27px; padding: 0 2px; border-width: 1px 1px 0; border-style: solid; border-top-color: {$blueborder}; border-left-color: {$blueborder}; border-right-color: {$blueborder}; background: {$lightbluebackground}; overflow: hidden; } .remarkup-assist-button { display: block; padding: 3px; margin: 2px 1px; float: left; border: 1px solid transparent; border-radius: 2px; } .remarkup-assist-button:hover { background: #f7f7f7; border-color: #c6c6c6; box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.10); } .remarkup-assist-button:active { outline: none; background: #f3f3f3; box-shadow: inset 1px 1px 1px rgba(0, 0, 0, 0.10); } .remarkup-assist-button:focus { outline: none; } .remarkup-assist-separator { display: block; float: left; margin: 7px 4px; height: 14px; width: 0px; border-right: 1px solid #cccccc; } .remarkup-interpreter-error { padding: 8px; border: 1px solid {$red}; background-color: {$lightred}; } .remarkup-cowsay { white-space: pre-wrap; } .remarkup-figlet { white-space: pre-wrap; } .remarkup-assist { display: block; width: 14px; height: 14px; overflow: hidden; } .remarkup-assist-right { float: right; } .jx-order-mask { background: #ffffff; opacity: 1.0; } .remarkup-assist-textarea { box-shadow: none; -webkit-box-shadow: none; } .remarkup-control-fullscreen-mode { position: fixed; top: -1px; bottom: -1px; left: -1px; right: -1px; } .remarkup-control-fullscreen-mode textarea.remarkup-assist-textarea { position: absolute; top: 26px; left: 0; right: 0; bottom: 0; /* NOTE: This doesn't work in Firefox, there's a JS behavior to correct it. */ height: auto; border-width: 1px 0 0 0; outline: none; } .phabricator-image-macro-hero { margin: auto; max-width: 95%; }