Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
3c2b284
Add EncodedHtmlString::withEncoding for coroutine-local encoder override
binaryfire May 13, 2026
0c5d3be
Add coroutine-local namespace overrides to FileViewFinder
binaryfire May 13, 2026
2603eb1
Add scopedNamespace to ViewFinderInterface
binaryfire May 13, 2026
acba287
Add View Factory::scopedNamespace delegating to finder
binaryfire May 13, 2026
ab26321
Add scopedNamespace to View Factory contract
binaryfire May 13, 2026
e22169b
Add scopedNamespace docblock to View facade
binaryfire May 13, 2026
80ebad7
Use scoped view namespace and accept per-call theme in Markdown render
binaryfire May 13, 2026
72fdd88
Update Markdown unit tests for scoped namespace API
binaryfire May 13, 2026
e6d39c4
Pass Markdown theme as render parameter instead of mutating singleton…
binaryfire May 13, 2026
c11854b
Pass Markdown theme as render parameter in MailChannel
binaryfire May 13, 2026
a95f357
Pass Markdown theme as render parameter in MailMessage
binaryfire May 13, 2026
30fd1cf
Assert Markdown singleton theme is preserved after Mail::send
binaryfire May 13, 2026
a3eb02a
Update notification mail mocks for theme-via-parameter
binaryfire May 13, 2026
823ec5e
Add coroutine isolation tests for EncodedHtmlString::withEncoding
binaryfire May 13, 2026
08cd5a6
Add scoped namespace tests for FileViewFinder
binaryfire May 13, 2026
61a16eb
Add Markdown coroutine safety regression tests
binaryfire May 13, 2026
fcd0e8a
Remove scopedNamespace from View Factory contract
binaryfire May 13, 2026
742c7a7
Remove scopedNamespace from ViewFinderInterface
binaryfire May 13, 2026
6da254b
Remove scopedNamespace docblock from View facade
binaryfire May 13, 2026
49d5765
Revert FileViewFinder to original Laravel-shape lookup
binaryfire May 13, 2026
619af57
Replace View Factory::scopedNamespace with __clone for render-time is…
binaryfire May 13, 2026
34e8b2f
Resolve mail:: component tags through $__env so cloned factories work
binaryfire May 13, 2026
69cca14
Use cloned view factory in Markdown render for namespace isolation
binaryfire May 13, 2026
63c09b7
Remove scopedNamespace tests from view finder
binaryfire May 13, 2026
7c8dc06
Test View Factory clone isolates finder and shared environment
binaryfire May 13, 2026
f7775d2
Update Markdown unit tests for cloned factory replaceNamespace
binaryfire May 13, 2026
bf5ee62
Add HTML probe fixture for Markdown coroutine safety tests
binaryfire May 13, 2026
96a6532
Add text probe fixture for Markdown coroutine safety tests
binaryfire May 13, 2026
3d9949d
Add HTML layout fixture for Markdown coroutine safety tests
binaryfire May 13, 2026
41663f4
Add HTML default theme fixture for Markdown coroutine safety tests
binaryfire May 13, 2026
e1ca8a6
Add component-tag view fixture for Markdown coroutine safety tests
binaryfire May 13, 2026
060337e
Replace Markdown namespace isolation test with real Blade rendering
binaryfire May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions src/mail/src/Mailable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}

Expand All @@ -376,26 +377,22 @@ 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)
);
};
}

/**
* 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'
);
}

/**
Expand Down
111 changes: 61 additions & 50 deletions src/mail/src/Markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,54 +46,52 @@ 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 = [
'[' => '\[',
'<' => '&lt;',
'>' => '&gt;',
];

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()
));
}

Expand All @@ -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')
Expand All @@ -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()
));
}

/**
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down
13 changes: 6 additions & 7 deletions src/notifications/src/Channels/MailChannel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}

Expand All @@ -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');
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/notifications/src/Messages/MailMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}

/**
Expand Down
34 changes: 33 additions & 1 deletion src/support/src/EncodedHtmlString.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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);
}
Expand All @@ -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.
*
Expand All @@ -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);
}
}
4 changes: 3 additions & 1 deletion src/view/src/Compilers/ComponentTagCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
11 changes: 11 additions & 0 deletions src/view/src/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/Mail/SendingMarkdownMailTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading