From f21e9aa769733aad975d9b1fa532dea733da1f92 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 11:57:20 +0300 Subject: [PATCH] Add Markdown footnotes --- docs/configuration.md | 2 + psalm-baseline.xml | 5 - src/Content/Model/MarkdownConfig.php | 2 + src/Content/Parser/SiteConfigParser.php | 3 + src/Render/MarkdownRenderer.php | 105 ++++++++++++++++++++- tests/Unit/Content/MarkdownConfigTest.php | 4 + tests/Unit/Render/MarkdownRendererTest.php | 36 ++++++- 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index bdce7f4..1db7bd3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -286,6 +286,7 @@ The `markdown` section controls which Markdown extensions are enabled. All optio ```yaml markdown: tables: true + footnotes: true strikethrough: true tasklists: true url_autolinks: true @@ -303,6 +304,7 @@ markdown: ``` - **tables** — GitHub-style tables (default: `true`) +- **footnotes** — Markdown footnotes using `[^id]` references and `[^id]: text` definitions (default: `true`) - **strikethrough** — strikethrough with `~text~` (default: `true`) - **tasklists** — GitHub-style task lists (default: `true`) - **url_autolinks** — recognize URLs as auto-links even without `<>` (default: `true`) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index aecee89..8311263 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -314,9 +314,4 @@ - - - flags)]]> - - diff --git a/src/Content/Model/MarkdownConfig.php b/src/Content/Model/MarkdownConfig.php index c8e4b89..83b74bc 100644 --- a/src/Content/Model/MarkdownConfig.php +++ b/src/Content/Model/MarkdownConfig.php @@ -8,6 +8,7 @@ { /** * @param bool $tables Enable tables extension. + * @param bool $footnotes Enable footnotes. * @param bool $strikethrough Enable strikethrough extension. * @param bool $tasklists Enable task list extension. * @param bool $urlAutolinks Recognize URLs as auto-links even without '<', '>'. @@ -25,6 +26,7 @@ */ public function __construct( public bool $tables = true, + public bool $footnotes = true, public bool $strikethrough = true, public bool $tasklists = true, public bool $urlAutolinks = true, diff --git a/src/Content/Parser/SiteConfigParser.php b/src/Content/Parser/SiteConfigParser.php index 604ae52..af43b39 100644 --- a/src/Content/Parser/SiteConfigParser.php +++ b/src/Content/Parser/SiteConfigParser.php @@ -117,6 +117,9 @@ private static function parseMarkdownConfig(mixed $data): MarkdownConfig if (array_key_exists('tables', $data)) { $constructorArgs['tables'] = (bool) $data['tables']; } + if (array_key_exists('footnotes', $data)) { + $constructorArgs['footnotes'] = (bool) $data['footnotes']; + } if (array_key_exists('strikethrough', $data)) { $constructorArgs['strikethrough'] = (bool) $data['strikethrough']; } diff --git a/src/Render/MarkdownRenderer.php b/src/Render/MarkdownRenderer.php index 50d6d1a..ec44570 100644 --- a/src/Render/MarkdownRenderer.php +++ b/src/Render/MarkdownRenderer.php @@ -55,10 +55,12 @@ final class MarkdownRenderer /** Force all soft breaks to act as hard breaks. */ private const int MD_FLAG_HARD_SOFT_BREAKS = 0x8000; private int $flags; + private bool $footnotes; public function __construct(MarkdownConfig $config = new MarkdownConfig()) { $this->flags = self::buildFlags($config); + $this->footnotes = $config->footnotes; } public function render(string $markdown): string @@ -67,7 +69,108 @@ public function render(string $markdown): string return ''; } - return md4c_toHtml($markdown, $this->flags); + if (!$this->footnotes || !str_contains($markdown, '[^')) { + return $this->toHtml($markdown); + } + + return $this->renderWithFootnotes($markdown); + } + + private function renderWithFootnotes(string $markdown): string + { + [$markdown, $definitions] = $this->extractFootnotes($markdown); + if ($definitions === []) { + return $this->toHtml($markdown); + } + + /** @var array $used */ + $used = []; + /** @var array $references */ + $references = []; + $markdown = preg_replace_callback( + '/\[\^([A-Za-z0-9_-]+)]/', + static function (array $matches) use ($definitions, &$used, &$references): string { + $id = $matches[1]; + if (!isset($definitions[$id])) { + return $matches[0]; + } + + $used[$id] ??= count($used) + 1; + $reference = count($references) + 1; + $references[$reference] = ['id' => $id, 'number' => $used[$id]]; + + return "\x1FFOOTNOTE_REF:" . $reference . "\x1F"; + }, + $markdown, + ) ?? $markdown; + + $html = $this->toHtml($markdown); + foreach ($references as $reference => $data) { + $id = $data['id']; + $number = $data['number']; + $escapedId = htmlspecialchars($id, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $escapedReferenceId = $this->referenceId($id, $reference); + $html = str_replace( + "\x1FFOOTNOTE_REF:" . $reference . "\x1F", + '' . $number . '', + $html, + ); + } + + if ($used === []) { + return $html; + } + + return $html . $this->renderFootnoteList($definitions, $used); + } + + private function toHtml(string $markdown): string + { + return (string) md4c_toHtml($markdown, $this->flags); + } + + private function referenceId(string $id, int $reference): string + { + $escapedId = htmlspecialchars($id, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return $reference === 1 ? 'fnref-' . $escapedId : 'fnref-' . $escapedId . '-' . $reference; + } + + /** + * @return array{0: string, 1: array} + */ + private function extractFootnotes(string $markdown): array + { + $definitions = []; + $bodyLines = []; + + foreach (explode("\n", $markdown) as $line) { + if (preg_match('/^\[\^([A-Za-z0-9_-]+)]:[ \t]*(.*)$/', $line, $matches) === 1) { + $definitions[$matches[1]] = $matches[2]; + continue; + } + + $bodyLines[] = $line; + } + + return [implode("\n", $bodyLines), $definitions]; + } + + /** + * @param array $definitions + * @param array $used + */ + private function renderFootnoteList(array $definitions, array $used): string + { + $html = "\n
\n
    \n"; + foreach ($used as $id => $_number) { + $content = trim($this->toHtml($definitions[$id])); + $content = preg_replace('/^

    (.*)<\/p>$/s', '$1', $content) ?? $content; + $escapedId = htmlspecialchars($id, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $html .= '

  1. ' . $content . ' Back
  2. ' . "\n"; + } + + return $html . "
\n
\n"; } private static function buildFlags(MarkdownConfig $config): int diff --git a/tests/Unit/Content/MarkdownConfigTest.php b/tests/Unit/Content/MarkdownConfigTest.php index b9b2587..483387a 100644 --- a/tests/Unit/Content/MarkdownConfigTest.php +++ b/tests/Unit/Content/MarkdownConfigTest.php @@ -20,6 +20,7 @@ public function testDefaultConfig(): void $config = new MarkdownConfig(); assertTrue($config->tables); + assertTrue($config->footnotes); assertTrue($config->strikethrough); assertTrue($config->tasklists); assertTrue($config->urlAutolinks); @@ -64,6 +65,7 @@ public function testParserReadsMarkdownSection(): void languages: [en] markdown: tables: false + footnotes: false strikethrough: false latex_math: true underline: true @@ -75,6 +77,7 @@ public function testParserReadsMarkdownSection(): void $config = $parser->parse($tmpFile); assertFalse($config->markdown->tables, 'tables should be false'); + assertFalse($config->markdown->footnotes, 'footnotes should be false'); assertFalse($config->markdown->strikethrough, 'strikethrough should be false'); assertTrue($config->markdown->tasklists, 'tasklists should be true (default)'); assertTrue($config->markdown->urlAutolinks, 'urlAutolinks should be true (default)'); @@ -106,6 +109,7 @@ public function testParserHandlesMissingMarkdownSection(): void $config = $parser->parse($tmpFile); assertTrue($config->markdown->tables); + assertTrue($config->markdown->footnotes); assertTrue($config->markdown->strikethrough); } finally { unlink($tmpFile); diff --git a/tests/Unit/Render/MarkdownRendererTest.php b/tests/Unit/Render/MarkdownRendererTest.php index c8cf092..46bb915 100644 --- a/tests/Unit/Render/MarkdownRendererTest.php +++ b/tests/Unit/Render/MarkdownRendererTest.php @@ -4,11 +4,13 @@ namespace YiiPress\Tests\Unit\Render; +use YiiPress\Content\Model\MarkdownConfig; use YiiPress\Render\MarkdownRenderer; use PHPUnit\Framework\TestCase; use function PHPUnit\Framework\assertSame; use function PHPUnit\Framework\assertStringContainsString; +use function PHPUnit\Framework\assertStringNotContainsString; final class MarkdownRendererTest extends TestCase { @@ -85,6 +87,38 @@ public function testRendersTaskList(): void
  • todo
  • EXPECTED - , $html); + , $html); + } + + public function testRendersFootnotes(): void + { + $markdown = "Text with a note.[^note]\n\n[^note]: Footnote text."; + $html = $this->renderer->render($markdown); + + assertStringContainsString('1', $html); + assertStringContainsString('
    ', $html); + assertStringContainsString('
  • Footnote text. Back
  • ', $html); + assertStringNotContainsString('[^note]:', $html); + } + + public function testLeavesFootnotesAsMarkdownWhenDisabled(): void + { + $renderer = new MarkdownRenderer(new MarkdownConfig(footnotes: false)); + $markdown = "Text with a note.[^note]\n\n[^note]: Footnote text."; + $html = $renderer->render($markdown); + + assertStringContainsString('[^note]', $html); + assertStringContainsString('[^note]: Footnote text.', $html); + assertStringNotContainsString('class="footnote-ref"', $html); + } + + public function testRendersRepeatedFootnoteReferencesWithUniqueReferenceIds(): void + { + $markdown = "First.[^note]\n\nSecond.[^note]\n\n[^note]: Footnote text."; + $html = $this->renderer->render($markdown); + + assertStringContainsString('1', $html); + assertStringContainsString('1', $html); + assertStringContainsString('
  • Footnote text. Back
  • ', $html); } }