From bf0b9167219314393bdb577efcee7fa6b0f54d0d Mon Sep 17 00:00:00 2001 From: Andrey Solovov Date: Sat, 16 Jan 2021 18:48:17 +0300 Subject: [PATCH 1/6] Footnotes implementation --- MarkdownExtra.php | 4 +- block/FooterTrait.php | 156 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 block/FooterTrait.php diff --git a/MarkdownExtra.php b/MarkdownExtra.php index eedcf77..c20614e 100644 --- a/MarkdownExtra.php +++ b/MarkdownExtra.php @@ -2,6 +2,7 @@ namespace cebe\markdown; +use cebe\markdown\block\FooterTrait; use cebe\markdown\block\TableTrait; // work around https://github.com/facebook/hhvm/issues/1120 @@ -19,6 +20,7 @@ class MarkdownExtra extends Markdown // include block element parsing using traits use block\TableTrait; use block\FencedCodeTrait; + use block\FooterTrait; // include inline element parsing using traits // TODO @@ -60,8 +62,6 @@ class MarkdownExtra extends Markdown // TODO implement definition lists - // TODO implement footnotes - // TODO implement Abbreviations diff --git a/block/FooterTrait.php b/block/FooterTrait.php new file mode 100644 index 0000000..8c34c9a --- /dev/null +++ b/block/FooterTrait.php @@ -0,0 +1,156 @@ + + * @copyright 1997-2021 Pan Russian solovov.ru + */ + +namespace cebe\markdown\block; + + +trait FooterTrait +{ + + protected $footnotes = []; + protected $footnoteNum = 1; + + /** + * @param $text + * @return string + */ + public function parse($text) + { + $absy = $this->parseBlocks(explode("\n", $text)); + + foreach ($absy as $block) { + if ($block[0] == 'footnote') { + $block['num'] = $this->footnoteNum; + $this->footnotes[] = $block; + $this->footnoteNum++; + } + } + $markup = parent::parse($text); + $markup = $this->applyFooter($markup, $this->footnotes); + + return $markup; + } + + /** + * @param $content + * @param $blocks + * @return string + */ + protected function applyFooter($content, $blocks) + { + $content .= '
'; + foreach ($blocks as $block) { + $number = $block['num'] . ". "; + $link = ''; + $text = $this->renderAbsy($block['content']); + $text = substr_replace($text, $number, 3, 0); + $text = substr_replace($text, $link, -5, 0); + + $content .= Html::tag('footnotes', $text, [ + 'id' => 'fn:'.$block['id'] + ]) . "\n"; + } + return $content; + } + + /** + * Parses a footnote link indicated by `[^`. + * @marker [^ + * @param $text + * @return array + */ + protected function parseFootnoteLink($text) + { + if (preg_match('/^\[\^(.+?)\]/', $text, $matches)) { + return [ + ['footnoteLink', $matches[1]], + strlen($matches[0]) + ]; + } + return [['text', $text[0]], 1]; + } + + /** + * @param $block + * @return string + */ + protected function renderFootnoteLink($block) + { + $footnoteId = $block[1]; + $num = 0; + $found = false; + foreach ($this->footnotes as $footnote) { + $num ++; + if ($footnote['id']==$footnoteId) { + $found = true; + break; + } + } + if (!$found) + $num = '?'; + + $text = htmlspecialchars($block[1], ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); + return '[' . $num . ']'; + } + + /** + * identify a line as the beginning of a footnote block + * + * @param $line + * @return false|int + */ + protected function identifyFootnoteList($line) + { + return preg_match('/^\[\^(.+?)\]:/', $line); + } + + /** + * Consume lines for a footnote + */ + protected function consumeFootnoteList($lines, $current) + { + $id = ''; + $content = []; + $count = count($lines); + for ($i = $current; $i < $count; $i++) { + $line = $lines[$i]; + + if ($id == '') { + if (preg_match('/^\[\^(.+?)\]:[ \t]+/', $line, $matches)) { + $id = $matches[1]; + $str = substr($line, strlen($matches[0])); + $content[] = $str; + } + } else if (strlen(trim($line)) == 0) { + break; + } else { + $content[] = ltrim($line); + } + } + + $block = ['footnote', 'id' => $id, 'content' => $this->parseBlocks($content)]; + + return [$block, $i]; + } + + /** + * @param $block + * @return string + */ + protected function renderFootnote($block) + { + return ''; + } + + +} \ No newline at end of file From 028229f7a3bc146efe186d184ee74dbe1bb9e129 Mon Sep 17 00:00:00 2001 From: Andrey Solovov Date: Sat, 16 Jan 2021 18:53:48 +0300 Subject: [PATCH 2/6] small fix --- block/FooterTrait.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/block/FooterTrait.php b/block/FooterTrait.php index 8c34c9a..0581513 100644 --- a/block/FooterTrait.php +++ b/block/FooterTrait.php @@ -56,9 +56,7 @@ protected function applyFooter($content, $blocks) $text = substr_replace($text, $number, 3, 0); $text = substr_replace($text, $link, -5, 0); - $content .= Html::tag('footnotes', $text, [ - 'id' => 'fn:'.$block['id'] - ]) . "\n"; + $content .= '' . $text . "\n"; } return $content; } From e602cef97872eec82b46617757e3f093478a1842 Mon Sep 17 00:00:00 2001 From: Andrew Solovov Date: Wed, 20 Jan 2021 20:40:28 +0300 Subject: [PATCH 3/6] footnote with link --- block/FooterTrait.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/block/FooterTrait.php b/block/FooterTrait.php index 0581513..c01af8c 100644 --- a/block/FooterTrait.php +++ b/block/FooterTrait.php @@ -51,8 +51,9 @@ protected function applyFooter($content, $blocks) $content .= '
'; foreach ($blocks as $block) { $number = $block['num'] . ". "; - $link = ''; + $link = ''; $text = $this->renderAbsy($block['content']); + $text = $this->renderAbsy($this->parseInline($text)); $text = substr_replace($text, $number, 3, 0); $text = substr_replace($text, $link, -5, 0); From a893b9672ade285ad39c9cc04f6dd5b707ae598c Mon Sep 17 00:00:00 2001 From: Andrew Solovov Date: Wed, 20 Jan 2021 22:27:54 +0300 Subject: [PATCH 4/6] Add tests --- block/FooterTrait.php | 1 - tests/extra-data/footernote.md | 3 +++ tests/extra-data/foternote.html | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 tests/extra-data/footernote.md create mode 100644 tests/extra-data/foternote.html diff --git a/block/FooterTrait.php b/block/FooterTrait.php index c01af8c..d215284 100644 --- a/block/FooterTrait.php +++ b/block/FooterTrait.php @@ -48,7 +48,6 @@ public function parse($text) */ protected function applyFooter($content, $blocks) { - $content .= '
'; foreach ($blocks as $block) { $number = $block['num'] . ". "; $link = ''; diff --git a/tests/extra-data/footernote.md b/tests/extra-data/footernote.md new file mode 100644 index 0000000..cd47d7f --- /dev/null +++ b/tests/extra-data/footernote.md @@ -0,0 +1,3 @@ +The implementation focus is to be fast (see benchmark) and extensible [^1] + +[^1]: See [benchmark](https://github.com/kzykhys/Markbench#readme). \ No newline at end of file diff --git a/tests/extra-data/foternote.html b/tests/extra-data/foternote.html new file mode 100644 index 0000000..85d2a0c --- /dev/null +++ b/tests/extra-data/foternote.html @@ -0,0 +1,6 @@ +The implementation focus is to be fast (see benchmark) and extensible [1] + +
+

