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']);