关于 craft-twig-guidelines
为 Craft CMS 5 项目提供 Twig 模板编码规范的技能,面向编写和维护模板的开发者。内容涵盖安全输出、组件隔离及 Craft 专用模板工具。
主要功能
- 01变量命名与空值处理规范
- 02使用 only 隔离模板包含
- 03组件渲染避免使用宏
- 04安全输出与内联资源规范
- 05集合数据处理约定
适用场景
- 01编写 Craft CMS 5 模板
- 02审查 Twig 组件与布局
- 03处理模板中的集合和字段数据
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ michtio/ craftcms-claude-skills --skill craft-twig-guidelines安装后在 Claude Code 中输入/craft-twig-guidelines确认已加载。
详细介绍
Twig 编码规范 — Craft CMS 5
Craft CMS 5 项目中 Twig 模板的编码约定。适用于所有 Twig 代码 — 原子组件、视图、布局、构建器、局部模板。
配套技能 — 始终一并加载
触发此技能时,还应加载:
craft-site— 模板架构和组件模式。创建或编辑组件、布局、视图或构建器时必需。craft-content-modeling— 内容架构。模板代码涉及元素查询、字段访问或分区决策时必需。
Twig 架构模式(原子设计、路由、构建器)请参阅
craft-site 技能。PHP 编码规范请参阅 craft-php-guidelines。
文档
- Craft 中的 Twig:https://craftcms.com/docs/5.x/development/twig.html
- 模板标签:https://craftcms.com/docs/5.x/reference/twig/tags.html
- 模板函数:https://craftcms.com/docs/5.x/reference/twig/functions.html
- Twig 3 文档:https://twig.symfony.com/doc/3.x/
如果这里未涵盖相关内容,请使用 WebFetch 查阅具体文档页面。
变量命名
优先使用单词、描述性、全小写的名称。需要多个单词时,使用 camelCase。
{# Correct #}
{% set heading = entry.title %}
{% set image = entry.heroImage.one() %}
{% set items = navigation.links.all() %}
{% set element = props.get('url') ? 'a' : 'span' %}
{% set buttonText = entry.callToAction %}
{% set containerClass = 'max-w-3xl' %}
{# Wrong — abbreviations #}
{% set el = props.get('url') ? 'a' : 'span' %}
{% set btn = entry.callToAction %}
{% set nav = navigation.links.all() %}
{# Wrong — snake_case #}
{% set button_text = entry.callToAction %}
{% set container_class = 'max-w-3xl' %}
不要使用缩写:用 element 而不是 el,用 button 而不是 btn,用 navigation 而不是 nav,
用 description 而不是 desc。
如果上下文能明确含义,优先使用单词名称(例如,在组件中使用 heading
比 sectionHeading 更好)。但为了清晰起见,完全可以使用多个单词的 camelCase 名称。
Null 处理
默认使用 ??。始终安全,且具有良好的可移植性。
如果项目已安装 nystudio107/craft-emptycoalesce 或 nystudio107/craft-seomatic,则可以使用 ???(空合并)——这两个插件都提供该运算符。但切勿仅为了使用 ??? 而安装插件。请先检查 composer.json。
{# Always correct #}
{% set heading = entry.heading ?? '' %}
{% set image = entry.heroImage.one() ?? null %}
{{ props.get('label') ?? 'Default' }}
{# OK if empty-coalesce or SEOmatic is installed — checks empty, not just null #}
{% set heading = entry.heading ??? '' %}
{# Wrong — verbose, unnecessary #}
{% if entry.heading is defined and entry.heading is not null %}
{% if entry.heading is not defined %}
Craft 5 支持 nullsafe 运算符(?.)。对于可能包含 null 链接的深层遍历链,请使用它——它能干净地传递 null,无需繁琐地使用
is defined and is not null:
{# Reach for ?. when any link in the chain may be null #}
{{ entry?.author?.fullName ?? 'Anonymous' }}
{# ?? alone is enough when only the leaf is in question #}
{{ entry.title ?? '' }}
对于简单的“值或回退值”情况,?? 仍是正确选择;对于中间链接可能缺失的链,则使用 ?.。不要在单个属性访问上使用 ?.——它只会增加冗余,不会提升安全性。
空白控制
使用 {%- 和 {{- 修剪空白。切勿使用 {%- minify -%}。
{# Correct — surgical whitespace control #}
{%- set heading = entry.title -%}
{%- if heading -%}
{{- heading -}}
{%- endif -%}
{# Wrong — deprecated minification approach #}
{%- minify -%}
{% set heading = entry.title %}
{%- endminify -%}
对会在输出中产生多余空行的标签应用空白控制。并非每个标签都需要这样处理——只在输出中的可见空白会产生影响时使用。
Include 隔离
每个 {% include %} 都必须使用 only。没有例外。
{# Correct — explicit, isolated #}
{%- include '_atoms/buttons/button--primary' with {
text: entry.title,
url: entry.url,
} only -%}
{# Wrong — ambient variables leak in #}
{%- include '_atoms/buttons/button--primary' with {
text: entry.title,
url: entry.url,
} -%}
不使用 only 时,组件可能会悄然依赖父作用域中的变量,从而产生不易察觉的耦合。
不要用宏构建组件
切勿使用 {% macro %} 构建 UI 组件。宏不支持 extends/block,且其作用域模型与 include 不同。
{# Wrong — macro for a component #}
{% macro button(text, url) %}
<a href="{{ url }}">{{ text }}</a>
{% endmacro %}
{# Correct — include with isolation #}
{%- include '_atoms/buttons/button--primary' with {
text: text,
url: url,
} only -%}
宏可用于返回字符串的实用函数(例如格式化辅助函数),但不能用于渲染 UI。
注释标题
每个组件文件都要有分节标题注释:
{# =========================================================================
Component Name
Brief description of what this component does.
========================================================================= #}
Props 文件、变体文件、视图、布局——所有文件都要有标题。=========
分隔符与 craft-php-guidelines 中的 PHP 约定一致。
Craft Twig 辅助工具
{% tag %} — 多态元素
渲染标签名称取决于 props 的元素时,首选此工具。
{%- set element = props.get('url') ? 'a' : 'span' -%}
{%- tag element with {
class: classes.implode(' '),
href: props.get('url') ?? false,
target: props.get('target') ?? false,
rel: props.get('rel') ?? false,
aria: {
label: props.get('label') ?? false,
},
} -%}
{{ props.get('text') }}
{%- endtag -%}
规则:
- 变量名必须具有描述性:
element、heading、wrapper。切勿使用el、hd。 false会从渲染后的 HTML 中完全省略该属性。null也会省略。显式排除时使用false,不存在时使用null。class支持数组,并会自动过滤假值。aria和data支持嵌套哈希,并会展开为aria-*/data-*属性。
tag() — 内联元素函数
适用于内容不复杂的简单元素:
{{ tag('span', { class: 'sr-only', text: '(opens in new window)' }) }}
{{ tag('img', { src: image.url, alt: image.title, loading: 'lazy' }) }}
{{ tag('i', { class: ['fa-solid', icon], aria: { hidden: 'true' } }) }}
{# Craft 5.10+: pass a string as the second arg as a text-only shortcut #}
{{ tag('span', 'Read more') }}
text:键 = 经 HTML 编码的内容。html:键 = 原始 HTML 内容(仅限可信输入)。- 自动处理自闭合元素(
img、input、br)。
attr() — 属性字符串
用于在非标签上下文中构建属性:
<div{{ attr({ class: ['card', active ? 'card--active'], data: { id: entry.id } }) }}>
返回以空格开头的属性字符串。与 {% tag %} 一样,false 表示省略,且会过滤类数组。
|attr 过滤器
用于将属性合并到现有 HTML 字符串:
{{ svg('@webroot/icons/check.svg')|attr({ class: 'w-4 h-4', aria: { hidden: 'true' } }) }}
|parseAttr 过滤器
用于从 HTML 字符串中提取属性,并存入哈希表以便操作:
{% set attributes = '<div class="foo" data-id="1">'|parseAttr %}
{# attributes = { class: 'foo', data: { id: '1' } } #}
|append 过滤器
用于向元素字符串中添加内容:
{{ svg('@webroot/icons/logo.svg')|append('<title>Company Logo</title>', 'replace') }}
svg() 函数
{{ svg('@webroot/icons/logo.svg') }}
{{ svg(entry.svgField.one()) }}
可与 |attr 搭配使用来设置类和 aria 属性。可使用 |append 在 SVG 内添加无障碍标签。
url() 中使用 false 参数(Craft 5.11+)
将 false 作为第二个参数传入绝对 URL 时,会移除所有查询字符串参数。适用于规范链接、分享 URL 和“清除筛选条件”链接:
<link rel="canonical" href="{{ url(craft.app.request.absoluteUrl, false) }}">
<a href="{{ url(craft.app.request.absoluteUrl, false) }}">Clear filters</a>
数组仍会合并传入的参数;false 则会移除所有参数。只有传入的 URL 是绝对 URL 时才会移除参数,因此请传入完整 URL,而非路径。PHP 中对应的调用是 UrlHelper::url($url, false),此外还有 UrlHelper::removeParams($url, [...]) 和 UrlHelper::removeAllParams($url),可进行更精细的控制。在 5.11 之前,请改用 siteUrl(craft.app.request.pathInfo) 重新构建不带参数的 URL。
heading() / h() / h1()…h6() — 以编程方式生成标题(Craft 5.10+)
无需拼接字符串,即可根据动态级别构建标题标签。适用于接收 level 属性、需要渲染对应标签的组件,无需手动执行 tag('h' ~ level, text)。
{# heading(level, text-or-attributes) — level is an int 1-6 #}
{{ heading(2, 'Section title') }} {# <h2>Section title</h2> #}
{{ heading(3, { class: 'text-xl', text: 'Subsection' }) }}
{# h() is the short alias for heading() #}
{{ h(2, 'Section title') }}
{# Bound-level shortcuts — only attributes/text needed #}
{{ h1('Page title') }}
{{ h2('Section') }}
{{ h6({ class: 'sr-only', text: 'Hidden heading' }) }}
这些是无状态的标签构建器,不会自动递增,也不会跟踪当前级别。需要在嵌套上下文间传递级别的组件仍需将其作为属性传入。若级别超出 1-6,heading() 会抛出 InvalidArgumentException。
过滤器新增项(Craft 5.10+)
{{ price|number(locale: 'de-DE') }} {# locale arg #}
{{ entry.postDate|datetime('long', withTimeZone: true) }}
{{ deadline|time('short', withTimeZone: true) }}
{{ maybeNull|timestamp }} {# returns "now" for null/empty #}
筛选与映射 — 默认使用 collect()
在模板中操作数据时,默认使用 collect()——通过统一、可链式调用的 API(where、firstWhere、groupBy、keyBy、unique、pluck、map、filter、sortByDesc)替代混用不同的写法:
{% set newsByYear = entries.collect
.where('type', 'news')
.sortByDesc('postDate')
.groupBy(e => e.postDate|date('Y')) %}
若只需单个简单的内联操作,裸用 Twig/Craft 数组过滤器也没问题——例如 {% for e in entries|filter(e => e.enabled) %}——此时 Collection 并无额外作用。但 Twig 的 |filter/|map 返回普通数组,而 Craft 的 |where/|firstWhere/|group/|index 会经由 ArrayHelper 处理,因此无法像 Collection 方法那样链式调用;一旦需要多个步骤,就改用 collect()。不要随意混用这两种写法。
没有 |indexBy 过滤器——用 |index 为列表设置键(或对 Collection 调用 .keyBy()),用 |group 分组(或调用 .groupBy())。(|where、|firstWhere、|contains、|group、|index、|explodeClass/|explodeStyle 均为 Craft 注册的过滤器。)
安全输出与内联资源
|t('category'):所有面向用户的字符串都必须通过翻译处理,切勿硬编码显示文案。|purify:对不可信或富 HTML 进行净化,不要使用|raw。仅对可信字段输出(CKEditor/Redactor)使用|raw;切勿对用户提交的内容或源自查询字符串的内容使用|raw。|explodeClass/|explodeStyle:在合并前将类名/样式字符串规范化为数组,不要手动按空格拆分。{% js %}/{% css %}/{% script %}:通过 View 注册内联资源(它会去重并安排位置),不要手动编写<script>/<style>。({% js %}/{% css %}通过资源管理器处理;{% script %}/{% html %}则按原样输出。){% dd %}/dump():仅用于调试;切勿将其随代码发布,也不要用{{ x|json_encode }}作为调试技巧。|e('js')会进行积极转义:标准 Twig JS 转义器会将每个非字母数字 ASCII 字符(包括空格(0x20))转换为\uXXXX序列(空格会变成)。这在内联 JS 中嵌入字符串时是正确且安全的,但这意味着翻译后的多词字符串不会在输出中原样出现,因此在渲染后的 HTML/JS 中搜索(或用测试assertSee()检查)完整短语时会悄悄漏掉。请改为匹配单个词或原始 JS 标识符。关于 QA/搜索部分,请参阅craftcms技能中的testing.md。
collect() 约定
构建 props 和类集合时,需遵循以下样式规则:
- 键使用 camelCase:
heroImage,不要使用hero_image。 - 每个关注点使用一个具名键:类集合应为每个样式关注点(
layout、color、spacing等)分别设置一个键,切勿让两个类争用同一个元素。 - 使用
.implode(' ')构建类字符串:切勿使用字符串拼接('flex ' ~ extraClass)。 - 空值/空字符串无妨:
implode(' ')会将它们作为空字符串连接,产生的额外空格会由浏览器在类属性中规范化。
{%- set classes = collect({
layout: 'flex items-center gap-2',
color: 'bg-brand-primary text-brand-on-primary',
hover: 'hover:bg-brand-accent',
utilities: props.get('utilities'),
}) -%}
class="{{ classes.implode(' ') }}"
有关 collect() 方法的完整参考和架构模式(props 集合、get()/merge()、作为 Collections 的 entry 查询),请参阅 craft-site(references/twig-collections.md);有关具名键 Tailwind 类模式,请参阅 craft-site(references/tailwind-conventions.md)。
文案风格
面向用户的文案中切勿使用 em 短横线(—)或 en 短横线(–),包括模板文本、|t 字符串以及写入模板的任何内容。请改用逗号、句号、冒号或括号;表示范围时,请写作“4 到 10”或使用普通 ASCII 连字符(“4-10”)。普通连字符可以使用。Twig 注释({# … #})不受此限制。完成前,请在模板中搜索 — 和 –。(这是共享规则在前端的部分;craft-php-guidelines 技能对 Craft::t() 字符串、字段标签、CP 通知和文档也有相同规定。)
常见陷阱
- 未安装插件却使用
???运算符 — 需要nystudio107/craft-emptycoalesce或nystudio107/craft-seomatic。使用前检查composer.json。默认使用??。 - 使用 snake_case 变量 — 使用 camelCase:
heroImage,而不是hero_image。 - 缺少
only— 会导致变量悄然泄漏,产生隐式耦合。 {%- minify -%}— 已弃用。使用{%-空白控制。- 使用缩写 —
el、btn、nav、desc、ctr→ 写出完整形式。 - 使用
is not defined— 冗长的空值检查。??可以处理。 - 将宏用作组件 — 作用域错误,且不支持 extends/block。
- 在类字符串中硬编码颜色 —
bg-yellow-600→ 使用bg-brand-accent。 - 用字符串拼接类名 —
'flex ' ~ extraClass→ 使用带命名键的collect({})。 - 在 Craft Model 上使用
is empty/|default(5.10+) — 现在,任何yii\base\Model(条目、设置、自定义模型)无论其属性值如何,都会被视为非空。这意味着{{ user|default('Guest') }}总是会渲染 user 对象;{% if entry is empty %}总是为 false。检查你关心的具体属性:{% if entry.title is empty %}。 - 使用
options.x模式 — 这是旧式宏约定。请使用直接的变量名。 - 在条件语句内定义块 —
{% if %}{% block foo %}{% endblock %}{% endif %}是无效的 Twig。块是编译时结构,不能有条件地定义。将条件判断移到块内:{% block foo %}{% if condition %}...{% endif %}{% endblock %}。 - 硬编码
/adminCP URL —cpTrigger可通过CRAFT_CP_TRIGGER环境变量或 general.php 中的cpTrigger配置。许多项目使用cp而不是admin。使用cpUrl()函数或检查.env,绝不要硬编码/admin/。 - 面向用户的文案中使用 em/en dash:不要在模板文本或
|t字符串中使用—或–。请使用逗号、冒号、括号或普通连字符;范围写作“4 到 10”。参见文案风格部分。 - Twig 没有
instanceof— 按类限定行为的 PHP(例如控制器中通过match (true)对instanceof进行检查)没有直接对应的模板写法,而模板尝试模拟时,两者会悄然产生偏差。请根据功能进行分支判断(object.hasProperty('handle')、object.hasMethod(...)),或让 PHP 向模板传入显式判别值(type: 'entry')。 - 在
{% cache %}内输出每个请求独有的内容:该标签会存储正文并重放其中捕获的{% js %}、{% script %}和{% css %},因此其中的 CSP nonce、csrfInput()、aria-current/活动状态类或语言切换器 URL 都会冻结为首次渲染时的值。缓存查询及其标记;将这些内容放在缓存区外。参数只能按一种顺序排列(globally、using key、for 1 hour/until、if/unless),并且没有tag()。参见craft-site技能中的template-caching.md。 - 在输出中搜索经过
|e('js')转义的短语 — JS 转义器会将每个空格转换为,因此翻译后的多词字符串不会原样出现在渲染后的 JS 中。请使用单个词元或原始标识符进行 grep/assert。参见“安全输出与内联资源”部分以及craftcms技能中的testing.md。