1. See benchmark. +

+
\ No newline at end of file From 20256b2dbc84dd746a4608ada755c40a90e98675 Mon Sep 17 00:00:00 2001 From: Sam Wilson Date: Mon, 20 Sep 2021 15:51:31 +0800 Subject: [PATCH 5/6] Fix up MarkdownExtra footnote implementation Handle more footnote syntax, improve tests, and correct a few errors. --- MarkdownExtra.php | 4 +- block/FooterTrait.php | 154 ----------------------- block/FootnoteTrait.php | 217 ++++++++++++++++++++++++++++++++ tests/extra-data/footernote.md | 3 - tests/extra-data/footnotes.html | 63 ++++++++++ tests/extra-data/footnotes.md | 41 ++++++ tests/extra-data/foternote.html | 6 - 7 files changed, 323 insertions(+), 165 deletions(-) delete mode 100644 block/FooterTrait.php create mode 100644 block/FootnoteTrait.php delete mode 100644 tests/extra-data/footernote.md create mode 100644 tests/extra-data/footnotes.html create mode 100644 tests/extra-data/footnotes.md delete mode 100644 tests/extra-data/foternote.html diff --git a/MarkdownExtra.php b/MarkdownExtra.php index c20614e..2508d32 100644 --- a/MarkdownExtra.php +++ b/MarkdownExtra.php @@ -2,7 +2,7 @@ namespace cebe\markdown; -use cebe\markdown\block\FooterTrait; +use cebe\markdown\block\FootnoteTrait; use cebe\markdown\block\TableTrait; // work around https://github.com/facebook/hhvm/issues/1120 @@ -20,7 +20,7 @@ class MarkdownExtra extends Markdown // include block element parsing using traits use block\TableTrait; use block\FencedCodeTrait; - use block\FooterTrait; + use block\FootnoteTrait; // include inline element parsing using traits // TODO diff --git a/block/FooterTrait.php b/block/FooterTrait.php deleted file mode 100644 index d215284..0000000 --- a/block/FooterTrait.php +++ /dev/null @@ -1,154 +0,0 @@ - - * @copyright 1997-2021 Pan Russian solovov.ru - */ - -namespace cebe\markdown\block; - - -trait FooterTrait -{ - - protected $footnotes = []; - protected $footnoteNum = 1; - - /** - * @param $text - * @return string - */ - public function parse($text) - { - $absy = $this->parseBlocks(explode("\n", $text)); - - foreach ($absy as $block) { - if ($block[0] == 'footnote') { - $block['num'] = $this->footnoteNum; - $this->footnotes[] = $block; - $this->footnoteNum++; - } - } - $markup = parent::parse($text); - $markup = $this->applyFooter($markup, $this->footnotes); - - return $markup; - } - - /** - * @param $content - * @param $blocks - * @return string - */ - protected function applyFooter($content, $blocks) - { - foreach ($blocks as $block) { - $number = $block['num'] . ". "; - $link = ''; - $text = $this->renderAbsy($block['content']); - $text = $this->renderAbsy($this->parseInline($text)); - $text = substr_replace($text, $number, 3, 0); - $text = substr_replace($text, $link, -5, 0); - - $content .= '' . $text . "\n"; - } - return $content; - } - - /** - * Parses a footnote link indicated by `[^`. - * @marker [^ - * @param $text - * @return array - */ - protected function parseFootnoteLink($text) - { - if (preg_match('/^\[\^(.+?)\]/', $text, $matches)) { - return [ - ['footnoteLink', $matches[1]], - strlen($matches[0]) - ]; - } - return [['text', $text[0]], 1]; - } - - /** - * @param $block - * @return string - */ - protected function renderFootnoteLink($block) - { - $footnoteId = $block[1]; - $num = 0; - $found = false; - foreach ($this->footnotes as $footnote) { - $num ++; - if ($footnote['id']==$footnoteId) { - $found = true; - break; - } - } - if (!$found) - $num = '?'; - - $text = htmlspecialchars($block[1], ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8'); - return '[' . $num . ']'; - } - - /** - * identify a line as the beginning of a footnote block - * - * @param $line - * @return false|int - */ - protected function identifyFootnoteList($line) - { - return preg_match('/^\[\^(.+?)\]:/', $line); - } - - /** - * Consume lines for a footnote - */ - protected function consumeFootnoteList($lines, $current) - { - $id = ''; - $content = []; - $count = count($lines); - for ($i = $current; $i < $count; $i++) { - $line = $lines[$i]; - - if ($id == '') { - if (preg_match('/^\[\^(.+?)\]:[ \t]+/', $line, $matches)) { - $id = $matches[1]; - $str = substr($line, strlen($matches[0])); - $content[] = $str; - } - } else if (strlen(trim($line)) == 0) { - break; - } else { - $content[] = ltrim($line); - } - } - - $block = ['footnote', 'id' => $id, 'content' => $this->parseBlocks($content)]; - - return [$block, $i]; - } - - /** - * @param $block - * @return string - */ - protected function renderFootnote($block) - { - return ''; - } - - -} \ No newline at end of file diff --git a/block/FootnoteTrait.php b/block/FootnoteTrait.php new file mode 100644 index 0000000..c26089e --- /dev/null +++ b/block/FootnoteTrait.php @@ -0,0 +1,217 @@ +footnotes) === 0) { + return $html; + } + + // Sort all found footnotes by the order in which they are linked in the text. + $footnotesSorted = []; + $footnoteNum = 0; + foreach ($this->footnoteLinks as $footnotePos => $footnoteLinkName) { + foreach ($this->footnotes as $footnoteName => $footnoteHtml) { + if ($footnoteLinkName === (string)$footnoteName) { + // First time sorting this footnote. + if (!isset($footnotesSorted[$footnoteName])) { + $footnoteNum++; + $footnotesSorted[$footnoteName] = [ + 'html' => $footnoteHtml, + 'num' => $footnoteNum, + 'refs' => [1 => $footnotePos], + ]; + } else { + // Subsequent times sorting this footnote (i.e. every time it's referenced). + $footnotesSorted[$footnoteName]['refs'][] = $footnotePos; + } + } + } + } + + // Replace the footnote substitution markers with their actual numbers. + $referencedHtml = preg_replace_callback('/\x1Afootnote-(refnum|num)(.*?)\x1A/', function ($match) use ($footnotesSorted) { + $footnoteName = $this->footnoteLinks[$match[2]]; + // Replace only the footnote number. + if ($match[1] === 'num') { + return $footnotesSorted[$footnoteName]['num']; + } + // For backlinks, some have a footnote number and an additional link number. + if (count($footnotesSorted[$footnoteName]['refs']) > 1) { + // If this footnote is referenced more than once, use the `-x` suffix. + $linkNum = array_search($match[2], $footnotesSorted[$footnoteName]['refs']); + return $footnotesSorted[$footnoteName]['num'] . '-' . $linkNum; + } else { + // Otherwise, just the number. + return $footnotesSorted[$footnoteName]['num']; + } + }, $html); + + // Get the footnote HTML and add it to the end of the document. + return $referencedHtml . $this->getFootnotesHtml($footnotesSorted); + } + + /** + * @param mixed[] $footnotesSorted Array with 'html', 'num', and 'refs' keys. + * @return string + */ + protected function getFootnotesHtml(array $footnotesSorted) + { + $hr = $this->html5 ? "
\n" : "
\n"; + $footnotesHtml = "\n
\n$hr
    \n\n"; + foreach ($footnotesSorted as $footnoteInfo) { + $backLinks = []; + foreach ($footnoteInfo['refs'] as $refIndex => $refNum) { + $fnref = count($footnoteInfo['refs']) > 1 + ? $footnoteInfo['num'] . '-' . $refIndex + : $footnoteInfo['num']; + $backLinks[] = '↩︎'; + } + $linksPara = '

    '.join("\n", $backLinks)."

    "; + $footnotesHtml .= "
  1. \n{$footnoteInfo['html']}$linksPara\n
  2. \n\n"; + } + $footnotesHtml .= "
