diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 4be085daa39c..4b81d3c4d45d 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -26,6 +26,7 @@ use Illuminate\Queue\Console\WorkCommand; use Illuminate\Queue\Queue; use Illuminate\Support\Carbon; +use Illuminate\Support\EncodedHtmlString; use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\ParallelTesting; use Illuminate\Support\Once; @@ -171,6 +172,7 @@ protected function tearDownTheTestEnvironment(): void Component::forgetFactory(); ConvertEmptyStringsToNull::flushState(); Factory::flushState(); + EncodedHtmlString::flushState(); EncryptCookies::flushState(); HandleExceptions::flushState(); Migrator::withoutMigrations([]); diff --git a/src/Illuminate/Mail/Mailable.php b/src/Illuminate/Mail/Mailable.php index f517b803dce6..1896dc0b0252 100644 --- a/src/Illuminate/Mail/Mailable.php +++ b/src/Illuminate/Mail/Mailable.php @@ -13,6 +13,7 @@ use Illuminate\Contracts\Support\Renderable; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Support\Collection; +use Illuminate\Support\EncodedHtmlString; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; @@ -1371,7 +1372,7 @@ public function assertHasSubject($subject) */ public function assertSeeInHtml($string, $escape = true) { - $string = $escape ? e($string) : $string; + $string = $escape ? EncodedHtmlString::convert($string, withQuote: isset($this->markdown)) : $string; [$html, $text] = $this->renderForAssertions(); @@ -1393,7 +1394,7 @@ public function assertSeeInHtml($string, $escape = true) */ public function assertDontSeeInHtml($string, $escape = true) { - $string = $escape ? e($string) : $string; + $string = $escape ? EncodedHtmlString::convert($string, withQuote: isset($this->markdown)) : $string; [$html, $text] = $this->renderForAssertions(); @@ -1415,7 +1416,9 @@ public function assertDontSeeInHtml($string, $escape = true) */ public function assertSeeInOrderInHtml($strings, $escape = true) { - $strings = $escape ? array_map('e', $strings) : $strings; + $strings = $escape ? array_map(function ($string) { + return EncodedHtmlString::convert($string, withQuote: isset($this->markdown)); + }, $strings) : $strings; [$html, $text] = $this->renderForAssertions(); diff --git a/src/Illuminate/Mail/Markdown.php b/src/Illuminate/Mail/Markdown.php index 8faf739eb393..e8ec2defc4c4 100644 --- a/src/Illuminate/Mail/Markdown.php +++ b/src/Illuminate/Mail/Markdown.php @@ -3,6 +3,7 @@ namespace Illuminate\Mail; use Illuminate\Contracts\View\Factory as ViewFactory; +use Illuminate\Support\EncodedHtmlString; use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use League\CommonMark\Environment\Environment; @@ -60,9 +61,19 @@ public function render($view, array $data = [], $inliner = null) { $this->view->flushFinderCache(); - $contents = $this->view->replaceNamespace( - 'mail', $this->htmlComponentPaths() - )->make($view, $data)->render(); + $bladeCompiler = $this->view + ->getEngineResolver() + ->resolve('blade') + ->getCompiler(); + + $contents = $bladeCompiler->usingEchoFormat( + 'new \Illuminate\Support\EncodedHtmlString(%s)', + function () use ($view, $data) { + return $this->view->replaceNamespace( + 'mail', $this->htmlComponentPaths() + )->make($view, $data)->render(); + } + ); if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) { $theme = $customTheme; @@ -105,16 +116,48 @@ public function renderText($view, array $data = []) */ public static function parse($text) { - $environment = new Environment([ + EncodedHtmlString::encodeUsing(function ($value) { + $replacements = [ + '[' => '\[', + '<' => '\<', + ]; + + $html = str_replace(array_keys($replacements), array_values($replacements), $value); + + return static::converter([ + 'html_input' => 'escape', + ])->convert($html)->getContent(); + }); + + $html = ''; + + try { + $html = static::converter()->convert($text)->getContent(); + } finally { + EncodedHtmlString::flushState(); + } + + return new HtmlString($html); + } + + /** + * Get a Markdown converter instance. + * + * @internal + * + * @param array $config + * @return \League\CommonMark\MarkdownConverter + */ + public static function converter(array $config = []) + { + $environment = new Environment(array_merge([ 'allow_unsafe_links' => false, - ]); + ], $config)); $environment->addExtension(new CommonMarkCoreExtension); $environment->addExtension(new TableExtension); - $converter = new MarkdownConverter($environment); - - return new HtmlString($converter->convert($text)->getContent()); + return new MarkdownConverter($environment); } /** diff --git a/src/Illuminate/Support/EncodedHtmlString.php b/src/Illuminate/Support/EncodedHtmlString.php new file mode 100644 index 000000000000..18928e75b633 --- /dev/null +++ b/src/Illuminate/Support/EncodedHtmlString.php @@ -0,0 +1,76 @@ +html, $this->doubleEncode); + } + + /** + * Set the callable that will be used to encode the HTML strings. + * + * @param callable|null $factory + * @return void + */ + public static function encodeUsing(?callable $factory = null) + { + static::$encodeUsingFactory = $factory; + } + + /** + * Flush the class's global state. + * + * @return void + */ + public static function flushState() + { + static::$encodeUsingFactory = null; + } +} diff --git a/src/Illuminate/View/Compilers/BladeCompiler.php b/src/Illuminate/View/Compilers/BladeCompiler.php index bd739b90a5d9..ca46c5a4158d 100644 --- a/src/Illuminate/View/Compilers/BladeCompiler.php +++ b/src/Illuminate/View/Compilers/BladeCompiler.php @@ -1003,6 +1003,28 @@ public function precompiler(callable $precompiler) $this->precompilers[] = $precompiler; } + /** + * Execute the given callback using a custom echo format. + * + * @param string $format + * @param callable $callback + * @return string + */ + public function usingEchoFormat($format, callable $callback) + { + $originalEchoFormat = $this->echoFormat; + + $this->setEchoFormat($format); + + try { + $output = call_user_func($callback); + } finally { + $this->setEchoFormat($originalEchoFormat); + } + + return $output; + } + /** * Set the echo format to be used by the compiler. * diff --git a/tests/Integration/Mail/MailableTest.php b/tests/Integration/Mail/MailableTest.php new file mode 100644 index 000000000000..339ebb2422d7 --- /dev/null +++ b/tests/Integration/Mail/MailableTest.php @@ -0,0 +1,72 @@ +addLocation(__DIR__.'/Fixtures'); + } + + #[DataProvider('markdownEncodedDataProvider')] + public function testItCanAssertMarkdownEncodedString($given, $expected) + { + $mailable = new class($given) extends Mailable + { + public function __construct(public string $message) + { + // + } + + public function envelope() + { + return new Envelope( + subject: 'My basic title', + ); + } + + public function content() + { + return new Content( + markdown: 'message', + ); + } + }; + + $mailable->assertSeeInHtml($expected, false); + } + + public static function markdownEncodedDataProvider() + { + yield ['[Laravel](https://laravel.com)', 'My message is: [Laravel](https://laravel.com)']; + + yield [ + '![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)', + 'My message is: ![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)', + ]; + + yield [ + 'Visit https://laravel.com/docs to browse the documentation', + 'My message is: Visit https://laravel.com/docs to browse the documentation', + ]; + + yield [ + 'Visit to browse the documentation', + 'My message is: Visit <https://laravel.com/docs> to browse the documentation', + ]; + + yield [ + 'Visit https://laravel.com/docs to browse the documentation', + 'My message is: Visit <span>https://laravel.com/docs</span> to browse the documentation', + ]; + } +} diff --git a/tests/Integration/Mail/MarkdownParserTest.php b/tests/Integration/Mail/MarkdownParserTest.php new file mode 100644 index 000000000000..d21602c9ad00 --- /dev/null +++ b/tests/Integration/Mail/MarkdownParserTest.php @@ -0,0 +1,74 @@ +assertInstanceOf(HtmlString::class, $html); + + $this->assertStringEqualsStringIgnoringLineEndings($expected.PHP_EOL, (string) $html); + $this->assertSame((string) $html, (string) $html->toHtml()); + }); + } + + #[DataProvider('markdownEncodedDataProvider')] + public function testItCanParseMarkdownEncodedString($given, $expected) + { + tap(Markdown::parse($given), function ($html) use ($expected) { + $this->assertInstanceOf(HtmlString::class, $html); + + $this->assertStringEqualsStringIgnoringLineEndings($expected.PHP_EOL, (string) $html); + }); + } + + public static function markdownDataProvider() + { + yield ['[Laravel](https://laravel.com)', '

Laravel

']; + yield ['\[Laravel](https://laravel.com)', '

[Laravel](https://laravel.com)

']; + yield ['![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)', '

Welcome to Laravel

']; + yield ['!\[Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)', '

![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)

']; + yield ['Visit https://laravel.com/docs to browse the documentation', '

Visit https://laravel.com/docs to browse the documentation

']; + yield ['Visit to browse the documentation', '

Visit https://laravel.com/docs to browse the documentation

']; + yield ['Visit https://laravel.com/docs to browse the documentation', '

Visit https://laravel.com/docs to browse the documentation

']; + } + + public static function markdownEncodedDataProvider() + { + yield [new EncodedHtmlString('[Laravel](https://laravel.com)'), '

[Laravel](https://laravel.com)

']; + + yield [ + new EncodedHtmlString('![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)'), + '

![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)

', + ]; + + yield [ + new EncodedHtmlString('Visit https://laravel.com/docs to browse the documentation'), + '

Visit https://laravel.com/docs to browse the documentation

', + ]; + + yield [ + new EncodedHtmlString('Visit to browse the documentation'), + '

Visit <https://laravel.com/docs> to browse the documentation

', + ]; + + yield [ + new EncodedHtmlString('Visit https://laravel.com/docs to browse the documentation'), + '

Visit <span>https://laravel.com/docs</span> to browse the documentation

', + ]; + + yield [ + '![Welcome to Laravel](https://laravel.com/assets/img/welcome/background.svg)
'.new EncodedHtmlString('Visit https://laravel.com/docs to browse the documentation'), + '

Welcome to Laravel
Visit <span>https://laravel.com/docs</span> to browse the documentation

', + ]; + } +} diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php index 88ebf67893b3..cc4137d12dcd 100644 --- a/tests/Mail/MailMarkdownTest.php +++ b/tests/Mail/MailMarkdownTest.php @@ -3,6 +3,8 @@ namespace Illuminate\Tests\Mail; use Illuminate\Mail\Markdown; +use Illuminate\View\Compilers\BladeCompiler; +use Illuminate\View\Engines\EngineResolver; use Illuminate\View\Factory; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -17,6 +19,14 @@ protected function tearDown(): void public function testRenderFunctionReturnsHtml() { $viewFactory = m::mock(Factory::class); + $engineResolver = m::mock(EngineResolver::class); + $bladeCompiler = m::mock(BladeCompiler::class); + $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver); + $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler); + $bladeCompiler->shouldReceive('usingEchoFormat') + ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure')) + ->andReturnUsing(fn ($echoFormat, $callback) => $callback()); + $markdown = new Markdown($viewFactory); $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); @@ -33,6 +43,14 @@ public function testRenderFunctionReturnsHtml() public function testRenderFunctionReturnsHtmlWithCustomTheme() { $viewFactory = m::mock(Factory::class); + $engineResolver = m::mock(EngineResolver::class); + $bladeCompiler = m::mock(BladeCompiler::class); + $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver); + $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler); + $bladeCompiler->shouldReceive('usingEchoFormat') + ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure')) + ->andReturnUsing(fn ($echoFormat, $callback) => $callback()); + $markdown = new Markdown($viewFactory); $markdown->theme('yaz'); $viewFactory->shouldReceive('flushFinderCache')->once(); @@ -50,6 +68,14 @@ public function testRenderFunctionReturnsHtmlWithCustomTheme() public function testRenderFunctionReturnsHtmlWithCustomThemeWithMailPrefix() { $viewFactory = m::mock(Factory::class); + $engineResolver = m::mock(EngineResolver::class); + $bladeCompiler = m::mock(BladeCompiler::class); + $viewFactory->shouldReceive('getEngineResolver')->andReturn($engineResolver); + $engineResolver->shouldReceive('resolve->getCompiler')->andReturn($bladeCompiler); + $bladeCompiler->shouldReceive('usingEchoFormat') + ->with('new \Illuminate\Support\EncodedHtmlString(%s)', m::type('Closure')) + ->andReturnUsing(fn ($echoFormat, $callback) => $callback()); + $markdown = new Markdown($viewFactory); $markdown->theme('mail.yaz'); $viewFactory->shouldReceive('flushFinderCache')->once();