diff --git a/examples/components/header.blade.php b/examples/components/header.blade.php new file mode 100644 index 0000000..f2b4f50 --- /dev/null +++ b/examples/components/header.blade.php @@ -0,0 +1,8 @@ + + diff --git a/examples/layouts/app.blade.php b/examples/layouts/app.blade.php new file mode 100644 index 0000000..ddde598 --- /dev/null +++ b/examples/layouts/app.blade.php @@ -0,0 +1,42 @@ + + + + + + @yield('title', 'Kiri Blade 示例') + + + +
+

@yield('header', 'Kiri Blade 模板引擎')

+
+ +
+ @yield('content') +
+ + + + + diff --git a/examples/test-blade.php b/examples/test-blade.php new file mode 100644 index 0000000..3c0e9fa --- /dev/null +++ b/examples/test-blade.php @@ -0,0 +1,53 @@ + '张三', + 'email' => 'zhangsan@example.com', + 'age' => 28, + 'skills' => ['PHP', 'JavaScript', 'MySQL', 'Redis'], + 'posts' => [ + [ + 'title' => 'Blade 模板引擎介绍', + 'content' => '这是一个类似 Laravel Blade 的模板引擎实现。', + 'date' => '2024-01-15' + ], + [ + 'title' => '如何使用 Blade', + 'content' => 'Blade 提供了简洁优雅的模板语法。', + 'date' => '2024-01-20' + ], + ] +]; + +// 渲染视图 +try { + echo "开始渲染视图...\n\n"; + $html = $factory->render('user.profile', $data); + echo $html; + echo "\n\n渲染完成!\n"; +} catch (\Exception $e) { + echo "错误: " . $e->getMessage() . "\n"; + echo "文件: " . $e->getFile() . "\n"; + echo "行号: " . $e->getLine() . "\n"; +} + diff --git a/examples/user/profile.blade.php b/examples/user/profile.blade.php new file mode 100644 index 0000000..082d48c --- /dev/null +++ b/examples/user/profile.blade.php @@ -0,0 +1,54 @@ +@extends('layouts.app') + +@section('title') + 用户资料 - Kiri Blade +@endsection + +@section('header') + 用户资料页面 +@endsection + +@section('content') +

用户信息

+ + @if(isset($name)) +

姓名:{{ $name }}

+ @else +

姓名未设置

+ @endif + + @if(isset($email)) +

邮箱:{{ $email }}

+ @endif + + @if(isset($age)) +

年龄:{{ $age }} 岁

+ @endif + +

技能列表

+ @if(isset($skills) && is_array($skills)) + + @else +

暂无技能

+ @endif + +

文章列表

+ @if(isset($posts) && is_array($posts)) +
+ @foreach($posts as $post) +
+

{{ $post['title'] ?? '无标题' }}

+

{{ $post['content'] ?? '无内容' }}

+ 发布时间:{{ $post['date'] ?? '未知' }} +
+ @endforeach +
+ @else +

暂无文章