\n
\n"; + return $footnotesHtml; + } + + /** + * Parses a footnote link indicated by `[^`. + * @marker [^ + * @param $text + * @return array + */ + protected function parseFootnoteLink($text) + { + if (preg_match('/^\[\^(.+?)]/', $text, $matches)) { + $footnoteName = $matches[1]; + + // We will later order the footnotes according to the order that the footnote links appear in. + $this->footnoteLinkNum++; + $this->footnoteLinks[$this->footnoteLinkNum] = $footnoteName; + + // To render a footnote link, we only need to know its link-number, + // which will later be turned into its footnote-number (after sorting). + return [ + ['footnoteLink', 'num' => $this->footnoteLinkNum], + strlen($matches[0]) + ]; + } + return [['text', $text[0]], 1]; + } + + /** + * @param string[] $block Array with 'num' key. + * @return string + */ + protected function renderFootnoteLink($block) + { + $substituteRefnum = "\x1Afootnote-refnum".$block['num']."\x1A"; + $substituteNum = "\x1Afootnote-num".$block['num']."\x1A"; + return '' + .'' . $substituteNum . '' + .''; + } + + /** + * identify a line as the beginning of a footnote block + * + * @param $line + * @return false|int + */ + protected function identifyFootnoteList($line) + { + return preg_match('/^\[\^(.+?)]:/', $line); + } + + /** + * Consume lines for a footnote + * @return array Array of two elements, the first element contains the block, + * the second contains the next line index to be parsed. + */ + protected function consumeFootnoteList($lines, $current) + { + $name = ''; + $footnotes = []; + $count = count($lines); + $nextLineIndent = null; + for ($i = $current; $i < $count; $i++) { + $line = $lines[$i]; + $startsFootnote = preg_match('/^\[\^(.+?)]:[ \t]*/', $line, $matches); + if ($startsFootnote) { + // Current line starts a footnote. + $name = $matches[1]; + $str = substr($line, strlen($matches[0])); + $footnotes[$name] = [ trim($str) ]; + } else if (strlen(trim($line)) === 0) { + // Current line is empty and ends this list of footnotes unless the next line is indented. + if (isset($lines[$i+1])) { + $nextLineIndented = preg_match('/^(\t| {4})/', $lines[$i + 1], $matches); + if ($nextLineIndented) { + // If the next line is indented, keep this empty line. + $nextLineIndent = $matches[1]; + $footnotes[$name][] = $line; + } else { + // Otherwise, end the current footnote. + break; + } + } + } elseif (!$startsFootnote && isset($footnotes[$name])) { + // Current line continues the current footnote. + $footnotes[$name][] = $nextLineIndent + ? substr($line, strlen($nextLineIndent)) + : trim($line); + } + } + + // Parse all collected footnotes. + $parsedFootnotes = []; + foreach ($footnotes as $footnoteName => $footnoteLines) { + $parsedFootnotes[$footnoteName] = $this->parseBlocks($footnoteLines); + } + + return [['footnoteList', 'content' => $parsedFootnotes], $i]; + } + + /** + * @param array $block + * @return string + */ + protected function renderFootnoteList($block) + { + foreach ($block['content'] as $footnoteName => $footnote) { + $this->footnotes[$footnoteName] = $this->renderAbsy($footnote); + } + // Render nothing, because all footnote lists will be concatenated at the end of the text. + return ''; + } +} diff --git a/tests/extra-data/footernote.md b/tests/extra-data/footernote.md deleted file mode 100644 index cd47d7f..0000000 --- a/tests/extra-data/footernote.md +++ /dev/null @@ -1,3 +0,0 @@ -The implementation focus is to be fast (see benchmark) and extensible [^1] - -[^1]: See [benchmark](https://github.com/kzykhys/Markbench#readme). \ No newline at end of file diff --git a/tests/extra-data/footnotes.html b/tests/extra-data/footnotes.html new file mode 100644 index 0000000..86126e9 --- /dev/null +++ b/tests/extra-data/footnotes.html @@ -0,0 +1,63 @@ +

