Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2893028
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Advanced/Developer...
View Handle
View Hovercard
Size
16 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Jan 19 2025, 17:45 (6 w, 20 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1127092
Default Alt Text
(16 KB)
Attached To
Mode
rP Phorge
Attached
Detach File
Event Timeline
Log In to Comment