diff --git a/src/markup/engine/PhutilRemarkupEngine.php b/src/markup/engine/PhutilRemarkupEngine.php index 901017a..52bd5d0 100644 --- a/src/markup/engine/PhutilRemarkupEngine.php +++ b/src/markup/engine/PhutilRemarkupEngine.php @@ -1,298 +1,298 @@ config[$key] = $value; return $this; } public function getConfig($key, $default = null) { return idx($this->config, $key, $default); } public function setMode($mode) { $this->mode = $mode; return $this; } public function isTextMode() { return $this->mode & self::MODE_TEXT; } public function setBlockRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupEngineBlockRule'); $rules = msort($rules, 'getPriority'); $this->blockRules = $rules; foreach ($this->blockRules as $rule) { $rule->setEngine($this); } $post_rules = array(); foreach ($this->blockRules as $block_rule) { foreach ($block_rule->getMarkupRules() as $rule) { $key = $rule->getPostprocessKey(); if ($key !== null) { $post_rules[$key] = $rule; } } } $this->postprocessRules = $post_rules; return $this; } public function getTextMetadata($key, $default = null) { if (isset($this->metadata[$key])) { return $this->metadata[$key]; } return idx($this->metadata, $key, $default); } public function setTextMetadata($key, $value) { $this->metadata[$key] = $value; return $this; } public function storeText($text) { if ($this->isTextMode()) { $text = phutil_safe_html($text); } return $this->storage->store($text); } public function overwriteStoredText($token, $new_text) { if ($this->isTextMode()) { $new_text = phutil_safe_html($new_text); } $this->storage->overwrite($token, $new_text); return $this; } public function markupText($text) { return $this->postprocessText($this->preprocessText($text)); } public function pushState($state) { if (empty($this->states[$state])) { $this->states[$state] = 0; } $this->states[$state]++; return $this; } public function popState($state) { if (empty($this->states[$state])) { throw new Exception("State '{$state}' pushed more than popped!"); } $this->states[$state]--; if (!$this->states[$state]) { unset($this->states[$state]); } return $this; } public function getState($state) { return !empty($this->states[$state]); } public function preprocessText($text) { $this->metadata = array(); $this->storage = new PhutilRemarkupBlockStorage(); $blocks = $this->splitTextIntoBlocks($text); $output = array(); foreach ($blocks as $block) { $output[] = $this->markupBlock($block); } $output = $this->flattenOutput($output); $map = $this->storage->getMap(); unset($this->storage); $metadata = $this->metadata; return array( 'output' => $output, 'storage' => $map, 'metadata' => $metadata, ); } private function splitTextIntoBlocks($text, $depth = 0) { // Apply basic block and paragraph normalization to the text. NOTE: We don't // strip trailing whitespace because it is semantic in some contexts, // notably inlined diffs that the author intends to show as a code block. $text = phutil_split_lines($text, true); $block_rules = $this->blockRules; $blocks = array(); $cursor = 0; $prev_block = array(); while (isset($text[$cursor])) { $starting_cursor = $cursor; foreach ($block_rules as $block_rule) { $num_lines = $block_rule->getMatchingLineCount($text, $cursor); if ($num_lines) { if ($blocks) { $prev_block = last($blocks); } $curr_block = array( 'start' => $cursor, 'num_lines' => $num_lines, 'rule' => $block_rule, 'is_empty' => self::isEmptyBlock($text, $cursor, $num_lines), 'children' => array(), ); if ($prev_block && self::shouldMergeBlocks($text, $prev_block, $curr_block)) { $blocks[last_key($blocks)]["num_lines"] += $curr_block["num_lines"]; $blocks[last_key($blocks)]["is_empty"] = $blocks[last_key($blocks)]["is_empty"] && $curr_block["is_empty"]; } else { $blocks[] = $curr_block; } $cursor += $num_lines; break; } } if ($starting_cursor === $cursor) { throw new Exception("Block in text did not match any block rule."); } } foreach ($blocks as $key => $block) { $lines = array_slice($text, $block['start'], $block['num_lines']); $blocks[$key]['text'] = implode('', $lines); } // Stop splitting child blocks apart if we get too deep. This arrests // any blocks which have looping child rules, and stops the stack from // exploding if someone writes a hilarious comment with 5,000 levels of // quoted text. if ($depth < self::MAX_CHILD_DEPTH) { foreach ($blocks as $key => $block) { $rule = $block['rule']; if (!$rule->supportsChildBlocks()) { continue; } list($parent_text, $child_text) = $rule->extractChildText( $block['text']); $blocks[$key]['text'] = $parent_text; $blocks[$key]['children'] = $this->splitTextIntoBlocks( $child_text, $depth + 1); } } return $blocks; } private function markupBlock(array $block) { $children = array(); foreach ($block['children'] as $child) { $children[] = $this->markupBlock($child); } if ($children) { $children = $this->flattenOutput($children); } else { $children = null; } return $block['rule']->markupText($block['text'], $children); } private function flattenOutput(array $output) { if ($this->isTextMode()) { $output = implode("\n\n", $output)."\n"; } else { $output = phutil_implode_html("\n\n", $output); } return $output; } private static function shouldMergeBlocks($text, $prev_block, $curr_block) { $block_rules = ipull(array($prev_block, $curr_block), "rule"); $default_rule = "PhutilRemarkupEngineRemarkupDefaultBlockRule"; try { assert_instances_of($block_rules, $default_rule); // If the last block was empty keep merging if ($prev_block['is_empty']) { return true; } // If this line is blank keep merging if ($curr_block['is_empty']) { return true; } // If the current line and the last line have content, keep merging if (strlen(trim($text[$curr_block["start"] - 1]))) { if (strlen(trim($text[$curr_block["start"]]))) { return true; } } } catch (Exception $e) { } return false; } private static function isEmptyBlock($text, $start, $num_lines) { for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) { if (strlen(trim($text[$cursor]))) { return false; } } return true; } public function postprocessText(array $dict) { $this->metadata = idx($dict, 'metadata', array()); $this->storage = new PhutilRemarkupBlockStorage(); $this->storage->setMap(idx($dict, 'storage', array())); foreach ($this->blockRules as $block_rule) { $block_rule->postprocess(); } foreach ($this->postprocessRules as $rule) { $rule->didMarkupText(); } return $this->restoreText(idx($dict, 'output'), $this->isTextMode()); } public function restoreText($text) { return $this->storage->restore($text, $this->isTextMode()); } } diff --git a/src/markup/engine/__tests__/remarkup/quotes.txt b/src/markup/engine/__tests__/remarkup/quotes.txt index 579e259..130303e 100644 --- a/src/markup/engine/__tests__/remarkup/quotes.txt +++ b/src/markup/engine/__tests__/remarkup/quotes.txt @@ -1,9 +1,10 @@ > Dear Sir, > I am utterly disgusted with the quality > of your inflight food service. ~~~~~~~~~~ -