A simple footnote1 and one with a label.2 Labels can be anything.3

+

Footnotes can be defined out of order4 (both where they're called and defined).

+

Block elements such as…

+

…headers…5

+
    +
  • …lists…5
  • +
+

…and quotes…6

+
+

…can contain footnotes, and footnotes can contain block elements. +One footnote can be referenced multiple times.

+

End of test.

+ +
+
+
    + +
  1. +

    The first footnote, with inline formatting.

    +

    ↩︎

    +
  2. + +
  3. +

    Labelled footnote (number 2) +also with +multiple lines.

    +

    ↩︎

    +
  4. + +
  5. +

    Any characters are allowed.

    +

    ↩︎

    +
  6. + +
  7. +

    Out of order +and with +multiple lines.

    +

    ↩︎

    +
  8. + +
  9. +

    A footnote (number 5) with block elements.

    +

    The blocks must be intented

    +
      +
    • by the same amount, and
    • +
    • with a tab or four spaces.
    • +
    +

    They can also contain

    +
    code blocks.
    +
    +

    ↩︎ +↩︎

    +
  10. + +
  11. +

    Block footnotes can start

    +

    on or after the first line.

    +

    ↩︎

    +
  12. + +
+
diff --git a/tests/extra-data/footnotes.md b/tests/extra-data/footnotes.md new file mode 100644 index 0000000..81ff138 --- /dev/null +++ b/tests/extra-data/footnotes.md @@ -0,0 +1,41 @@ +A *simple* footnote[^1] and one with a label.[^label] Labels can be anything.[^✳&|^"] + +[^1]: The *first* footnote, with [inline](https://example.org/) formatting. +[^third]: Out of order + and with + multiple lines. +[^label]: Labelled footnote (number 2) +also with +multiple lines. +[^✳&|^"]: Any characters are allowed. + +Footnotes can be defined out of order[^third] (both where they're called and defined). + +Block elements such as… + +## …headers…[^block] + +* …lists…[^block] + +> …and quotes…[^block2] + +…can contain footnotes, and footnotes can contain block elements. +One footnote can be referenced multiple times. + +[^block]: A footnote (number 5) with block elements. + + The blocks must be _intented_ + + * by the same *amount*, and + * with a tab or four spaces. + + They can also contain + + code blocks. + +[^block2]: + Block footnotes can start + + on or after the first line. + +End of test. diff --git a/tests/extra-data/foternote.html b/tests/extra-data/foternote.html deleted file mode 100644 index 85d2a0c..0000000 --- a/tests/extra-data/foternote.html +++ /dev/null @@ -1,6 +0,0 @@ -The implementation focus is to be fast (see benchmark) and extensible [1] - -
-

1. See benchmark. -

-
\ No newline at end of file From a47291a5ac201b78f6daa06d86f0066a9e60ee63 Mon Sep 17 00:00:00 2001 From: Sam Wilson Date: Sun, 21 Nov 2021 10:36:42 +0800 Subject: [PATCH 6/6] Don't override Markdown::parse() --- MarkdownExtra.php | 8 ++++++++ block/FootnoteTrait.php | 7 +++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/MarkdownExtra.php b/MarkdownExtra.php index 2508d32..c819a3a 100644 --- a/MarkdownExtra.php +++ b/MarkdownExtra.php @@ -196,6 +196,14 @@ protected function renderSpecialAttributes($block) return '{' . $block[1] . '}'; } + /** + * @inheritdoc + */ + function parse($text) + { + return $this->addParsedFootnotes(parent::parse($text)); + } + protected function parseInline($text) { $elements = parent::parseInline($text); diff --git a/block/FootnoteTrait.php b/block/FootnoteTrait.php index c26089e..5e2ef31 100644 --- a/block/FootnoteTrait.php +++ b/block/FootnoteTrait.php @@ -25,13 +25,12 @@ abstract protected function parseBlocks($lines); abstract protected function renderAbsy($blocks); /** - * @param $text + * Add footnotes' HTML to the end of parsed HTML. + * @param string $html The HTML output of Markdown::parse(). * @return string */ - public function parse($text) + public function addParsedFootnotes($html) { - $html = parent::parse($text); - // If no footnotes found, do nothing more. if (count($this->footnotes) === 0) { return $html;