diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php
index ff45b3ccb..058872a6f 100644
--- a/src/mail/src/Mailable.php
+++ b/src/mail/src/Mailable.php
@@ -358,9 +358,10 @@ protected function additionalMessageData(): array
*/
protected function buildMarkdownHtml(array $viewData): Closure
{
- return fn ($data) => $this->markdownRenderer()->render(
+ return fn ($data) => Container::getInstance()->make(Markdown::class)->render(
$this->markdown,
array_merge($data, $viewData),
+ theme: $this->markdownTheme()
);
}
@@ -376,7 +377,7 @@ protected function buildMarkdownText(array $viewData): Closure
]);
}
- return $this->textView ?? $this->markdownRenderer()->renderText(
+ return $this->textView ?? Container::getInstance()->make(Markdown::class)->renderText(
$this->markdown,
array_merge($data, $viewData)
);
@@ -384,18 +385,14 @@ protected function buildMarkdownText(array $viewData): Closure
}
/**
- * Resolves a Markdown instance with the mail's theme.
+ * Resolve the Markdown theme for this message.
*/
- protected function markdownRenderer(): Markdown
+ protected function markdownTheme(): string
{
- return tap(Container::getInstance()->make(Markdown::class), function ($markdown) {
- $markdown->theme(
- $this->theme ?: Container::getInstance()->make('config')->get(
- 'mail.markdown.theme',
- 'default'
- )
- );
- });
+ return $this->theme ?: Container::getInstance()->make('config')->get(
+ 'mail.markdown.theme',
+ 'default'
+ );
}
/**
diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php
index b9129f56f..5edddd980 100644
--- a/src/mail/src/Markdown.php
+++ b/src/mail/src/Markdown.php
@@ -46,20 +46,26 @@ public function __construct(
/**
* Render the Markdown template into HTML.
*/
- public function render(string $view, array $data = [], mixed $inliner = null): HtmlString
+ public function render(string $view, array $data = [], mixed $inliner = null, ?string $theme = null): HtmlString
{
- $this->view->flushFinderCache();
+ $viewFactory = $this->viewFactoryWithMailNamespace($this->htmlComponentPaths());
- $bladeCompiler = $this->view
+ $bladeCompiler = $viewFactory
->getEngineResolver() // @phpstan-ignore method.notFound
->resolve('blade')
->getCompiler();
$contents = $bladeCompiler->usingEchoFormat(
'new \Hypervel\Support\EncodedHtmlString(%s)',
- function () use ($view, $data) {
- if (static::$withSecuredEncoding === true) {
- EncodedHtmlString::encodeUsing(function ($value) {
+ function () use ($viewFactory, $view, $data) {
+ $render = fn () => $viewFactory->make($view, $data)->render();
+
+ if (static::$withSecuredEncoding === false) {
+ return $render();
+ }
+
+ return EncodedHtmlString::withEncoding(
+ function ($value) {
$replacements = [
'[' => '\[',
'<' => '<',
@@ -67,33 +73,25 @@ function () use ($view, $data) {
];
return str_replace(array_keys($replacements), array_values($replacements), $value);
- });
- }
-
- try {
- $contents = $this->view->replaceNamespace(
- 'mail',
- $this->htmlComponentPaths()
- )->make($view, $data)->render();
- } finally {
- EncodedHtmlString::flushState();
- }
-
- return $contents;
+ },
+ $render
+ );
}
);
- if ($this->view->exists($customTheme = Str::start($this->theme, 'mail.'))) {
+ $theme ??= $this->theme;
+
+ if ($viewFactory->exists($customTheme = Str::start($theme, 'mail.'))) {
$theme = $customTheme;
} else {
- $theme = str_contains($this->theme, '::')
- ? $this->theme
- : 'mail::themes.' . $this->theme;
+ $theme = str_contains($theme, '::')
+ ? $theme
+ : 'mail::themes.' . $theme;
}
return new HtmlString(($inliner ?: new CssToInlineStyles)->convert(
str_replace('\[', '[', $contents),
- $this->view->make($theme, $data)->render()
+ $viewFactory->make($theme, $data)->render()
));
}
@@ -102,12 +100,9 @@ function () use ($view, $data) {
*/
public function renderText(string $view, array $data = []): HtmlString
{
- $this->view->flushFinderCache();
-
- $contents = $this->view->replaceNamespace(
- 'mail',
- $this->textComponentPaths()
- )->make($view, $data)->render();
+ $contents = $this->viewFactoryWithMailNamespace($this->textComponentPaths())
+ ->make($view, $data)
+ ->render();
return new HtmlString(
html_entity_decode(preg_replace("/[\r\n]{2,}/", "\n\n", $contents), ENT_QUOTES, 'UTF-8')
@@ -123,28 +118,21 @@ public static function parse(Stringable|string $text, bool $encoded = false): Ht
return new HtmlString(static::converter()->convert((string) $text)->getContent());
}
- EncodedHtmlString::encodeUsing(function ($value) {
- $replacements = [
- '[' => '\[',
- '<' => '\<',
- ];
+ return new HtmlString(EncodedHtmlString::withEncoding(
+ function ($value) {
+ $replacements = [
+ '[' => '\[',
+ '<' => '\<',
+ ];
- $html = str_replace(array_keys($replacements), array_values($replacements), $value);
+ $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((string) $text)->getContent();
- } finally {
- EncodedHtmlString::flushState();
- }
-
- return new HtmlString($html);
+ return static::converter([
+ 'html_input' => 'escape',
+ ])->convert($html)->getContent();
+ },
+ fn () => static::converter()->convert((string) $text)->getContent()
+ ));
}
/**
@@ -186,6 +174,20 @@ public function textComponentPaths(): array
}, $this->componentPaths());
}
+ /**
+ * Clone the view factory with render-local mail component paths.
+ */
+ protected function viewFactoryWithMailNamespace(array $paths): ViewFactory
+ {
+ // Markdown swaps mail:: between HTML and text component paths while
+ // rendering. Keep that namespace change on a short-lived clone so the
+ // singleton view finder stays untouched for concurrent coroutines.
+ $viewFactory = clone $this->view;
+ $viewFactory->replaceNamespace('mail', $paths);
+
+ return $viewFactory;
+ }
+
/**
* Get the component paths.
*/
@@ -198,6 +200,9 @@ protected function componentPaths(): array
/**
* Register new mail component paths.
+ *
+ * Boot-only. Mutates the shared Markdown renderer's component paths; use
+ * scoped view namespaces for per-render component path changes.
*/
public function loadComponentsFrom(array $paths = []): void
{
@@ -206,6 +211,9 @@ public function loadComponentsFrom(array $paths = []): void
/**
* Set the default theme to be used.
+ *
+ * Boot-only. Mutates the shared Markdown renderer's default theme; pass a
+ * per-render theme to render() for message-specific themes.
*/
public function theme(string $theme): static
{
@@ -246,6 +254,9 @@ public static function withoutSecuredEncoding(): void
/**
* Flush the class's global state.
+ *
+ * Boot or tests only. Clears worker-wide Markdown flags; concurrent renders
+ * may switch encoding behavior depending on timing.
*/
public static function flushState(): void
{
diff --git a/src/notifications/src/Channels/MailChannel.php b/src/notifications/src/Channels/MailChannel.php
index 38a6739a8..8786a2e69 100644
--- a/src/notifications/src/Channels/MailChannel.php
+++ b/src/notifications/src/Channels/MailChannel.php
@@ -85,9 +85,10 @@ protected function buildView(MailMessage $message): array|string
*/
protected function buildMarkdownHtml(MailMessage $message): Closure
{
- return fn ($data) => $this->markdownRenderer($message)->render(
+ return fn ($data) => $this->markdown->render(
$message->markdown,
array_merge($data, $message->data()),
+ theme: $this->markdownTheme($message)
);
}
@@ -96,23 +97,21 @@ protected function buildMarkdownHtml(MailMessage $message): Closure
*/
protected function buildMarkdownText(MailMessage $message): Closure
{
- return fn ($data) => $this->markdownRenderer($message)->renderText(
+ return fn ($data) => $this->markdown->renderText(
$message->markdown,
array_merge($data, $message->data()),
);
}
/**
- * Get the Markdown implementation.
+ * Resolve the Markdown theme for the notification.
*/
- protected function markdownRenderer(MailMessage $message): Markdown
+ protected function markdownTheme(MailMessage $message): string
{
$config = Container::getInstance()
->make('config');
- $theme = $message->theme ?? $config->get('mail.markdown.theme', 'default');
-
- return $this->markdown->theme($theme);
+ return $message->theme ?? $config->get('mail.markdown.theme', 'default');
}
/**
diff --git a/src/notifications/src/Messages/MailMessage.php b/src/notifications/src/Messages/MailMessage.php
index a6caa7d7b..8e683336f 100644
--- a/src/notifications/src/Messages/MailMessage.php
+++ b/src/notifications/src/Messages/MailMessage.php
@@ -317,8 +317,11 @@ public function render(): string
$markdown = Container::getInstance()
->make(Markdown::class);
- return (string) $markdown->theme($this->theme ?: $markdown->getTheme())
- ->render($this->markdown, $this->data());
+ return (string) $markdown->render(
+ $this->markdown,
+ $this->data(),
+ theme: $this->theme ?: $markdown->getTheme()
+ );
}
/**
diff --git a/src/support/src/EncodedHtmlString.php b/src/support/src/EncodedHtmlString.php
index bcd80346a..ed14bba94 100644
--- a/src/support/src/EncodedHtmlString.php
+++ b/src/support/src/EncodedHtmlString.php
@@ -6,12 +6,18 @@
use BackedEnum;
use Closure;
+use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Support\DeferringDisplayableValue;
use Hypervel\Contracts\Support\Htmlable;
use Override;
class EncodedHtmlString extends HtmlString
{
+ /**
+ * Context key for temporarily scoped encoder callbacks.
+ */
+ protected const ENCODER_CONTEXT_KEY = '__support.encoded_html.encoder';
+
/**
* The callback that should be used to encode the HTML strings.
*/
@@ -57,7 +63,9 @@ public function toHtml(): string
$value = $value->value;
}
- return (static::$encodeUsingFactory ?? function ($value, $doubleEncode) {
+ $factory = CoroutineContext::get(self::ENCODER_CONTEXT_KEY) ?? static::$encodeUsingFactory;
+
+ return ($factory ?? function ($value, $doubleEncode) {
return static::convert($value, doubleEncode: $doubleEncode);
})($value, $this->doubleEncode);
}
@@ -73,6 +81,29 @@ public static function encodeUsing(?callable $factory = null): void
static::$encodeUsingFactory = $factory;
}
+ /**
+ * Execute the given callback using a temporary encoder.
+ */
+ public static function withEncoding(callable $factory, callable $callback): mixed
+ {
+ // Preserve an outer scoped encoder so nested Markdown renders restore
+ // the previous coroutine-local state instead of falling back globally.
+ $hadPreviousFactory = CoroutineContext::has(self::ENCODER_CONTEXT_KEY);
+ $previousFactory = CoroutineContext::get(self::ENCODER_CONTEXT_KEY);
+
+ CoroutineContext::set(self::ENCODER_CONTEXT_KEY, $factory);
+
+ try {
+ return $callback();
+ } finally {
+ if ($hadPreviousFactory) {
+ CoroutineContext::set(self::ENCODER_CONTEXT_KEY, $previousFactory);
+ } else {
+ CoroutineContext::forget(self::ENCODER_CONTEXT_KEY);
+ }
+ }
+ }
+
/**
* Flush the class's global state.
*
@@ -82,5 +113,6 @@ public static function encodeUsing(?callable $factory = null): void
public static function flushState(): void
{
static::$encodeUsingFactory = null;
+ CoroutineContext::forget(self::ENCODER_CONTEXT_KEY);
}
}
diff --git a/src/view/src/Compilers/ComponentTagCompiler.php b/src/view/src/Compilers/ComponentTagCompiler.php
index 5bb79c5d2..3f342b73a 100644
--- a/src/view/src/Compilers/ComponentTagCompiler.php
+++ b/src/view/src/Compilers/ComponentTagCompiler.php
@@ -235,8 +235,10 @@ protected function componentString(string $component, array $attributes): string
// component and pass the component as a view parameter to the data so it
// can be accessed within the component and we can render out the view.
if (! class_exists($class)) {
+ // Markdown renders mail components through cloned view factories, so
+ // mail:: tags must resolve through the current $__env, not the singleton.
$view = Str::startsWith($component, 'mail::')
- ? "\$__env->getContainer()->make(Hypervel\\View\\Factory::class)->make('{$component}')"
+ ? "\$__env->make('{$component}')"
: "'{$class}'";
$parameters = [
diff --git a/src/view/src/Factory.php b/src/view/src/Factory.php
index 85af40613..92b73513d 100755
--- a/src/view/src/Factory.php
+++ b/src/view/src/Factory.php
@@ -94,6 +94,17 @@ public function __construct(
$this->share('__env', $this);
}
+ /**
+ * Clone the factory for isolated render-time namespace changes.
+ */
+ public function __clone(): void
+ {
+ // Cloned factories may change namespace hints for one render; the
+ // singleton finder and Blade's $__env shared binding must stay isolated.
+ $this->finder = clone $this->finder;
+ $this->shared['__env'] = $this;
+ }
+
/**
* Get the evaluated view contents for the given view.
*/
diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php
index cbef1a7e1..6e8ffbd24 100644
--- a/tests/Integration/Mail/SendingMarkdownMailTest.php
+++ b/tests/Integration/Mail/SendingMarkdownMailTest.php
@@ -146,7 +146,7 @@ public function testTheme()
$this->assertSame('default', $this->app->make(Markdown::class)->getTheme());
Mail::to('test@mail.com')->send(new MarkdownBasicMailableWithTheme);
- $this->assertSame('taylor', $this->app->make(Markdown::class)->getTheme());
+ $this->assertSame('default', $this->app->make(Markdown::class)->getTheme());
Mail::to('test@mail.com')->send(new MarkdownBasicMailable);
$this->assertSame('default', $this->app->make(Markdown::class)->getTheme());
diff --git a/tests/Integration/Notifications/SendingMailNotificationsTest.php b/tests/Integration/Notifications/SendingMailNotificationsTest.php
index 5c65af7ec..dd31a2fd9 100644
--- a/tests/Integration/Notifications/SendingMailNotificationsTest.php
+++ b/tests/Integration/Notifications/SendingMailNotificationsTest.php
@@ -72,8 +72,9 @@ public function testMailIsSent()
'email' => 'taylor@laravel.com',
]);
- $this->markdown->shouldReceive('theme')->twice()->with('default')->andReturn($this->markdown);
- $this->markdown->shouldReceive('render')->once()->andReturn(new HtmlString('htmlContent'));
+ $this->markdown->shouldReceive('render')->once()
+ ->withArgs(fn (...$args) => ($args['theme'] ?? $args[3] ?? null) === 'default')
+ ->andReturn(new HtmlString('htmlContent'));
$this->markdown->shouldReceive('renderText')->once()->andReturn(new HtmlString('textContent'));
$this->setMailerSendAssertions($notification, $user, function ($closure) {
@@ -110,8 +111,9 @@ public function testMailIsSentWithCustomTheme()
'email' => 'taylor@laravel.com',
]);
- $this->markdown->shouldReceive('theme')->twice()->with('my-custom-theme')->andReturn($this->markdown);
- $this->markdown->shouldReceive('render')->once()->andReturn(new HtmlString('htmlContent'));
+ $this->markdown->shouldReceive('render')->once()
+ ->withArgs(fn (...$args) => ($args['theme'] ?? $args[3] ?? null) === 'my-custom-theme')
+ ->andReturn(new HtmlString('htmlContent'));
$this->markdown->shouldReceive('renderText')->once()->andReturn(new HtmlString('textContent'));
$this->setMailerSendAssertions($notification, $user, function ($closure) {
@@ -184,8 +186,9 @@ public function testMailIsSentToNamedAddress()
'name' => 'Taylor Otwell',
]);
- $this->markdown->shouldReceive('theme')->twice()->with('default')->andReturn($this->markdown);
- $this->markdown->shouldReceive('render')->once()->andReturn(new HtmlString('htmlContent'));
+ $this->markdown->shouldReceive('render')->once()
+ ->withArgs(fn (...$args) => ($args['theme'] ?? $args[3] ?? null) === 'default')
+ ->andReturn(new HtmlString('htmlContent'));
$this->markdown->shouldReceive('renderText')->once()->andReturn(new HtmlString('textContent'));
$this->setMailerSendAssertions($notification, $user, function ($closure) {
@@ -222,8 +225,9 @@ public function testMailIsSentWithSubject()
'email' => 'taylor@laravel.com',
]);
- $this->markdown->shouldReceive('theme')->with('default')->twice()->andReturn($this->markdown);
- $this->markdown->shouldReceive('render')->once()->andReturn(new HtmlString('htmlContent'));
+ $this->markdown->shouldReceive('render')->once()
+ ->withArgs(fn (...$args) => ($args['theme'] ?? $args[3] ?? null) === 'default')
+ ->andReturn(new HtmlString('htmlContent'));
$this->markdown->shouldReceive('renderText')->once()->andReturn(new HtmlString('textContent'));
$this->setMailerSendAssertions($notification, $user, function ($closure) {
@@ -250,8 +254,9 @@ public function testMailIsSentToMultipleAddresses()
'email' => 'taylor@laravel.com',
]);
- $this->markdown->shouldReceive('theme')->with('default')->twice()->andReturn($this->markdown);
- $this->markdown->shouldReceive('render')->once()->andReturn(new HtmlString('htmlContent'));
+ $this->markdown->shouldReceive('render')->once()
+ ->withArgs(fn (...$args) => ($args['theme'] ?? $args[3] ?? null) === 'default')
+ ->andReturn(new HtmlString('htmlContent'));
$this->markdown->shouldReceive('renderText')->once()->andReturn(new HtmlString('textContent'));
$this->setMailerSendAssertions($notification, $user, function ($closure) {
diff --git a/tests/Mail/Fixtures/Markdown/components/html/layout.blade.php b/tests/Mail/Fixtures/Markdown/components/html/layout.blade.php
new file mode 100644
index 000000000..a67e85811
--- /dev/null
+++ b/tests/Mail/Fixtures/Markdown/components/html/layout.blade.php
@@ -0,0 +1,2 @@
+html-layout
+{{ $slot }}
diff --git a/tests/Mail/Fixtures/Markdown/components/html/probe.blade.php b/tests/Mail/Fixtures/Markdown/components/html/probe.blade.php
new file mode 100644
index 000000000..634ea66c6
--- /dev/null
+++ b/tests/Mail/Fixtures/Markdown/components/html/probe.blade.php
@@ -0,0 +1,3 @@
+html-start
+
+html-end
diff --git a/tests/Mail/Fixtures/Markdown/components/html/themes/default.css b/tests/Mail/Fixtures/Markdown/components/html/themes/default.css
new file mode 100644
index 000000000..224a49076
--- /dev/null
+++ b/tests/Mail/Fixtures/Markdown/components/html/themes/default.css
@@ -0,0 +1 @@
+theme-css
diff --git a/tests/Mail/Fixtures/Markdown/components/text/probe.blade.php b/tests/Mail/Fixtures/Markdown/components/text/probe.blade.php
new file mode 100644
index 000000000..90da157ff
--- /dev/null
+++ b/tests/Mail/Fixtures/Markdown/components/text/probe.blade.php
@@ -0,0 +1,3 @@
+text-start
+
+text-end
diff --git a/tests/Mail/Fixtures/Markdown/views/component-tag.blade.php b/tests/Mail/Fixtures/Markdown/views/component-tag.blade.php
new file mode 100644
index 000000000..58b4ab34a
--- /dev/null
+++ b/tests/Mail/Fixtures/Markdown/views/component-tag.blade.php
@@ -0,0 +1,3 @@
+
+Component Body
+
diff --git a/tests/Mail/MailMarkdownTest.php b/tests/Mail/MailMarkdownTest.php
index 8991496bb..1816cef4e 100644
--- a/tests/Mail/MailMarkdownTest.php
+++ b/tests/Mail/MailMarkdownTest.php
@@ -29,7 +29,6 @@ public function testRenderFunctionReturnsHtml()
->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('view', [])->andReturn($viewInterface);
@@ -56,7 +55,6 @@ public function testRenderFunctionReturnsHtmlWithCustomTheme()
$markdown = new Markdown($viewFactory);
$markdown->theme('yaz');
- $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', [])->andReturn($viewInterface);
@@ -83,7 +81,6 @@ public function testRenderFunctionReturnsHtmlWithCustomThemeWithMailPrefix()
$markdown = new Markdown($viewFactory);
$markdown->theme('mail.yaz');
- $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', [])->andReturn($viewInterface);
@@ -101,7 +98,6 @@ public function testRenderTextReturnsText()
$viewFactory = m::mock(ViewFactory::class);
$markdown = new Markdown($viewFactory);
- $viewFactory->shouldReceive('flushFinderCache')->once();
$viewFactory->shouldReceive('replaceNamespace')->once()->with('mail', $markdown->textComponentPaths())->andReturnSelf();
$viewFactory->shouldReceive('make')->with('view', [])->andReturn($viewInterface);
diff --git a/tests/Mail/MarkdownCoroutineSafetyTest.php b/tests/Mail/MarkdownCoroutineSafetyTest.php
new file mode 100644
index 000000000..3682a0a55
--- /dev/null
+++ b/tests/Mail/MarkdownCoroutineSafetyTest.php
@@ -0,0 +1,441 @@
+ "boot:{$value}");
+
+ $markdown = new Markdown(new MarkdownTestViewFactory(
+ fn () => (new EncodedHtmlString('rendered'))->toHtml()
+ ));
+
+ $this->assertSame('boot:rendered', $markdown->render('view', inliner: new MarkdownPassthroughInliner)->toHtml());
+ $this->assertSame('boot:after', (new EncodedHtmlString('after'))->toHtml());
+ }
+
+ public function testEncodedParseDoesNotClearBootEncoder(): void
+ {
+ EncodedHtmlString::encodeUsing(fn ($value) => "boot:{$value}");
+
+ $html = Markdown::parse(new EncodedHtmlString('Visit https://hypervel.org/docs'), encoded: true);
+
+ $this->assertStringContainsString('Visit <span>https://hypervel.org/docs</span>', $html->toHtml());
+ $this->assertSame('boot:after', (new EncodedHtmlString('after'))->toHtml());
+ }
+
+ public function testSecuredRenderEncodingIsIsolatedBetweenCoroutines(): void
+ {
+ Markdown::withSecuredEncoding();
+
+ $results = parallel([
+ 'a' => fn () => $this->renderEncodingProbe(),
+ 'b' => fn () => $this->renderEncodingProbe(),
+ ]);
+
+ $this->assertSame('scoped:scoped', $results['a']);
+ $this->assertSame('scoped:scoped', $results['b']);
+ }
+
+ public function testRenderUsesPerCallThemeWithoutMutatingDefaultTheme(): void
+ {
+ $markdown = new Markdown(new MarkdownTestViewFactory(
+ fn () => 'content',
+ fn (string $view) => $view
+ ));
+ $markdown->theme('default-a');
+
+ $this->assertSame('content|theme:mail::themes.theme-b', $markdown->render(
+ 'view',
+ inliner: new MarkdownPassthroughInliner,
+ theme: 'theme-b'
+ )->toHtml());
+ $this->assertSame('default-a', $markdown->getTheme());
+ }
+
+ public function testRenderDoesNotFlushUnrelatedFinderCache(): void
+ {
+ $factory = new MarkdownTestViewFactory(fn () => 'content');
+ $factory->cacheView('unrelated', '/cached/unrelated.php');
+
+ $markdown = new Markdown($factory);
+
+ $markdown->render('view', inliner: new MarkdownPassthroughInliner);
+
+ $this->assertSame('/cached/unrelated.php', $factory->cachedView('unrelated'));
+ }
+
+ public function testRenderAndRenderTextUseIsolatedMailNamespaces(): void
+ {
+ $markdown = new Markdown($this->makeRealViewFactory(), [
+ 'paths' => [$this->markdownComponentsPath()],
+ ]);
+
+ $results = parallel([
+ 'html' => fn () => $markdown->render('mail::probe', inliner: new MarkdownPassthroughInliner)->toHtml(),
+ 'text' => fn () => $markdown->renderText('mail::probe')->toHtml(),
+ ]);
+
+ $this->assertStringContainsString('html-start', $results['html']);
+ $this->assertStringContainsString('html-end', $results['html']);
+ $this->assertStringNotContainsString('text-start', $results['html']);
+ $this->assertStringContainsString('text-start', $results['text']);
+ $this->assertStringContainsString('text-end', $results['text']);
+ $this->assertStringNotContainsString('html-start', $results['text']);
+ }
+
+ public function testMailComponentTagsResolveThroughClonedFactory(): void
+ {
+ $markdown = new Markdown($this->makeRealViewFactory(), [
+ 'paths' => [$this->markdownComponentsPath()],
+ ]);
+
+ $result = $markdown->render('component-tag', inliner: new MarkdownPassthroughInliner)->toHtml();
+
+ $this->assertStringContainsString('html-layout', $result);
+ $this->assertStringContainsString('Component Body', $result);
+ }
+
+ public function testConcurrentMailablesUseTheirOwnThemes(): void
+ {
+ $markdown = $this->swapThemeProbeMarkdownIntoContainer();
+
+ $results = parallel([
+ 'a' => fn () => (new ThemeProbeMailable('theme-a'))->renderProbe(),
+ 'b' => fn () => (new ThemeProbeMailable('theme-b'))->renderProbe(),
+ ]);
+
+ $this->assertSame('theme-a:theme-a', $results['a']);
+ $this->assertSame('theme-b:theme-b', $results['b']);
+ $this->assertSame('default', $markdown->getTheme());
+ }
+
+ public function testMailMessageRenderUsesThemeWithoutMutatingMarkdownSingleton(): void
+ {
+ $markdown = $this->swapThemeProbeMarkdownIntoContainer();
+
+ $message = (new NotificationMailMessage)->theme('theme-a');
+
+ $this->assertSame('theme-a:theme-a', $message->render());
+ $this->assertSame('default', $markdown->getTheme());
+ }
+
+ public function testMailChannelUsesNotificationThemeWithoutMutatingMarkdownSingleton(): void
+ {
+ $markdown = $this->swapThemeProbeMarkdownIntoContainer();
+ $channel = new ThemeProbeMailChannel(m::mock(MailFactory::class), $markdown);
+ $message = (new NotificationMailMessage)->theme('theme-a');
+
+ $this->assertSame('theme-a:theme-a', $channel->renderHtml($message));
+ $this->assertSame('default', $markdown->getTheme());
+ }
+
+ protected function swapThemeProbeMarkdownIntoContainer(): ThemeProbeMarkdown
+ {
+ $container = new Container;
+ $markdown = new ThemeProbeMarkdown;
+
+ $container->instance('config', new Repository([
+ 'mail' => [
+ 'markdown' => [
+ 'theme' => 'default',
+ ],
+ ],
+ ]));
+ $container->instance(Markdown::class, $markdown);
+
+ Container::setInstance($container);
+
+ return $markdown;
+ }
+
+ protected function renderEncodingProbe(): string
+ {
+ $markdown = new Markdown(new MarkdownTestViewFactory(function () {
+ $before = $this->encodingMarker();
+ usleep(5000);
+
+ return $before . ':' . $this->encodingMarker();
+ }));
+
+ return $markdown->render('view', inliner: new MarkdownPassthroughInliner)->toHtml();
+ }
+
+ protected function encodingMarker(): string
+ {
+ return (new EncodedHtmlString('['))->toHtml() === '\['
+ ? 'scoped'
+ : 'default';
+ }
+
+ protected function makeRealViewFactory(): ViewFactory
+ {
+ $container = new Container;
+ $filesystem = new Filesystem;
+ $resolver = new EngineResolver;
+ $application = m::mock(ApplicationContract::class);
+ $compiledPath = sys_get_temp_dir() . '/hypervel-markdown-coroutine-safety-views';
+ $bladeCompiler = new BladeCompiler($filesystem, $compiledPath, shouldCache: false);
+
+ $application->shouldReceive('getNamespace')->andReturn('App\\');
+ $filesystem->ensureDirectoryExists($compiledPath);
+
+ $resolver->register('blade', fn () => new CompilerEngine($bladeCompiler, $filesystem));
+ $resolver->register('file', fn () => new FileEngine($filesystem));
+ $resolver->register('php', fn () => new PhpEngine($filesystem));
+
+ $factory = new ViewFactory(
+ $resolver,
+ new FileViewFinder($filesystem, [$this->markdownViewsPath()]),
+ new Dispatcher($container)
+ );
+
+ $factory->setContainer($container);
+ $container->instance(ApplicationContract::class, $application);
+ $container->instance(ViewFactory::class, $factory);
+ $container->instance(ViewFactoryContract::class, $factory);
+ Container::setInstance($container);
+
+ return $factory;
+ }
+
+ protected function markdownViewsPath(): string
+ {
+ return __DIR__ . '/Fixtures/Markdown/views';
+ }
+
+ protected function markdownComponentsPath(): string
+ {
+ return __DIR__ . '/Fixtures/Markdown/components';
+ }
+}
+
+class MarkdownTestViewFactory implements ViewFactoryContract
+{
+ protected array $cachedViews = [];
+
+ public function __construct(
+ protected Closure $renderer,
+ protected ?Closure $themeRenderer = null
+ ) {
+ }
+
+ public function exists(string $view): bool
+ {
+ return false;
+ }
+
+ public function file(string $path, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ return $this->make($path, $data, $mergeData);
+ }
+
+ public function make(string $view, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ if (str_starts_with($view, 'mail::themes.')) {
+ return new MarkdownTestView(fn () => is_null($this->themeRenderer)
+ ? ''
+ : call_user_func($this->themeRenderer, $view));
+ }
+
+ return new MarkdownTestView($this->renderer);
+ }
+
+ public function first(array $views, Arrayable|array $data = [], array $mergeData = []): ViewContract
+ {
+ return $this->make($views[0], $data, $mergeData);
+ }
+
+ public function share(array|string $key, mixed $value = null): mixed
+ {
+ return null;
+ }
+
+ public function composer(array|string $views, Closure|string $callback): array
+ {
+ return [];
+ }
+
+ public function creator(array|string $views, Closure|string $callback): array
+ {
+ return [];
+ }
+
+ public function addNamespace(string $namespace, string|array $hints): static
+ {
+ return $this;
+ }
+
+ public function replaceNamespace(string $namespace, string|array $hints): static
+ {
+ return $this;
+ }
+
+ public function flushFinderCache(): void
+ {
+ $this->cachedViews = [];
+ }
+
+ public function getEngineResolver(): MarkdownTestEngineResolver
+ {
+ return new MarkdownTestEngineResolver;
+ }
+
+ public function cacheView(string $name, string $path): void
+ {
+ $this->cachedViews[$name] = $path;
+ }
+
+ public function cachedView(string $name): ?string
+ {
+ return $this->cachedViews[$name] ?? null;
+ }
+}
+
+class MarkdownTestEngineResolver
+{
+ public function resolve(string $engine): MarkdownTestCompilerEngine
+ {
+ return new MarkdownTestCompilerEngine;
+ }
+}
+
+class MarkdownTestCompilerEngine
+{
+ public function getCompiler(): MarkdownTestBladeCompiler
+ {
+ return new MarkdownTestBladeCompiler;
+ }
+}
+
+class MarkdownTestBladeCompiler
+{
+ public function usingEchoFormat(string $format, callable $callback): string
+ {
+ return $callback();
+ }
+}
+
+class MarkdownTestView implements ViewContract
+{
+ public function __construct(protected Closure $renderer)
+ {
+ }
+
+ public function render(): string
+ {
+ return call_user_func($this->renderer);
+ }
+
+ public function name(): string
+ {
+ return 'view';
+ }
+
+ public function with(string|array $key, mixed $value = null): static
+ {
+ return $this;
+ }
+
+ public function getData(): array
+ {
+ return [];
+ }
+
+ public function getPath(): string
+ {
+ return __FILE__;
+ }
+}
+
+class MarkdownPassthroughInliner
+{
+ public function convert(string $html, string $css): string
+ {
+ return $css === '' ? $html : "{$html}|theme:{$css}";
+ }
+}
+
+class ThemeProbeMarkdown extends Markdown
+{
+ public function __construct()
+ {
+ }
+
+ public function render(string $view, array $data = [], mixed $inliner = null, ?string $theme = null): HtmlString
+ {
+ $before = $theme ?? $this->getTheme();
+ usleep(5000);
+
+ return new HtmlString($before . ':' . ($theme ?? $this->getTheme()));
+ }
+
+ public function renderText(string $view, array $data = []): HtmlString
+ {
+ return new HtmlString('text');
+ }
+}
+
+class ThemeProbeMailable extends Mailable
+{
+ public string $markdown = 'message';
+
+ public function __construct(string $theme)
+ {
+ $this->theme = $theme;
+ }
+
+ public function renderProbe(): string
+ {
+ return $this->buildMarkdownHtml([])([])->toHtml();
+ }
+}
+
+class ThemeProbeMailChannel extends MailChannel
+{
+ public function renderHtml(NotificationMailMessage $message): string
+ {
+ return $this->buildMarkdownHtml($message)([])->toHtml();
+ }
+}
diff --git a/tests/Support/SupportEncodedHtmlStringTest.php b/tests/Support/SupportEncodedHtmlStringTest.php
new file mode 100644
index 000000000..954744cc3
--- /dev/null
+++ b/tests/Support/SupportEncodedHtmlStringTest.php
@@ -0,0 +1,78 @@
+ "boot:{$value}");
+
+ $result = EncodedHtmlString::withEncoding(
+ fn ($value) => "outer:{$value}",
+ function () {
+ $outerBefore = (new EncodedHtmlString('before'))->toHtml();
+
+ $inner = EncodedHtmlString::withEncoding(
+ fn ($value) => "inner:{$value}",
+ fn () => (new EncodedHtmlString('during'))->toHtml()
+ );
+
+ $outerAfter = (new EncodedHtmlString('after'))->toHtml();
+
+ return [$outerBefore, $inner, $outerAfter];
+ }
+ );
+
+ $this->assertSame(['outer:before', 'inner:during', 'outer:after'], $result);
+ $this->assertSame('boot:final', (new EncodedHtmlString('final'))->toHtml());
+ }
+
+ public function testScopedEncodingIsIsolatedBetweenCoroutines(): void
+ {
+ EncodedHtmlString::encodeUsing(fn ($value) => "boot:{$value}");
+
+ $results = parallel([
+ 'a' => function () {
+ return EncodedHtmlString::withEncoding(
+ fn ($value) => "a:{$value}",
+ function () {
+ $before = (new EncodedHtmlString('before'))->toHtml();
+ usleep(5000);
+
+ return [$before, (new EncodedHtmlString('after'))->toHtml()];
+ }
+ );
+ },
+ 'b' => function () {
+ return EncodedHtmlString::withEncoding(
+ fn ($value) => "b:{$value}",
+ function () {
+ $before = (new EncodedHtmlString('before'))->toHtml();
+ usleep(5000);
+
+ return [$before, (new EncodedHtmlString('after'))->toHtml()];
+ }
+ );
+ },
+ ]);
+
+ $this->assertSame(['a:before', 'a:after'], $results['a']);
+ $this->assertSame(['b:before', 'b:after'], $results['b']);
+ $this->assertSame('boot:final', (new EncodedHtmlString('final'))->toHtml());
+ }
+}
diff --git a/tests/View/ViewFactoryTest.php b/tests/View/ViewFactoryTest.php
index ddf193928..f956e314b 100755
--- a/tests/View/ViewFactoryTest.php
+++ b/tests/View/ViewFactoryTest.php
@@ -19,6 +19,7 @@
use Hypervel\View\Engines\CompilerEngine;
use Hypervel\View\Engines\EngineResolver;
use Hypervel\View\Engines\PhpEngine;
+use Hypervel\View\FileViewFinder;
use Hypervel\View\Factory;
use Hypervel\View\View;
use Hypervel\View\ViewFinderInterface;
@@ -35,6 +36,29 @@ protected function tearDown(): void
m::close();
}
+ public function testCloneIsolatesFinderAndSharedEnvironment()
+ {
+ $factory = new Factory(
+ m::mock(EngineResolver::class),
+ new FileViewFinder(new Filesystem, [__DIR__ . '/Fixtures']),
+ m::mock(DispatcherContract::class)
+ );
+
+ $clone = clone $factory;
+ $clone->replaceNamespace('foo', __DIR__ . '/Fixtures/namespaced');
+ $clone->share('scoped', 'value');
+
+ $this->assertSame(__DIR__ . '/Fixtures/namespaced/basic.php', $clone->getFinder()->find('foo::basic'));
+ $this->assertSame($factory, $factory->shared('__env'));
+ $this->assertSame($clone, $clone->shared('__env'));
+ $this->assertNull($factory->shared('scoped'));
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('No hint path defined for [foo].');
+
+ $factory->getFinder()->find('foo::basic');
+ }
+
public function testMakeCreatesNewViewInstanceWithProperPathAndEngine()
{
unset($_SERVER['__test.view']);