migears / yaml-pages
Declarative YAML page definitions compiled to miGears template files
Requires
- php: ^8.1
- ext-yaml: *
- migears/pages: ^2.0
Requires (Dev)
- migears/template: ^2.0
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A declarative YAML page definition tool that compiles .page.yaml declarations into miGears Template files (.tpl.php), which the template engine then compiles to pure PHP on first render. The YAML declaration is the single source of truth; generated templates are derived artifacts and must not be hand-edited.
Background: miGears is the open-source successor of TinyGears, a self-developed PHP framework. It was renamed and open-sourced recently because the name TinyGears is already taken in the open-source community.
Features
- PHP 8.1+, PSR-4 autoloading, namespace
MiGears\YamlPages - YAML parsing via PECL
ext-yaml(pecl install yaml) — no third-party composer packages - Declares page structure, data binding, conditionals (
if), loops (each), form fields, table columns and layout inheritance {{ path }}interpolation with auto-escaping — XSS protection inherited from the template engine- Compile-time validation of structure, fields, paths and keys — nothing is silently dropped
- Attribute passthrough for front-end frameworks:
"@click",x-on:click,v-bind:href,wire:click,hx-get,data-*,class/id/styleare forwarded to the emitted tag, plusbindfor the framework's own binding - Generic
elnode, so wrapper attributes (Alpine'sx-data) have somewhere to live - Built-in components (
card,button,alert,badge) plus custom components written per miGears Template conventions - Deliberately out of scope: business logic, event handling, state management, routing, runtime YAML parsing — those belong to the front-end framework you pair it with
Boundaries
In scope
- The YAML front end: parsing a
.page.yamldeclaration into the array IR (MiGears\YamlPages\Compiler extends MiGears\Pages\Compiler), using PECLext-yaml'syaml_parse— a hard runtime requirement (ext-yamlsits in this package'srequire). - The YAML surface spelling: a quoted key can express any attribute name (
"@click",":href",x-on:click), so there is no__eventmapping and no<attr>node; a__-prefixed key is refused with a hint to write"@...". - YAML-facing parse behaviour and errors: one document per stream, any libyaml warning is fatal,
yaml.decode_phpis forced off for the parse, and errors carry a node path (MiGears\YamlPages\Exception\CompileException); plus thebin/yaml-pages compile [output-dir] [--check]CLI. - The four built-in components shipped in
components/(card,button,alert,badge) and the runnable examples underexamples/.
Not in scope (by design)
- The node vocabulary, node compilation, interpolation, validation and attribute passthrough — inherited from the shared compiler in
migears/pages; this package overrides onlyparse()and the spelling hooks. - The XML spelling of the same declarations — owned by
migears/xml-pages(the XML twin:__clickfor@click,<attr>nodes, duplicate elements refused); the two front ends must compile to identical artifacts. - The second compilation to pure PHP and rendering at runtime — owned by
migears/template'sTemplateCompiler, reached transitively throughmigears/pages. - Business logic, event handling, state management, routing and runtime YAML parsing — belong to the front-end framework you pair the page with; they never enter YAML.
How It Works
Two deliberate compilations:
- yaml-pages parses the YAML declaration into the array DSL of
migears/pages; the shared compiler there turns it into.tpl.phpsugar syntax (## $expr ##). The intermediate output stays readable, so each DSL keyword maps visibly to template syntax. migears/template'sTemplateCompilerturns that sugar into a pure PHP template (mtime-cached, recompiled only when the template changes). Rendering is plain PHP: the template runs and its variables are output to the browser as HTML. The declaration layer never enters runtime.
The generated .tpl.php file is a derived artifact — re-running the compiler overwrites it. Edit the YAML, never the output.
Installation
composer require migears/yaml-pages
Requires PHP 8.1+, the yaml extension, and migears/pages ^2.0 — the shared compiler, which pulls in migears/template ^2.0 to render what it emits.
Install the yaml extension on macOS:
brew install libyaml echo "$(brew --prefix libyaml)" | pecl install yaml
On Linux, pecl install yaml usually works directly.
Make the built-in components findable by the template engine:
use MiGears\Template\Template; $tpl = new Template(__DIR__ . '/views'); $tpl->addPath('vendor/migears/yaml-pages/components');
Or copy components/ into your project's template directory.
Quick Start
Write a page declaration views/pages/users.page.yaml:
title: 用户管理 layout: layout/main sections: content: - type: heading level: 2 text: 用户列表 - type: table items: users as: user empty: 暂无数据 columns: - label: ID pop: '{{ user.id }}' - label: 姓名 pop: '{{ user.name }}' - label: 操作 content: - type: link href: /users/{{ user.id }}/edit text: 编辑
Compile it:
php vendor/bin/yaml-pages compile views/pages/users.page.yaml
This produces views/pages/users.tpl.php. Render it like any other template:
echo $tpl->render('pages/users', [ 'users' => [ ['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob'], ], ]);
A complete example covering every syntax feature ships in examples/full-featured.page.yaml, with a minimal layout in examples/views/layout/main.php. Compile it in place and it renders straight away:
php bin/yaml-pages compile examples/full-featured.page.yaml examples/views
YAML Notes
The parser is libyaml (via ext-yaml), which follows YAML 1.1. Quote a value when it would otherwise be mis-parsed:
| Case | Wrong | Right |
|---|---|---|
Value starts with { / [ |
text: {{ user.name }} |
text: '{{ user.name }}' |
Value starts with ! (tag prefix) |
when: !user.hidden |
when: '!user.hidden' |
Value contains : |
text: 时间: 12:00 |
text: '时间: 12:00' |
Value contains # (comment) |
text: a # b |
text: 'a # b' |
Key starts with @ or : |
@click: go() |
"@click": go() |
Also:
{{ ... }}in the middle of a value (e.g./users/{{ user.id }}/edit) needs no quotes.true/false/yes/no/on/offare booleans in YAML 1.1, in any capitalisation, and so are the single lettersy/n—required: trueis intentional, whiledata-x: yreaches the artifact asdata-x="true". Quote a value you mean as a string.level: 2,rows: 4parse as integers, matching theheading.level/textarea.rowstype checks.- In double-quoted strings
\nis a newline; use single quotes for a literal backslash-n. - A key containing a colon (
x-on:click:,wire:click:) needs no quotes — a colon not followed by whitespace is not a mapping separator. !php/objectand the other!php/tags are never decoded: the compiler turnsyaml.decode_phpoff for its own parse and restores it afterwards, so a declaration cannot smuggle an object in whatever php.ini says. The tag's value arrives as plain text.- One stream, one document: a second document after a
---separator is a compile error, because a page declaration is a single document. A leading---start marker does not count as one, and a---inside a block scalar is text. - Duplicate keys in one mapping are merged by libyaml before PHP sees the document: the last one wins, with no warning and nothing left to detect — a second
body:silently replaces the first, and so do a secondsections.content, a node field written twice, a repeatedoptionskey and a repeatedcomponent.datakey. YAML itself makes two equal keys in one mapping an error, so the loader is being lenient here; keep one key per mapping. (migears/xml-pagesrefuses the equivalent duplicate element.)
Front-end Framework Integration
Keys on a node fall into three groups:
- DSL fields — consumed by the node itself (
heading.level,link.href,form.action), including structural children (then,body,fields,columns,data,options,content). - Forwarded attributes — emitted on the tag the node produces:
"@event"— the Alpine / Vue event shorthand, written as a quoted key- any name containing a colon:
x-on:click,x-bind:href,v-on:click,wire:click,on:click;":href"needs quotes - prefixes
x-,v-,hx-,data- - the HTML hooks
class,id,style
- Everything else is a compile error — treated as a typo, never dropped silently.
Nodes that emit no tag of their own (text, if, each, component) reject forwarded attributes; wrap them in el instead. The page root likewise accepts only title / layout / body / sections.
Forwarded values are HTML-escaped first and interpolated second, so {{ }} works inside them (and single quotes stay readable). Scalars are normalised for HTML: 7 → "7", true → "true", x-cloak: → x-cloak="" (the YAML spelling of a valueless attribute); anything non-scalar is an error.
A forwarded name is emitted exactly as written, so it has to be a legal attribute name: whitespace, quotes, <, >, /, = and control bytes are compile errors (the XML front end refuses the same shapes while it parses, and refuses an <attr> name that is not one). The DSL's own literal attributes — link.href, link.target, form.action, field.name / id / placeholder, option value — are escaped the same way, so a quote in an href can no longer end the attribute early. Element text is not escaped: that part is yours, exactly as on a text node.
- type: el tag: div x-data: '{ open: false }' class: panel "@click": open = ! open body: - type: text text: 切换
How this differs from XML
XML attribute names cannot contain @, so the XML variant spells it __click. YAML writes "@click" directly — there is no __ mapping here, and no <attr> node either, because a quoted YAML key can express any attribute name. Writing __click in YAML fails with a hint to use "@click".
Framework matrix
| Framework | Key style | Result |
|---|---|---|
| Alpine | x-on:click, x-bind:href, x-data, x-show, x-cloak: |
works |
| Alpine | "@click" |
works (quoted key) |
| Alpine | @click unquoted |
fails — YAML scan error; quote it |
| Alpine | x-on-click (hyphen) |
rejected — Alpine only has the colon form |
| Vue | v-on:click, v-bind:href, ":href" |
works |
| Vue | "@click" |
works |
| htmx | hx-get, hx-trigger |
works |
| Stimulus | data-controller, data-action |
works |
| Livewire | wire:click, wire:model.live |
works |
One owner per region
Server-side and client-side rendering must not both own the same DOM region. Render structure with each / if / table on the server, then hang interaction on top with Alpine — do not also drive that list with x-for, or Alpine regenerates it and you get duplicated nodes plus flicker.
Data Binding
{{ path }} interpolates a dot path into an auto-escaped output:
| Path | Compiles to |
|---|---|
{{ users }} |
## $users ?? '' ## |
{{ user.name }} |
## $user['name'] ?? '' ## |
{{ form.errors.email }} |
## $form['errors']['email'] ?? '' ## |
Rules:
- Only
a.b.cpaths — no function calls, no arithmetic, no string literals. Anything else is a compile error. - Paths compile to array access; normalize Domain entities to arrays at the controller boundary.
- Only
if.whentakes a leading!for negation; a!anywhere else (e.g.each.items) is a compile error. - Conditions and loops fall back with
?? null, bound text and attributes with?? ''. - At most two braces per interpolation:
{{{or}}}is a compile error. A third brace slips past the pairing check and would leave stray braces in the rendered output. - Literal fields —
layout, section names,form.method,field.name,field.label,optionvalue and text,table.empty,column.label,component.name— are emitted as-is. Writing{{ }}there is a compile error, not a silent no-op.
Node Reference
Every node in body / sections is an object with a type field. Available types: text, heading, link, if, each, form, table, el, component. Nested structures (field, column) are typed by their position — they need no type, and a written one must match (field / column) or compilation fails.
Page root
| Field | Required | Meaning |
|---|---|---|
title |
no | Page title; becomes the title section (only with layout) |
layout |
no | Layout template name, e.g. layout/main |
body |
conditional | Node tree when there is no layout |
sections |
conditional | Section name → node tree, required together with layout |
layout + sections and body are mutually exclusive.
text / heading / link
- type: text text: 你好,{{ user.name }} - type: heading level: 2 text: 用户管理 - type: link href: /users/{{ user.id }}/edit text: 编辑
text—textrequired; literal output, interpolations auto-escapedheading—textrequired,level1–6 (default 1)link—hrefandtextrequired,targetoptional
if
| Field | Required | Meaning |
|---|---|---|
when |
yes | Path, optional ! prefix (quote it: '!user.hidden') |
then |
yes | Node tree |
else |
no | Node tree |
each
| Field | Required | Meaning |
|---|---|---|
items |
yes | Path |
as |
no | Loop variable, default item |
index |
no | Index variable name |
body |
yes | Node tree |
form
| Field | Required | Meaning |
|---|---|---|
action |
yes | Form action |
method |
no | post (default) or get |
fields |
yes | Field array |
Fields support these inputs: text (default), password, email, number, textarea, select, checkbox, hidden, submit.
| Field | Required | Meaning |
|---|---|---|
name |
yes | Input name / id |
label |
yes | Label text; button text for submit |
input |
no | One of the inputs above |
value |
no | Bound path → value="## $path ?? '' ##" |
required |
no | Adds the required attribute |
placeholder |
no | text/password/email/number |
options |
select only | admin: 管理员 mapping |
checked |
checkbox only | Bound path; outputs checked when truthy |
rows |
textarea only | Default 4 |
select rejects value (selected-state binding is out of scope); options on a non-select field is a compile error.
table
| Field | Required | Meaning |
|---|---|---|
items |
yes | Path |
as |
no | Row variable, default row |
columns |
yes | Column array |
empty |
no | Text shown for an empty list |
Columns: label required; exactly one of pop (a data reference in braces, e.g. '{{ user.id }}' — the leading variable must be the table's as) or content (node tree in row scope). A field's id defaults to its name; bind names the front-end variable the framework binds to.
component
- type: component name: card data: title: '{{ user.name }}' body: 简介
name required, data optional. Data values support {{ path }} and are compiled to PHP string concatenation.
Interpolated values reach the component unescaped — the component template owns escaping, choosing $this->e() for text or $this->raw() for trusted markup. Pre-escaping here would double-encode anything containing HTML. Among the built-ins, card.title / button.text / alert.text / badge.text go through e(), while card.body uses raw().
Built-in components (plain template files in components/, readable and copyable): card (title, body), button (text, href, type), alert (type, text), badge (text, type). Custom components are ordinary miGears Template files referenced by name.
Custom components
Write the file, make its directory findable, reference it by name — there is no registry, and the file's existence is not checked at compile time. examples/components/my-card.php is a runnable one, referenced from examples/full-featured.page.yaml:
// components/my-card.php <div class="my-card"> <h3><?= $this->e($title ?? '') ?></h3> <div><?= $this->raw((string) ($body ?? '')) ?></div> </div>
$tpl = new Template(__DIR__ . '/views'); $tpl->addPath(__DIR__ . '/components'); // your own components $tpl->addPath('vendor/migears/yaml-pages/components'); // the built-ins
- type: component name: my-card data: title: '{{ user.name }}' body: '正文,可含 <em>HTML</em>。'
A .tpl.php component works the same way — the engine compiles the ## ## sugar on first render:
// components/my-card.tpl.php <div class="my-card"> <h3>## $title ?? '' ##</h3> <div>### $body ?? '' ###</div> </div>
## $expr ## compiles to $this->e($expr) and ### $expr ### to $this->raw($expr) — raw is one extra #, not a different function. So ## $this->raw($expr) ## does not give raw output: the sugar wraps it in e() anyway and quietly escapes, which is why the built-ins above are plain PHP. Every .tpl.php also leaves a compiled artifact in the template cache directory (writable; system temp by default) and wins over a same-named .php, while plain PHP output is never auto-escaped (<?= $title ?> prints raw) — the sugar's one real safety advantage.
- The name is a path relative to a registered directory, so sub-directories work:
name: admin/tableresolves<path>/admin/table.php. The same file has two legal names depending on which directory you registered —cardwhencomponents/itself is a path,components/cardwhen the package root is. addPath()searches the directory added last first, so a same-named file in a later path overrides an earlier one — that is how a built-in component gets restyled or replaced.- A component receives only its
datakeys — page variables are not passed down — and those values are strings. - The
componentnode emits no tag of its own, soclassor a framework directive cannot sit on it; wrap it inel. - A missing component is not caught at compile time; rendering throws
Component not found: <name>.
el
- type: el tag: div x-data: '{ open: false }' class: panel body: - type: heading level: 3 text: '{{ user.name }}' - type: text text: Body
tag required (lowercase HTML tag name); body is the child node tree and may be omitted (treated as empty); accepts any forwarded attribute. This is how wrapper attributes such as x-data get a home, since text / if / each emit no tag.
CLI
php bin/yaml-pages compile <input> [output-dir] [--check] php bin/yaml-pages --help
<input>— a.page.yamlfile or a directory (processed recursively)[output-dir]— defaults to the source directory--check— validate only, write nothingusers.page.yaml→users.tpl.php; existing outputs are overwritten unconditionally- Exit code:
0all good,1any failure; directory mode continues with the remaining files - A missing Composer autoloader or
ext-yamlis named in one line and exits1before any file is read, instead of ending in an uncaught fatal;--helpanswers either way
Errors
Compile errors throw MiGears\YamlPages\Exception\CompileException with a node path, e.g.:
views/pages/users.page.yaml: sections.content[2].columns[2]: a column cannot specify both pop and content
The CLI prints errors to stderr with the file name; directory mode keeps going on failure.
Testing
composer test
Unit tests assert exact compiled output; integration tests render the compiled page through the full miGears Template pipeline. With migears/xml-pages checked out beside this package, FrontEndParityTest compiles the same page written in both syntaxes and requires the two artifacts to be identical — every fixture under tests/fixtures/pages has an XML twin, and every one under tests/fixtures/errors must be refused with the same message.
License
MIT
migears/yaml-pages
基于 YAML 的声明式页面定义工具:把 .page.yaml 页面声明编译为 miGears 模板文件(.tpl.php),模板引擎在首次渲染时再将其编译为纯 PHP。YAML 声明是唯一事实标准;生成的模板是派生文件,不应手工修改。
特性
- PHP 8.1+,PSR-4 自动加载,命名空间
MiGears\YamlPages - 解析用 PECL
ext-yaml(pecl install yaml)—— 无 composer 第三方包 - 声明页面结构、数据绑定、条件显示(
if)、循环列表(each)、表单字段、表格列与 layout 继承 {{ path }}插值自动转义 —— XSS 防护由模板引擎承担- 编译期校验结构、字段、路径与键,不静默丢弃任何东西
- 属性透传:
"@click"、x-on:click、v-bind:href、wire:click、hx-get、data-*、class/id/style输出到生成的标签;bind用于前端框架自己的绑定 - 通用容器
el,给x-data这类包裹层属性一个落点 - 内置组件(
card、button、alert、badge),自定义组件按 miGears Template 规范编写 - 明确不做:业务逻辑、事件处理、状态管理、路由、运行期解析 YAML —— 这些交给你搭配的前端框架
边界
范围内
- YAML 前端:把
.page.yaml声明解析为数组 IR(MiGears\YamlPages\Compiler extends MiGears\Pages\Compiler),解析用 PECLext-yaml的yaml_parse—— 这是硬性运行要求(ext-yaml在本包require中)。 - YAML 表层拼写:引号键可以表达任何属性名(
"@click"、":href"、x-on:click),因此没有__event映射、也没有<attr>节点;__开头的键会被拒并提示改写为"@..."。 - 面向 YAML 的解析行为与报错:一个流只能一个文档、任何 libyaml 警告都致命、解析期间强制关闭
yaml.decode_php、错误带节点路径(MiGears\YamlPages\Exception\CompileException);以及bin/yaml-pages compile [output-dir] [--check]CLI。 components/随包分发的四个内置组件(card、button、alert、badge)与examples/下可运行的示例。
范围外(刻意不做)
- 节点词表、节点编译、插值、校验与属性透传 —— 继承自
migears/pages的共享编译器;本包只覆写parse()与拼写钩子。 - 同一套声明的 XML 写法 —— 由
migears/xml-pages承担(XML 孪生:用__click表示@click、有<attr>节点、重复元素直接报错);两个前端必须编译出完全一致的产物。 - 第二次编译为纯 PHP 与运行期渲染 —— 由
migears/template的TemplateCompiler承担,经migears/pages间接引入。 - 业务逻辑、事件处理、状态管理、路由与运行期解析 YAML —— 属于你搭配页面的前端框架,永不进入 YAML。
工作原理
刻意两次编译:
- yaml-pages 把 YAML 声明解析为
migears/pages的数组 DSL,由那里的共享编译器翻译为.tpl.php糖语法(## $expr ##)。中间产物保持可读,每个 DSL 词汇对应什么模板语法一目了然。 migears/template的TemplateCompiler把糖编译成纯 PHP 模板(mtime 缓存,仅模板变更后重编一次)。渲染由 PHP 执行:模板运行时把变量以 HTML 形式输出给浏览器,声明层不进入运行期。
生成的 .tpl.php 是派生文件——重新编译即覆盖。修改 YAML,不要改产物。
安装
composer require migears/yaml-pages
要求 PHP 8.1+、yaml 扩展,以及共享编译器 migears/pages ^2.0——它带来渲染产物所需的 migears/template ^2.0。
macOS 上安装 yaml 扩展:
brew install libyaml echo "$(brew --prefix libyaml)" | pecl install yaml
Linux 上通常直接 pecl install yaml 即可。
让模板引擎能找到内置组件:
use MiGears\Template\Template; $tpl = new Template(__DIR__ . '/views'); $tpl->addPath('vendor/migears/yaml-pages/components');
或把 components/ 拷入项目的模板目录。
快速开始
编写页面声明 views/pages/users.page.yaml:
title: 用户管理 layout: layout/main sections: content: - type: heading level: 2 text: 用户列表 - type: table items: users as: user empty: 暂无数据 columns: - label: ID pop: '{{ user.id }}' - label: 姓名 pop: '{{ user.name }}' - label: 操作 content: - type: link href: /users/{{ user.id }}/edit text: 编辑
编译:
php vendor/bin/yaml-pages compile views/pages/users.page.yaml
生成 views/pages/users.tpl.php。与普通模板一样渲染:
echo $tpl->render('pages/users', [ 'users' => [ ['id' => 1, 'name' => 'Alice'], ['id' => 2, 'name' => 'Bob'], ], ]);
覆盖全部语法特性的完整示例见 examples/full-featured.page.yaml,配套最小布局在 examples/views/layout/main.php。编译到该目录即可直接渲染:
php bin/yaml-pages compile examples/full-featured.page.yaml examples/views
YAML 编写注意
解析层是 libyaml(ext-yaml),遵循 YAML 1.1。以下写法必须引号包裹,否则会被误解析:
| 场景 | 错误写法 | 正确写法 |
|---|---|---|
值以 { / [ 开头 |
text: {{ user.name }} |
text: '{{ user.name }}' |
值以 ! 开头(tag 前缀) |
when: !user.hidden |
when: '!user.hidden' |
值内含 : |
text: 时间: 12:00 |
text: '时间: 12:00' |
值内含 #(注释) |
text: a # b |
text: 'a # b' |
键以 @ 或 : 开头 |
@click: go() |
"@click": go() |
其余注意:
{{ ... }}在值中间(如/users/{{ user.id }}/edit)可裸写,无需引号。true/false/yes/no/on/off在 YAML 1.1 中是布尔值且大小写不敏感,单字母y/n同样是布尔值——required: true是故意的布尔语义,而data-x: y写进产物会变成data-x="true"。凡是要当字符串用的值都请加引号。level: 2、rows: 4解析为整数,与heading.level/textarea.rows的类型校验一致。- 双引号字符串中
\n是换行;需要字面\n时用单引号。 - 键内含冒号(
x-on:click:、wire:click:)可裸写——冒号后紧跟非空白字符即不构成映射分隔。 !php/object及其它!php/标签永不解码:编译器在自身解析期间关闭yaml.decode_php,结束后还原,因此无论 php.ini 怎么设,声明都无法夹带对象进来;标签的值按纯文本处理。- 一个流只能有一个文档:
---分隔出的第二个文档会编译报错,因为页面声明只能是单个文档。文档开头的---起始标记不算第二个文档,块标量里的---是文本。 - 同一映射里的重复键由 libyaml 在 PHP 看到文档之前合并:后者胜、无警告、事后无从探测——写两个
body:时第一个被静静替换,sections.content重复、节点字段写两次、options的同一个键、component.data的同一个键,都是如此。YAML 本身规定同一映射中两个相等的键是错误,这里只是加载器宽容;每个映射保持一个键即可。(migears/xml-pages对等价的重复元素是直接报错的。)
前端框架集成
节点上的键分三类:
- DSL 字段 —— 节点自己消费(
heading.level、link.href、form.action),含结构性子键(then、body、fields、columns、data、options、content)。 - 透传属性 —— 输出到该节点生成的标签:
"@event"—— Alpine / Vue 的事件简写,写成带引号的键- 带冒号的名字:
x-on:click、x-bind:href、v-on:click、wire:click、on:click;":href"需要引号 - 前缀:
x-、v-、hx-、data- - HTML 钩子:
class、id、style
- 其余一律编译错误 —— 视为拼写错误,绝不静默丢弃。
不输出标签的节点(text、if、each、component)不接受透传属性,用 el 包裹即可。页面根同理,只认 title / layout / body / sections。
透传值先转义、后插值,所以 {{ }} 在属性值里照常可用(单引号也保持可读)。标量按 HTML 属性的形态归一:7 → "7"、true → "true"、x-cloak: → x-cloak=""(无值属性的 YAML 写法);非标量报错。
透传的名字按原样写进标签,因此必须是合法的属性名:含空白、引号、<、>、/、= 或控制字符一律编译错误(XML 前端在解析时拒掉同样的形态,<attr> 名不合法同样报错)。DSL 自己的字面量属性——link.href、link.target、form.action、field.name / id / placeholder、option 的 value——现在走同一套转义,href 里的引号再也不会提前结束属性。元素文本不转义:那部分由你掌控,与 text 节点一致。
- type: el tag: div x-data: '{ open: false }' class: panel "@click": open = ! open body: - type: text text: 切换
与 XML 版的差异
XML 的属性名装不下 @,所以那边用 __click 表示 @click。YAML 直接写 "@click" —— 这里没有 __ 映射,也不需要 <attr> 节点(引号键可以表达任何属性名)。在 YAML 里写 __click 会报错并提示改用 "@click"。
框架可用性
| 框架 | 键写法 | 结果 |
|---|---|---|
| Alpine | x-on:click、x-bind:href、x-data、x-show、x-cloak: |
可用 |
| Alpine | "@click" |
可用(引号键) |
| Alpine | @click 裸写 |
失败 —— YAML 扫描错误,必须加引号 |
| Alpine | x-on-click(连字符) |
拒绝 —— Alpine 只有冒号形式 |
| Vue | v-on:click、v-bind:href、":href" |
可用 |
| Vue | "@click" |
可用 |
| htmx | hx-get、hx-trigger |
可用 |
| Stimulus | data-controller、data-action |
可用 |
| Livewire | wire:click、wire:model.live |
可用 |
同一区域只能有一个 owner
服务端渲染与客户端渲染不能同时拥有同一块 DOM。结构交给服务端的 each / if / table,交互挂在 Alpine 上——但不要再对该列表用 x-for,否则 Alpine 会用它的模板重新生成,结果是重复节点加闪烁。
数据绑定
{{ path }} 把点路径插值为自动转义输出:
| 路径 | 编译为 |
|---|---|
{{ users }} |
## $users ?? '' ## |
{{ user.name }} |
## $user['name'] ?? '' ## |
{{ form.errors.email }} |
## $form['errors']['email'] ?? '' ## |
规则:
- 仅支持
a.b.c形式的路径——函数调用、算术、字符串字面量一律编译错误。 - 路径编译为数组访问;Domain 实体请在控制器边界转数组。
- 只有
if.when支持!前缀取反;其他位置(如each.items)写!一律编译错误。 - 条件与循环用
?? null兜底,文本与属性用?? ''兜底。 - 插值最多两个花括号:
{{{或}}}属编译错误。第三个花括号会骗过配对计数,把错乱的花括号留在渲染结果里。 - 字面量字段——
layout、section 名、form.method、field.name、field.label、option的 value 与显示文本、table.empty、column.label、component.name——原样输出;在其中写{{ }}属编译错误,不会静默忽略。
节点参考
body / sections 中的每个节点都是带 type 字段的对象。可选类型:text、heading、link、if、each、form、table、el、component。内嵌结构(field、column)的类型由位置决定——不必写 type;若写出,值必须匹配(field / column),否则编译失败。
页面根
| 字段 | 必填 | 说明 |
|---|---|---|
title |
否 | 页面标题,写入 title section(仅 layout 时生效) |
layout |
否 | 继承的布局模板名,如 layout/main |
body |
视情况 | 无 layout 时的节点树 |
sections |
视情况 | section 名 → 节点树,与 layout 搭配 |
layout + sections 与 body 互斥。
text / heading / link
- type: text text: 你好,{{ user.name }} - type: heading level: 2 text: 用户管理 - type: link href: /users/{{ user.id }}/edit text: 编辑
text——text必填;字面输出,插值自动转义heading——text必填,level取值 1–6(默认 1)link——href、text必填,target可选
if
| 字段 | 必填 | 说明 |
|---|---|---|
when |
是 | 路径,支持 ! 前缀(记得加引号:'!user.hidden') |
then |
是 | 节点树 |
else |
否 | 节点树 |
each
| 字段 | 必填 | 说明 |
|---|---|---|
items |
是 | 路径 |
as |
否 | 循环变量,默认 item |
index |
否 | 索引变量名 |
body |
是 | 节点树 |
form
| 字段 | 必填 | 说明 |
|---|---|---|
action |
是 | 表单提交地址 |
method |
否 | post(默认)或 get |
fields |
是 | 字段数组 |
字段支持以下 input:text(默认)、password、email、number、textarea、select、checkbox、hidden、submit。
| 字段 | 必填 | 说明 |
|---|---|---|
name |
是 | 输入框 name / id |
label |
是 | 标签文本;submit 时为按钮文字 |
input |
否 | 上述 input 之一 |
value |
否 | 绑定路径 → value="## $path ?? '' ##" |
required |
否 | 输出 required 属性 |
placeholder |
否 | text/password/email/number |
options |
仅 select | admin: 管理员 形式的映射 |
checked |
仅 checkbox | 绑定路径;真值时输出 checked |
rows |
仅 textarea | 默认 4 |
select 不接受 value(选中态绑定不在范围内);options 用在非 select 字段上是编译错误。
table
| 字段 | 必填 | 说明 |
|---|---|---|
items |
是 | 路径 |
as |
否 | 行变量,默认 row |
columns |
是 | 列数组 |
empty |
否 | 空列表时显示的文本 |
列:label 必填;pop(花括号形式的数据引用,如 '{{ user.id }}',首段必须是该表格的 as)与 content(行变量作用域内的节点树)二选一。字段的 id 默认等于 name;bind 是前端框架绑定的变量名。
component
- type: component name: card data: title: '{{ user.name }}' body: 简介
name 必填,data 可选。data 值支持 {{ path }} 插值,编译为 PHP 字符串拼接。
插值值以未转义形式传给组件——转义由组件模板决定:文本用 $this->e(),信任的 HTML 用 $this->raw()。编译期预转义会与组件模板的转义叠成双重转义。内置组件中 card.title / button.text / alert.text / badge.text 走 e(),card.body 走 raw()。
内置组件(components/ 下的普通模板文件,可直接阅读复制):card(title、body)、button(text、href、type)、alert(type、text)、badge(text、type)。自定义组件是普通的 miGears Template 文件,按名引用。
自定义组件
写文件、让目录可被找到、按名引用——没有注册表,编译期也不检查文件是否存在。examples/components/my-card.php 就是一个可直接运行的自定义组件,由 examples/full-featured.page.yaml 引用:
// components/my-card.php <div class="my-card"> <h3><?= $this->e($title ?? '') ?></h3> <div><?= $this->raw((string) ($body ?? '')) ?></div> </div>
$tpl = new Template(__DIR__ . '/views'); $tpl->addPath(__DIR__ . '/components'); // 你自己的组件 $tpl->addPath('vendor/migears/yaml-pages/components'); // 包内置组件
- type: component name: my-card data: title: '{{ user.name }}' body: '正文,可含 <em>HTML</em>。'
.tpl.php 组件同样可用——引擎会在首次渲染时把 ## ## 糖编译成 PHP:
// components/my-card.tpl.php <div class="my-card"> <h3>## $title ?? '' ##</h3> <div>### $body ?? '' ###</div> </div>
## $expr ## 编译为 $this->e($expr),### $expr ### 编译为 $this->raw($expr)——raw 是多一个 #,不是换一个函数;所以 ## $this->raw($expr) ## 不会原样输出,糖会再包一层 e() 静默转义,上面几个内置组件因此在原生 PHP 里写 $this->raw()。另外每个 .tpl.php 会在模板缓存目录留一份编译产物(目录需可写,未配置时是系统临时目录),且同名时优先于 .php;而原生 PHP 的输出永不自动转义(<?= $title ?> 原样输出),这是糖唯一的实质安全优势。
- 名字是相对某个已注册目录的路径,所以子目录可以直接用:
name: admin/table命中<path>/admin/table.php。同一份文件会因你注册了哪个目录而有两个合法名字——注册components/本身时它叫card,注册包根时它叫components/card。 addPath()是「后加的目录先被搜索」,所以同名文件放进后注册的目录即可覆盖先前的——内置组件就是这样改造或替换的。- 组件只拿到自己的
data键(页面的其它变量不会透传进来),且这些值都是字符串。 component节点自身不输出标签,挂不上class或框架指令,需要外层属性时用el包裹。- 组件缺失不会在编译期报错,渲染时才抛
Component not found: <name>。
el
- type: el tag: div x-data: '{ open: false }' class: panel body: - type: heading level: 3 text: '{{ user.name }}' - type: text text: 正文
tag 必填(小写 HTML 标签名);body 为子节点树,可省略(视为空);接受任意透传属性。由于 text / if / each 自己不输出标签,这是给 x-data 这类包裹层属性找落点的唯一方式。
CLI
php bin/yaml-pages compile <input> [output-dir] [--check] php bin/yaml-pages --help
<input>——.page.yaml文件或目录(目录时递归处理)[output-dir]—— 缺省与源文件同目录--check—— 仅校验,不写文件users.page.yaml→users.tpl.php;已有产物无条件覆盖- 退出码:
0全部成功,1任一失败;目录模式出错不中断 - 缺 Composer autoloader 或
ext-yaml时,在任何文件被读取前一行点名并退出1,不再以未捕获致命错误收场;两种情况--help都可用
错误处理
编译错误抛出 MiGears\YamlPages\Exception\CompileException,信息带节点路径,例如:
views/pages/users.page.yaml: sections.content[2].columns[2]: a column cannot specify both pop and content
CLI 将错误输出到 stderr 并附文件名;目录模式继续处理其余文件。
测试
composer test
单元测试断言编译产物,集成测试把编译产物经 migears/template 完整渲染验证。当 migears/xml-pages 与本包并排检出时,FrontEndParityTest 会把同一页面用两种语法各编译一次并要求产物完全一致——tests/fixtures/pages 下每个 fixture 都有对应的 XML 孪生文件,tests/fixtures/errors 下的每个则必须被同样的消息拒绝。
License
MIT