diff --git a/src/CommonMark/Extensions/Highlighter/CodeRenderer.php b/src/CommonMark/Extensions/Highlighter/CodeRenderer.php new file mode 100644 index 000000000..9d65ff036 --- /dev/null +++ b/src/CommonMark/Extensions/Highlighter/CodeRenderer.php @@ -0,0 +1,49 @@ +parseEncodedHtml($node); + + $attrs = $node->data->get('attributes'); + + return new HtmlElement('code', $attrs, Xml::escape($node->getLiteral())); + } + + public function getXmlTagName(Node $node): string + { + return 'code'; + } + + /** + * {@inheritDoc} + */ + public function getXmlAttributes(Node $node): array + { + return []; + } +} diff --git a/src/CommonMark/Extensions/Highlighter/Concerns/DecodesHtmlEntities.php b/src/CommonMark/Extensions/Highlighter/Concerns/DecodesHtmlEntities.php new file mode 100644 index 000000000..71de37c72 --- /dev/null +++ b/src/CommonMark/Extensions/Highlighter/Concerns/DecodesHtmlEntities.php @@ -0,0 +1,31 @@ +getLiteral(); + $hasEncodedHtml = preg_match_all('/&(\w+|\d+);/', $content, $matches); + if ($hasEncodedHtml === false || $hasEncodedHtml === 0) { + return $node; + } + + $entitiesToUpdate = []; + foreach (array_unique($matches[0]) as $element) { + $entitiesToUpdate[$element] = Html5EntityDecoder::decode($element); + } + + $content = str_replace(array_keys($entitiesToUpdate), $entitiesToUpdate, $content); + + $node->setLiteral($content); + + return $node; + } +} diff --git a/src/CommonMark/Extensions/Highlighter/FencedCodeRenderer.php b/src/CommonMark/Extensions/Highlighter/FencedCodeRenderer.php index 65c96e2c6..ce6c10f49 100644 --- a/src/CommonMark/Extensions/Highlighter/FencedCodeRenderer.php +++ b/src/CommonMark/Extensions/Highlighter/FencedCodeRenderer.php @@ -4,6 +4,7 @@ namespace ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter; +use ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter\Concerns\DecodesHtmlEntities; use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode; use League\CommonMark\Extension\CommonMark\Renderer\Block\FencedCodeRenderer as BaseFencedCodeRenderer; use League\CommonMark\Node\Node; @@ -15,6 +16,8 @@ final class FencedCodeRenderer implements NodeRendererInterface, XmlNodeRendererInterface { + use DecodesHtmlEntities; + /** @var \ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter\CodeBlockHighlighter */ private $highlighter; @@ -29,7 +32,10 @@ public function __construct() public function render(Node $node, ChildNodeRendererInterface $childRenderer): \Stringable { - $element = $this->baseRenderer->render($node, $childRenderer); + $element = $this->baseRenderer->render( + $this->parseEncodedHtml($node), + $childRenderer + ); $this->configureLineNumbers($element); @@ -81,13 +87,13 @@ private function configureLineNumbers(HtmlElement $element): void } } - private function getSpecifiedLanguage(FencedCode $block): ?string + private function getSpecifiedLanguage(FencedCode $block): string { $infoWords = $block->getInfoWords(); /* @phpstan-ignore-next-line */ if (empty($infoWords) || empty($infoWords[0])) { - return null; + return 'plaintext'; } return Xml::escape($infoWords[0]); diff --git a/src/Providers/CommonMarkServiceProvider.php b/src/Providers/CommonMarkServiceProvider.php index c7a9055f6..347d5853c 100644 --- a/src/Providers/CommonMarkServiceProvider.php +++ b/src/Providers/CommonMarkServiceProvider.php @@ -5,6 +5,7 @@ namespace ARKEcosystem\Foundation\Providers; use ARKEcosystem\Foundation\CommonMark\Extensions\HeadingPermalink\HeadingPermalinkRenderer; +use ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter\CodeRenderer; use ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter\FencedCodeRenderer; use ARKEcosystem\Foundation\CommonMark\Extensions\Highlighter\IndentedCodeRenderer; use ARKEcosystem\Foundation\CommonMark\Extensions\Image\ImageRenderer; @@ -22,6 +23,7 @@ use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock; use League\CommonMark\Extension\CommonMark\Node\Block\ListItem; use League\CommonMark\Extension\CommonMark\Node\Block\ThematicBreak; +use League\CommonMark\Extension\CommonMark\Node\Inline\Code; use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis; use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline; use League\CommonMark\Extension\CommonMark\Node\Inline\Image; @@ -175,7 +177,7 @@ private function registerCommonMarkEnvironment(): void $environment->addRenderer(Paragraph::class, new ParagraphRenderer(), 0); $environment->addRenderer(ThematicBreak::class, new ThematicBreakRenderer(), 0); - // $environment->addRenderer(Code::class, new CodeRenderer(), 0); + $environment->addRenderer(Code::class, new CodeRenderer(), 0); $environment->addRenderer(Emphasis::class, new EmphasisRenderer(), 0); $environment->addRenderer(HtmlInline::class, new HtmlInlineRenderer(), 0); $environment->addRenderer(Image::class, new ImageRenderer(), 0); @@ -184,6 +186,7 @@ private function registerCommonMarkEnvironment(): void $environment->addRenderer(Text::class, new TextRenderer(), 0); $inlineRenderers = array_merge([ + Code::class => CodeRenderer::class, Emphasis::class => EmphasisRenderer::class, HtmlInline::class => HtmlInlineRenderer::class, Image::class => ImageRenderer::class, diff --git a/tests/CommonMark/Extensions/Highlighter/CodeRendererTest.php b/tests/CommonMark/Extensions/Highlighter/CodeRendererTest.php new file mode 100644 index 000000000..2b0cd3587 --- /dev/null +++ b/tests/CommonMark/Extensions/Highlighter/CodeRendererTest.php @@ -0,0 +1,63 @@ +inlineCode = new Code(); + $this->renderer = new CodeRenderer(); + + $document = new Document(); + + $documentRenderer = $this->createMock(NodeRendererInterface::class); + $documentRenderer->method('render')->willReturn('::document::'); + + $environment = new Environment(); + $environment->addRenderer(Document::class, $documentRenderer); + $this->htmlRenderer = new HtmlRenderer($environment); +}); + +it('should render', function () { + $this->inlineCode->setLiteral('test'); + + $result = $this->renderer->render($this->inlineCode, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('code'); + expect($result->getContents())->toBe('<span>test</span>'); +}); + +it('should parse encoded html characters', function () { + $this->inlineCode->setLiteral('<span>test</span>'); + + $result = $this->renderer->render($this->inlineCode, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('code'); + expect($result->getContents())->toBe('<span>test</span>'); +}); + +it('should do nothing if no encoded html characters', function () { + $this->inlineCode->setLiteral('this is a test'); + + $result = $this->renderer->render($this->inlineCode, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('code'); + expect($result->getContents())->toBe('this is a test'); +}); + +it('should get an xml tag name', function () { + expect($this->renderer->getXmlTagName($this->inlineCode))->toBe('code'); +}); + +it('should return no xml attributes', function () { + expect($this->renderer->getXmlAttributes($this->inlineCode))->toBe([]); +}); diff --git a/tests/CommonMark/Extensions/Highlighter/FencedCodeRendererTest.php b/tests/CommonMark/Extensions/Highlighter/FencedCodeRendererTest.php new file mode 100644 index 000000000..87860c767 --- /dev/null +++ b/tests/CommonMark/Extensions/Highlighter/FencedCodeRendererTest.php @@ -0,0 +1,132 @@ +block = new FencedCode(3, '`', 0); + $this->renderer = new FencedCodeRenderer(); + + $document = new Document(); + + $documentRenderer = $this->createMock(NodeRendererInterface::class); + $documentRenderer->method('render')->willReturn('::document::'); + + $environment = new Environment(); + $environment->addRenderer(Document::class, $documentRenderer); + $this->htmlRenderer = new HtmlRenderer($environment); +}); + +it('should render', function () { + $this->block->setInfo('blade'); + $this->block->setLiteral('test'); + + $result = $this->renderer->render($this->block, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('div'); + expect($result->getContents(false)->getTagName())->toBe('pre'); + expect($result->getContents())->toContain(''); + expect($result->getContents())->toContain('<span>test</span>'); + expect($result->getAllAttributes())->toBe([ + 'class' => 'p-4 mb-6 rounded-xl bg-theme-secondary-800 overflow-x-auto', + ]); + expect($result->getContents(false)->getAllAttributes())->toBe(['class' => 'hljs']); +}); + +it('should parse encoded html characters', function () { + $this->block->setInfo('html'); + $this->block->setLiteral('<span>test</span>'); + + $result = $this->renderer->render($this->block, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('div'); + expect($result->getContents(false)->getTagName())->toBe('pre'); + expect($result->getContents())->toContain(''); + expect($result->getContents())->toContain('<span>test</span>'); + expect($result->getAllAttributes())->toBe([ + 'class' => 'p-4 mb-6 rounded-xl bg-theme-secondary-800 overflow-x-auto', + ]); + expect($result->getContents(false)->getAllAttributes())->toBe(['class' => 'hljs']); +}); + +it('should do nothing if no encoded html characters', function () { + $this->block->setInfo('html'); + $this->block->setLiteral('this is a test'); + + $result = $this->renderer->render($this->block, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('div'); + expect($result->getContents(false)->getTagName())->toBe('pre'); + expect($result->getContents())->toContain(''); + expect($result->getContents())->toContain('this is a test'); + expect($result->getAllAttributes())->toBe([ + 'class' => 'p-4 mb-6 rounded-xl bg-theme-secondary-800 overflow-x-auto', + ]); + expect($result->getContents(false)->getAllAttributes())->toBe(['class' => 'hljs']); +}); + +it('should handle line numbers', function () { + $this->block->setInfo('plaintext'); + $this->block->setLiteral('test + test + test'); + + $result = $this->renderer->render($this->block, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('div'); + expect($result->getContents(false)->getTagName())->toBe('pre'); + expect($result->getContents())->toContain(''); + expect($result->getContents())->toContain('test + test + test'); + expect($result->getAllAttributes())->toBe([ + 'class' => 'p-4 mb-6 rounded-xl bg-theme-secondary-800 overflow-x-auto', + ]); + expect($result->getContents(false)->getAllAttributes())->toBe(['class' => 'hljs line-numbers']); +}); + +it('should handle no codeblock type', function () { + $this->block->setInfo(''); + $this->block->setLiteral('test + test + test'); + + $result = $this->renderer->render($this->block, $this->htmlRenderer); + + expect($result)->toBeInstanceOf(HtmlElement::class); + expect($result->getTagName())->toBe('div'); + expect($result->getContents(false)->getTagName())->toBe('pre'); + expect($result->getContents())->toContain(''); + expect($result->getContents())->toContain('test + test + test'); + expect($result->getAllAttributes())->toBe([ + 'class' => 'p-4 mb-6 rounded-xl bg-theme-secondary-800 overflow-x-auto', + ]); + expect($result->getContents(false)->getAllAttributes())->toBe(['class' => 'hljs line-numbers']); +}); + +it('should get an xml tag name', function () { + expect($this->renderer->getXmlTagName($this->block))->toBe('code_block'); +}); + +it('should get xml attributes', function () { + $this->block->setInfo('blade'); + + expect($this->renderer->getXmlAttributes($this->block))->toBe(['info' => 'blade']); +}); + +it('should get no xml attributes if no codeblock type', function () { + expect($this->renderer->getXmlAttributes($this->block))->toBe([]); +});