Skip to content
Merged
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
13 changes: 12 additions & 1 deletion src/Renderer/AnsiRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use Djot\Node\Inline\Code;
use Djot\Node\Inline\Delete;
use Djot\Node\Inline\Emphasis;
use Djot\Node\Inline\EscapedText;
use Djot\Node\Inline\FootnoteRef;
use Djot\Node\Inline\HardBreak;
use Djot\Node\Inline\Highlight;
Expand Down Expand Up @@ -403,6 +404,7 @@ protected function renderNode(Node $node): string
$node instanceof LineBlock => $this->renderLineBlock($node),
$node instanceof Footnote => $this->renderFootnote($node),
$node instanceof Text => $node->getContent(),
$node instanceof EscapedText => $node->getContent(),
$node instanceof Abbreviation => $this->renderAbbreviation($node),
$node instanceof Emphasis => $this->renderEmphasis($node),
$node instanceof Strong => $this->renderStrong($node),
Expand Down Expand Up @@ -608,7 +610,16 @@ protected function renderThematicBreak(): string

protected function renderDiv(Div $node): string
{
return $this->renderChildren($node);
$body = $this->renderChildren($node);
// A Div's quoted title (e.g. an admonition title carried as the `title`
// attribute) is preserved as a leading bold line instead of being
// dropped.
$title = $node->getAttribute('title');
if (is_string($title) && $title !== '') {
return $this->style($title, self::BOLD) . "\n\n" . $body;
}

return $body;
}

protected function renderTable(Table $node): string
Expand Down
67 changes: 65 additions & 2 deletions src/Renderer/MarkdownRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

use Djot\Event\RenderEvent;
use Djot\Node\Block\BlockQuote;
use Djot\Node\Block\Caption;
use Djot\Node\Block\CodeBlock;
use Djot\Node\Block\Comment;
use Djot\Node\Block\DefinitionDescription;
use Djot\Node\Block\DefinitionList;
use Djot\Node\Block\DefinitionTerm;
use Djot\Node\Block\Div;
use Djot\Node\Block\Figure;
use Djot\Node\Block\Footnote;
use Djot\Node\Block\Heading;
use Djot\Node\Block\LineBlock;
Expand All @@ -24,9 +26,11 @@
use Djot\Node\Block\TableRow;
use Djot\Node\Block\ThematicBreak;
use Djot\Node\Document;
use Djot\Node\Inline\Abbreviation;
use Djot\Node\Inline\Code;
use Djot\Node\Inline\Delete;
use Djot\Node\Inline\Emphasis;
use Djot\Node\Inline\EscapedText;
use Djot\Node\Inline\FootnoteRef;
use Djot\Node\Inline\HardBreak;
use Djot\Node\Inline\Highlight;
Expand Down Expand Up @@ -140,6 +144,14 @@ protected function renderNode(Node $node): string
$node instanceof LineBlock => $this->renderLineBlock($node),
$node instanceof Footnote => $this->renderFootnote($node),
$node instanceof Text => $this->escapeText($node->getContent()),
// Keep the backslash so the literal stays literal when re-parsed as
// Markdown: a bare `.` from `\.` would turn `1\. x` back into an
// ordered list. EscapedText only ever holds escaped ASCII
// punctuation, all of which CommonMark allows a `\` before.
$node instanceof EscapedText => '\\' . $node->getContent(),
$node instanceof Figure => $this->renderFigure($node),
$node instanceof Caption => $this->renderCaption($node),
$node instanceof Abbreviation => $this->renderAbbreviation($node),
$node instanceof Emphasis => $this->renderEmphasis($node),
$node instanceof Strong => $this->renderStrong($node),
$node instanceof Code => $this->renderCode($node),
Expand Down Expand Up @@ -287,8 +299,17 @@ protected function renderDefinitionDescription(DefinitionDescription $node): str

protected function renderDiv(Div $node): string
{
// Divs don't exist in Markdown, just render content
return $this->renderChildren($node);
// Divs/admonitions don't exist in Markdown; render the content. A Div's
// quoted title (e.g. an admonition title carried as the `title`
// attribute) would otherwise be lost - preserve it as a leading bold
// line.
$body = $this->renderChildren($node);
$title = $node->getAttribute('title');
if (is_string($title) && $title !== '') {
return '**' . $this->escapeText($title) . "**\n\n" . $body;
}

return $body;
}

protected function renderTable(Table $node): string
Expand Down Expand Up @@ -475,6 +496,48 @@ protected function renderRawInline(RawInline $node): string
return '';
}

/**
* A figure renders its target then its caption as a separate block
* (Markdown has no figure element). A BLANK line before the caption is
* required, not just a newline: against a block-quote target a single
* newline would make the caption a lazy continuation of the quote and
* swallow it.
*/
protected function renderFigure(Figure $node): string
{
$output = '';
foreach ($node->getChildren() as $child) {
if ($child instanceof Caption) {
$output = rtrim($output) . "\n\n" . $this->renderCaption($child);
} else {
$output .= $this->renderNode($child);
}
}

return $output;
}

protected function renderCaption(Caption $node): string
{
return trim($this->renderChildren($node)) . "\n\n";
}

