Skip to content

Instantly share code, notes, and snippets.

@inxilpro
Created September 21, 2019 19:21
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save inxilpro/5ede10d173097ffb3f7ff32f127c867c to your computer and use it in GitHub Desktop.
Save inxilpro/5ede10d173097ffb3f7ff32f127c867c to your computer and use it in GitHub Desktop.
Blade Extension to transpile inline <script> tags using Babel (during the Blade compile step)
<?php
namespace App\Support\View;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log;
use Illuminate\View\Compilers\BladeCompiler;
use Illuminate\View\Compilers\Concerns\CompilesEchos;
use RuntimeException;
use Symfony\Component\Process\Process;
use Throwable;
class BabelBladeExtension
{
use CompilesEchos;
protected $placeholder = '__PHP_PLACEHOLDER';
protected $scriptTypes = [
'text/javascript',
'application/javascript',
'text/babel',
];
protected $babelPresets = [
'babel-preset-env',
'babel-preset-stage-0',
];
protected $rawTags = ['{!!', '!!}'];
protected $contentTags = ['{{', '}}'];
protected $escapedTags = ['{{{', '}}}'];
protected $echoFormat = 'e(%s)';
public function __construct()
{
if (!config('app.debug')) {
$this->babelPresets[] = 'babel-preset-minify';
}
}
public static function register() : self
{
$extension = new self();
Blade::extend($extension);
return $extension;
}
public function __invoke(string $content, BladeCompiler $compiler) : string
{
$pattern = '/(<script[^>]*>)(.*?)(<\/script>)/si';
try {
return preg_replace_callback($pattern, [$this, 'compileScriptTag'], $content);
} catch (Throwable $exception) {
if (true === config('app.debug')) {
throw $exception;
}
Log::warning("Unable to transpile javascript block in '{$compiler->getPath()}': {$exception->getMessage()}");
return $content;
}
}
protected function compileScriptTag(array $matches) : string
{
[$original, $opening_tag, $code, $closing_tag] = $matches;
// Don't try to transpile an empty script tag
if (empty($code)) {
return $original;
}
// Only transpile whitelisted script types
if (!in_array($this->getScriptType($opening_tag), $this->scriptTypes)) {
return $original;
}
// Compile blade echo statements
$code = $this->compileEchos($code);
// Compile all PHP code to JS-compatible placeholders
[$code, $placeholders] = $this->compilePhpPlaceholders($code);
// Transpile code
try {
$code = $this->transpileJavascript($code);
} catch (Throwable $exception) {
if (true === config('app.debug')) {
throw $exception;
}
return $original;
}
// Replace placeholders with original code
$code = $this->restorePhpPlaceholders($code, $placeholders);
return "{$opening_tag}{$code}{$closing_tag}";
}
protected function transpileJavascript(string $code) : string
{
$process = new Process([
base_path('node_modules/.bin/babel'),
'--presets='.implode(',', $this->babelPresets),
'--no-babelrc',
], base_path());
$process->setInput($code);
$process->run();
if (!$process->isSuccessful()) {
throw new RuntimeException($process->getErrorOutput());
}
return $process->getOutput();
}
protected function compilePhpPlaceholders(string $code) : array
{
$placeholders = [];
$compiled = '';
$buffer = '';
$tokens = token_get_all($code);
foreach ($tokens as $i => $token) {
[$id, $content] = is_array($token) ? $token : [null, $token];
if (T_INLINE_HTML === $id || $i === count($tokens) - 1) {
if ('' !== $buffer) {
$key = count($placeholders);
$placeholders[$key] = $buffer;
$compiled .= "{$this->placeholder}_{$key}()";
$buffer = '';
}
$compiled .= $content;
continue;
}
$buffer .= $content;
}
return [$compiled, $placeholders];
}
protected function restorePhpPlaceholders(string $code, array $placeholders) : string
{
$pattern = "/{$this->placeholder}_(\d+)\(\)/";
$replacer = function($matches) use ($placeholders) {
return $placeholders[$matches[1]];
};
return preg_replace_callback($pattern, $replacer, $code);
}
protected function getScriptType(string $tag) : string
{
preg_match('/type=[\'"]([^"\']+)[\'"]/i', $tag, $matches);
if ($matches && $matches[1]) {
return $matches[1];
}
return 'application/javascript';
}
}
<?php
namespace Tests\Unit\View;
use App\Support\View\BabelBladeExtension;
use Illuminate\View\Compilers\BladeCompiler;
use RuntimeException;
use Tests\TestCase;
class BabelBladeExtensionTest extends TestCase
{
public function test_it_compiles_blade_echo_statements() : void
{
$code = '
<script>
const plain_js = "plain js";
const short_tag = <?=json_encode($short_tag)?>;
const raw_echo = <?php echo $echo; ?>;
const escaped_blade = {{ $escaped }};
const unescaped = {!! $unescaped !!};
const es_text = `still js`;
</script>
';
$extension = new BabelBladeExtension();
$result = $extension($code, app(BladeCompiler::class));
$this->assertStringContainsString('"use strict";', $result);
$this->assertStringContainsString('var plain_js = "plain js";', $result);
$this->assertStringContainsString('var short_tag = <?=json_encode($short_tag)?>;', $result);
$this->assertStringContainsString('var raw_echo = <?php echo $echo; ?>;', $result);
$this->assertStringContainsString('var escaped_blade = <?php echo e($escaped); ?>;', $result);
$this->assertStringContainsString('var unescaped = <?php echo $unescaped; ?>;', $result);
$this->assertStringContainsString('var es_text = "still js";', $result);
}
public function test_it_throws_an_exception_on_invalid_code_when_debug_is_on() : void
{
$this->expectException(RuntimeException::class);
$code = '
<script>
this is invalid javascript!!!!
</script>
';
$extension = new BabelBladeExtension();
$extension($code, app(BladeCompiler::class));
}
public function test_it_uses_original_code_if_invalid_when_debug_is_off() : void
{
config()->set('app.debug', false);
$code = '
<script>
this is invalid javascript!!!!
</script>
';
$extension = new BabelBladeExtension();
$result = $extension($code, app(BladeCompiler::class));
$this->assertEquals($code, $result);
}
public function test_it_compiles_configured_script_types() : void
{
$code = '
<script>
const plain_js = "plain js";
const short_tag = <?=json_encode($short_tag)?>;
</script>
<script type="text/javascript">
const raw_echo = <?php echo $echo; ?>;
</script>
<script type="text/babel">
const escaped_blade = {{ $escaped }};
</script>
<script type="application/javascript">
const unescaped = {!! $unescaped !!};
const es_text = `still js`;
</script>
<script type="text/unchanged">
const do_not_transpile = true;
</script>
';
$extension = new BabelBladeExtension();
$result = $extension($code, app(BladeCompiler::class));
$this->assertStringContainsString('"use strict";', $result);
$this->assertStringContainsString('var plain_js = "plain js";', $result);
$this->assertStringContainsString('var short_tag = <?=json_encode($short_tag)?>;', $result);
$this->assertStringContainsString('var raw_echo = <?php echo $echo; ?>;', $result);
$this->assertStringContainsString('var escaped_blade = <?php echo e($escaped); ?>;', $result);
$this->assertStringContainsString('var unescaped = <?php echo $unescaped; ?>;', $result);
$this->assertStringContainsString('var es_text = "still js";', $result);
$this->assertStringContainsString('const do_not_transpile = true;', $result);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment