-
-
Save gcavanunez/16e302795aaabb26de0923e0b594065b to your computer and use it in GitHub Desktop.
LilBlaze - check it out on a full Laravel app here https://github.com/gcavanunez/lil-blaze
This file contains hidden or 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\LilBlaze; | |
| class LilBlaze | |
| { | |
| private array $folded = []; | |
| public function compile(string $template): string | |
| { | |
| // Protect @php/@verbatim blocks | |
| [$template, $blocks] = $this->store($template); | |
| // Split into tokens | |
| $tokens = $this->tokenize($template); | |
| // Build AST from tokens | |
| $nodes = $this->parse($tokens); | |
| // Apply optimization | |
| $this->walk($nodes); | |
| // Render AST back to string | |
| $output = implode('', array_map(fn ($n) => $n->render(), $nodes)); | |
| // Stamp folded dependencies | |
| $output = $this->stamp($output); | |
| // Restore protected blocks | |
| return $this->restore($output, $blocks); | |
| } | |
| private function store(string $template): array | |
| { | |
| $blocks = []; | |
| $counter = 0; | |
| $replace = function ($m) use (&$blocks, &$counter) { | |
| $placeholder = "__LILBLAZE_BLOCK_{$counter}__"; | |
| $blocks[$placeholder] = $m[0]; | |
| $counter++; | |
| return $placeholder; | |
| }; | |
| $template = preg_replace_callback('/@verbatim(.*?)@endverbatim/s', $replace, $template); | |
| // Same pattern for @php ... @endphp blocks | |
| $template = preg_replace_callback('/@php(.*?)@endphp/s', $replace, $template); | |
| return [$template, $blocks]; | |
| } | |
| private function restore(string $template, array $blocks): string | |
| { | |
| foreach ($blocks as $placeholder => $original) { | |
| $template = str_replace($placeholder, $original, $template); | |
| } | |
| return $template; | |
| } | |
| private function tokenize(string $template): array | |
| { | |
| preg_match_all( | |
| '/<\/?x-[\w\-:.]+[^>]*?\/?>/i', | |
| $template, | |
| $rawMatches, | |
| PREG_OFFSET_CAPTURE | |
| ); | |
| $result = collect($rawMatches[0]) | |
| ->reduce(function ($carry, $match) use ($template) { | |
| [$raw, $pos] = $match; | |
| if ($pos > $carry['offset']) { | |
| $carry['tokens'][] = new Token('text', substr($template, $carry['offset'], $pos - $carry['offset'])); | |
| } | |
| $carry['tokens'][] = $this->classifyToken($raw); | |
| $carry['offset'] = $pos + strlen($raw); | |
| return $carry; | |
| }, ['tokens' => [], 'offset' => 0]); | |
| if ($result['offset'] < strlen($template)) { | |
| $result['tokens'][] = new Token('text', substr($template, $result['offset'])); | |
| } | |
| return $result['tokens']; | |
| } | |
| private function classifyToken(string $raw): Token | |
| { | |
| if (str_starts_with($raw, '</')) { | |
| preg_match('/<\/x-([\w\-:.]+)\s*>/', $raw, $matches); | |
| return new Token('tag_close', $raw, $matches[1] ?? ''); | |
| } | |
| if (str_ends_with(rtrim($raw, " \t>"), '/')) { | |
| preg_match('/<x-([\w\-:.]+)\s*(.*?)\s*\/>/s', $raw, $matches); | |
| return new Token('tag_self_close', $raw, $matches[1] ?? '', trim($matches[2] ?? '')); | |
| } | |
| preg_match('/<x-([\w\-:.]+)((?:\s[^>]*)?)>/s', $raw, $matches); | |
| return new Token('tag_open', $raw, $matches[1] ?? '', trim($matches[2] ?? '')); | |
| } | |
| private function parse(array $tokens): array | |
| { | |
| $stack = []; | |
| return collect($tokens) | |
| ->reduce(function ($root, Token $token) use (&$stack) { | |
| if ($token->type === 'tag_open') { | |
| $node = new ComponentNode($token->name, $token->attributes); | |
| $this->addChild($root, $stack, $node); | |
| $stack[] = $node; | |
| return $root; | |
| } | |
| match ($token->type) { | |
| 'text' => $this->addChild($root, $stack, new TextNode($token->content)), | |
| 'tag_self_close' => $this->addChild($root, $stack, new ComponentNode( | |
| $token->name, $token->attributes, [], true, | |
| )), | |
| 'tag_close' => array_pop($stack), | |
| default => null, | |
| }; | |
| return $root; | |
| }, []); | |
| } | |
| private function addChild(array &$root, array &$stack, TextNode|ComponentNode $node): void | |
| { | |
| if (empty($stack)) { | |
| $root[] = $node; | |
| } else { | |
| $stack[count($stack) - 1]->children[] = $node; | |
| } | |
| } | |
| private function walk(array &$nodes): void | |
| { | |
| foreach ($nodes as $i => $node) { | |
| if (! ($node instanceof ComponentNode)) { | |
| continue; | |
| } | |
| if (! $node->selfClosing) { | |
| $this->walk($node->children); | |
| } | |
| $result = $this->fold($node) ?? $this->compileNode($node); | |
| if ($result !== null) { | |
| $nodes[$i] = $result; | |
| } | |
| } | |
| } | |
| private function fold(ComponentNode $node): ?TextNode | |
| { | |
| $path = resource_path("views/components/{$node->name}.blade.php"); | |
| if (! file_exists($path)) { | |
| return null; | |
| } | |
| if (preg_match('/:\w+\s*=/', $node->attributes)) { | |
| return null; | |
| } | |
| preg_match_all('/(\w+)="([^"]*)"/', $node->attributes, $matches, PREG_SET_ORDER); | |
| $source = file_get_contents($path); | |
| $source = preg_replace('/@\w+[^\n]*\n?/', '', $source); | |
| // Replace attributes | |
| foreach ($matches as $match) { | |
| $source = str_replace('{{ $'.$match[1].' }}', $match[2], $source); | |
| } | |
| // Replace default slot from children | |
| if (! $node->selfClosing) { | |
| $slotContent = implode('', array_map(fn ($n) => $n->render(), $node->children)); | |
| $source = str_replace('{{ $slot }}', $slotContent, $source); | |
| } | |
| $source = preg_replace('/\{\{\s*\$\w+\s*\}\}/', '', $source); | |
| $this->recordFolded($node->name, $path); | |
| return new TextNode($source); | |
| } | |
| private function compileNode(ComponentNode $node): ?TextNode | |
| { | |
| $path = resource_path("views/components/{$node->name}.blade.php"); | |
| if (! file_exists($path)) { | |
| return null; | |
| } | |
| $hash = substr(md5($path), 0, 12); | |
| $funcName = "_lilblaze_{$hash}"; | |
| $this->wrapComponent($path, $funcName); | |
| $attrs = $this->buildAttributeArray($node->attributes); | |
| $requirePath = "storage_path('framework/views/{$funcName}.php')"; | |
| if ($node->selfClosing) { | |
| return new TextNode( | |
| "<?php require_once {$requirePath}; {$funcName}({$attrs}); ?>" | |
| ); | |
| } | |
| $slotContent = implode('', array_map(fn ($n) => $n->render(), $node->children)); | |
| return new TextNode( | |
| "<?php ob_start(); ?>{$slotContent}<?php \$__slot = ob_get_clean(); " | |
| ."require_once {$requirePath}; " | |
| ."{$funcName}(array_merge({$attrs}, ['slot' => \$__slot])); ?>" | |
| ); | |
| } | |
| private function wrapComponent(string $sourcePath, string $funcName): void | |
| { | |
| $compiledPath = storage_path("framework/views/{$funcName}.php"); | |
| if (file_exists($compiledPath) && filemtime($compiledPath) >= filemtime($sourcePath)) { | |
| return; | |
| } | |
| $source = file_get_contents($sourcePath); | |
| $defaults = '[]'; | |
| if (preg_match('/@props\(\s*(\[.*?\])\s*\)/s', $source, $m)) { | |
| $defaults = $m[1]; | |
| } | |
| $source = preg_replace('/@\w+\(.*?\)\s*\n?/s', '', $source); | |
| $source = preg_replace('/@\w+\s*\n?/', '', $source); | |
| $source = preg_replace_callback( | |
| '/\{\{\s*(.+?)\s*\}\}/s', | |
| fn ($m) => '<?php echo e('.$m[1].'); '.'?>', | |
| $source | |
| ); | |
| $php = implode(PHP_EOL, [ | |
| "<?php if (!function_exists('{$funcName}')):", | |
| "function {$funcName}(array \$__data = []) {", | |
| " \$__data = array_merge({$defaults}, \$__data);", | |
| ' extract($__data);', | |
| ' ob_start();', | |
| "?>{$source}<?php", | |
| ' echo ltrim(ob_get_clean());', | |
| '}', | |
| 'endif; ?>', | |
| '', | |
| ]); | |
| file_put_contents($compiledPath, $php); | |
| } | |
| private function buildAttributeArray(string $attributes): string | |
| { | |
| if (empty(trim($attributes))) { | |
| return '[]'; | |
| } | |
| preg_match_all('/:?(\w+)="([^"]*)"/', $attributes, $matches, PREG_SET_ORDER); | |
| if (empty($matches)) { | |
| return '[]'; | |
| } | |
| $pairs = array_map(function ($matching) { | |
| $isDynamic = str_starts_with($matching[0], ':'); | |
| return $isDynamic | |
| ? "'".addslashes($matching[1])."' => ".$matching[2] | |
| : "'".addslashes($matching[1])."' => '".addslashes($matching[2])."'"; | |
| }, $matches); | |
| return '['.implode(', ', $pairs).']'; | |
| } | |
| private function recordFolded(string $name, string $path): void | |
| { | |
| $this->folded[] = [ | |
| 'name' => $name, | |
| 'path' => $path, | |
| 'mtime' => filemtime($path), | |
| ]; | |
| } | |
| private function stamp(string $compiled): string | |
| { | |
| $header = ''; | |
| foreach ($this->folded as $dep) { | |
| $header .= "<?php /* [LilBlazeFolded]:{$dep['name']}:{$dep['path']}:{$dep['mtime']} */ ?>".PHP_EOL; | |
| } | |
| $this->folded = []; | |
| return $header.$compiled; | |
| } | |
| } |
This file contains hidden or 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\LilBlaze; | |
| class ComponentNode | |
| { | |
| public array $children; | |
| public function __construct( | |
| public readonly string $name, | |
| public readonly string $attributes = '', | |
| array $children = [], | |
| public readonly bool $selfClosing = false, | |
| ) { | |
| $this->children = $children; | |
| } | |
| public function render(): string | |
| { | |
| $attrs = $this->attributes ? " {$this->attributes}" : ''; | |
| if ($this->selfClosing) { | |
| return "<x-{$this->name}{$attrs} />"; | |
| } | |
| $children = implode('', array_map(fn ($n) => $n->render(), $this->children)); | |
| return "<x-{$this->name}{$attrs}>{$children}</x-{$this->name}>"; | |
| } | |
| } |
This file contains hidden or 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\LilBlaze; | |
| class TextNode | |
| { | |
| public function __construct( | |
| public readonly string $content, | |
| ) {} | |
| public function render(): string | |
| { | |
| return $this->content; | |
| } | |
| } |
This file contains hidden or 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\LilBlaze; | |
| class Token | |
| { | |
| public function __construct( | |
| // 'text', 'tag_open', 'tag_close', 'tag_self_close' | |
| public readonly string $type, | |
| // raw matched string | |
| public readonly string $content, | |
| // component name | |
| public readonly string $name = '', | |
| // attribute string (the 'type="error" class="bold"' in <x-alert type="error" class="bold">) | |
| public readonly string $attributes = '', | |
| ) {} | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment