3
final class PhutilRemarkupEngine extends PhutilMarkupEngine {
5
const MODE_DEFAULT = 0;
8
const MAX_CHILD_DEPTH = 32;
10
private $blockRules = array();
11
private $config = array();
13
private $metadata = array();
14
private $states = array();
15
private $postprocessRules = array();
17
public function setConfig($key, $value) {
18
$this->config[$key] = $value;
22
public function getConfig($key, $default = null) {
23
return idx($this->config, $key, $default);
26
public function setMode($mode) {
31
public function isTextMode() {
32
return $this->mode & self::MODE_TEXT;
35
public function setBlockRules(array $rules) {
36
assert_instances_of($rules, 'PhutilRemarkupBlockRule');
38
$rules = msort($rules, 'getPriority');
40
$this->blockRules = $rules;
41
foreach ($this->blockRules as $rule) {
42
$rule->setEngine($this);
45
$post_rules = array();
46
foreach ($this->blockRules as $block_rule) {
47
foreach ($block_rule->getMarkupRules() as $rule) {
48
$key = $rule->getPostprocessKey();
50
$post_rules[$key] = $rule;
55
$this->postprocessRules = $post_rules;
60
public function getTextMetadata($key, $default = null) {
61
if (isset($this->metadata[$key])) {
62
return $this->metadata[$key];
64
return idx($this->metadata, $key, $default);
67
public function setTextMetadata($key, $value) {
68
$this->metadata[$key] = $value;
72
public function storeText($text) {
73
if ($this->isTextMode()) {
74
$text = phutil_safe_html($text);
76
return $this->storage->store($text);
79
public function overwriteStoredText($token, $new_text) {
80
if ($this->isTextMode()) {
81
$new_text = phutil_safe_html($new_text);
83
$this->storage->overwrite($token, $new_text);
87
public function markupText($text) {
88
return $this->postprocessText($this->preprocessText($text));
91
public function pushState($state) {
92
if (empty($this->states[$state])) {
93
$this->states[$state] = 0;
95
$this->states[$state]++;
99
public function popState($state) {
100
if (empty($this->states[$state])) {
101
throw new Exception("State '{$state}' pushed more than popped!");
103
$this->states[$state]--;
104
if (!$this->states[$state]) {
105
unset($this->states[$state]);
110
public function getState($state) {
111
return !empty($this->states[$state]);
114
public function preprocessText($text) {
115
$this->metadata = array();
116
$this->storage = new PhutilRemarkupBlockStorage();
118
$blocks = $this->splitTextIntoBlocks($text);
121
foreach ($blocks as $block) {
122
$output[] = $this->markupBlock($block);
124
$output = $this->flattenOutput($output);
126
$map = $this->storage->getMap();
127
unset($this->storage);
128
$metadata = $this->metadata;
134
'metadata' => $metadata,
138
private function splitTextIntoBlocks($text, $depth = 0) {
139
// Apply basic block and paragraph normalization to the text. NOTE: We don't
140
// strip trailing whitespace because it is semantic in some contexts,
141
// notably inlined diffs that the author intends to show as a code block.
142
$text = phutil_split_lines($text, true);
143
$block_rules = $this->blockRules;
146
$prev_block = array();
148
while (isset($text[$cursor])) {
149
$starting_cursor = $cursor;
150
foreach ($block_rules as $block_rule) {
151
$num_lines = $block_rule->getMatchingLineCount($text, $cursor);
155
$prev_block = last($blocks);
160
'num_lines' => $num_lines,
161
'rule' => $block_rule,
162
'is_empty' => self::isEmptyBlock($text, $cursor, $num_lines),
163
'children' => array(),
167
&& self::shouldMergeBlocks($text, $prev_block, $curr_block)) {
168
$blocks[last_key($blocks)]['num_lines'] += $curr_block['num_lines'];
169
$blocks[last_key($blocks)]['is_empty'] =
170
$blocks[last_key($blocks)]['is_empty'] && $curr_block['is_empty'];
172
$blocks[] = $curr_block;
175
$cursor += $num_lines;
180
if ($starting_cursor === $cursor) {
181
throw new Exception('Block in text did not match any block rule.');
185
foreach ($blocks as $key => $block) {
186
$lines = array_slice($text, $block['start'], $block['num_lines']);
187
$blocks[$key]['text'] = implode('', $lines);
190
// Stop splitting child blocks apart if we get too deep. This arrests
191
// any blocks which have looping child rules, and stops the stack from
192
// exploding if someone writes a hilarious comment with 5,000 levels of
195
if ($depth < self::MAX_CHILD_DEPTH) {
196
foreach ($blocks as $key => $block) {
197
$rule = $block['rule'];
198
if (!$rule->supportsChildBlocks()) {
202
list($parent_text, $child_text) = $rule->extractChildText(
204
$blocks[$key]['text'] = $parent_text;
205
$blocks[$key]['children'] = $this->splitTextIntoBlocks(
214
private function markupBlock(array $block) {
216
foreach ($block['children'] as $child) {
217
$children[] = $this->markupBlock($child);
221
$children = $this->flattenOutput($children);
226
return $block['rule']->markupText($block['text'], $children);
229
private function flattenOutput(array $output) {
230
if ($this->isTextMode()) {
231
$output = implode("\n\n", $output)."\n";
233
$output = phutil_implode_html("\n\n", $output);
239
private static function shouldMergeBlocks($text, $prev_block, $curr_block) {
240
$block_rules = ipull(array($prev_block, $curr_block), 'rule');
242
$default_rule = 'PhutilRemarkupDefaultBlockRule';
244
assert_instances_of($block_rules, $default_rule);
246
// If the last block was empty keep merging
247
if ($prev_block['is_empty']) {
251
// If this line is blank keep merging
252
if ($curr_block['is_empty']) {
256
// If the current line and the last line have content, keep merging
257
if (strlen(trim($text[$curr_block['start'] - 1]))) {
258
if (strlen(trim($text[$curr_block['start']]))) {
262
} catch (Exception $e) {}
267
private static function isEmptyBlock($text, $start, $num_lines) {
268
for ($cursor = $start; $cursor < $start + $num_lines; $cursor++) {
269
if (strlen(trim($text[$cursor]))) {
276
public function postprocessText(array $dict) {
277
$this->metadata = idx($dict, 'metadata', array());
279
$this->storage = new PhutilRemarkupBlockStorage();
280
$this->storage->setMap(idx($dict, 'storage', array()));
282
foreach ($this->blockRules as $block_rule) {
283
$block_rule->postprocess();
286
foreach ($this->postprocessRules as $rule) {
287
$rule->didMarkupText();
290
return $this->restoreText(idx($dict, 'output'), $this->isTextMode());
293
public function restoreText($text) {
294
return $this->storage->restore($text, $this->isTextMode());