@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 365 lines 9.6 kB view raw
1<?php 2 3final class PhutilRemarkupCodeBlockRule extends PhutilRemarkupBlockRule { 4 5 public function getMatchingLineCount(array $lines, $cursor) { 6 $num_lines = 0; 7 $match_ticks = null; 8 if (preg_match('/^(\s{2,}).+/', $lines[$cursor])) { 9 $match_ticks = false; 10 } else if (preg_match('/^\s*(```)/', $lines[$cursor])) { 11 $match_ticks = true; 12 } else { 13 return $num_lines; 14 } 15 16 $num_lines++; 17 18 if ($match_ticks && 19 preg_match('/^\s*(```)(.*)(```)\s*$/', $lines[$cursor])) { 20 return $num_lines; 21 } 22 23 $cursor++; 24 25 while (isset($lines[$cursor])) { 26 if ($match_ticks) { 27 if (preg_match('/```\s*$/', $lines[$cursor])) { 28 $num_lines++; 29 break; 30 } 31 $num_lines++; 32 } else { 33 if (strlen(trim($lines[$cursor]))) { 34 if (!preg_match('/^\s{2,}/', $lines[$cursor])) { 35 break; 36 } 37 } 38 $num_lines++; 39 } 40 $cursor++; 41 } 42 43 return $num_lines; 44 } 45 46 public function markupText($text, $children) { 47 // Header/footer eventually useful to be nice with "flavored markdown". 48 // When it starts with ```stuff the header is 'stuff' (->language) 49 // When it ends with stuff``` the footer is 'stuff' (->garbage) 50 $header_line = null; 51 $footer_line = null; 52 53 $matches = null; 54 if (preg_match('/^\s*```(.*)/', $text, $matches)) { 55 if (isset($matches[1])) { 56 $header_line = $matches[1]; 57 } 58 59 // If this is a ```-style block, trim off the backticks and any leading 60 // blank line. 61 $text = preg_replace('/^\s*```(\s*\n)?/', '', $text); 62 $text = preg_replace('/```\s*$/', '', $text); 63 } 64 65 $lines = explode("\n", $text); 66 67 // If we have a flavored header, it has sense to look for the footer. 68 if ($header_line !== null && $lines) { 69 $footer_line = $lines[last_key($lines)]; 70 } 71 72 // Strip final empty lines 73 while ($lines && !strlen(last($lines))) { 74 unset($lines[last_key($lines)]); 75 } 76 77 $options = array( 78 'counterexample' => false, 79 'lang' => null, 80 'name' => null, 81 'lines' => null, 82 ); 83 84 $parser = new PhutilSimpleOptions(); 85 $custom = $parser->parse(head($lines)); 86 $valid_options = null; 87 if ($custom) { 88 $valid_options = true; 89 foreach ($custom as $key => $value) { 90 if (!array_key_exists($key, $options)) { 91 $valid_options = false; 92 break; 93 } 94 } 95 if ($valid_options) { 96 array_shift($lines); 97 $options = $custom + $options; 98 } 99 } 100 101 // Parse flavored markdown strictly to don't eat legitimate Remarkup. 102 // Proceed only if we tried to parse options and we failed 103 // (no options also mean no language). 104 // For example this is not a valid option: ```php 105 // Proceed only if the footer exists and it is not: blabla``` 106 // Accept only 2 lines or more. First line: header; then content. 107 if ( 108 $valid_options === false && 109 $header_line !== null && 110 $footer_line === '' && 111 count($lines) > 1 112 ) { 113 if (self::isKnownLanguageCode($header_line)) { 114 array_shift($lines); 115 $options['lang'] = $header_line; 116 } 117 } 118 119 // Normalize the text back to a 0-level indent. 120 $min_indent = 80; 121 foreach ($lines as $line) { 122 for ($ii = 0; $ii < strlen($line); $ii++) { 123 if ($line[$ii] != ' ') { 124 $min_indent = min($ii, $min_indent); 125 break; 126 } 127 } 128 } 129 130 $text = implode("\n", $lines); 131 if ($min_indent) { 132 $indent_string = str_repeat(' ', $min_indent); 133 $text = preg_replace('/^'.$indent_string.'/m', '', $text); 134 } 135 136 if ($this->getEngine()->isTextMode()) { 137 $out = array(); 138 139 $header = array(); 140 if ($options['counterexample']) { 141 $header[] = 'counterexample'; 142 } 143 if ($options['name'] != '') { 144 $header[] = 'name='.$options['name']; 145 } 146 if ($header) { 147 $out[] = implode(', ', $header); 148 } 149 150 $text = preg_replace('/^/m', ' ', $text); 151 $out[] = $text; 152 153 return implode("\n", $out); 154 } 155 156 // The name is usually a sufficient source of information for file ext. 157 if (empty($options['lang']) && isset($options['name'])) { 158 $options['lang'] = $this->guessFilenameExtension($options['name']); 159 } 160 161 if (empty($options['lang'])) { 162 // If the user hasn't specified "lang=..." explicitly, try to guess the 163 // language. If we fail, fall back to configured defaults. 164 $lang = PhutilLanguageGuesser::guessLanguage($text); 165 if (!$lang) { 166 $lang = nonempty( 167 $this->getEngine()->getConfig('phutil.codeblock.language-default'), 168 'text'); 169 } 170 $options['lang'] = $lang; 171 } 172 173 $code_body = $this->highlightSource($text, $options); 174 175 $name_header = null; 176 $block_style = null; 177 if ($this->getEngine()->isHTMLMailMode()) { 178 $map = $this->getEngine()->getConfig('phutil.codeblock.style-map'); 179 180 if ($map) { 181 $raw_body = id(new PhutilPygmentizeParser()) 182 ->setMap($map) 183 ->parse((string)$code_body); 184 $code_body = phutil_safe_html($raw_body); 185 } 186 187 $style_rules = array( 188 'padding: 6px 12px;', 189 'font-size: 13px;', 190 'font-weight: bold;', 191 'display: inline-block;', 192 'border-top-left-radius: 3px;', 193 'border-top-right-radius: 3px;', 194 'color: rgba(0,0,0,.75);', 195 ); 196 197 if ($options['counterexample']) { 198 $style_rules[] = 'background: #f7e6e6'; 199 } else { 200 $style_rules[] = 'background: rgba(71, 87, 120, 0.08);'; 201 } 202 203 $header_attributes = array( 204 'style' => implode(' ', $style_rules), 205 ); 206 207 $block_style = 'margin: 12px 0;'; 208 } else { 209 $header_attributes = array( 210 'class' => 'remarkup-code-header', 211 ); 212 } 213 214 if ($options['name']) { 215 $name_header = phutil_tag( 216 'div', 217 $header_attributes, 218 $options['name']); 219 } 220 221 $class = 'remarkup-code-block'; 222 if ($options['counterexample']) { 223 $class = 'remarkup-code-block code-block-counterexample'; 224 } 225 226 $attributes = array( 227 'class' => $class, 228 'style' => $block_style, 229 'data-code-lang' => $options['lang'], 230 'data-sigil' => 'remarkup-code-block', 231 ); 232 233 return phutil_tag( 234 'div', 235 $attributes, 236 array($name_header, $code_body)); 237 } 238 239 private function highlightSource($text, array $options) { 240 if ($options['counterexample']) { 241 $aux_class = ' remarkup-counterexample'; 242 } else { 243 $aux_class = null; 244 } 245 246 $aux_style = null; 247 248 if ($this->getEngine()->isHTMLMailMode()) { 249 $aux_style = array( 250 'font: 11px/15px "Menlo", "Consolas", "Monaco", monospace;', 251 'padding: 12px;', 252 'margin: 0;', 253 ); 254 255 if ($options['counterexample']) { 256 $aux_style[] = 'background: #f7e6e6;'; 257 } else { 258 $aux_style[] = 'background: rgba(71, 87, 120, 0.08);'; 259 } 260 261 $aux_style = implode(' ', $aux_style); 262 } 263 264 if ($options['lines']) { 265 // Put a minimum size on this because the scrollbar is otherwise 266 // unusable. 267 $height = max(6, (int)$options['lines']); 268 $aux_style = $aux_style 269 .' ' 270 .'max-height: ' 271 .(2 * $height) 272 .'em; overflow: auto;'; 273 } 274 275 $engine = $this->getEngine()->getConfig('syntax-highlighter.engine'); 276 if (!$engine) { 277 $engine = 'PhutilDefaultSyntaxHighlighterEngine'; 278 } 279 $engine = newv($engine, array()); 280 $engine->setConfig( 281 'pygments.enabled', 282 $this->getEngine()->getConfig('pygments.enabled')); 283 return phutil_tag( 284 'pre', 285 array( 286 'class' => 'remarkup-code'.$aux_class, 287 'style' => $aux_style, 288 ), 289 PhutilSafeHTML::applyFunction( 290 'rtrim', 291 $engine->highlightSource($options['lang'], $text))); 292 } 293 294 /** 295 * Check if a language code can be used in a generic flavored markdown. 296 * @param string $lang Language code 297 * @return bool 298 */ 299 private static function isKnownLanguageCode($lang) { 300 $languages = self::knownLanguageCodes(); 301 return isset($languages[$lang]); 302 } 303 304 /** 305 * Get the available languages for a generic flavored markdown. 306 * @return array Languages as array keys. Ignore the value. 307 */ 308 private static function knownLanguageCodes() { 309 // This is a friendly subset from https://pygments.org/languages/ 310 static $map = array( 311 'arduino' => 1, 312 'assembly' => 1, 313 'awk' => 1, 314 'bash' => 1, 315 'bat' => 1, 316 'c' => 1, 317 'cmake' => 1, 318 'cobol' => 1, 319 'cpp' => 1, 320 'css' => 1, 321 'csharp' => 1, 322 'dart' => 1, 323 'delphi' => 1, 324 'fortran' => 1, 325 'go' => 1, 326 'groovy' => 1, 327 'haskell' => 1, 328 'java' => 1, 329 'javascript' => 1, 330 'kotlin' => 1, 331 'lisp' => 1, 332 'lua' => 1, 333 'matlab' => 1, 334 'make' => 1, 335 'perl' => 1, 336 'php' => 1, 337 'powershell' => 1, 338 'python' => 1, 339 'r' => 1, 340 'ruby' => 1, 341 'rust' => 1, 342 'scala' => 1, 343 'sh' => 1, 344 'sql' => 1, 345 'typescript' => 1, 346 'vba' => 1, 347 ); 348 return $map; 349 } 350 351 /** 352 * Get the extension from a filename. 353 * @param string $name "/path/to/something.name" 354 * @return null|string ".name" 355 */ 356 private function guessFilenameExtension($name) { 357 $name = basename($name); 358 $pos = strrpos($name, '.'); 359 if ($pos !== false) { 360 return substr($name, $pos + 1); 361 } 362 return null; 363 } 364 365}