/**
* Markdown has no abbreviation syntax; emit inline abbr HTML so the title
* is preserved (mirrors how subscript/superscript fall back to inline HTML).
*/
protected function renderAbbreviation(Abbreviation $node): string
{
// The whole element is raw inline HTML, so both the title (attribute)
// and the text (element content) need HTML escaping, NOT Markdown text
// escaping: a `"` in the title or a `<` in the text would otherwise
// break the tag / be misparsed as markup downstream.
$title = htmlspecialchars($node->getTitle(), ENT_QUOTES, 'UTF-8');
$text = htmlspecialchars($this->renderChildren($node), ENT_QUOTES, 'UTF-8');

return '<abbr title="' . $title . '">' . $text . '</abbr>';
}

protected function escapeText(string $text): string
{
// Escape special Markdown characters in text
Expand Down
17 changes: 17 additions & 0 deletions src/Renderer/PlainTextRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Djot\Node\Block\DefinitionDescription;
use Djot\Node\Block\DefinitionList;
use Djot\Node\Block\DefinitionTerm;
use Djot\Node\Block\Div;
use Djot\Node\Block\Footnote;
use Djot\Node\Block\Heading;
use Djot\Node\Block\LineBlock;
Expand All @@ -25,6 +26,7 @@
use Djot\Node\Document;
use Djot\Node\Inline\Code;
use Djot\Node\Inline\Delete;
use Djot\Node\Inline\EscapedText;
use Djot\Node\Inline\FootnoteRef;
use Djot\Node\Inline\HardBreak;
use Djot\Node\Inline\Image;
Expand Down Expand Up @@ -114,6 +116,7 @@ protected function renderNode(Node $node): string

return match (true) {
$node instanceof Document => $this->renderChildren($node),
$node instanceof Div => $this->renderDiv($node),
$node instanceof Paragraph => $this->renderParagraph($node),
$node instanceof Heading => $this->renderHeading($node),
$node instanceof CodeBlock => $this->renderCodeBlock($node),
Expand All @@ -132,6 +135,7 @@ protected function renderNode(Node $node): string
$node instanceof LineBlock => $this->renderLineBlock($node),
$node instanceof Footnote => $this->renderFootnote($node),
$node instanceof Text => $node->getContent(),
$node instanceof EscapedText => $node->getContent(),
$node instanceof Code => $node->getContent(),
$node instanceof Math => $node->getContent(),
$node instanceof Image => $node->getAlt(),
Expand Down Expand Up @@ -161,6 +165,19 @@ protected function renderParagraph(Paragraph $node): string
return $this->renderChildren($node) . "\n\n";
}

protected function renderDiv(Div $node): string
{
$body = $this->renderChildren($node);
// A Div's quoted title (e.g. an admonition title carried as the `title`
// attribute) is preserved as a leading line instead of being dropped.
$title = $node->getAttribute('title');
if (is_string($title) && $title !== '') {
return $title . "\n\n" . $body;
}

return $body;
}

protected function renderHeading(Heading $node): string
{
return $this->renderChildren($node) . "\n\n";
Expand Down
54 changes: 54 additions & 0 deletions tests/TestCase/Renderer/RendererContentLossTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace Djot\Test\TestCase\Renderer;

use Djot\DjotConverter;
use PHPUnit\Framework\TestCase;

/**
* Renderer content loss: the non-HTML renderers (markdown/plain/ansi) must not
* silently drop content the HTML renderer keeps.
*
* Ported from carve-php commit 217b72f.
*/
class RendererContentLossTest extends TestCase
{
public function testEscapedTextIsNotDroppedInNonHtmlRenderers(): void
{
$md = DjotConverter::markdown()->convert('a \*lit\* b');
$this->assertStringContainsString('lit', $md);
$this->assertStringContainsString('*', $md);

$plain = DjotConverter::plainText()->convert('a \*lit\* b');
$this->assertStringContainsString('*lit*', $plain);

$ansi = DjotConverter::ansi()->convert('a \*lit\* b');
$this->assertStringContainsString('*lit*', $ansi);
}

public function testAbbreviationTitlePreservedInMarkdown(): void
{
$md = DjotConverter::markdown()->convert("The HTML spec.\n\n*[HTML]: HyperText Markup Language");
$this->assertStringContainsString('HyperText Markup Language', $md);
}

public function testFigureCaptionNotGluedInMarkdown(): void
{
$md = DjotConverter::markdown()->convert("![a](i.png)\n^ Cap text");
// caption sits on its own line, not glued to the image
$this->assertStringNotContainsString('i.png)Cap', $md);
$this->assertStringContainsString('Cap text', $md);
}

public function testDivTitlePreservedInNonHtmlRenderers(): void
{
// A Div carries a `title` attribute via Djot's attribute syntax; the
// non-HTML renderers must surface it as a leading line, not drop it.
$src = "{title=\"Heads up\"}\n:::\nbody\n:::";
$this->assertStringContainsString('Heads up', DjotConverter::markdown()->convert($src));
$this->assertStringContainsString('Heads up', DjotConverter::plainText()->convert($src));
$this->assertStringContainsString('Heads up', DjotConverter::ansi()->convert($src));
}
}
Loading