diff --git a/MarkdownExtra.php b/MarkdownExtra.php index eedcf77..c819a3a 100644 --- a/MarkdownExtra.php +++ b/MarkdownExtra.php @@ -2,6 +2,7 @@ namespace cebe\markdown; +use cebe\markdown\block\FootnoteTrait; 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\FootnoteTrait; // 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 @@ -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 new file mode 100644 index 0000000..5e2ef31 --- /dev/null +++ b/block/FootnoteTrait.php @@ -0,0 +1,216 @@ +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/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

+ +

…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.