Skip to content

Commit 322bedb

Browse files
committed
Views: Support Mail Markdown
1 parent ce3b509 commit 322bedb

File tree

5 files changed

+285
-57
lines changed

5 files changed

+285
-57
lines changed

src/AffordableMobiles/GServerlessSupportLaravel/Console/GServerlessViewCompileCommand.php

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Illuminate\Contracts\View\Factory as ViewFactory;
1111
use Illuminate\Filesystem\Filesystem;
1212
use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths;
13+
use Illuminate\Mail\MailServiceProvider;
14+
use Illuminate\Mail\Markdown;
1315
use Illuminate\Support\Str;
1416
use Symfony\Component\Console\Input\InputOption;
1517
use Symfony\Component\Finder\SplFileInfo;
@@ -54,9 +56,9 @@ class GServerlessViewCompileCommand extends Command
5456
/**
5557
* Data structure to be written to manifest.php.
5658
*
57-
* @var array{map: array<string, string>, views: array<string, string>}
59+
* @var array{map: array<string, string>, views: array<string, string>, dynamic_namespaces: array}
5860
*/
59-
protected $manifestData = ['map' => [], 'views' => []];
61+
protected $manifestData = ['map' => [], 'views' => [], 'dynamic_namespaces' => []];
6062

6163
/** @var array<string, bool> */
6264
protected $processedPaths = [];
@@ -68,7 +70,6 @@ public function __construct(
6870
Filesystem $files,
6971
CompileTimeBladeCompilerWrapper $compilerWrapper,
7072
ViewFactory $viewFactory
71-
// Removed ConfigRepository
7273
) {
7374
parent::__construct();
7475
$this->files = $files;
@@ -102,19 +103,37 @@ public function handle(): int
102103
$this->info('Created view storage directory.');
103104
}
104105

105-
// 2. Initialize and process mappings
106-
$this->manifestData = ['map' => [], 'views' => []];
106+
// 2. Initialize manifests and process dynamic namespaces
107+
$this->manifestData = ['map' => [], 'views' => [], 'dynamic_namespaces' => []];
107108
$this->processedPaths = [];
108-
$fileMap = $this->buildFileMapFromOptions();
109+
110+
// Special handling for dynamic Markdown Mail namespaces
111+
if ($this->laravel->providerIsLoaded(MailServiceProvider::class)) {
112+
$this->info('MailServiceProvider detected, preparing mail markdown components for compilation...');
113+
$markdown = $this->laravel->make(Markdown::class);
114+
$basePath = $this->laravel->basePath().'/';
115+
$makeRelative = static fn (array $paths) => array_map(static fn ($path) => Str::after($path, $basePath), $paths);
116+
117+
$htmlPaths = $markdown->htmlComponentPaths();
118+
$textPaths = $markdown->textComponentPaths();
119+
120+
// Add the dynamic map to the manifest data
121+
$this->manifestData['dynamic_namespaces']['mail'] = [
122+
'gserverless-mail-html' => $makeRelative($htmlPaths),
123+
'gserverless-mail-text' => $makeRelative($textPaths),
124+
];
125+
126+
// Register these paths as new namespaces so the regular compilation process finds them
127+
$this->viewFactory->getFinder()->addNamespace('gserverless-mail-html', $htmlPaths);
128+
$this->viewFactory->getFinder()->addNamespace('gserverless-mail-text', $textPaths);
129+
130+
$this->info('Dynamic mail namespaces registered for compilation.');
131+
}
109132

110133
// 3. Compile views
111-
// Compile explicitly mapped files/dirs first (from CLI options + default health check)
134+
$fileMap = $this->buildFileMapFromOptions();
112135
$this->compileMappedViews($fileMap);
113-
114-
// Compile standard namespaced views (avoiding already processed paths)
115136
$this->compileNamespaceViews();
116-
117-
// Compile standard default views (avoiding already processed paths)
118137
$this->compileDefaultViews();
119138

120139
// 4. Write manifest
@@ -172,8 +191,6 @@ protected function buildFileMapFromOptions(): array
172191
$map[str_replace('\\', '/', $relativePath)] = $canonicalName;
173192
}
174193

175-
// Process --map-dir options (will be handled in compileMappedViews)
176-
// We just return the file map here. Directories are handled separately.
177194
return $map;
178195
}
179196

@@ -226,6 +243,12 @@ protected function compileSingleMappedFile(string $relativePath, string $canonic
226243
return;
227244
}
228245

246+
if (!Str::endsWith($absolutePath, '.blade.php')) {
247+
$this->warn(" > Mapped file is not a Blade view, skipping: {$relativePath}");
248+
249+
return;
250+
}
251+
229252
$realAbsolutePath = realpath($absolutePath);
230253
if (false === $realAbsolutePath) {
231254
$this->warn(" > Could not resolve real path for mapped view: {$absolutePath}. Skipping.");
@@ -376,8 +399,12 @@ protected function compileViewsFromPath(string $path, ?string $namespace): void
376399
*
377400
* @return null|string the canonical name, or null on error
378401
*/
379-
protected function generateCanonicalName(string $realBasePath, string $absolutePath, ?string $namespace): ?string
402+
protected function generateCanonicalName(string $realBasePath, ?string $absolutePath, ?string $namespace): ?string
380403
{
404+
if (!$absolutePath) {
405+
return null;
406+
}
407+
381408
$realBasePath = rtrim($realBasePath, \DIRECTORY_SEPARATOR).\DIRECTORY_SEPARATOR;
382409
if (!Str::startsWith($absolutePath, $realBasePath)) {
383410
return null;

src/AffordableMobiles/GServerlessSupportLaravel/View/Compilers/FakeCompiler.php

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace AffordableMobiles\GServerlessSupportLaravel\View\Compilers;
66

7+
use AffordableMobiles\GServerlessSupportLaravel\View\Exceptions\RuntimeCompilationNotSupportedException;
78
use Illuminate\Support\Str;
89
use Illuminate\View\Compilers\CompilerInterface;
910

@@ -14,6 +15,27 @@
1415
*/
1516
class FakeCompiler implements CompilerInterface
1617
{
18+
/**
19+
* The default echo format.
20+
*
21+
* @var string
22+
*/
23+
public const DEFAULT_ECHO_FORMAT = 'e(%s)';
24+
25+
/**
26+
* The "regular" / legacy echo string format.
27+
*
28+
* @var string
29+
*/
30+
protected $echoFormat = self::DEFAULT_ECHO_FORMAT;
31+
32+
/**
33+
* The stack of last echo formats.
34+
*
35+
* @var array
36+
*/
37+
protected $lastEchoFormat = [];
38+
1739
/**
1840
* The 'views' portion of the manifest: [canonicalName => hashedFilename.php].
1941
*
@@ -85,15 +107,74 @@ public function getCompiledPath($path): string // Parameter is the fake path now
85107
}
86108

87109
/**
88-
* Compile the view. Throws an exception as runtime compilation is disabled.
110+
* Execute the given callback using a custom echo format.
89111
*
90-
* @param string $name the view name
112+
* @param string $format
113+
*
114+
* @return string
115+
*/
116+
public function usingEchoFormat($format, callable $callback)
117+
{
118+
// Push the current echo format to the stack and set the new one.
119+
$this->lastEchoFormat[] = $this->echoFormat;
120+
$this->setEchoFormat($format);
121+
122+
try {
123+
// Execute the callback which will render the view.
124+
$output = \call_user_func($callback);
125+
} finally {
126+
// Restore the original echo format from the stack.
127+
$this->setEchoFormat(array_pop($this->lastEchoFormat));
128+
}
129+
130+
return $output;
131+
}
132+
133+
/**
134+
* Set the echo format to be used by the compiler.
135+
*
136+
* @param string $format
137+
*/
138+
public function setEchoFormat($format): void
139+
{
140+
$this->echoFormat = $format;
141+
}
142+
143+
/**
144+
* Get the current echo format.
145+
*
146+
* @return string
147+
*/
148+
public function getEchoFormat()
149+
{
150+
return $this->echoFormat;
151+
}
152+
153+
/**
154+
* Get the default echo format.
155+
*
156+
* @return string
157+
*/
158+
public function getDefaultEchoFormat()
159+
{
160+
return self::DEFAULT_ECHO_FORMAT;
161+
}
162+
163+
/**
164+
* Compile the view at the given path.
165+
* This should never be called at runtime in a pre-compiled environment.
166+
*
167+
* @param null|string $path
91168
*
92-
* @throws \RuntimeException always
169+
* @throws RuntimeCompilationNotSupportedException
93170
*/
94-
public function compile($name): void
171+
public function compile($path = null): void
95172
{
96-
throw new \RuntimeException('Runtime Blade compilation is disabled in this environment.');
173+
// If this method is called, it means a .blade.php file was found on disk
174+
// that was not in our manifest, triggering an attempt at runtime compilation.
175+
throw new RuntimeCompilationNotSupportedException(
176+
'Runtime Blade compilation is disabled in this environment.'
177+
);
97178
}
98179

99180
/**

src/AffordableMobiles/GServerlessSupportLaravel/View/Engines/CompilerEngine.php

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace AffordableMobiles\GServerlessSupportLaravel\View\Engines;
66

7+
use AffordableMobiles\GServerlessSupportLaravel\View\Compilers\FakeCompiler;
78
use Illuminate\Support\Str;
89
use Illuminate\View\Compilers\CompilerInterface;
910
use Illuminate\View\Engines\CompilerEngine as LaravelCompilerEngine;
@@ -18,6 +19,93 @@ public function __construct(CompilerInterface $compiler) // Ensure constructor m
1819
parent::__construct($compiler);
1920
}
2021

22+
/**
23+
* Get the evaluated contents of the view at the given path.
24+
*
25+
* @param string $path
26+
* @param array $data
27+
*
28+
* @return string
29+
*/
30+
protected function evaluatePath($path, $data)
31+
{
32+
$compiler = $this->getCompiler();
33+
34+
// When using pre-compiled views, we sometimes need to change the echo
35+
// format at runtime, like when rendering markdown mailables. This logic
36+
// checks if a custom echo format is active and replaces the standard
37+
// echo statements in the pre-compiled file with the custom format.
38+
if ($compiler instanceof FakeCompiler && $compiler->getEchoFormat() !== $compiler->getDefaultEchoFormat()) {
39+
$obLevel = ob_get_level();
40+
ob_start();
41+
42+
$tempFileHandle = tmpfile();
43+
if (false === $tempFileHandle) {
44+
throw new \RuntimeException('Unable to create temporary file for Blade rendering.');
45+
}
46+
$tempFilePath = stream_get_meta_data($tempFileHandle)['uri'];
47+
48+
try {
49+
$originalContents = $this->files->get($path);
50+
$customFormat = $compiler->getEchoFormat();
51+
$defaultFormat = $compiler->getDefaultEchoFormat();
52+
53+
// Deconstruct the format strings into prefix and suffix by splitting on '%s'.
54+
// This is safer than assuming a simple `function(%s)` structure.
55+
$defaultPos = strpos($defaultFormat, '%s');
56+
if (false === $defaultPos) {
57+
throw new \RuntimeException("Default echo format does not contain a '%s' placeholder.");
58+
}
59+
$defaultPrefix = substr($defaultFormat, 0, $defaultPos);
60+
$defaultSuffix = substr($defaultFormat, $defaultPos + 2);
61+
62+
$customPos = strpos($customFormat, '%s');
63+
if (false === $customPos) {
64+
throw new \RuntimeException("Custom echo format does not contain a '%s' placeholder.");
65+
}
66+
$customPrefix = substr($customFormat, 0, $customPos);
67+
$customSuffix = substr($customFormat, $customPos + 2);
68+
69+
// This regex handles balanced parentheses to correctly find the boundaries of the echo statement.
70+
// It captures the `echo`, the prefix, the content, and the suffix separately.
71+
$pattern = \sprintf(
72+
'/(echo\s+)(%s)((?:[^()]+|\((?3)\))*)(%s)/s',
73+
preg_quote($defaultPrefix, '/'),
74+
preg_quote($defaultSuffix, '/')
75+
);
76+
77+
$newContents = preg_replace_callback($pattern, static function ($matches) use ($customPrefix, $customSuffix) {
78+
// Reconstruct the echo statement with the new custom format parts.
79+
// $matches[1] is "echo "
80+
// $matches[2] is the default prefix (e.g., "e(")
81+
// $matches[3] is the content inside the parentheses
82+
// $matches[4] is the default suffix (e.g., ")")
83+
return $matches[1].$customPrefix.$matches[3].$customSuffix;
84+
}, $originalContents);
85+
86+
if (null === $newContents) {
87+
throw new \RuntimeException('PCRE error during Blade echo format replacement: '.preg_last_error_msg());
88+
}
89+
90+
fwrite($tempFileHandle, $newContents);
91+
fflush($tempFileHandle);
92+
93+
// Now, require the temporary file with the modified contents.
94+
$this->files->getRequire($tempFilePath, $data);
95+
} catch (\Throwable $e) {
96+
// Let the parent class handle the exception. It will correctly wrap it
97+
// in a ViewException and use the overridden getMessage() method.
98+
parent::handleViewException($e, $obLevel);
99+
} finally {
100+
fclose($tempFileHandle); // This also deletes the file.
101+
}
102+
103+
return ltrim(ob_get_clean());
104+
}
105+
106+
return parent::evaluatePath($path, $data);
107+
}
108+
21109
/**
22110
* Get the exception message for an exception.
23111
* Overrides the parent method to display the canonical view name.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AffordableMobiles\GServerlessSupportLaravel\View\Exceptions;
6+
7+
/**
8+
* Thrown when an attempt is made to compile a Blade view at runtime in an
9+
* environment where this has been explicitly disabled.
10+
*/
11+
class RuntimeCompilationNotSupportedException extends \RuntimeException
12+
{
13+
// No additional logic is needed; the type itself is the differentiator.
14+
}

0 commit comments

Comments
 (0)