Page MenuHomePhorge

No OneTemporary

diff --git a/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php b/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
index b8fef85b98..8b23fe6541 100644
--- a/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
+++ b/src/infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php
@@ -1,252 +1,346 @@
<?php
final class PhutilRemarkupCodeBlockRule extends PhutilRemarkupBlockRule {
public function getMatchingLineCount(array $lines, $cursor) {
$num_lines = 0;
$match_ticks = null;
if (preg_match('/^(\s{2,}).+/', $lines[$cursor])) {
$match_ticks = false;
} else if (preg_match('/^\s*(```)/', $lines[$cursor])) {
$match_ticks = true;
} else {
return $num_lines;
}
$num_lines++;
if ($match_ticks &&
preg_match('/^\s*(```)(.*)(```)\s*$/', $lines[$cursor])) {
return $num_lines;
}
$cursor++;
while (isset($lines[$cursor])) {
if ($match_ticks) {
if (preg_match('/```\s*$/', $lines[$cursor])) {
$num_lines++;
break;
}
$num_lines++;
} else {
if (strlen(trim($lines[$cursor]))) {
if (!preg_match('/^\s{2,}/', $lines[$cursor])) {
break;
}
}
$num_lines++;
}
$cursor++;
}
return $num_lines;
}
public function markupText($text, $children) {
- if (preg_match('/^\s*```/', $text)) {
+ // Header/footer eventually useful to be nice with "flavored markdown".
+ // When it starts with ```stuff the header is 'stuff' (->language)
+ // When it ends with stuff``` the footer is 'stuff' (->garbage)
+ $header_line = null;
+ $footer_line = null;
+
+ $matches = null;
+ if (preg_match('/^\s*```(.*)/', $text, $matches)) {
+ if (isset($matches[1])) {
+ $header_line = $matches[1];
+ }
+
// If this is a ```-style block, trim off the backticks and any leading
// blank line.
$text = preg_replace('/^\s*```(\s*\n)?/', '', $text);
$text = preg_replace('/```\s*$/', '', $text);
}
$lines = explode("\n", $text);
+
+ // If we have a flavored header, it has sense to look for the footer.
+ if ($header_line !== null && $lines) {
+ $footer_line = $lines[last_key($lines)];
+ }
+
+ // Strip final empty lines
while ($lines && !strlen(last($lines))) {
unset($lines[last_key($lines)]);
}
$options = array(
'counterexample' => false,
'lang' => null,
'name' => null,
'lines' => null,
);
$parser = new PhutilSimpleOptions();
$custom = $parser->parse(head($lines));
+ $valid_options = null;
if ($custom) {
- $valid = true;
+ $valid_options = true;
foreach ($custom as $key => $value) {
if (!array_key_exists($key, $options)) {
- $valid = false;
+ $valid_options = false;
break;
}
}
- if ($valid) {
+ if ($valid_options) {
array_shift($lines);
$options = $custom + $options;
}
}
+ // Parse flavored markdown strictly to don't eat legitimate Remarkup.
+ // Proceed only if we tried to parse options and we failed
+ // (no options also mean no language).
+ // For example this is not a valid option: ```php
+ // Proceed only if the footer exists and it is not: blabla```
+ // Accept only 2 lines or more. First line: header; then content.
+ if (
+ $valid_options === false &&
+ $header_line !== null &&
+ $footer_line === '' &&
+ count($lines) > 1
+ ) {
+ if (self::isKnownLanguageCode($header_line)) {
+ array_shift($lines);
+ $options['lang'] = $header_line;
+ }
+ }
+
// Normalize the text back to a 0-level indent.
$min_indent = 80;
foreach ($lines as $line) {
for ($ii = 0; $ii < strlen($line); $ii++) {
if ($line[$ii] != ' ') {
$min_indent = min($ii, $min_indent);
break;
}
}
}
$text = implode("\n", $lines);
if ($min_indent) {
$indent_string = str_repeat(' ', $min_indent);
$text = preg_replace('/^'.$indent_string.'/m', '', $text);
}
if ($this->getEngine()->isTextMode()) {
$out = array();
$header = array();
if ($options['counterexample']) {
$header[] = 'counterexample';
}
if ($options['name'] != '') {
$header[] = 'name='.$options['name'];
}
if ($header) {
$out[] = implode(', ', $header);
}
$text = preg_replace('/^/m', ' ', $text);
$out[] = $text;
return implode("\n", $out);
}
if (empty($options['lang'])) {
// If the user hasn't specified "lang=..." explicitly, try to guess the
// language. If we fail, fall back to configured defaults.
$lang = PhutilLanguageGuesser::guessLanguage($text);
if (!$lang) {
$lang = nonempty(
$this->getEngine()->getConfig('phutil.codeblock.language-default'),
'text');
}
$options['lang'] = $lang;
}
$code_body = $this->highlightSource($text, $options);
$name_header = null;
$block_style = null;
if ($this->getEngine()->isHTMLMailMode()) {
$map = $this->getEngine()->getConfig('phutil.codeblock.style-map');
if ($map) {
$raw_body = id(new PhutilPygmentizeParser())
->setMap($map)
->parse((string)$code_body);
$code_body = phutil_safe_html($raw_body);
}
$style_rules = array(
'padding: 6px 12px;',
'font-size: 13px;',
'font-weight: bold;',
'display: inline-block;',
'border-top-left-radius: 3px;',
'border-top-right-radius: 3px;',
'color: rgba(0,0,0,.75);',
);
if ($options['counterexample']) {
$style_rules[] = 'background: #f7e6e6';
} else {
$style_rules[] = 'background: rgba(71, 87, 120, 0.08);';
}
$header_attributes = array(
'style' => implode(' ', $style_rules),
);
$block_style = 'margin: 12px 0;';
} else {
$header_attributes = array(
'class' => 'remarkup-code-header',
);
}
if ($options['name']) {
$name_header = phutil_tag(
'div',
$header_attributes,
$options['name']);
}
$class = 'remarkup-code-block';
if ($options['counterexample']) {
$class = 'remarkup-code-block code-block-counterexample';
}
$attributes = array(
'class' => $class,
'style' => $block_style,
'data-code-lang' => $options['lang'],
'data-sigil' => 'remarkup-code-block',
);
return phutil_tag(
'div',
$attributes,
array($name_header, $code_body));
}
private function highlightSource($text, array $options) {
if ($options['counterexample']) {
$aux_class = ' remarkup-counterexample';
} else {
$aux_class = null;
}
$aux_style = null;
if ($this->getEngine()->isHTMLMailMode()) {
$aux_style = array(
'font: 11px/15px "Menlo", "Consolas", "Monaco", monospace;',
'padding: 12px;',
'margin: 0;',
);
if ($options['counterexample']) {
$aux_style[] = 'background: #f7e6e6;';
} else {
$aux_style[] = 'background: rgba(71, 87, 120, 0.08);';
}
$aux_style = implode(' ', $aux_style);
}
if ($options['lines']) {
// Put a minimum size on this because the scrollbar is otherwise
// unusable.
$height = max(6, (int)$options['lines']);
$aux_style = $aux_style
.' '
.'max-height: '
.(2 * $height)
.'em; overflow: auto;';
}
$engine = $this->getEngine()->getConfig('syntax-highlighter.engine');
if (!$engine) {
$engine = 'PhutilDefaultSyntaxHighlighterEngine';
}
$engine = newv($engine, array());
$engine->setConfig(
'pygments.enabled',
$this->getEngine()->getConfig('pygments.enabled'));
return phutil_tag(
'pre',
array(
'class' => 'remarkup-code'.$aux_class,
'style' => $aux_style,
),
PhutilSafeHTML::applyFunction(
'rtrim',
$engine->highlightSource($options['lang'], $text)));
}
+ /**
+ * Check if a language code can be used in a generic flavored markdown.
+ * @param string $lang Language code
+ * @return bool
+ */
+ private static function isKnownLanguageCode($lang) {
+ $languages = self::knownLanguageCodes();
+ return isset($languages[$lang]);
+ }
+
+ /**
+ * Get the available languages for a generic flavored markdown.
+ * @return array Languages as array keys. Ignore the value.
+ */
+ private static function knownLanguageCodes() {
+ // This is a friendly subset from https://pygments.org/languages/
+ static $map = array(
+ 'arduino' => 1,
+ 'assembly' => 1,
+ 'awk' => 1,
+ 'bash' => 1,
+ 'bat' => 1,
+ 'c' => 1,
+ 'cmake' => 1,
+ 'cobol' => 1,
+ 'cpp' => 1,
+ 'css' => 1,
+ 'csharp' => 1,
+ 'dart' => 1,
+ 'delphi' => 1,
+ 'fortran' => 1,
+ 'go' => 1,
+ 'groovy' => 1,
+ 'haskell' => 1,
+ 'java' => 1,
+ 'javascript' => 1,
+ 'kotlin' => 1,
+ 'lisp' => 1,
+ 'lua' => 1,
+ 'matlab' => 1,
+ 'make' => 1,
+ 'perl' => 1,
+ 'php' => 1,
+ 'powershell' => 1,
+ 'python' => 1,
+ 'r' => 1,
+ 'ruby' => 1,
+ 'rust' => 1,
+ 'scala' => 1,
+ 'sh' => 1,
+ 'sql' => 1,
+ 'typescript' => 1,
+ 'vba' => 1,
+ );
+ return $map;
+ }
+
}
diff --git a/src/infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php b/src/infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php
index c3b4960d0c..38831d034a 100644
--- a/src/infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php
+++ b/src/infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php
@@ -1,132 +1,134 @@
<?php
/**
* Test cases for @{class:PhutilRemarkupEngine}.
+ * @TODO: This unit is not always triggered when you need it.
+ * https://we.phorge.it/T15500
*/
final class PhutilRemarkupEngineTestCase extends PhutilTestCase {
public function testEngine() {
$root = dirname(__FILE__).'/remarkup/';
foreach (Filesystem::listDirectory($root, $hidden = false) as $file) {
$this->markupText($root.$file);
}
}
private function markupText($markup_file) {
$contents = Filesystem::readFile($markup_file);
$file = basename($markup_file);
$parts = explode("\n~~~~~~~~~~\n", $contents);
$this->assertEqual(3, count($parts), $markup_file);
list($input_remarkup, $expected_output, $expected_text) = $parts;
$input_remarkup = $this->unescapeTrailingWhitespace($input_remarkup);
$expected_output = $this->unescapeTrailingWhitespace($expected_output);
$expected_text = $this->unescapeTrailingWhitespace($expected_text);
$engine = $this->buildNewTestEngine();
switch ($file) {
case 'raw-escape.txt':
// NOTE: Here, we want to test PhutilRemarkupEscapeRemarkupRule and
// PhutilRemarkupBlockStorage, which are triggered by "\1". In the
// test, "~" is used as a placeholder for "\1" since it's hard to type
// "\1".
$input_remarkup = str_replace('~', "\1", $input_remarkup);
$expected_output = str_replace('~', "\1", $expected_output);
$expected_text = str_replace('~', "\1", $expected_text);
break;
case 'toc.txt':
$engine->setConfig('header.generate-toc', true);
break;
case 'link-same-window.txt':
$engine->setConfig('uri.same-window', true);
break;
case 'link-square.txt':
$engine->setConfig('uri.base', 'http://www.example.com/');
$engine->setConfig('uri.here', 'http://www.example.com/page/');
break;
}
$actual_output = (string)$engine->markupText($input_remarkup);
switch ($file) {
case 'toc.txt':
$table_of_contents =
PhutilRemarkupHeaderBlockRule::renderTableOfContents($engine);
$actual_output = $table_of_contents."\n\n".$actual_output;
break;
}
$this->assertEqual(
$expected_output,
$actual_output,
pht("Failed to markup HTML in file '%s'.", $file));
$engine->setMode(PhutilRemarkupEngine::MODE_TEXT);
$actual_output = (string)$engine->markupText($input_remarkup);
$this->assertEqual(
$expected_text,
$actual_output,
pht("Failed to markup text in file '%s'.", $file));
}
private function buildNewTestEngine() {
$engine = new PhutilRemarkupEngine();
$engine->setConfig(
'uri.allowed-protocols',
array(
'http' => true,
'mailto' => true,
'tel' => true,
));
$rules = array();
$rules[] = new PhutilRemarkupEscapeRemarkupRule();
$rules[] = new PhutilRemarkupMonospaceRule();
$rules[] = new PhutilRemarkupDocumentLinkRule();
$rules[] = new PhutilRemarkupHyperlinkRule();
$rules[] = new PhutilRemarkupBoldRule();
$rules[] = new PhutilRemarkupItalicRule();
$rules[] = new PhutilRemarkupDelRule();
$rules[] = new PhutilRemarkupUnderlineRule();
$rules[] = new PhutilRemarkupHighlightRule();
$blocks = array();
$blocks[] = new PhutilRemarkupQuotesBlockRule();
$blocks[] = new PhutilRemarkupReplyBlockRule();
$blocks[] = new PhutilRemarkupHeaderBlockRule();
$blocks[] = new PhutilRemarkupHorizontalRuleBlockRule();
$blocks[] = new PhutilRemarkupCodeBlockRule();
$blocks[] = new PhutilRemarkupLiteralBlockRule();
$blocks[] = new PhutilRemarkupNoteBlockRule();
$blocks[] = new PhutilRemarkupTableBlockRule();
$blocks[] = new PhutilRemarkupSimpleTableBlockRule();
$blocks[] = new PhutilRemarkupDefaultBlockRule();
$blocks[] = new PhutilRemarkupListBlockRule();
$blocks[] = new PhutilRemarkupInterpreterBlockRule();
foreach ($blocks as $block) {
if (!($block instanceof PhutilRemarkupCodeBlockRule)) {
$block->setMarkupRules($rules);
}
}
$engine->setBlockRules($blocks);
return $engine;
}
private function unescapeTrailingWhitespace($input) {
// Remove up to one "~" at the end of each line so trailing whitespace may
// be written in tests as " ~".
return preg_replace('/~$/m', '', $input);
}
}
diff --git a/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-flavored.txt b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-flavored.txt
new file mode 100644
index 0000000000..f224942b1d
--- /dev/null
+++ b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-flavored.txt
@@ -0,0 +1,7 @@
+```cpp
+code
+```
+~~~~~~~~~~
+<div class="remarkup-code-block" data-code-lang="cpp" data-sigil="remarkup-code-block"><pre class="remarkup-code">code</pre></div>
+~~~~~~~~~~
+ code
diff --git a/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-comment.txt b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-comment.txt
new file mode 100644
index 0000000000..bcdaca8063
--- /dev/null
+++ b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-comment.txt
@@ -0,0 +1,18 @@
+```#comment
+code
+
+#more comment
+more code```
+
+~~~~~~~~~~
+<div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">#comment
+code
+
+#more comment
+more code</pre></div>
+~~~~~~~~~~
+ #comment
+ code
+
+ #more comment
+ more code
diff --git a/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-empty.txt b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-empty.txt
new file mode 100644
index 0000000000..c05d44ec0f
--- /dev/null
+++ b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored-empty.txt
@@ -0,0 +1,9 @@
+```
+cpp
+second line```
+~~~~~~~~~~
+<div class="remarkup-code-block" data-code-lang="text" data-sigil="remarkup-code-block"><pre class="remarkup-code">cpp
+second line</pre></div>
+~~~~~~~~~~
+ cpp
+ second line
diff --git a/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored.txt b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored.txt
new file mode 100644
index 0000000000..5dc2cef421
--- /dev/null
+++ b/src/infrastructure/markup/remarkup/__tests__/remarkup/tick-block-multi-flavored.txt
@@ -0,0 +1,20 @@
+```cpp
+code
+
+more code
+
+more code
+```
+
+~~~~~~~~~~
+<div class="remarkup-code-block" data-code-lang="cpp" data-sigil="remarkup-code-block"><pre class="remarkup-code">code
+
+more code
+
+more code</pre></div>
+~~~~~~~~~~
+ code
+
+ more code
+
+ more code

File Metadata

Mime Type
text/x-diff
Expires
Jan 19 2025, 17:45 (6 w, 10 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1127092
Default Alt Text
(16 KB)

Event Timeline