Created
September 21, 2019 19:21
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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'; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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