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";
+ 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 '';
+ }
+
+ /**
+ * 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 footnote and one with a label. Labels can be anything.
+Footnotes can be defined out of order (both where they're called and defined).
+Block elements such as…
+…headers…
+
+…and quotes…
+
+…can contain footnotes, and footnotes can contain block elements.
+One footnote can be referenced multiple times.
+End of test.
+
+
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.