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, '/');
+ }
+}