Skip to content

Commit

Permalink
Merge pull request #33 from samwilson/footnotes
Browse files Browse the repository at this point in the history
Add Markdown footnotes
  • Loading branch information
samwilson authored Sep 28, 2021
2 parents 3ee4627 + 02c3f9c commit 8309ef0
Show file tree
Hide file tree
Showing 6 changed files with 257 additions and 3 deletions.
7 changes: 7 additions & 0 deletions docs/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ figure img {
height: auto;
}

.footnotes { font-size: 0.75em; }
.footnotes hr { border:1px solid #bbb; border-width:1px 0 0 0; }
.footnotes li > * { display: inline; }
.footnote-ref { font-size: 0.6em; }
.footnote-ref a::before { content: "[";}
.footnote-ref a::after { content: "]";}

footer {
margin-top: 2em;
font-size: smaller;
Expand Down
9 changes: 7 additions & 2 deletions docs/content/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ An example of a page file at `content/topics/goats.md`:

This is the part where we explain more about the 'goat' topic.

Because content is usually in Markdown format, there are some useful Markdown additions that can be used in content pages.
Because content is usually in [Markdown format](https://www.markdownguide.org/getting-started/),
there are some useful Markdown additions that can be used in content pages.
The rest of this page explains these.

## Images
Expand All @@ -36,7 +37,7 @@ see [the Assets section](index.html) of the documentation overview.

## Embeds

This section documents 'embeds', which are what we call a URL on its own line in a Markdown document.
This section documents 'embeds', which are what we call a URL on its own line in a Markdown document.[^embed]
Embeds are simple ways to include images, videos, and summaries of other web pages.
For example, this is a photo from Wikimedia Commons:

Expand All @@ -50,6 +51,10 @@ All of the other information (image URL, caption, etc.) is retrieved from the Co

Embeds can be rendered to any output format; they're not limited to HTML.

[^embed]: The term 'embed' comes from WordPress,
which has a [similar function](https://wordpress.org/support/article/embeds/).
Basildon doesn't yet support the [oEmbed standard](https://oembed.com/).

### Configuration

To configure a new embed, add a name and a URL pattern to your site's `config.yaml`, under the `embeds` key.
Expand Down
4 changes: 4 additions & 0 deletions example/assets/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ footer {
font-size: smaller;
text-align: right;
}

.footnotes { font-size: smaller; }
.footnotes hr { border:1px solid #bbb; border-width:1px 0 0 0; }
.footnotes li > * { display: inline; }
4 changes: 3 additions & 1 deletion example/content/index.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ title: Welcome
template: recent
---

Lorem ipsum. This is a test. It has [links](https://example.org/) and a [ref link to goats].
Lorem ipsum. This is a test. It has [links](https://example.org/) and a [ref link to goats].[^1]

[ref link to goats]: ./tags/goats.html

[^1]: It can also have footnotes.
229 changes: 229 additions & 0 deletions src/Markdown/FootnoteTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php

declare(strict_types=1);

namespace App\Markdown;

/**
* @TODO Remove this once it's in cebe/markdown https://github.com/cebe/markdown/pull/183
*/
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 */
public $html5 = false;

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

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

/**
* @param string $html
*/
public function addFootnotes($html): string
{
// 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.
$substitutePattern = '/\x1Afootnote-(refnum|num)(.*?)\x1A/';
$referencedHtml = preg_replace_callback($substitutePattern, 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.
*/
protected function getFootnotesHtml(array $footnotesSorted): string
{
$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 = [];
// phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable
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 `[^`.
*
* @param string $text
* @return mixed[][]
*
* @marker [^
*/
protected function parseFootnoteLink($text): array
{
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.
*/
protected function renderFootnoteLink($block): string
{
$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 string $line
* @return false|int
*/
// phpcs:ignore SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint
protected function identifyFootnoteList($line)
{
return preg_match('/^\[\^(.+?)]:/', $line);
}

/**
* Consume lines for a footnote
*
* @param string[] $lines
* @param int $current
* @return mixed[] 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): array
{
$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) ];
} elseif (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 mixed[] $block
*/
protected function renderFootnoteList($block): string
{
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 '';
}
}
7 changes: 7 additions & 0 deletions src/Markdown/MarkdownToHtml.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@
class MarkdownToHtml extends Markdown
{
use EmbedTrait;
use FootnoteTrait;

public function getFormat(): string
{
return 'html';
}

// phpcs:ignore
public function parse($text)
{
return $this->addFootnotes(parent::parse($text));
}

/**
* @inheritdoc
*/
Expand Down

0 comments on commit 8309ef0

Please sign in to comment.