Dear Sir,
I am utterly disgusted with the quality
of your inflight food service.

+

Dear Sir, + I am utterly disgusted with the quality + of your inflight food service.

~~~~~~~~~~ -> Dear Sir, -> I am utterly disgusted with the quality -> of your inflight food service. +> Dear Sir, I am utterly disgusted with the quality of your inflight food service. + diff --git a/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineBlockRule.php b/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineBlockRule.php index 48fd534..04e424e 100644 --- a/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineBlockRule.php +++ b/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineBlockRule.php @@ -1,146 +1,146 @@ engine = $engine; $this->updateRules(); return $this; } final protected function getEngine() { return $this->engine; } public function setMarkupRules(array $rules) { assert_instances_of($rules, 'PhutilRemarkupRule'); $this->rules = $rules; $this->updateRules(); return $this; } private function updateRules() { $engine = $this->getEngine(); if ($engine) { $this->rules = msort($this->rules, 'getPriority'); foreach ($this->rules as $rule) { $rule->setEngine($engine); } } return $this; } final public function getMarkupRules() { return $this->rules; } final public function postprocess() { $this->didMarkupText(); } final protected function applyRules($text) { foreach ($this->getMarkupRules() as $rule) { $text = $rule->apply($text); } return $text; } public function supportsChildBlocks() { return false; } public function extractChildText($text) { - throw new Exception(pht('Not implemnted!')); + throw new Exception(pht('Not implemented!')); } protected function renderRemarkupTable(array $out_rows) { assert_instances_of($out_rows, 'array'); if ($this->getEngine()->isTextMode()) { $lengths = array(); foreach ($out_rows as $r => $row) { foreach ($row['content'] as $c => $cell) { $text = $this->getEngine()->restoreText($cell['content']); $lengths[$c][$r] = phutil_utf8_strlen($text); } } $max_lengths = array_map('max', $lengths); $out = array(); foreach ($out_rows as $r => $row) { $headings = false; foreach ($row['content'] as $c => $cell) { $length = $max_lengths[$c] - $lengths[$c][$r]; $out[] = '| '.$cell['content'].str_repeat(' ', $length).' '; if ($cell['type'] == 'th') { $headings = true; } } $out[] = "|\n"; if ($headings) { foreach ($row['content'] as $c => $cell) { $char = ($cell['type'] == 'th' ? '-' : ' '); $out[] = '| '.str_repeat($char, $max_lengths[$c]).' '; } $out[] = "|\n"; } } return rtrim(implode('', $out), "\n"); } $out = array(); $out[] = "\n"; foreach ($out_rows as $row) { $cells = array(); foreach ($row['content'] as $cell) { $cells[] = phutil_tag($cell['type'], array(), $cell['content']); } $out[] = phutil_tag($row['type'], array(), $cells); $out[] = "\n"; } return phutil_tag('table', array('class' => 'remarkup-table'), $out); } } diff --git a/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineRemarkupQuotesBlockRule.php b/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineRemarkupQuotesBlockRule.php index 6511eeb..957acaf 100644 --- a/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineRemarkupQuotesBlockRule.php +++ b/src/markup/engine/remarkup/blockrule/PhutilRemarkupEngineRemarkupQuotesBlockRule.php @@ -1,44 +1,43 @@ /", $lines[$cursor])) { - $num_lines++; - $cursor++; + if (preg_match('/^>/', $lines[$pos])) { + do { + ++$pos; + } while (isset($lines[$pos]) && preg_match('/^>/', $lines[$pos])); + } + + return ($pos - $cursor); + } - while (isset($lines[$cursor])) { - if (strlen(trim($lines[$cursor]))) { - $num_lines++; - $cursor++; - continue; - } + public function supportsChildBlocks() { + return true; + } - break; - } + public function extractChildText($text) { + $text = phutil_split_lines($text, true); + foreach ($text as $key => $line) { + $text[$key] = substr($line, 1); } - return $num_lines; + return array('', implode('', $text)); } public function markupText($text, $children) { - $lines = array(); - foreach (explode("\n", $text) as $line) { - $lines[] = $this->applyRules(preg_replace('/^>\s*/', '', $line)); - } - if ($this->getEngine()->isTextMode()) { + $lines = phutil_split_lines($children); return '> '.implode("\n> ", $lines); } - return hsprintf( - '

%s

', - phutil_implode_html(phutil_tag('br'), $lines)); + return phutil_tag( + 'blockquote', + array(), + $children); } + }