diff --git a/composer.json b/composer.json index 4c1b569..0057f43 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "phpunit/phpunit": "^12.0" }, "suggest": { + "liquid/liquid": "Required for the Liquid template driver (set view.driver to 'liquid').", "workerman/workerman": "Required for the WebSocket module (^5.0). Install when config/websocket.php is present." }, "autoload": { diff --git a/docs/view.md b/docs/view.md index 0aaa3d1..54bc433 100644 --- a/docs/view.md +++ b/docs/view.md @@ -8,6 +8,9 @@ ```php return [ + // 模板驱动:twig(默认)或 liquid(需 composer require liquid/liquid) + 'driver' => 'twig', + // 模板根目录 'view_path' => app_path('view'), @@ -29,11 +32,12 @@ return [ | 配置项 | 类型 | 默认值 | 说明 | | ------------ | -------- | ----------------------- | -------------------------------------- | +| `driver` | string | `'twig'` | 模板引擎驱动,可选 `twig` 或 `liquid` | | `view_path` | string | `app_path('view')` | 模板文件所在目录 | -| `cache_path` | string | `runtime_path('twig')` | Twig 编译缓存目录 | -| `debug` | bool | `false` | 是否开启 Twig 调试模式 | +| `cache_path` | string | `runtime_path('twig')` | 模板编译缓存目录 | +| `debug` | bool | `false` | 是否开启调试模式 | | `base_url` | string | `''` | 静态资源 URL 前缀,用于 `asset()`/`url()` | -| `extensions` | string[] | `['.twig', '.html']` | 允许的模板后缀,按查找优先级排序 | +| `extensions` | string[] | `['.twig', '.html']` | 允许的模板后缀,按查找优先级排序(liquid 驱动默认为 `['.liquid']`) | ## 模板后缀 @@ -181,3 +185,105 @@ $twig->addFilter(new \Twig\TwigFilter('money', fn ($value) => number_format((flo ```bash php lee run --host=127.0.0.1 --port=8000 ``` + +## 切换到 Liquid 驱动 + +框架内置了基于 [liquid/liquid](https://github.com/kalimatas/php-liquid) 的 Liquid 驱动,可通过配置一键切换,无需编写适配代码。 + +### 1. 安装依赖 + +```bash +composer require liquid/liquid +``` + +### 2. 修改配置 + +在 `config/view.php` 中将 `driver` 设为 `liquid`: + +```php +return [ + 'driver' => 'liquid', + 'view_path' => app_path('view'), + 'cache_path' => runtime_path('liquid'), + 'extensions' => ['.liquid'], + // ... +]; +``` + +切换后,`view()`、`asset()` 辅助函数及 `app('view')` 自动使用 Liquid 驱动,控制器代码无需改动: + +```php +public function index() +{ + return view('user/profile', ['name' => 'Lychee']); +} +``` + +### Liquid 模板示例 + +```liquid +{# app/view/user/profile.liquid #} +

Hello, {{ name }}!

+ +{% if user %} +

Email: {{ user.email }}

+{% endif %} + +{% for item in items %} +
  • {{ item }}
  • +{% endfor %} +``` + +> **注意**:框架内置的异常页面(调试模式的堆栈页、生产环境的通用错误页)始终使用 Twig 渲染,不受驱动切换影响,确保异常情况下也能正常展示错误信息。 + +## 自定义模板驱动 + +如需使用 Twig、Liquid 之外的其他模板引擎(如 Blade、Smarty),可实现 `Lychee\view\ViewInterface` 接口并通过容器绑定覆盖。 + +### 1. 实现 ViewInterface + +```php +viewPath . '/' . $template); + } + + public function asset(string $path): string + { + return $this->baseUrl . '/' . ltrim($path, '/'); + } +} +``` + +### 2. 注册到容器 + +在 `app/common.php` 中绑定(加载时机早于视图模块初始化): + +```php +app()->instance(\Lychee\view\ViewInterface::class, new \App\view\CustomDriver( + viewPath: app_path('view'), + baseUrl: config('view.base_url', ''), +)); +``` + +移除绑定即可恢复框架默认的 Twig 驱动。 + diff --git a/phpstan.neon b/phpstan.neon index b741482..1562184 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,4 +5,12 @@ parameters: - tests checkExplicitMixed: false bootstrapFiles: - - tests/stub/bootstrap.php \ No newline at end of file + - tests/stub/bootstrap.php + ignoreErrors: + # Liquid 驱动依赖可选的 liquid/liquid 包,未安装时静态分析无法解析其符号 + - + identifier: class.notFound + path: src/view/driver/Liquid.php + - + identifier: constructor.unusedParameter + path: src/view/driver/Liquid.php \ No newline at end of file diff --git a/src/Application.php b/src/Application.php index 6ed2207..2361a85 100644 --- a/src/Application.php +++ b/src/Application.php @@ -35,6 +35,7 @@ use Lychee\session\Session; use Lychee\view\ExceptionRenderer; use Lychee\view\View; +use Lychee\view\ViewInterface; use Lychee\websocket\command\ServerCommand; use Lychee\websocket\WebSocketServer; use Psr\Log\LoggerInterface; @@ -555,28 +556,43 @@ private function resolveMigrationPdo(): ?PDO private function bootView(): void { - /** @var Config $config */ - $config = $this->container->get('config'); - $viewConfig = $config->get('view', []); - - $viewPath = (string) ($viewConfig['view_path'] ?? ($this->basePath . '/app/view')); - $cachePath = (string) ($viewConfig['cache_path'] ?? ($this->container->runtimePath . 'twig')); - $debug = (bool) ($viewConfig['debug'] ?? false); - $baseUrl = (string) ($viewConfig['base_url'] ?? ''); - $extensions = (array) ($viewConfig['extensions'] ?? ['.twig', '.html']); + // 若应用侧已通过容器绑定自定义模板驱动,则优先使用 + if ($this->container->has(ViewInterface::class)) { + $view = $this->container->get(ViewInterface::class); + } else { + /** @var Config $config */ + $config = $this->container->get('config'); + $viewConfig = $config->get('view', []); + + $driver = (string) ($viewConfig['driver'] ?? 'twig'); + $viewPath = (string) ($viewConfig['view_path'] ?? ($this->basePath . '/app/view')); + $cachePath = (string) ($viewConfig['cache_path'] ?? ($this->container->runtimePath . 'twig')); + $debug = (bool) ($viewConfig['debug'] ?? false); + $baseUrl = (string) ($viewConfig['base_url'] ?? ''); + + if (!is_dir($viewPath)) { + @mkdir($viewPath, 0777, true); + } - if (!is_dir($viewPath)) { - @mkdir($viewPath, 0777, true); + $view = match ($driver) { + 'liquid' => new \Lychee\view\driver\Liquid( + viewPath: $viewPath, + cachePath: $cachePath, + debug: $debug, + baseUrl: $baseUrl, + extensions: (array) ($viewConfig['extensions'] ?? ['.liquid']), + ), + default => new View( + viewPath: $viewPath, + cachePath: $cachePath, + debug: $debug, + baseUrl: $baseUrl, + extensions: (array) ($viewConfig['extensions'] ?? ['.twig', '.html']), + ), + }; } - $view = new View( - viewPath: $viewPath, - cachePath: $cachePath, - debug: $debug, - baseUrl: $baseUrl, - extensions: $extensions, - ); - + $this->container->instance(ViewInterface::class, $view); $this->container->instance(View::class, $view); $this->container->instance('view', $view); } diff --git a/src/console/stubs/config/view.stub b/src/console/stubs/config/view.stub index 754acda..a33c8aa 100644 --- a/src/console/stubs/config/view.stub +++ b/src/console/stubs/config/view.stub @@ -3,6 +3,9 @@ declare(strict_types=1); return [ + // 模板驱动:twig(默认)或 liquid(需 composer require liquid/liquid) + 'driver' => 'twig', + // 模板根目录 'view_path' => app_path('view'), @@ -15,6 +18,6 @@ return [ // 静态资源基础 URL,如配置 CDN 可填 'https://cdn.example.com' 'base_url' => '', - // 允许的模板后缀,按查找优先级排序 + // 允许的模板后缀,按查找优先级排序(twig 驱动默认 ['.twig', '.html'],liquid 驱动默认 ['.liquid']) 'extensions' => ['.twig', '.html'], ]; diff --git a/src/helpers.php b/src/helpers.php index e468aab..4092bf9 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -381,7 +381,7 @@ function error_message(): string */ function view(string $template, array $data = []): string { - /** @var \Lychee\view\View $view */ + /** @var \Lychee\view\ViewInterface $view */ $view = app('view'); return $view->render($template, $data); @@ -396,7 +396,7 @@ function view(string $template, array $data = []): string */ function asset(string $path): string { - /** @var \Lychee\view\View $view */ + /** @var \Lychee\view\ViewInterface $view */ $view = app('view'); return $view->asset($path); diff --git a/src/view/View.php b/src/view/View.php index 4eedd0a..b777f56 100644 --- a/src/view/View.php +++ b/src/view/View.php @@ -13,8 +13,11 @@ * 基于 Twig 的模板引擎。 * * 模板文件默认从 app/view 目录读取,编译缓存写入 runtime/twig。 + * + * 这是 {@see ViewInterface} 的默认实现;如需切换到 Liquid 等其他模板引擎, + * 实现 ViewInterface 后通过容器覆盖绑定即可。 */ -class View +class View implements ViewInterface { protected Environment $twig; diff --git a/src/view/ViewInterface.php b/src/view/ViewInterface.php new file mode 100644 index 0000000..e71d8b4 --- /dev/null +++ b/src/view/ViewInterface.php @@ -0,0 +1,34 @@ + $data 传递给模板的数据 + */ + public function render(string $template, array $data = []): string; + + /** + * 判断模板是否存在。 + */ + public function exists(string $template): bool; + + /** + * 生成 public 目录下静态资源的 URL。 + */ + public function asset(string $path): string; +} diff --git a/src/view/driver/Liquid.php b/src/view/driver/Liquid.php new file mode 100644 index 0000000..95c9d18 --- /dev/null +++ b/src/view/driver/Liquid.php @@ -0,0 +1,113 @@ +viewPath = rtrim($viewPath, '/\\'); + $this->cachePath = rtrim($cachePath, '/\\'); + $this->baseUrl = rtrim($baseUrl, '/\\'); + $this->extensions = $extensions; + + // 默认开启 HTML 自动转义,与 Twig 的 autoescape 行为一致 + LiquidEngine::set('ESCAPE_BY_DEFAULT', true); + } + + /** + * 将模板名称解析为实际的模板文件路径。 + */ + protected function resolveTemplate(string $template): string + { + if (str_contains($template, DIRECTORY_SEPARATOR) && is_file($template)) { + return $template; + } + + $candidate = $this->viewPath . DIRECTORY_SEPARATOR . $template; + + if (is_file($candidate)) { + return $candidate; + } + + foreach ($this->extensions as $ext) { + if (str_ends_with($template, $ext)) { + return $candidate; + } + } + + foreach ($this->extensions as $ext) { + if (is_file($candidate . $ext)) { + return $candidate . $ext; + } + } + + return $candidate; + } + + public function render(string $template, array $data = []): string + { + $file = $this->resolveTemplate($template); + + $tpl = new LiquidTemplate(); + + if ($this->cachePath !== '' && is_dir($this->cachePath)) { + $tpl->setCache(new FileCache($this->cachePath)); + } + + $tpl->parseFile($file); + + return $tpl->render($data); + } + + public function exists(string $template): bool + { + return is_file($this->resolveTemplate($template)); + } + + public function asset(string $path): string + { + return $this->baseUrl . '/' . ltrim($path, '/'); + } +}