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",
+ '',
+ $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";
}
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('', $html);
+ assertStringContainsString('