marko6

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Marko 6 language

Marko 6 语言

Distilled from the full Marko 6 docs (reference, explanation, guide, tutorial; Marko 5 / class API intentionally excluded). This file = rules agents get wrong + quick reference. Full detail lives in
references/
. When something here doesn't match observed behavior, the live docs win — Marko moves fast: index at https://markojs.com/llms.txt; any page fetches as markdown by appending
.md
(e.g. https://markojs.com/docs/reference/core-tag.md).
从完整的 Marko 6 文档(reference、explanation、guide、tutorial;有意排除 Marko 5 / class API)中提炼而来。此文件 = Agent 容易搞错的规则 + 快速参考。完整细节位于
references/
。如果这里的内容与实际观察到的行为不一致,以最新在线文档为准——Marko 发展很快:索引见 https://markojs.com/llms.txt;给任意页面 URL 追加
.md
即可获取 Markdown(例如 https://markojs.com/docs/reference/core-tag.md)。

Marko 6 only — never write Marko 5

仅限 Marko 6——绝不要编写 Marko 5

Reject on sight:
class { onCreate/onMount... }
blocks,
$ const x = ...
scriptlets,
<if(cond)>
/
<for(x in y)>
paren args, string handlers
on-click("handleClick")
,
state
/
this.state
,
<include>
/
<invoke>
,
components/
discovery,
Marko.Component
types. Marko 6 equivalents:
<let>
/
<const>
/
<script>
/
<lifecycle>
,
<if=cond>
,
<for|x| of=y>
,
onClick() {}
,
tags/
discovery,
export interface Input
.
看到以下内容应立即拒绝:
class { onCreate/onMount... }
代码块、
$ const x = ...
脚本片段、带括号的参数形式
<if(cond)>
/
<for(x in y)>
、字符串型 handler
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
。

Checklist for any new or edited
.marko
file

任何新建或编辑的
.marko
文件检查清单

  • Marko 6 syntax only (list above).
  • State first: no
    <script>
    /
    <lifecycle>
    where
    <let>
    /
    <const>
    /controllable natives (
    value:=
    ) do the job;
    <script>
    only for genuine browser effects.
  • Handlers are functions (
    onClick() {}
    /
    onClick=fn
    ), never strings;
    (event, element)
    args, no
    currentTarget
    .
  • export interface Input
    declared; typecheck with
    mtc
    (
    @marko/type-check
    ) —
    tsc
    skips
    .marko
    and reports success.
  • Ship minimal client JS: prefer HTML/CSS (
    <details>
    ,
    <dialog>
    ,
    :has
    ,
    popover
    ) over scripted behavior; only reactive parts ship anyway.
  • Keep components small; split by concern, not by technology (bundle size is unaffected by splitting).
  • SSR-safe: nothing unserializable in
    <let>
    ;
    $signal
    /element refs only in
    <script>
    /
    <lifecycle>
    /handlers.
  • Immutable updates (
    x = x.concat()
    ),
    by=
    on reorderable
    <for>
    s,
    <show>
    vs
    <if>
    chosen deliberately.
FileRead when
references/language.md
syntax: statements, attributes & shorthands, content, attr tags, tag vars/params, dynamic tags, concise mode, whitespace
references/core-tags.md
<if>
<show>
<for>
<let>
<const>
<return>
<script>
<style>
<define>
<lifecycle>
<id>
<await>
<try>
+ reactivity/scheduling model
references/native-tags.md
class=
/
style=
/
content=
, event delegation, controllable
value:=
/
checked:=
/
open:=
, spread ownership
references/typescript.md
Input
,
Marko.*
types, augmenting natives/globals/CSS, TS syntax in tags
references/template-api.md
render
/
mount
,
$global
(
serializedGlobals
,
cspNonce
,
renderId
,
runtimeId
)
references/custom-tags-styling.md
tag discovery order,
tags/
dirs,
marko.json
, styles, lazy
with { load }
references/patterns.md
controllable components, nested state, serializable/immutable state, streaming, tooling
Component architecture for this repo (Zag wiring, attr-tag APIs):
skills/port-to-marko/references/marko-gotchas.md
.
  • 仅使用 Marko 6 语法(见上方列表)。
  • 状态优先:如果
    <let>
    /
    <const>
    /可控原生标签(
    value:=
    )能够完成任务,就不要使用
    <script>
    /
    <lifecycle>
    ;只有在真正需要浏览器副作用时才使用
    <script>
    。
  • Handler 必须是函数(
    onClick() {}
    /
    onClick=fn
    ),绝不能是字符串;参数为
    (event, element)
    ,不要使用
    currentTarget
    。
  • 声明
    export interface Input
    ;使用
    mtc
    (
    @marko/type-check
    )进行类型检查——
    tsc
    会跳过
    .marko
    并报告成功。
  • 尽量减少客户端 JS:优先使用 HTML/CSS(
    <details>
    、
    <dialog>
    、
    :has
    、
    popover
    ),而不是脚本化行为;无论如何只会向浏览器发送具备响应性的部分。
  • 保持组件小巧;按关注点拆分,而不是按技术拆分(拆分不会影响 bundle size)。
  • SSR-safe:
    <let>
    中不能包含不可序列化的内容;
    $signal
    /元素引用只能出现在
    <script>
    /
    <lifecycle>
    /handler 中。
  • 使用不可变更新(
    x = x.concat()
    );可重排的
    <for>
    使用
    by=
    ;有意识地选择
    <show>
    或
    <if>
    。
文件何时阅读
references/language.md
语法:语句、属性与简写、内容、attr tags、tag 变量/参数、动态 tags、concise mode、空白处理
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:=
、spread 属性归属
references/typescript.md
Input
、
Marko.*
类型、扩展原生标签/全局变量/CSS、tag 中的 TypeScript 语法
references/template-api.md
render
/
mount
、
$global
(
serializedGlobals
、
cspNonce
、
renderId
、
runtimeId
)
references/custom-tags-styling.md
tag 发现顺序、
tags/
目录、
marko.json
、样式、延迟加载
with { load }
references/patterns.md
可控组件、嵌套状态、可序列化/不可变状态、流式传输、工具链
本仓库的组件架构(Zag 接线、attr-tag API):
skills/port-to-marko/references/marko-gotchas.md
。

Mental model (10 lines)

心智模型(10 行)

  1. File = component.
    input
    = attrs.
    tags/
    dirs auto-discover
    <tag-name>
    ; PascalCase variables are tags; strings in
    <${...}>
    are always native elements.
  2. Module statements only:
    import
    ,
    export
    ,
    static
    ,
    server
    ,
    client
    . Everything else is template body (reactive).
  3. Reactive sources: tag vars (
    /x
    ), tag params (
    |x|
    ),
    input
    . Graph is compiled, not tracked at runtime. Expressions must be pure.
  4. <let>
    = state (assignment-reactive, immutable updates, serialized on SSR).
    <const>
    = derived (lazy, flush-time).
    static const
    = module constant.
  5. Updates batch after a microtask. After
    count++
    the
    <let>
    is new, every
    <const>
    /DOM is stale until flush.
  6. <script>
    = browser-only effect that re-runs on referenced tag var/param changes; cleanup via
    $signal.onabort
    .
    <lifecycle>
    =
    onMount/onUpdate/onDestroy
    with persistent
    this
    .
  7. Native tag var
    <div/el/>
    is a getter function, browser-only, usable only after render.
  8. Controllable everything:
    value:=x
    ≡
    value=x valueChange(v){x=v}
    ; on member exprs ≡
    value=input.x valueChange=input.xChange
    .
    <let/x:=input.x>
    makes a component controllable.
  9. Server/client boundary: only serializable plain data crosses; no class instances, DOM nodes, or foreign-module closures in state;
    $global
    stays server-side unless in
    serializedGlobals
    .
  10. Zero-JS by default: only reactive parts ship to the browser; component granularity doesn't change bundle size.
  1. 文件 = 组件。
    input
    = attrs。
    tags/
    目录会自动发现
    <tag-name>
    ;PascalCase 变量表示 tags;
    <${...}>
    中的字符串始终表示原生元素。
  2. 只允许模块语句:
    import
    、
    export
    、
    static
    、
    server
    、
    client
    。其他所有内容都是模板正文(具备响应性)。
  3. 响应式数据源:tag 变量(
    /x
    )、tag 参数(
    |x|
    )、
    input
    。依赖图在编译期生成,而不是在运行时追踪。表达式必须保持纯粹。
  4. <let>
    = 状态(赋值触发响应式、使用不可变更新、在 SSR 时序列化)。
    <const>
    = 派生值(惰性计算,在 flush 时求值)。
    static const
    = 模块常量。
  5. 更新会在 microtask 之后批量处理。执行
    count++
    后,
    <let>
    中保存的是新值;在 flush 之前,所有
    <const>
    /DOM 都是陈旧的。
  6. <script>
    = 仅限浏览器的副作用,会在引用的 tag 变量/参数发生变化时重新运行;通过
    $signal.onabort
    清理。
    <lifecycle>
    = 提供
    onMount/onUpdate/onDestroy
    的
    this
    会持续存在。
  7. 原生 tag 变量
    <div/el/>
    是一个 getter function,仅限浏览器端使用,并且只能在 render 完成后使用。
  8. 所有内容都可控制:
    value:=x
    等价于
    value=x valueChange(v){x=v}
    ;作用于成员表达式时,等价于
    value=input.x valueChange=input.xChange
    。
    <let/x:=input.x>
    可让组件变为可控组件。
  9. Server/client 边界:只有可序列化的纯数据可以跨越边界;状态中不能包含 class 实例、DOM 节点或外部模块闭包;除非包含在
    serializedGlobals
    中,否则
    $global
    应保留在 server 端。
  10. 默认 Zero-JS:只有具备响应性的部分才会发送到浏览器;组件粒度不会改变 bundle size。

Quick reference — the rules that bite

快速参考——最容易踩中的规则

Syntax

语法

  • Unenclosed
    >
    in attr value →
    value=(a > b)
    .
    >=
    and
    =>
    OK.
  • <div#id.cls.cls2/>
    shorthand;
    <Comp.x/>
    is
    class="x"
    , not property access →
    <${Comp.x}/>
    .
  • Bare root text in concise mode parses as tags →
    -- text
    . Multi-line attrs: leading commas.
  • Attribute string is a JS literal;
    aria-*
    enumerated attrs need strings:
    aria-pressed=on && "true"
    .
  • null|undefined|false
    attrs skipped;
    0
    ,
    ""
    ,
    NaN
    written. Text:
    null|undefined|false|""|NaN|0n
    skipped,
    0
    rendered.
  • Line break between two inline tags = no space. Put the space on the same line.
  • <if>
    /
    <else if=…>
    /
    <else>
    are direct siblings. Docs write
    <else if=cond>
    ;
    <else-if=cond>
    also compiles on 6.3.34 (verified).
  • <${input.content}/>
    forwards content; native tags also accept
    content=input.content
    or just
    <div ...input/>
    . Literal body (even a comment) overrides
    content=
    .
  • A component's
    content
    attr must be the entirety of its default body — Marko sets
    input.content
    from a tag's non-attr-tag children automatically. Callers never write
    <@content>
    for it; the body goes in directly (params too:
    <Tag|params|>body</Tag>
    , not
    <@content|params|>
    ). If a component needs two-or-more peer body-shaped slots, name them (e.g.
    main
    ), never
    content
    —
    content
    implies "the rest of the body" and a second same-shaped slot breaks that.
  • Repeated
    <@item>
    →
    input.item
    is first + iterable:
    <for|it| of=input.item>
    or
    [...input.item || []]
    . Type
    Marko.AttrTag<{...}>
    .
  • Tag params:
    <child|{ n }|>
    ; args form
    <${input.content}(1,2)/>
    →
    |a, b|
    . Attrs or args, not both. Attr tags can't read params.
  • Closing tag name optional (
    </>
    ); dynamic tags close with
    </>
    .
  • 属性值中未用括号包住的
    >
    →
    value=(a > b)
    。
    >=
    和
    =>
    无需处理。
  • <div#id.cls.cls2/>
    是简写;
    <Comp.x/>
    表示
    class="x"
    ,而不是属性访问;后者应写成
    <${Comp.x}/>
    。
  • 在 concise mode 中,根级纯文本会被解析为 tags →
    -- text
    。多行属性:后续行使用前导逗号。
  • 属性中的字符串是 JS literal;
    aria-*
    枚举属性需要字符串:
    aria-pressed=on && "true"
    。
  • null|undefined|false
    属性会被跳过;
    0
    、
    ""
    、
    NaN
    会被写入。文本:
    null|undefined|false|""|NaN|0n
    会被跳过,
    0
    会渲染。
  • 两个 inline tags 之间换行 = 没有空格。需要空格时,应将空格放在同一行上。
  • <if>
    /
    <else if=…>
    /
    <else>
    必须是直接同级元素。文档写作
    <else if=cond>
    ;
    <else-if=cond>
    在 6.3.34 中也可以编译(已验证)。
  • <${input.content}/>
    用于转发 content;原生 tags 也接受
    content=input.content
    ,或者直接使用
    <div ...input/>
    。字面量正文(即使只是注释)会覆盖
    content=
    。
  • 组件的
    content
    属性必须是其默认正文的全部内容——Marko 会根据 tag 中非 attr-tag 的子元素自动设置
    input.content
    。调用方绝不需要为它写
    <@content>
    ;正文应直接放入(带参数时也一样:
    <Tag|params|>body</Tag>
    ,而不是
    <@content|params|>
    )。如果组件需要两个或更多并列的正文形态插槽,应为它们命名(例如
    main
    ),绝不要命名为
    content
    ——
    content
    意味着“正文的其余部分”,第二个同形态插槽会破坏这一规则。
  • 重复的
    <@item>
    →
    input.item
    是“第一个 item + iterable”:
    <for|it| of=input.item>
    或
    [...input.item || []]
    。类型为
    Marko.AttrTag<{...}>
    。
  • Tag 参数:
    <child|{ n }|>
    ;参数形式
    <${input.content}(1,2)/>
    →
    |a, b|
    。属性和参数不能同时使用。Attr tags 无法读取参数。
  • 闭合 tag 名称可以省略(
    </>
    );动态 tags 使用
    </>
    闭合。

Reactivity & timing

响应式与时序

  • <let/x=input.x>
    does NOT follow later
    input.x
    changes. Use
    <let/x:=input.x>
    (controllable) or read
    input.x
    directly.
  • Mutation isn't reactive:
    arr.push()
    ❌ →
    arr = arr.concat()
    ;
    obj.k = v
    ❌ →
    obj = { ...obj, k: v }
    .
  • In handlers recompute from
    <let>
    s;
    <const>
    values and DOM are stale until flush.
  • <if>
    destroys/recreates state;
    <show>
    keeps it mounted.
    <for ... by="id">
    to preserve state on reorder.
  • $signal
    only inside
    <script>
    /
    <lifecycle>
    /handlers (throws on server elsewhere). Capture it before
    await
    .
  • <lifecycle>
    : return an object from
    onMount
    (bare fn cleanup is dropped); don't overwrite attribute props on
    this
    .
  • <return>
    : one per template/content, top-level; vary its
    value=
    .
  • <let/x=input.x>
    不会跟踪后续的
    input.x
    变化。使用
    <let/x:=input.x>
    (可控),或者直接读取
    input.x
    。
  • 变更不会触发响应式:
    arr.push()
    ❌ →
    arr = arr.concat()
    ;
    obj.k = v
    ❌ →
    obj = { ...obj, k: v }
    。
  • 在 handler 中应基于
    <let>
    重新计算;在 flush 之前,
    <const>
    值和 DOM 都是陈旧的。
  • <if>
    会销毁并重新创建状态;
    <show>
    会保持其挂载状态。使用
    <for ... by="id">
    在重排后保留状态。
  • $signal
    只能出现在
    <script>
    /
    <lifecycle>
    /handler 中(在其他位置使用会抛出 server 端错误)。必须在
    await
    之前捕获它。
  • <lifecycle>
    :
    onMount
    必须返回一个对象(直接返回函数会导致清理逻辑丢失);不要覆盖
    this
    上的属性 props。
  • <return>
    :每个模板/content 只能有一个,必须位于顶层;可以通过
    value=
    返回不同值。

Native tags & events

原生 tags 与事件

  • Handler
    (event, element)
    ; no
    event.currentTarget
    (delegated) — use 2nd arg or ref. Marko handlers run before native listeners; non-bubbling events only on the target.
  • <input value:=n>
    gives strings →
    value:parseFloat:=n
    /
    valueChange(v){ n = +v }
    .
  • <select value:=x>
    : every
    <option>
    needs
    value=
    ; no
    selected=
    .
  • style={}
    keys hyphen-case, no unit inference; conditional declarations at array level.
  • Spread owns attrs (removed when missing later); write fixed attrs after the spread.
  • <details open:=o>
    ,
    <dialog open:=o>
    ;
    showModal()
    doesn't fire
    openChange
    .
  • Handler 参数为
    (event, element)
    ;不要使用
    event.currentTarget
    (事件采用委托)——请使用第二个参数或 ref。Marko handler 会先于原生 listener 执行;不冒泡的事件只能在 target 上处理。
  • <input value:=n>
    返回字符串 →
    value:parseFloat:=n
    /
    valueChange(v){ n = +v }
    。
  • <select value:=x>
    :每个
    <option>
    都需要
    value=
    ;不要使用
    selected=
    。
  • style={}
    的键使用连字符格式,不推断单位;条件声明应放在数组层级。
  • Spread 拥有属性控制权(后续缺失相应属性时会被移除);固定属性应写在 spread 之后。
  • <details open:=o>
    、
    <dialog open:=o>
    ;
    showModal()
    不会触发
    openChange
    。

Types

类型

  • export interface Input {}
    (bare
    interface
    = parse error).
    Marko.Body<[params], { value }>
    ,
    Marko.AttrTag<T>
    ,
    Marko.HTML.Button
    for extending natives,
    Marko.Global
    augmentation,
    Marko.NativeTags
    for custom elements. Typecheck with
    mtc
    (
    @marko/type-check
    ), not
    tsc
    .
  • export interface Input {}
    (裸写
    interface
    会产生解析错误)。
    Marko.Body<[params], { value }>
    、
    Marko.AttrTag<T>
    、用于扩展原生标签的
    Marko.HTML.Button
    、
    Marko.Global
    augmentation、用于自定义元素的
    Marko.NativeTags
    。使用
    mtc
    (
    @marko/type-check
    )进行类型检查,而不是
    tsc
    。

Discovery & assets

发现机制与资源

  • Only
    tags/
    dirs are searched (upward); sibling files need
    import
    . Packages export via
    marko.json
    exports
    . Adjacent
    style.css
    /
    foo.style.css
    auto-attached.
  • <style>
    global, once;
    <style/styles>
    CSS Modules;
    ${}
    inside
    <style>
    → custom props, declaration values only, above the styled content.
  • Lazy:
    import X from "<x>" with { load: "visible#id | idle?timeout=2000" }
    static string; facade tag to centralize.
  • 只会向上搜索
    tags/
    目录;同级文件需要使用
    import
    。Package 通过
    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 tag 集中管理。

Template API

模板 API

  • T.render(input)
    consumed once:
    for await
    ,
    .pipe(res)
    ,
    .toReadable()
    ,
    await
    ,
    .toString()
    (sync-only).
    T.mount(input, node, pos)
    →
    update/destroy/value
    .
  • $global.serializedGlobals
    to expose globals to the client (never secrets);
    cspNonce
    ;
    renderId
    /
    runtimeId
    identifier-safe.
  • T.render(input)
    只能消费一次:
    for await
    、
    .pipe(res)
    、
    .toReadable()
    、
    await
    、
    .toString()
    (仅支持同步)。
    T.mount(input, node, pos)
    →
    update/destroy/value
    。
  • 使用
    $global.serializedGlobals
    将全局变量暴露给客户端(绝不能暴露 secrets);还有
    cspNonce
    ;
    renderId
    /
    runtimeId
    必须符合标识符安全要求。

Known agent-error catalog (verified against this repo)

已知 Agent 错误案例目录(已针对本仓库验证)

Verified against real components in
packages/shadcn/ui/
and
notes/bug-marko-dynamic-tag-hydration-crash.md
.
  • <if>
    /
    <else>
    must be direct template siblings
    —
    <else>
    is not valid nested inside a wrapper, and nothing (not even whitespace-only text expressions) may sit between
    </if>
    and
    <else>
    other than other
    <else-if>
    /
    <else>
    tags.
    marko
    <!-- right -->
    <if=a>A</if>
    <else-if=b>B</else-if>
    <else>C</else>
    <!-- wrong: wrapping breaks the sibling chain -->
    <div>
      <if=a>A</if>
    </div>
    <else>C</else>
  • Bare top-level
    interface
    /
    type
    /
    const
    gets parsed as a tag attempt, not TypeScript.
    Every top-level TS declaration needs
    export
    (or
    static
    for runtime
    const
    /
    function
    ) in front of it.
    marko
    <!-- wrong: "interface" parses as a custom tag name -->
    interface Input { name: string }
    <!-- right -->
    export interface Input { name: string }
    <!-- wrong: bare top-level const is a template-body reactive statement, not module scope, and usually not what you meant -->
    const PAGE_SIZE = 20;
    <!-- right -->
    static const PAGE_SIZE = 20;
  • !/
    is ambiguous
    — Marko overloads
    !
    (unescaped-HTML marker,
    $!{...}
    ) and
    /
    (tag-variable marker,
    <tag/name/>
    ) as adjacent single-character sigils; a hand-written
    !/
    sequence risks being parsed as the wrong one. Not independently reproduced against the compiler for this skill (no minimal repro found before publishing — treat as lower-confidence pending verification) — the safe rule regardless: always write the explicit
    $!{expr}
    form for unescaped output, and never place a tag-variable directly after an unescaped-output position without a separating space or reordering the attributes.
    marko
    <!-- right: unambiguous -->
    <div>$!{input.articleHtml}</div>
  • Self-recursive tag imports — a tag that imports itself (e.g. a tree node component rendering its own children) must import itself via the relative-file custom-tag path or an explicit PascalCase import of its own compiled file, never by referencing its own bare tag name as if it were already a global custom tag; relative discovery only finds tags under a
    tags/
    directory (see https://markojs.com/docs/reference/custom-tag), so a same-directory recursive component needs an explicit self-import.
    marko
    <!-- tree-node.marko -->
    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 boundary serializability contract (closure-wrap rule). Every value that crosses from server-rendered
    <let>
    /reactive state into the client must be plain, serializable data — never a live object with methods (a Zag service, a
    connect()
    result, any class instance holding closures). This is a REQUIREMENT of Marko's resumability model, not a bug to work around: if resume corrupts, the default read is "an unserializable value is still crossing the boundary somewhere," not "Marko is broken." The fix is to serialize only primitives and re-derive anything stateful inside a template-written closure (the module getter on
    <zag/api=() => mod .../>
    , or
    props=
    on
    <zag>
    /
    <zag-machine>
    ) that runs fresh on each side, never a raw value passed through
    input
    .
    marko
    <!-- wrong: service/api is a live object with methods — throws "Unable to serialize" -->
    <let/api=switchMachine.connect(service, normalizeProps)>
    <!-- right: <zag> wires the machine and returns a getter, re-evaluated on each side -->
    <zag/api=() => switchMachine from=input/>
  • Walk-order
    <const>
    hazard — fixed upstream in marko 6.3.46, no workaround needed.
    When a component was mounted through dynamic-tag-content (
    <${input.content}/>
    ) nested inside an ancestor's own portal/dynamic-tag mount, that component's OWN walk-time reads of its OWN
    <const>
    -derived value — in
    <if>
    /
    <for>
    branch selectors, attribute spreads, anywhere evaluated during the first mount walk — could fire before the
    <const>
    's lazy/signal-deferred value had ever computed on that fresh scope, throwing
    TypeError: ... is not a function
    . This was a genuine defect in
    @marko/runtime-tags
    , fixed by marko-js/marko#4062, shipped in
    @marko/runtime-tags@6.3.46
    . Plain
    <zag/api=...>
    +
    api()
    at every call site — the ordinary pattern — is correct; do not reintroduce a
    connectFresh
    -style static-function mirror.
    marko
    <!-- correct, at marko >=6.3.46: plain api() getter, no mirror needed -->
    <zag/api=() => machine from=input/>
    <span ...api().getRootProps()>
      <if=api().open>...</if>
    </span>
    Historical note: before the fix landed,
    calendar.marko
    /
    avatar.marko
    /
    radio-group.marko
    each carried a module-level
    static function connectFresh(...)
    that called
    machine.connect()
    directly against the raw service, bypassing the
    <const>
    entirely, as a workaround. All three were reverted to plain
    api()
    once the workspace's
    marko
    pin moved to 6.3.46 (2026-08-26). Full incident writeup, including the mirror shapes that were tried and still crashed:
    notes/bug-marko-dynamic-tag-hydration-crash.md
    .
  • Dead-scope closure hazard: content unrendered at SSR time loses its captured variables — pass entry data via tag parameters, not closures. Closure capture itself WORKS: an
    <@item>
    body inside
    <for|person|>
    reading
    ${person}
    renders correctly on the server and through the ordinary hydration walk (always-open control proves it). The hazard is resumability-specific: each content body is serialized as a reference to its OWNER SCOPE (the per-iteration
    <for>
    scope holding the loop binding); a body that never rendered during SSR (e.g. behind a closed
    <if>
    ) gets no serialized owner scope, so when it first fires on a CLIENT-ONLY mount, ANY read through the closure —
    ${person}
    placeholder or a
    value=person
    attribute expression alike — hits an undefined scope and throws
    TypeError: Cannot read properties of undefined (reading 'person')
    (minified:
    (reading '2')
    /
    'N'
    ). Tag parameters dodge this structurally: the data travels inside the render invocation at fire time (
    <${entry.content}(entry.value)/>
    +
    <@item|name| value=person>
    reading
    ${name}
    ), not through a serialized scope reference. Menu-like components should therefore always pass entry data to item content via args. Verified as a discriminating pair in the Marko playground (v6.3.45, SSR+hydration). Writeup + playground links:
    notes/bug-marko-dynamic-tag-hydration-crash.md
    ; minimal repro:
    scratch/content-closures-repro/
    .
  • JSON.stringify(input.item)
    shows only the first attr tag by design
    — the repeated-attr-tag object is "first item + iterable"; stringify serializes own enumerable props only. Count with
    [...input.item].length
    , and never branch on
    Array.isArray(input.item)
    (it is never an array —
    Array.isArray(x) ? x : [x]
    silently discards all but the first tag; spread directly:
    [...(input.item ?? [])]
    ).
已使用
packages/shadcn/ui/
中的真实组件以及
notes/bug-marko-dynamic-tag-hydration-crash.md
进行验证。
  • <if>
    /
    <else>
    必须是直接的模板同级元素
    ——
    <else>
    不能嵌套在 wrapper 内,而且
    </if>
    与
    <else>
    之间不得放置任何内容(即使是仅含空白的文本表达式),只能放置其他
    <else-if>
    /
    <else>
    tags。
    marko
    <!-- right -->
    <if=a>A</if>
    <else-if=b>B</else-if>
    <else>C</else>
    <!-- wrong: wrapping breaks the sibling chain -->
    <div>
      <if=a>A</if>
    </div>
    <else>C</else>
  • 顶层裸写的
    interface
    /
    type
    /
    const
    会被解析为 tag 尝试,而不是 TypeScript。
    每个顶层 TS declaration 前面都必须加
    export
    (运行时的
    const
    /
    function
    则加
    static
    )。
    marko
    <!-- wrong: "interface" parses as a custom tag name -->
    interface Input { name: string }
    <!-- right -->
    export interface Input { name: string }
    <!-- wrong: bare top-level const is a template-body reactive statement, not module scope, and usually not what you meant -->
    const PAGE_SIZE = 20;
    <!-- right -->
    static const PAGE_SIZE = 20;
  • !/
    存在歧义
    ——Marko 将
    !
    (未转义 HTML 标记,
    $!{...}
    )和
    /
    (tag 变量标记,
    <tag/name/>
    )用作相邻的单字符符号;手写
    !/
    序列可能被解析成错误的含义。该问题尚未针对此 skill 在编译器中独立复现(发布前未找到最小复现——在得到验证前应视为置信度较低),但无论是否如此,安全规则始终是:始终使用明确的
    $!{expr}
    形式输出未转义内容;不要在未转义输出位置之后直接放置 tag 变量,除非使用空格分隔或重新排列属性。
    marko
    <!-- right: unambiguous -->
    <div>$!{input.articleHtml}</div>
  • 自递归 tag 导入——一个导入自身的 tag(例如渲染自身子项的 tree node 组件),必须通过相对文件 custom-tag 路径,或通过显式 PascalCase import 导入自身编译后的文件;绝不能像使用全局 custom tag 一样直接引用自己的裸 tag 名称。相对发现机制只会找到
    tags/
    目录下的 tags(参见 https://markojs.com/docs/reference/custom-tag),因此,同目录下的递归组件需要显式自导入。
    marko
    <!-- tree-node.marko -->
    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 边界的可序列化契约(closure-wrap 规则)。 每个从 server 端渲染的
    <let>
    /响应式状态跨越到 client 端的值,都必须是纯且可序列化的数据——绝不能是带方法的活动对象(Zag service、
    connect()
    的结果、任何持有闭包的 class 实例)。这是 Marko resumability 模型的硬性要求,不是需要绕过的 bug;如果 resume 发生损坏,默认判断应该是“仍有不可序列化的值跨越了边界”,而不是“Marko 出了问题”。修复方式是只序列化基础类型,并在模板编写的闭包中重新派生任何有状态内容(
    <zag/api=() => mod .../>
    上的 module getter,或
    <zag>
    /
    <zag-machine>
    上的
    props=
    ),该闭包会在 server/client 两侧分别重新运行;绝不能通过
    input
    传递原始值。
    marko
    <!-- wrong: service/api is a live object with methods — throws "Unable to serialize" -->
    <let/api=switchMachine.connect(service, normalizeProps)>
    <!-- right: <zag> wires the machine and returns a getter, re-evaluated on each side -->
    <zag/api=() => switchMachine from=input/>
  • 遍历顺序
    <const>
    隐患——已在 marko 6.3.46 中从上游修复,无需 workaround。
    当一个组件通过嵌套在祖先组件自身的 portal/dynamic-tag mount 中的 dynamic-tag-content 挂载时,该组件在遍历时读取自身
    <const>
    派生值——包括在
    <if>
    /
    <for>
    分支选择器、属性 spread,以及首次 mount 遍历期间求值的任何位置——可能在该
    <const>
    的惰性/signal-deferred 值尚未于新作用域中计算之前触发,从而抛出
    TypeError: ... is not a function
    。这是
    @marko/runtime-tags
    中真实存在的缺陷,已由 marko-js/marko#4062 修复,并随
    @marko/runtime-tags@6.3.46
    发布。普通的
    <zag/api=...>
    + 在每个调用点使用
    api()
    才是正确模式;不要重新引入
    connectFresh
    风格的静态函数镜像。
    marko
    <!-- correct, at marko >=6.3.46: plain api() getter, no mirror needed -->
    <zag/api=() => machine from=input/>
    <span ...api().getRootProps()>
      <if=api().open>...</if>
    </span>
    历史说明:在修复落地前,
    calendar.marko
    /
    avatar.marko
    /
    radio-group.marko
    各自包含一个模块级
    static function connectFresh(...)
    ,直接针对原始 service 调用
    machine.connect()
    ,绕过
    <const>
    ,作为 workaround。workspace 中的
    marko
    固定版本升级到 6.3.46(2026-08-26)后,这三个文件都恢复为普通的
    api()
    。完整事件记录,包括尝试过的镜像实现及其仍会崩溃的原因:
    notes/bug-marko-dynamic-tag-hydration-crash.md
    。
  • 死作用域闭包隐患:SSR 时未渲染的 content 会丢失捕获的变量——通过 tag 参数传递入口数据,而不是闭包。 闭包捕获本身可以正常工作:
    <for|person|>
    中读取
    ${person}
    的
    <@item>
    正文,在 server 端以及普通 hydration 遍历中都能正确渲染(始终打开的对照组可以证明这一点)。隐患与 resumability 特性有关:每个 content 正文都会作为其所属作用域的引用进行序列化,也就是保存循环绑定变量的每次迭代作用域;如果某个正文在 SSR 期间从未渲染过(例如位于关闭的
    <if>
    后面),它就不会获得已序列化的所属作用域。因此,当它首次在仅 client 端挂载时,任何通过闭包进行的读取——无论是
    ${person}
    占位符,还是
    value=person
    属性表达式——都会遇到未定义作用域并抛出
    TypeError: Cannot read properties of undefined (reading 'person')
    (压缩后为
    (reading '2')
    /
    'N'
    )。Tag 参数可以从结构上避开这个问题:数据会在触发时随 render 调用一起传递(
    <${entry.content}(entry.value)/>
    +
    <@item|name| value=person>
    读取
    ${name}
    ),而不是依赖已序列化的作用域引用。因此,menu 类组件应始终通过 args 将条目数据传递给 item content。已在 Marko playground(v6.3.45,SSR+hydration)中通过一组有区分度的对照用例完成验证。记录与 playground 链接:
    notes/bug-marko-dynamic-tag-hydration-crash.md
    ;最小复现:
    scratch/content-closures-repro/
    。
  • JSON.stringify(input.item)
    按设计只显示第一个 attr tag
    ——重复的 attr-tag 对象是“第一个 item + iterable”;
    stringify
    只会序列化其自身 enumerable props。请使用
    [...input.item].length
    计数,并且绝不能基于
    Array.isArray(input.item)
    分支处理(它永远不是数组——
    Array.isArray(x) ? x : [x]
    会静默丢弃除第一个之外的所有 tag;应直接使用 spread:
    [...(input.item ?? [])]
    )。

4. Concise-mode vs HTML-mode gotchas

4. Concise mode 与 HTML mode 的陷阱

  • Bare root-level text is misparsed as tags in concise mode. Text needs the
    --
    fence (or must live inside an HTML-mode tag's content).
    marko
    <!-- wrong: each word/line is parsed as an attempted tag -->
    Hello World
    Welcome to Marko
    <!-- right -->
    -- Hello World
    -- Welcome to Marko
  • Attribute lists spanning lines in concise mode use leading commas, by convention at the start of each continuation line (not trailing).
    marko
    <!-- right -->
    div id="hello" class="world",
      style={ border: "1px solid red" }
  • A file starts concise and switches to HTML mode at the first HTML-syntax (bracketed) tag — mixing is allowed at the file level (switch once, don't flip back and forth per tag), but a given tag occurrence must be written fully one way or the other.
  • Unenclosed
    >
    in a concise-mode attribute value needs parentheses
    — a bare
    >
    reads as end-of-tag;
    >=
    (with surrounding whitespace) and
    =>
    are exempted as unambiguous.
    marko
    <!-- wrong: > closes the tag early -->
    <if=count > 0>...</if>
    <!-- right -->
    <if=(count > 0)>...</if>
  • 在 concise mode 中,顶层裸文本会被错误解析为 tags。 文本需要使用
    --
    围栏(或放在 HTML-mode tag 的 content 中)。
    marko
    <!-- wrong: each word/line is parsed as an attempted tag -->
    Hello World
    Welcome to Marko
    <!-- right -->
    -- Hello World
    -- Welcome to Marko
  • 在 concise mode 中,跨行属性列表按惯例使用前导逗号,即放在每个续行的开头(而不是结尾)。
    marko
    <!-- right -->
    div id="hello" class="world",
      style={ border: "1px solid red" }
  • 文件以 concise mode 开始,并在遇到第一个 HTML-syntax(带方括号的)tag 时切换到 HTML mode——允许在文件层级混用(只切换一次,不要在不同 tag 之间来回切换),但每个具体的 tag 实例必须完整使用其中一种写法。
  • 在 concise mode 的属性值中,未用括号包住的
    >
    需要加括号
    ——裸写
    >
    会被视为 tag 结束符;带空格的
    >=
    和
    =>
    是明确无歧义的例外。
    marko
    <!-- wrong: > closes the tag early -->
    <if=count > 0>...</if>
    <!-- right -->
    <if=(count > 0)>...</if>

Samples verified

已验证的样例

Every nontrivial
.marko
sample above that is not an intentionally-broken ("wrong") fragment was dropped into a scratch file under
apps/docs/
(so
marko
's own dependency tree resolves) and compiled with
compileFile
from
node_modules/.bun/marko@6.3.34/node_modules/@marko/compiler/dist/index.js
, run via
bun
(plain
node
cannot resolve the nested
marko/translator
self-reference inside bun's
.bun/
store layout —
bun <script>.mjs
is required, matching the compiler resolution the repo's own tooling relies on). All 17 real samples compiled successfully:
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
The four intentionally-wrong fragments (bare top-level
interface
,
<if>
/
<else>
split by an intervening
<div>
, unparenthesized
<if=input.count > 0>
, unenclosed root text without
--
) were compiled too, to confirm each fails for the reason the corresponding rule claims — not an unrelated syntax mistake. The compiler's own diagnostics independently confirm the rule text:
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"
上面每个非平凡且并非故意错误(“wrong”)片段的
.marko
样例,都被放入
apps/docs/
下的 scratch 文件中(这样
marko
自身的依赖树可以完成解析),并使用
node_modules/.bun/marko@6.3.34/node_modules/@marko/compiler/dist/index.js
中的
compileFile
进行编译,再通过
bun
运行(普通
node
无法在 bun 的
.bun/
store 布局中解析嵌套的
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
另外还对四个故意错误的片段进行了编译,以确认每个片段确实是因为对应规则所述的原因而失败,而不是因为其他无关的语法错误。编译器自身的 diagnostics 也独立确认了规则内容:
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"