From 9322cb99cdf9038693936e574591eff6c1e2802e Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Tue, 2 Dec 2025 12:43:16 +0530 Subject: [PATCH] Add dark mode support for email templates and extract media queries --- src/Illuminate/Mail/Markdown.php | 89 ++++++++++-- .../resources/views/html/layout.blade.php | 6 +- .../resources/views/html/themes/default.css | 91 ++++++++++++ tests/Mail/MailMarkdownTest.php | 130 +++++++++++++++++- 4 files changed, 296 insertions(+), 20 deletions(-) diff --git a/src/Illuminate/Mail/Markdown.php b/src/Illuminate/Mail/Markdown.php index d99a232b7f0b..d200160aee17 100644 --- a/src/Illuminate/Mail/Markdown.php +++ b/src/Illuminate/Mail/Markdown.php @@ -42,6 +42,13 @@ class Markdown */ protected static $withSecuredEncoding = false; + /** + * The extracted head styles (media queries) from the theme. + * + * @var string + */ + protected static $headStyles = ''; + /** * Create a new Markdown renderer instance. * @@ -67,6 +74,20 @@ public function render($view, array $data = [], $inliner = null) { $this->view->flushFinderCache(); + $this->view->replaceNamespace('mail', $this->htmlComponentPaths()); + + if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) { + $theme = $customTheme; + } else { + $theme = str_contains($this->theme, '::') + ? $this->theme + : 'mail::themes.'.$this->theme; + } + + $themeCss = $this->view->make($theme, $data)->render(); + + [$inlineCss, static::$headStyles] = $this->extractMediaQueries($themeCss); + $bladeCompiler = $this->view ->getEngineResolver() ->resolve('blade') @@ -88,9 +109,7 @@ function () use ($view, $data) { } try { - $contents = $this->view->replaceNamespace( - 'mail', $this->htmlComponentPaths() - )->make($view, $data)->render(); + $contents = $this->view->make($view, $data)->render(); } finally { EncodedHtmlString::flushState(); } @@ -99,16 +118,8 @@ function () use ($view, $data) { } ); - if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) { - $theme = $customTheme; - } else { - $theme = str_contains($this->theme, '::') - ? $this->theme - : 'mail::themes.'.$this->theme; - } - return new HtmlString(($inliner ?: new CssToInlineStyles)->convert( - str_replace('\[', '[', $contents), $this->view->make($theme, $data)->render() + str_replace('\[', '[', $contents), $inlineCss )); } @@ -289,5 +300,59 @@ public static function withoutSecuredEncoding() public static function flushState() { static::$withSecuredEncoding = false; + static::$headStyles = ''; + } + + /** + * Extract media queries from CSS that cannot be inlined. + * + * @param string $css + * @return array{0: string, 1: string} + */ + protected function extractMediaQueries($css) + { + $mediaBlocks = ''; + $inlineCss = ''; + $offset = 0; + $length = strlen($css); + + while (($pos = strpos($css, '@media', $offset)) !== false) { + $inlineCss .= substr($css, $offset, $pos - $offset); + + $open = strpos($css, '{', $pos); + + if ($open === false) { + break; + } + + $braceCount = 1; + $i = $open + 1; + + while ($i < $length && $braceCount > 0) { + if ($css[$i] === '{') { + $braceCount++; + } elseif ($css[$i] === '}') { + $braceCount--; + } + $i++; + } + + $mediaBlocks .= substr($css, $pos, $i - $pos)."\n"; + $offset = $i; + } + + $inlineCss .= substr($css, $offset); + + return [$inlineCss, $mediaBlocks]; + } + + /** + * Get the extracted head styles (media queries) from the theme. + * + * @return string + */ + public static function getHeadStyles() + { + return static::$headStyles; } } diff --git a/src/Illuminate/Mail/resources/views/html/layout.blade.php b/src/Illuminate/Mail/resources/views/html/layout.blade.php index 0fa6b82f72b2..3217f0db49f5 100644 --- a/src/Illuminate/Mail/resources/views/html/layout.blade.php +++ b/src/Illuminate/Mail/resources/views/html/layout.blade.php @@ -4,8 +4,8 @@ {{ config('app.name') }} - - + + {!! $head ?? '' !!} diff --git a/src/Illuminate/Mail/resources/views/html/themes/default.css b/src/Illuminate/Mail/resources/views/html/themes/default.css index 80465b220e9e..e1431df65b62 100644 --- a/src/Illuminate/Mail/resources/views/html/themes/default.css +++ b/src/Illuminate/Mail/resources/views/html/themes/default.css @@ -295,3 +295,94 @@ img { .break-all { word-break: break-all; } + +/* Dark Mode */ + +@media (prefers-color-scheme: dark) { + body, + .wrapper, + .body { + background-color: #18181b !important; + } + + .inner-body { + background-color: #27272a !important; + border-color: #3f3f46 !important; + } + + p, + ul, + ol, + blockquote, + span, + td { + color: #e4e4e7 !important; + } + + a { + color: #a5b4fc !important; + } + + h1, + h2, + h3, + .header a { + color: #fafafa !important; + } + + .logo { + filter: invert(23%) sepia(5%) saturate(531%) hue-rotate(202deg) brightness(96%) contrast(91%) !important; + } + + .button-primary, + .button-blue { + background-color: #fafafa !important; + border-color: #fafafa !important; + color: #18181b !important; + } + + .button-secondary { + background-color: #3f3f46 !important; + border-color: #3f3f46 !important; + color: #fafafa !important; + } + + .button-success, + .button-green { + background-color: #22c55e !important; + border-color: #22c55e !important; + color: #fff !important; + } + + .button-error, + .button-red { + background-color: #ef4444 !important; + border-color: #ef4444 !important; + color: #fff !important; + } + + .footer p, + .footer a { + color: #71717a !important; + } + + .panel { + border-left-color: #d4d4d8 !important; + } + + .panel-content { + background-color: #3f3f46 !important; + } + + .panel-content p { + color: #e4e4e7 !important; + } + + .subcopy { + border-top-color: #3f3f46 !important; + } + + .table th { + border-bottom-color: #3f3f46 !important; + } +} diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php index ed96b332da89..0a754a33c6f9 100644 --- a/tests/Mail/MailMarkdownTest.php +++ b/tests/Mail/MailMarkdownTest.php @@ -14,6 +14,7 @@ class MailMarkdownTest extends TestCase protected function tearDown(): void { m::close(); + Markdown::flushState(); } public function testRenderFunctionReturnsHtml(): void @@ -31,9 +32,9 @@ public function testRenderFunctionReturnsHtml(): void $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); - $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); - $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}'); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', ''); $result = $markdown->render('view', []); @@ -56,9 +57,9 @@ public function testRenderFunctionReturnsHtmlWithCustomTheme(): void $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); $viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true); - $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); $viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf(); - $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}'); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', ''); $result = $markdown->render('view', []); @@ -81,9 +82,9 @@ public function testRenderFunctionReturnsHtmlWithCustomThemeWithMailPrefix(): vo $viewFactory->shouldReceive('flushFinderCache')->once(); $viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->htmlComponentPaths())->andReturnSelf(); $viewFactory->shouldReceive('exists')->with('mail.yaz')->andReturn(true); - $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); $viewFactory->shouldReceive('make')->with('mail.yaz', [])->andReturnSelf(); - $viewFactory->shouldReceive('render')->twice()->andReturn('', 'body {}'); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + $viewFactory->shouldReceive('render')->twice()->andReturn('body {}', ''); $result = $markdown->render('view', []); @@ -113,4 +114,121 @@ public function testParseReturnsParsedMarkdown(): void $this->assertSame("

Something

\n", $result); } + + public function testMediaQueriesAreExtractedFromThemeCss(): void + { + $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(); + $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); + $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + + $cssWithMedia = 'body { color: #000; } @media (prefers-color-scheme: dark) { body { color: #fff; } } .footer { color: gray; }'; + $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, ''); + + $markdown->render('view', []); + + $headStyles = Markdown::getHeadStyles(); + + $this->assertStringContainsString('@media (prefers-color-scheme: dark)', $headStyles); + $this->assertStringContainsString('body { color: #fff; }', $headStyles); + } + + public function testMediaQueriesAreNotInlinedIntoHtml(): void + { + $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(); + $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); + $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + + $cssWithMedia = 'body { color: #000; } @media (prefers-color-scheme: dark) { body { color: #fff; } }'; + $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, ''); + + $result = $markdown->render('view', []); + + $this->assertStringContainsString('color: #000', $result); + $this->assertStringNotContainsString('@media', $result); + } + + public function testFlushStateClearsHeadStyles(): void + { + $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(); + $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); + $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + + $cssWithMedia = '@media (prefers-color-scheme: dark) { body { color: #fff; } }'; + $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, ''); + + $markdown->render('view', []); + + $this->assertNotEmpty(Markdown::getHeadStyles()); + + Markdown::flushState(); + + $this->assertEmpty(Markdown::getHeadStyles()); + } + + public function testNestedMediaQueriesAreExtractedCorrectly(): void + { + $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(); + $viewFactory->shouldReceive('exists')->with('mail.default')->andReturn(false); + $viewFactory->shouldReceive('make')->with('mail::themes.default', [])->andReturnSelf(); + $viewFactory->shouldReceive('make')->with('view', [])->andReturnSelf(); + + $cssWithMedia = '.base { color: red; } @media (max-width: 600px) { .inner { width: 100%; } } @media (prefers-color-scheme: dark) { body { background: #000; } .panel { background: #333; } } .end { margin: 0; }'; + $viewFactory->shouldReceive('render')->twice()->andReturn($cssWithMedia, ''); + + $markdown->render('view', []); + + $headStyles = Markdown::getHeadStyles(); + + $this->assertStringContainsString('@media (max-width: 600px)', $headStyles); + $this->assertStringContainsString('@media (prefers-color-scheme: dark)', $headStyles); + $this->assertStringContainsString('.inner { width: 100%; }', $headStyles); + $this->assertStringContainsString('body { background: #000; }', $headStyles); + } }