Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand Down
5 changes: 0 additions & 5 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,4 @@
<code><![CDATA[$params]]></code>
</MixedArgumentTypeCoercion>
</file>
<file src="src/Render/MarkdownRenderer.php">
<MixedReturnStatement>
<code><![CDATA[md4c_toHtml($markdown, $this->flags)]]></code>
</MixedReturnStatement>
</file>
</files>
2 changes: 2 additions & 0 deletions src/Content/Model/MarkdownConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<', '>'.
Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/Content/Parser/SiteConfigParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
}
Expand Down
105 changes: 104 additions & 1 deletion src/Render/MarkdownRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Comment on lines +81 to +84

/** @var array<string, int> $used */
$used = [];
/** @var array<int, array{id: string, number: int}> $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",
'<sup id="' . $escapedReferenceId . '" class="footnote-ref"><a href="#fn-' . $escapedId . '">' . $number . '</a></sup>',
$html,
);
}
Comment on lines +107 to +118

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<string, string>}
*/
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<string, string> $definitions
* @param array<string, int> $used
*/
private function renderFootnoteList(array $definitions, array $used): string
{
$html = "\n<section class=\"footnotes\" role=\"doc-endnotes\">\n<ol>\n";
foreach ($used as $id => $_number) {
$content = trim($this->toHtml($definitions[$id]));
$content = preg_replace('/^<p>(.*)<\/p>$/s', '$1', $content) ?? $content;
$escapedId = htmlspecialchars($id, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html .= '<li id="fn-' . $escapedId . '">' . $content . ' <a href="#' . $this->referenceId($id, 1) . '" class="footnote-backref" aria-label="Back to reference">Back</a></li>' . "\n";
}

return $html . "</ol>\n</section>\n";
}

private static function buildFlags(MarkdownConfig $config): int
Expand Down
4 changes: 4 additions & 0 deletions tests/Unit/Content/MarkdownConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -64,6 +65,7 @@ public function testParserReadsMarkdownSection(): void
languages: [en]
markdown:
tables: false
footnotes: false
strikethrough: false
latex_math: true
underline: true
Expand All @@ -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)');
Expand Down Expand Up @@ -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);
Expand Down
36 changes: 35 additions & 1 deletion tests/Unit/Render/MarkdownRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -85,6 +87,38 @@ public function testRendersTaskList(): void
<li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled>todo</li>
</ul>
EXPECTED
, $html);
, $html);
}

public function testRendersFootnotes(): void
{
$markdown = "Text with a note.[^note]\n\n[^note]: Footnote text.";
$html = $this->renderer->render($markdown);

assertStringContainsString('<sup id="fnref-note" class="footnote-ref"><a href="#fn-note">1</a></sup>', $html);
assertStringContainsString('<section class="footnotes" role="doc-endnotes">', $html);
assertStringContainsString('<li id="fn-note">Footnote text. <a href="#fnref-note" class="footnote-backref" aria-label="Back to reference">Back</a></li>', $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('<sup id="fnref-note" class="footnote-ref"><a href="#fn-note">1</a></sup>', $html);
assertStringContainsString('<sup id="fnref-note-2" class="footnote-ref"><a href="#fn-note">1</a></sup>', $html);
assertStringContainsString('<li id="fn-note">Footnote text. <a href="#fnref-note" class="footnote-backref" aria-label="Back to reference">Back</a></li>', $html);
}
Comment on lines +115 to 123
}
Loading