+ @endif +@endsection + diff --git a/src/Blade/BladeCompiler.php b/src/Blade/BladeCompiler.php new file mode 100644 index 0000000..e6cedb3 --- /dev/null +++ b/src/Blade/BladeCompiler.php @@ -0,0 +1,408 @@ + + */ + protected array $directives = []; + + /** + * 自定义指令处理器 + * @var array + */ + protected array $customDirectives = []; + + /** + * 编译后的模板缓存目录 + */ + protected string $cachePath; + + /** + * 视图文件目录 + */ + protected string $viewPath; + + /** + * @param string $viewPath 视图文件路径 + * @param string $cachePath 编译缓存路径 + */ + public function __construct(string $viewPath, string $cachePath) + { + $this->viewPath = rtrim($viewPath, '/\\'); + $this->cachePath = rtrim($cachePath, '/\\'); + $this->registerDefaultDirectives(); + } + + /** + * 注册默认指令 + */ + protected function registerDefaultDirectives(): void + { + $this->directives = [ + 'if' => 'if', + 'elseif' => 'elseif', + 'else' => 'else', + 'endif' => 'endif', + 'foreach' => 'foreach', + 'endforeach' => 'endforeach', + 'for' => 'for', + 'endfor' => 'endfor', + 'while' => 'while', + 'endwhile' => 'endwhile', + 'break' => 'break', + 'continue' => 'continue', + 'switch' => 'switch', + 'case' => 'case', + 'default' => 'default', + 'endswitch' => 'endswitch', + ]; + } + + /** + * 编译模板文件 + * + * @param string $view 视图名称 + * @return string 编译后的 PHP 文件路径 + */ + public function compile(string $view): string + { + $viewFile = $this->viewPath . '/' . str_replace('.', '/', $view) . '.blade.php'; + + if (!file_exists($viewFile)) { + throw new \RuntimeException("视图文件不存在: {$viewFile}"); + } + + $compiledPath = $this->getCompiledPath($view); + $compiledDir = dirname($compiledPath); + + if (!is_dir($compiledDir)) { + mkdir($compiledDir, 0755, true); + } + + // 如果源文件未修改,直接返回缓存的编译文件 + if (file_exists($compiledPath) && filemtime($viewFile) <= filemtime($compiledPath)) { + return $compiledPath; + } + + $content = file_get_contents($viewFile); + $compiled = $this->compileString($content); + + file_put_contents($compiledPath, $compiled); + + return $compiledPath; + } + + /** + * 编译模板字符串 + * + * @param string $content Blade 模板内容 + * @param bool $skipLayouts 是否跳过布局编译(避免递归) + * @return string 编译后的 PHP 代码 + */ + public function compileString(string $content, bool $skipLayouts = false): string + { + // 移除注释 + $content = $this->compileComments($content); + + // 编译 Echo 语句 + $content = $this->compileEchos($content); + + // 编译指令 + $content = $this->compileDirectives($content); + + // 编译布局和继承(如果未跳过) + if (!$skipLayouts) { + $content = $this->compileLayouts($content); + } + + // 编译包含 + $content = $this->compileIncludes($content); + + // 编译原始 PHP + $content = $this->compilePhp($content); + + return $content; + } + + /** + * 编译注释 {{-- ... --}} + * + * @param string $content + * @return string + */ + protected function compileComments(string $content): string + { + return preg_replace('/\{\{--\s*(.*?)\s*--\}\}/s', '', $content); + } + + /** + * 编译 Echo 语句 + * {{ $var }} => + * {!! $var !!} => + * + * @param string $content + * @return string + */ + protected function compileEchos(string $content): string + { + // 编译转义的 Echo {{ }} + $content = preg_replace_callback('/\{\{\s*(.+?)\s*\}\}/', function ($matches) { + $expression = trim($matches[1]); + return ""; + }, $content); + + // 编译原始 Echo {!! !!} + $content = preg_replace_callback('/\{!!\s*(.+?)\s*!!\}/', function ($matches) { + $expression = trim($matches[1]); + return ""; + }, $content); + + return $content; + } + + /** + * 编译指令 @if, @foreach 等 + * + * @param string $content + * @return string + */ + protected function compileDirectives(string $content): string + { + foreach ($this->directives as $directive => $phpDirective) { + $pattern = '/@' . $directive . '\s*\((.+?)\)/'; + $replacement = ""; + + $content = preg_replace($pattern, $replacement, $content); + + // 处理结束指令 + $endPattern = '/@end' . $directive . '/'; + $endReplacement = ""; + $content = preg_replace($endPattern, $endReplacement, $content); + } + + // 处理自定义指令 + foreach ($this->customDirectives as $directive => $handler) { + $pattern = '/@' . $directive . '\s*(?:\((.+?)\))?/'; + $content = preg_replace_callback($pattern, function ($matches) use ($handler) { + $expression = $matches[1] ?? ''; + return $handler($expression); + }, $content); + } + + // 处理 @else + $content = preg_replace('/@else\b/', '', $content); + + // 处理 @elseif + $content = preg_replace('/@elseif\s*\((.+?)\)/', '', $content); + + // 处理 @case + $content = preg_replace('/@case\s*\((.+?)\)/', '', $content); + + // 处理 @default + $content = preg_replace('/@default\b/', '', $content); + + // 处理 @break 和 @continue + $content = preg_replace('/@break\b/', '', $content); + $content = preg_replace('/@continue\b/', '', $content); + + return $content; + } + + /** + * 编译布局和继承 + * @extends('layout') + * @section('content') ... @endsection + * @yield('content') + * @parent + * + * @param string $content + * @return string + */ + protected function compileLayouts(string $content): string + { + // 处理 @extends + if (preg_match('/@extends\s*\([\'"](.+?)[\'"]\)/', $content, $matches)) { + $layout = $matches[1]; + $content = preg_replace('/@extends\s*\([\'"](.+?)[\'"]\)/', '', $content); + + // 提取所有 @section + $sections = []; + $content = preg_replace_callback('/@section\s*\([\'"](.+?)[\'"]\)\s*(.*?)\s*@endsection/s', function ($matches) use (&$sections) { + $name = $matches[1]; + $sectionContent = trim($matches[2]); + $sections[$name] = $sectionContent; + return ''; + }, $content); + + // 获取布局内容 + $layoutContent = $this->getLayoutContent($layout); + + // 先编译 section 内容 + $compiledSections = []; + foreach ($sections as $name => $sectionContent) { + $compiledSections[$name] = $this->compileString($sectionContent, true); + } + + // 编译布局内容(跳过布局处理以避免递归) + $layoutContent = $this->compileString($layoutContent, true); + + // 替换 @yield 为对应的 section 内容 + foreach ($compiledSections as $name => $sectionContent) { + $layoutContent = preg_replace('/@yield\s*\([\'"](?:' . preg_quote($name, '/') . ')[\'"]\)/', $sectionContent, $layoutContent); + } + + // 替换剩余的 @yield 为空 + $layoutContent = preg_replace('/@yield\s*\([\'"](.+?)[\'"]\)/', '', $layoutContent); + + return $layoutContent; + } + + // 处理 @section ... @endsection (非继承模式,用于组件等) + $content = preg_replace_callback('/@section\s*\([\'"](.+?)[\'"]\)\s*(.*?)\s*@endsection/s', function ($matches) { + $name = $matches[1]; + $sectionContent = trim($matches[2]); + // 处理 @parent + $sectionContent = preg_replace('/@parent/', '', $sectionContent); + return "{$sectionContent}"; + }, $content); + + // 处理 @yield(非继承模式) + $content = preg_replace_callback('/@yield\s*\([\'"](.+?)[\'"]\s*(?:,\s*[\'"](.+?)[\'"]\s*)?\)/', function ($matches) { + $name = $matches[1]; + $default = $matches[2] ?? ''; + if ($default) { + return ""; + } + return ""; + }, $content); + + // 处理 @parent(在 section 中使用) + $content = preg_replace('/@parent/', '', $content); + + return $content; + } + + /** + * 编译包含 @include('view') + * + * @param string $content + * @return string + */ + protected function compileIncludes(string $content): string + { + return preg_replace_callback('/@include\s*\([\'"](.+?)[\'"]\s*(?:,\s*\[(.+?)\])?\)/', function ($matches) { + $view = $matches[1]; + $dataStr = $matches[2] ?? ''; + + // 解析数据数组 + if ($dataStr) { + // 尝试解析数组字符串,如果失败则使用空数组 + try { + $data = eval("return [{$dataStr}];"); + $dataCode = var_export($data, true); + } catch (\Throwable $e) { + $dataCode = '[]'; + } + } else { + $dataCode = '[]'; + } + + // 使用静态方法调用,因为我们需要在运行时获取 BladeFactory 实例 + return ""; + }, $content); + } + + /** + * 编译原始 PHP @php ... @endphp + * + * @param string $content + * @return string + */ + protected function compilePhp(string $content): string + { + return preg_replace('/@php\s*(.*?)\s*@endphp/s', '', $content); + } + + /** + * 获取布局文件内容 + * + * @param string $layout + * @return string + */ + protected function getLayoutContent(string $layout): string + { + $layoutFile = $this->viewPath . '/' . str_replace('.', '/', $layout) . '.blade.php'; + + if (!file_exists($layoutFile)) { + throw new \RuntimeException("布局文件不存在: {$layoutFile}"); + } + + return file_get_contents($layoutFile); + } + + /** + * 获取编译后的文件路径 + * + * @param string $view + * @return string + */ + protected function getCompiledPath(string $view): string + { + $hash = md5($view); + return $this->cachePath . '/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash . '.php'; + } + + /** + * 注册自定义指令 + * + * @param string $name 指令名称 + * @param callable $handler 处理函数 + */ + public function directive(string $name, callable $handler): void + { + $this->customDirectives[$name] = $handler; + } + + /** + * 清除编译缓存 + * + * @return void + */ + public function clearCache(): void + { + if (is_dir($this->cachePath)) { + $this->deleteDirectory($this->cachePath); + } + } + + /** + * 递归删除目录 + * + * @param string $dir + * @return void + */ + protected function deleteDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . '/' . $file; + is_dir($path) ? $this->deleteDirectory($path) : unlink($path); + } + + rmdir($dir); + } +} + diff --git a/src/Blade/BladeFactory.php b/src/Blade/BladeFactory.php new file mode 100644 index 0000000..99a69c8 --- /dev/null +++ b/src/Blade/BladeFactory.php @@ -0,0 +1,144 @@ +viewPath = rtrim($viewPath, '/\\'); + $this->cachePath = rtrim($cachePath, '/\\'); + } + + /** + * 获取编译器实例 + * + * @return BladeCompiler + */ + public function getCompiler(): BladeCompiler + { + if ($this->compiler === null) { + $this->compiler = new BladeCompiler($this->viewPath, $this->cachePath); + } + + return $this->compiler; + } + + /** + * 创建视图实例 + * + * @param string $view 视图名称 + * @param array $data 视图数据 + * @return BladeView + */ + public function make(string $view, array $data = []): BladeView + { + // 合并共享数据 + $data = array_merge($this->shared, $data); + + return new BladeView($view, $data, $this->getCompiler()); + } + + /** + * 渲染视图 + * + * @param string $view 视图名称 + * @param array $data 视图数据 + * @return string + */ + public function render(string $view, array $data = []): string + { + return $this->make($view, $data)->render(); + } + + /** + * 共享数据到所有视图 + * + * @param string|array $key 键名或数据数组 + * @param mixed $value 值(当 $key 为数组时忽略) + * @return $this + */ + public function share(string|array $key, mixed $value = null): self + { + if (is_array($key)) { + $this->shared = array_merge($this->shared, $key); + } else { + $this->shared[$key] = $value; + } + + return $this; + } + + /** + * 注册自定义指令 + * + * @param string $name 指令名称 + * @param callable $handler 处理函数 + * @return $this + */ + public function directive(string $name, callable $handler): self + { + $this->getCompiler()->directive($name, $handler); + return $this; + } + + /** + * 清除编译缓存 + * + * @return void + */ + public function clearCache(): void + { + $this->getCompiler()->clearCache(); + } + + /** + * 获取视图路径 + * + * @return string + */ + public function getViewPath(): string + { + return $this->viewPath; + } + + /** + * 获取缓存路径 + * + * @return string + */ + public function getCachePath(): string + { + return $this->cachePath; + } +} + diff --git a/src/Blade/BladeHelper.php b/src/Blade/BladeHelper.php new file mode 100644 index 0000000..460eaf1 --- /dev/null +++ b/src/Blade/BladeHelper.php @@ -0,0 +1,58 @@ +render($view, $data); + } +} + diff --git a/src/Blade/BladeView.php b/src/Blade/BladeView.php new file mode 100644 index 0000000..af5b4b9 --- /dev/null +++ b/src/Blade/BladeView.php @@ -0,0 +1,86 @@ +view = $view; + $this->data = $data; + $this->compiler = $compiler; + } + + /** + * 渲染视图 + * + * @return string + */ + public function render(): string + { + // 编译视图 + $compiledPath = $this->compiler->compile($this->view); + + // 提取数据 + extract($this->data, EXTR_SKIP); + + // 开始输出缓冲 + ob_start(); + + try { + // 包含编译后的 PHP 文件 + require $compiledPath; + } catch (\Throwable $e) { + ob_end_clean(); + throw new \RuntimeException("视图渲染失败: {$this->view}", 0, $e); + } + + return ob_get_clean(); + } + + /** + * 获取视图名称 + * + * @return string + */ + public function getView(): string + { + return $this->view; + } + + /** + * 获取视图数据 + * + * @return array + */ + public function getData(): array + { + return $this->data; + } +} + diff --git a/src/Blade/README.md b/src/Blade/README.md new file mode 100644 index 0000000..e6f30c2 --- /dev/null +++ b/src/Blade/README.md @@ -0,0 +1,217 @@ +# Kiri Blade 模板引擎 + +这是一个类似 Laravel Blade 的模板引擎实现,用于 kiri-router 项目。 + +## 功能特性 + +- ✅ 变量输出:`{{ $variable }}` 和 `{!! $variable !!}` +- ✅ 控制结构:`@if`, `@elseif`, `@else`, `@endif` +- ✅ 循环:`@foreach`, `@endforeach`, `@for`, `@endfor`, `@while`, `@endwhile` +- ✅ 布局继承:`@extends`, `@section`, `@yield`, `@endsection` +- ✅ 包含视图:`@include` +- ✅ 注释:`{{-- comment --}}` +- ✅ 原始 PHP:`@php ... @endphp` +- ✅ 编译缓存:自动缓存编译后的模板以提高性能 + +## 使用方法 + +### 基本用法 + +```php +use function Kiri\Router\View; + +// 在控制器中渲染视图 +return View('user.profile', [ + 'name' => 'John Doe', + 'email' => 'john@example.com' +]); +``` + +### 视图文件结构 + +视图文件应放在 `resources/view` 目录下,使用 `.blade.php` 扩展名: + +``` +resources/ + view/ + layouts/ + app.blade.php + user/ + profile.blade.php + components/ + header.blade.php +``` + +### 模板语法示例 + +#### 1. 变量输出 + +```blade + +

{{ $title }}

+

{{ $description }}

+ + +
{!! $htmlContent !!}
+``` + +#### 2. 条件语句 + +```blade +@if($user->isAdmin()) +

管理员

+@elseif($user->isModerator()) +

版主

+@else +

普通用户

+@endif +``` + +#### 3. 循环 + +```blade +@foreach($users as $user) +
+

{{ $user->name }}

+

{{ $user->email }}

+
+@endforeach + +@for($i = 0; $i < 10; $i++) +

Item {{ $i }}

+@endfor +``` + +#### 4. 布局继承 + +**layouts/app.blade.php:** +```blade + + + + @yield('title', '默认标题') + + +
+ @include('components.header') +
+ +
+ @yield('content') +
+ + + + +``` + +**user/profile.blade.php:** +```blade +@extends('layouts.app') + +@section('title') + 用户资料 +@endsection + +@section('content') +

用户资料

+

姓名:{{ $name }}

+

邮箱:{{ $email }}

+@endsection +``` + +#### 5. 包含视图 + +```blade +@include('components.header') + +@include('components.footer', ['year' => 2024]) +``` + +#### 6. 注释 + +```blade +{{-- 这是注释,不会输出到 HTML --}} +``` + +#### 7. 原始 PHP + +```blade +@php + $count = count($items); + $total = array_sum($prices); +@endphp + +

总数:{{ $count }}

+``` + +## 高级用法 + +### 自定义指令 + +```php +use Kiri\Router\Blade\BladeHelper; + +$factory = BladeHelper::getFactory(); + +// 注册自定义指令 +$factory->directive('datetime', function ($expression) { + return ""; +}); +``` + +使用自定义指令: + +```blade +@datetime(time()) +``` + +### 共享数据 + +```php +use Kiri\Router\Blade\BladeHelper; + +$factory = BladeHelper::getFactory(); + +// 共享数据到所有视图 +$factory->share('siteName', 'My Website'); +$factory->share([ + 'version' => '1.0.0', + 'author' => 'Kiri Team' +]); +``` + +### 清除缓存 + +```php +use Kiri\Router\Blade\BladeHelper; + +$factory = BladeHelper::getFactory(); +$factory->clearCache(); +``` + +## 配置 + +视图路径和缓存路径可以通过常量配置: + +```php +// 视图路径 +define('APP_PATH', __DIR__ . '/'); + +// 缓存路径(可选) +define('RUNTIME_PATH', __DIR__ . '/runtime'); +``` + +默认情况下: +- 视图路径:`APP_PATH . 'resources/view'` +- 缓存路径:`RUNTIME_PATH . '/blade'` 或系统临时目录 + +## 注意事项 + +1. 模板文件必须使用 `.blade.php` 扩展名 +2. 视图路径使用点号分隔,如 `user.profile` 对应 `user/profile.blade.php` +3. 编译后的模板会自动缓存,修改源文件后会自动重新编译 +4. 建议在生产环境中定期清理缓存目录 + diff --git a/src/Response.php b/src/Response.php index ad1f896..1aaacaa 100644 --- a/src/Response.php +++ b/src/Response.php @@ -4,13 +4,17 @@ declare(strict_types=1); namespace Kiri\Router; use Kiri\Di\Interface\ResponseEmitterInterface; +use Kiri\Router\Blade\BladeFactory; +use Kiri\Router\Blade\BladeHelper; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; /** - * @param string $path - * @param array $data + * 渲染 Blade 视图 + * + * @param string $path 视图路径(支持 . 分隔,如 'user.profile') + * @param array $data 视图数据 * * @return ResponseInterface */ @@ -18,21 +22,26 @@ function View(string $path, array $data = []): ResponseInterface { $response = \response(); $response->withAddedHeader('Content-Type', 'text/html; charset=utf-8'); + try { - ob_start(); - $__path = $path; - $__data = $data; - - extract($__data, EXTR_SKIP); - - require APP_PATH . 'resources/view/' . $path . '.php'; - - $content = ltrim(ob_get_clean()); + // 获取视图路径和缓存路径 + $viewPath = APP_PATH . 'resources/view'; + $cachePath = storage(null, 'view/cache'); + + // 创建或获取 BladeFactory 实例 + $factory = BladeHelper::getFactory(); + if ($factory->getViewPath() !== $viewPath) { + $factory = new BladeFactory($viewPath, $cachePath); + BladeHelper::setFactory($factory); + } + + // 渲染视图 + $content = $factory->render($path, $data); } catch (\Exception $e) { - $content = throwable($e); - } finally { - return $response->html($content); + $content = function_exists('throwable') ? throwable($e) : $e->getMessage(); } + + return $response->html($content); } /**