Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions MarkdownExtra.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -60,8 +62,6 @@ class MarkdownExtra extends Markdown

// TODO implement definition lists

// TODO implement footnotes

// TODO implement Abbreviations


Expand Down
217 changes: 217 additions & 0 deletions block/FootnoteTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<?php

namespace cebe\markdown\block;

trait FootnoteTrait
{

/** @var string[][] Unordered array of footnotes. */
protected $footnotes = [];

/** @var int Incrementing counter of the footnote links. */
protected $footnoteLinkNum = 0;

/** @var string[] Ordered array of footnote links. */
protected $footnoteLinks = [];

/**
* @inheritDoc
*/
abstract protected function parseBlocks($lines);

/**
* @inheritDoc
*/
abstract protected function renderAbsy($blocks);

/**
* @param $text
* @return string
*/
public function parse($text)
{
$html = parent::parse($text);

// If no footnotes found, do nothing more.
if (count($this->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 ? "<hr>\n" : "<hr />\n";
$footnotesHtml = "\n<div class=\"footnotes\" role=\"doc-endnotes\">\n$hr<ol>\n\n";
foreach ($footnotesSorted as $footnoteInfo) {
$backLinks = [];
foreach ($footnoteInfo['refs'] as $refIndex => $refNum) {
$fnref = count($footnoteInfo['refs']) > 1
? $footnoteInfo['num'] . '-' . $refIndex
: $footnoteInfo['num'];
$backLinks[] = '<a href="#fnref'.'-'.$fnref.'" role="doc-backlink">&#8617;&#xFE0E;</a>';
}
$linksPara = '<p class="footnote-backrefs">'.join("\n", $backLinks)."</p>";
$footnotesHtml .= "<li id=\"fn-{$footnoteInfo['num']}\" role=\"doc-endnote\">\n{$footnoteInfo['html']}$linksPara\n</li>\n\n";
}
$footnotesHtml .= "</ol>\n</div>\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 '<sup id="fnref-' . $substituteRefnum . '" class="footnote-ref">'
.'<a href="#fn-' . $substituteNum . '" role="doc-noteref">' . $substituteNum . '</a>'
.'</sup>';
}

/**
* 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 '';
}
}
63 changes: 63 additions & 0 deletions tests/extra-data/footnotes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<p>A <em>simple</em> footnote<sup id="fnref-1" class="footnote-ref"><a href="#fn-1" role="doc-noteref">1</a></sup> and one with a label.<sup id="fnref-2" class="footnote-ref"><a href="#fn-2" role="doc-noteref">2</a></sup> Labels can be anything.<sup id="fnref-3" class="footnote-ref"><a href="#fn-3" role="doc-noteref">3</a></sup></p>
<p>Footnotes can be defined out of order<sup id="fnref-4" class="footnote-ref"><a href="#fn-4" role="doc-noteref">4</a></sup> (both where they're called and defined).</p>
<p>Block elements such as…</p>
<h2>…headers…<sup id="fnref-5-1" class="footnote-ref"><a href="#fn-5" role="doc-noteref">5</a></sup></h2>
<ul>
<li>…lists…<sup id="fnref-5-2" class="footnote-ref"><a href="#fn-5" role="doc-noteref">5</a></sup></li>
</ul>
<blockquote><p>…and quotes…<sup id="fnref-6" class="footnote-ref"><a href="#fn-6" role="doc-noteref">6</a></sup></p>
</blockquote>
<p>…can contain footnotes, and footnotes can contain block elements.
One footnote can be referenced multiple times.</p>
<p>End of test.</p>

<div class="footnotes" role="doc-endnotes">
<hr />
<ol>

<li id="fn-1" role="doc-endnote">
<p>The <em>first</em> footnote, with <a href="https://example.org/">inline</a> formatting.</p>
<p class="footnote-backrefs"><a href="#fnref-1" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

<li id="fn-2" role="doc-endnote">
<p>Labelled footnote (number 2)
also with
multiple lines.</p>
<p class="footnote-backrefs"><a href="#fnref-2" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

<li id="fn-3" role="doc-endnote">
<p>Any characters are allowed.</p>
<p class="footnote-backrefs"><a href="#fnref-3" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

<li id="fn-4" role="doc-endnote">
<p>Out of order
and with
multiple lines.</p>
<p class="footnote-backrefs"><a href="#fnref-4" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

<li id="fn-5" role="doc-endnote">
<p>A footnote (number 5) with block elements.</p>
<p>The blocks must be <em>intented</em></p>
<ul>
<li>by the same <em>amount</em>, and</li>
<li>with a tab or four spaces.</li>
</ul>
<p>They can also contain</p>
<pre><code>code blocks.
</code></pre>
<p class="footnote-backrefs"><a href="#fnref-5-1" role="doc-backlink">&#8617;&#xFE0E;</a>
<a href="#fnref-5-2" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

<li id="fn-6" role="doc-endnote">
<p>Block footnotes can start</p>
<p>on or after the first line.</p>
<p class="footnote-backrefs"><a href="#fnref-6" role="doc-backlink">&#8617;&#xFE0E;</a></p>
</li>

</ol>
</div>
41 changes: 41 additions & 0 deletions tests/extra-data/footnotes.md
Original file line number Diff line number Diff line change
@@ -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.