关于 marko6
marko6 是供编写或修改 `.marko` 文件的 Agent 使用的 Marko 6 语言参考资料,涵盖语法、响应性、类型和渲染规则,并明确不包含 Marko 5。
主要功能
- 01Marko 6 语法参考
- 02响应式状态与更新规则
- 03TypeScript 和模板类型
- 04服务端与客户端序列化说明
- 05标签发现与资源约定
适用场景
- 01编写或修改 `.marko` 组件
- 02排查响应性和渲染问题
- 03检查 Marko 专用 TypeScript 类型
- 04避免服务端与客户端序列化错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ svallory/ marko-ui --skill marko6安装后在 Claude Code 中输入/marko6确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/core-tags.mdcustom-tags-styling.mdlanguage.mdnative-tags.mdpatterns.mdtemplate-api.mdtypescript.md详细介绍
Marko 6 语言
从完整的 Marko 6 文档(参考、说明、指南、教程)中提炼而成(有意排除 Marko 5/类 API)。此文件列出 Agent 容易出错的规则和速查内容。详细内容见 references/。如果此处内容与观察到的行为不符,以在线文档为准——Marko 更新很快:索引见 https://markojs.com/llms.txt;在任意页面 URL 末尾添加 .md,即可获取 Markdown 格式页面(例如 https://markojs.com/docs/reference/core-tag.md)。
仅限 Marko 6——绝不编写 Marko 5
一眼识别并拒绝:class { onCreate/onMount... } 代码块、$ const x = ... 脚本片段、<if(cond)>/<for(x in y)> 括号参数、字符串事件处理器 on-click("handleClick")、state/this.state、<include>/<invoke>、components/ 自动发现机制、Marko.Component 类型。Marko 6 的对应写法:<let>/<const>/<script>/<lifecycle>、<if=cond>、<for|x| of=y>、onClick() {}、tags/ 自动发现机制、export interface Input。
新建或编辑任何 .marko 文件时的检查清单
- 仅使用 Marko 6 语法(见上文)。
- 优先使用状态机制:如果
<let>/<const>/可控原生元素(value:=)可以完成任务,就不要使用<script>/<lifecycle>;只有真正需要浏览器副作用时才使用<script>。 - 事件处理器必须是函数(
onClick() {}/onClick=fn),绝不能是字符串;参数使用(event, element),不要用currentTarget。 - 声明
export interface Input;使用mtc(@marko/type-check)进行类型检查——tsc会跳过.marko并报告成功。 - 尽量减少客户端 JS:优先使用 HTML/CSS(
<details>、<dialog>、:has、popover),而不是脚本行为;无论如何,只有响应式部分会被打包。 - 保持组件精简;按关注点拆分,而不是按技术拆分(拆分不会影响打包体积)。
- 确保 SSR 安全:
<let>中不要放置无法序列化的内容;$signal/元素引用仅用于<script>/<lifecycle>/事件处理器。 - 使用不可变更新(
x = x.concat());可重新排序的<for>使用by=;有意选择<show>或<if>。
| 文件 | 阅读时机 |
|---|---|
references/language.md | 语法:语句、属性及简写、内容、属性标签、标签变量/参数、动态标签、精简模式、空白字符 |
references/core-tags.md | <if> <show> <for> <let> <const> <return> <script> <style> <define> <lifecycle> <id> <await> <try>,以及响应式/调度模型 |
references/native-tags.md | class=/style=/content=、事件委托、可控的 value:=/checked:=/open:=、展开属性的归属 |
references/typescript.md | Input、Marko.* 类型、扩展原生元素/全局变量/CSS、标签中的 TS 语法 |
references/template-api.md | render/mount、$global(serializedGlobals、cspNonce、renderId、runtimeId) |
references/custom-tags-styling.md | 标签发现顺序、tags/ 目录、marko.json、样式、惰性 with { load } |
references/patterns.md | 可控组件、嵌套状态、可序列化/不可变状态、流式处理、工具 |
本仓库的组件架构(Zag 接线、属性标签 API):skills/port-to-marko/references/marko-gotchas.md。
心智模型(10 行)
- 文件 = 组件。
input= 属性。tags/目录会自动发现<tag-name>;PascalCase 变量是标签;<${...}>中的字符串始终是原生元素。 - 仅模块语句:
import、export、static、server、client。其他所有内容都是模板主体(响应式)。 - 响应式来源:标签变量(
/x)、标签参数(|x|)、input。图在编译时生成,不会在运行时跟踪。表达式必须是纯函数。 <let>= 状态(赋值触发响应,更新不可变,SSR 时序列化)。<const>= 派生值(惰性求值,在刷新时执行)。static const= 模块常量。- 更新会在微任务后批量处理。执行
count++后,<let>是新的,但所有<const>/DOM 在刷新前都是过期的。 <script>= 仅在浏览器运行的副作用,会在引用的标签变量/参数变化时重新执行;通过$signal.onabort清理。<lifecycle>= 使用持久化this的onMount/onUpdate/onDestroy。- 原生标签变量
<div/el/>是一个getter 函数,仅在浏览器中可用,且只能在渲染后使用。 - 一切皆可控:
value:=x≡value=x valueChange(v){x=v};成员表达式上等价于value=input.x valueChange=input.xChange。<let/x:=input.x>会让组件变为可控组件。 - 服务端/客户端边界:只有可序列化的普通数据可以跨越;状态中不能有类实例、DOM 节点或外部模块闭包;除非
$global在serializedGlobals中,否则它留在服务端。 - 默认不生成 JS:只有响应式部分会发送到浏览器;组件粒度不会改变打包体积。
快速参考 — 容易踩坑的规则
语法
- 属性值中未转义的
>→value=(a > b)。>=和=>可以。 <div#id.cls.cls2/>是简写;<Comp.x/>表示class="x",不是属性访问 →<${Comp.x}/>。- 简洁模式下,根节点裸文本会被解析为标签 →
-- text。多行属性:行首加逗号。 - 属性字符串是 JS 字面量;
aria-*枚举属性需要字符串:aria-pressed=on && "true"。 null|undefined|false属性会被跳过;0、""、NaN会被写入。文本中会跳过null|undefined|false|""|NaN|0n,而0会被渲染。- 两个行内标签之间的换行 = 没有空格。请在同一行中添加空格。
<if>/<else if=…>/<else>必须是直接兄弟节点。文档使用<else if=cond>;在 6.3.34 版本中,<else-if=cond>也可以编译(已验证)。<${input.content}/>会转发内容;原生标签也接受content=input.content或直接使用<div ...input/>。字面量主体(甚至是注释)会覆盖content=。- 组件的
content属性必须是其默认主体的全部内容——Marko 会自动根据标签的非属性标签子节点设置input.content。调用方无需为此编写<@content>;直接将主体内容放入即可(参数也一样:<Tag|params|>body</Tag>,而不是<@content|params|>)。如果组件需要两个或更多并列的、形似主体的插槽,请为它们命名(例如main),不要命名为content——content表示“主体的其余部分”,第二个同形插槽会导致冲突。 - 重复的
<@item>→input.item是首个值加可迭代对象:<for|it| of=input.item>或[...input.item || []]。类型为Marko.AttrTag<{...}>。 - 标签参数:
<child|{ n }|>;参数形式<${input.content}(1,2)/>→|a, b|。属性和参数不能同时使用。属性标签无法读取参数。 - 结束标签名可以省略(
</>);动态标签使用</>闭合。
响应性与时序
<let/x=input.x>不会跟随之后的input.x变化。请使用<let/x:=input.x>(可控),或直接读取input.x。- 修改操作不具备响应性:
arr.push()❌ →arr = arr.concat();obj.k = v❌ →obj = { ...obj, k: v }。 - 在处理程序中,从
<let>重新计算;刷新完成前,<const>的值和 DOM 都是陈旧的。 <if>会销毁并重新创建状态;<show>会保持挂载。使用<for ... by="id">,以便在重新排序时保留状态。$signal只能在<script>/<lifecycle>/处理程序中使用(在服务器端其他位置使用会抛出错误)。在await之前捕获它。<lifecycle>:从onMount返回一个对象(直接返回清理函数会被丢弃);不要覆盖this上的属性。<return>:每个模板/内容只能有一个,且必须位于顶层;通过改变其value=来调整返回值。
原生标签与事件
- 处理程序参数为
(event, element);不要使用event.currentTarget(事件采用委托方式)——请使用第二个参数或 ref。Marko 处理程序在原生监听器之前运行;不会冒泡的事件只能在目标上处理。 <input value:=n>会得到字符串 → 使用value:parseFloat:=n/valueChange(v){ n = +v }。<select value:=x>:每个<option>都需要value=;不要使用selected=。style={}的键使用连字符格式,不会推断单位;条件声明应放在数组层级。- 展开属性会独占属性(之后缺失时会被移除);请将固定属性写在展开属性之后。
<details open:=o>、<dialog open:=o>;showModal()不会触发openChange。
类型
export interface Input {}(单独的interface会导致解析错误)。Marko.Body<[params], { value }>,Marko.AttrTag<T>,Marko.HTML.Button用于扩展原生标签,Marko.Global用于扩充全局类型,Marko.NativeTags用于自定义元素。请使用mtc(@marko/type-check)进行类型检查,而不是tsc。
发现与资源
- 仅搜索
tags/目录(向上查找);同级文件需要import。包通过marko.json的exports导出。相邻的style.css/foo.style.css会自动附加。 <style>为全局样式,且只处理一次;<style/styles>是 CSS Modules;<style>中的${}→ 自定义属性,仅限声明值,且必须位于所设置样式的内容上方。- 延迟加载:
import X from "<x>" with { load: "visible#id | idle?timeout=2000" }使用静态字符串;使用 facade 标签集中管理。
模板 API
T.render(input)只能消费一次:使用for await、.pipe(res)、.toReadable()、await、.toString()(仅同步)。T.mount(input, node, pos)→update/destroy/value。- 使用
$global.serializedGlobals将全局变量暴露给客户端(绝不能包含机密);cspNonce;renderId/runtimeId必须是标识符安全的。
已知 Agent 错误目录(已根据此仓库验证)
已通过 packages/shadcn/ui/ 中的真实组件和 notes/bug-marko-dynamic-tag-hydration-crash.md 验证。
-
<if>/<else>必须是模板中的直接同级项——<else>不能嵌套在包装元素中,而且</if>与<else>之间不能有任何内容(包括仅含空白的文本表达式),只能有其他<else-if>/<else>标签。<if=a>A</if> <else-if=b>B</else-if> <else>C</else> <div> <if=a>A</if> </div> <else>C</else> -
顶层裸写的
interface/type/const会被解析为标签,而不是 TypeScript。 每个顶层 TS 声明都必须以export开头(运行时的const/function则使用static)。interface Input { name: string } export interface Input { name: string } const PAGE_SIZE = 20; static const PAGE_SIZE = 20; -
!/存在歧义——Marko 将!(未转义 HTML 标记$!{...})和/(标签变量标记<tag/name/>)作为相邻的单字符符号重载;手写!/序列可能会被解析成错误的符号。此技能尚未通过编译器独立复现(发布前未找到最小复现案例——在验证前应视为可信度较低)——无论如何,安全规则是:始终使用显式的$!{expr}形式输出未转义内容;并且不要在未转义输出位置后直接放置标签变量,除非用空格隔开或调整属性顺序。<div>$!{input.articleHtml}</div> -
自递归标签导入 — 标签导入自身(例如,树节点组件渲染自身的子节点)时,必须通过相对文件自定义标签路径或显式导入其自身已编译文件的 PascalCase 名称来导入自身,绝不能像引用已是全局自定义标签那样引用自身的裸标签名;相对路径发现机制只会查找
tags/目录下的标签(参见 https://markojs.com/docs/reference/custom-tag),因此,同目录中的递归组件需要显式导入自身。import TreeNode from "./tree-node.marko"; <li> ${input.label} <if=input.children?.length> <ul> <for|child| of=input.children> <TreeNode ...child/> </for> </ul> </if> </li> -
SSR→hydration 边界可序列化契约(闭包封装规则)。 从服务端渲染的
<let>/响应式状态传到客户端的每个值,都必须是普通的可序列化数据——绝不能是带方法的活动对象(例如 Zag 服务、connect()的结果,或持有闭包的任何类实例)。这是 Marko 可恢复性模型的 REQUIREMENT,并非需要绕过的 bug:如果恢复过程出现损坏,默认判断应是“仍有不可序列化的值跨越了边界”,而不是“Marko 出问题了”。修复方法是只序列化原始值,并在模板编写的闭包中重新派生任何有状态的内容(<zag/api=() => mod .../>上的模块 getter,或<zag>/<zag-machine>上的props=);这些闭包会在两端分别重新运行,绝不要将原始值通过input传递。<let/api=switchMachine.connect(service, normalizeProps)> <zag/api=() => switchMachine from=input/> -
遍历顺序导致的
<const>隐患——已在上游 marko 6.3.46 中修复,无需变通方案。 当组件通过动态标签内容(<${input.content}/>) 挂载,且嵌套在某个祖先组件自身的 portal/动态标签挂载内时,该组件在遍历时读取自身由<const>派生的值——包括<if>/<for>分支选择器、属性展开,以及首次挂载遍历期间求值的任何内容——可能会在该新作用域中的<const>延迟/信号推迟值尚未计算时触发,进而抛出TypeError: ... is not a function。这是@marko/runtime-tags中的真实缺陷,已由 marko-js/marko#4062 修复,并随@marko/[email protected]发布。普通的<zag/api=...>加上每个调用点的api()是正确的常规模式;不要重新引入connectFresh风格的静态函数镜像。<zag/api=() => machine from=input/> <span ...api().getRootProps()> <if=api().open>...</if> </span>历史说明:修复合入之前,
calendar.marko/avatar.marko/radio-group.marko各自包含一个模块级static function connectFresh(...),它会直接针对原始服务调用machine.connect(),完全绕过<const>,作为变通方案。工作区将marko固定版本更新到 6.3.46 后(2026-08-26),这三个组件都恢复为普通的api()。完整的事故说明(包括尝试过但仍会崩溃的镜像形式)见:notes/bug-marko-dynamic-tag-hydration-crash.md。 -
死作用域闭包风险:在 SSR 时未渲染的内容会丢失捕获的变量——通过标签参数传递条目数据,不要使用闭包。 闭包捕获本身有效:
<for|person|>中的<@item>正文读取${person},在服务器端和常规的水合遍历中都能正确渲染(始终打开的控制结构已验证这一点)。风险仅与可恢复性有关:每个内容正文都会被序列化为对其所有者作用域的引用(即保存循环绑定的每次迭代<for>作用域);从未在 SSR 期间渲染的正文(例如位于关闭的<if>后面)不会获得序列化的所有者作用域,因此当它首次在仅客户端挂载时触发,任何通过闭包进行的读取——无论是${person}占位符,还是value=person属性表达式——都会访问未定义的作用域并抛出TypeError: Cannot read properties of undefined (reading 'person')(压缩后:(reading '2')/'N')。标签参数从结构上规避了这个问题:数据在触发时通过渲染调用传递(<${entry.content}(entry.value)/>+<@item|name| value=person>读取${name}),而不是通过序列化的作用域引用传递。因此,类似菜单的组件应始终通过参数向条目内容传递数据。已在 Marko playground(v6.3.45,SSR+水合)中通过对照组验证。说明和 playground 链接:notes/bug-marko-dynamic-tag-hydration-crash.md;最小复现:scratch/content-closures-repro/。 -
按设计,
JSON.stringify(input.item)只显示第一个属性标签——重复属性标签对象是“第一项 + 可迭代对象”。stringify 只会序列化自身的可枚举属性。使用[...input.item].length计数,绝不要根据Array.isArray(input.item)进行分支判断(它永远不是数组——Array.isArray(x) ? x : [x]会静默丢弃除第一个标签之外的所有标签;直接展开即可:[...(input.item ?? [])])。
4. 简洁模式与 HTML 模式的易错点
来源:https://markojs.com/docs/reference/concise-syntax
-
简洁模式下,根级裸文本会被误解析为标签。 文本需要使用
--分隔符(或放在 HTML 模式标签的内容中)。Hello World Welcome to Marko -- Hello World -- Welcome to Marko -
简洁模式下跨行的属性列表使用行首逗号,按照惯例,每个续行的行首都要加逗号(而不是行尾)。
div id="hello" class="world", style={ border: "1px solid red" } -
文件以简洁模式开始,并在遇到第一个 HTML 语法(带方括号)的标签时切换到 HTML 模式——文件级别允许混用(切换一次即可,不要逐个标签来回切换),但同一个标签实例必须完整地使用其中一种写法。
-
简洁模式的属性值中,未括在括号内的
>需要加括号——裸>会被解析为标签结束符;>=(两侧有空格)和=>不会产生歧义,因此不受此限制。<if=count > 0>...</if> <if=(count > 0)>...</if>
已验证的示例
上文所有非平凡的 .marko 示例(有意写错的片段除外)都放入 apps/docs/ 下的临时文件中(以便解析 marko 自身的依赖树),并使用 node_modules/.bun/[email protected]/node_modules/@marko/compiler/dist/index.js 中的 compileFile 进行编译,再通过 bun 运行(普通的 node 无法解析 bun 的 .bun/ 存储布局中嵌套的 marko/translator 自引用——必须使用 bun <script>.mjs,与项目自身工具所依赖的编译器解析方式一致)。全部 17 个有效示例均编译成功:
OK: 01-class-id-shorthand.marko -> code length 293
OK: 02-dynamic-native-tag.marko -> code length 771
OK: 03-content-forward.marko -> code length 595
OK: 04-attr-tag-collect.marko -> code length 995
OK: 05-tag-params.marko -> code length 575
OK: 05b-tag-params-parent.marko -> code length 865
OK: 06-tag-var-ref.marko -> code length 444
OK: 07-let-script-signal.marko -> code length 492
OK: 08-lifecycle-cleanup.marko -> code length 382
OK: 09-static-function.marko -> code length 651
OK: 10-if-elseif-else.marko -> code length 1459
OK: 11-export-interface-static-const.marko -> code length 635
OK: 12-self-recursive.marko -> code length 2150
OK: 13-if-paren-compare.marko -> code length 1014
OK: 14-root-text-fence.marko -> code length 270
OK: 15-leading-comma-attrs.marko -> code length 310
OK: 16-connect-closure.marko -> code length 1276
OK: 17-connect-fresh-static.marko -> code length 990
另外也编译了四个有意写错的片段(顶层裸写的 interface、被中间插入的 <div> 分隔的 <if> / <else>、未加括号的 <if=input.count > 0>,以及没有 -- 的根级裸文本),以确认每个片段都因对应规则所述的原因而失败,而非其他语法错误。编译器自身的诊断信息也独立证实了这些规则:
w1-bare-interface.marko -> "Invalid attribute name." (bare `interface` parsed as a tag)
w2-if-else-wrapped.marko -> "The <else> tag must have a preceding <if=cond> or <else if=cond>."
w3-unparenthesized-if.marko -> "Ambiguous \">\" in attribute... wrap it in parentheses, eg \"=(input.count > 0)\"."
w4-root-text-no-fence.marko -> "Unable to find entry point for custom tag <Hello>... prefix it with -- ... or wrap it in an element"