@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
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}