Binding

Interpolate data with `{{ path || default }}` and conditionally render content with `::if`, and repeat content with `::for`.

The comark/plugins/binding module lets you interpolate values with {{ path || default }} and conditionally render content with ::if, and repeat content with ::for. Values can come from frontmatter, the renderer's data prop, the tree's meta, or a parent component's props.

The binding() parser plugin emits a binding component node whose :value attribute points at a dot-path. The data binding layer resolves that path against the ambient render context, so bindings work across HTML, ANSI, Vue, React, Svelte, Angular, and Nuxt, and round-trip back to their source form via renderMarkdown.

Basic usage

Registering the plugin

parse.ts
import { parseMarkdown } from 'comark'
import binding from 'comark/plugins/binding'

const tree = await parseMarkdown(content, {
  plugins: [binding()],
})

In Markdown

Wrap a dot-path in {{ … }} to interpolate a value, and use || to declare a default for unresolved paths:

---
user:
  name: Ada
  role: admin
---

Welcome, {{ frontmatter.user.name || guest }} ({{ frontmatter.user.role }}).

Rendered HTML:

<p>Welcome, Ada (admin).</p>

Render handlers

The plugin ships a renderer-specific Binding export for every first-party package so the <binding> AST node turns into the resolved value (falling back to the || default) rather than a literal <binding> tag.

import binding, { Binding } from '@comark/html/plugins/binding'
import { createHtmlRenderer } from '@comark/html'

const renderHtml = createHtmlRenderer({
  plugins: [binding()],
  components: { Binding },
})

const html = await renderHtml(`
---
user:
  name: Ada
---

Hello {{ frontmatter.user.name }}!
`)
// → <p>Hello Ada!</p>

Conditional content

Register the renderer-specific If export to render a block only when its resolved props pass. The ::if syntax uses Comark's default component parser and data-binding layer, so it doesn't require the binding() parser plugin unless the same document also uses {{ … }} interpolation.

render.ts
import { renderHtml } from '@comark/html'
import { If } from '@comark/html/plugins/binding'

const html = await renderHtml(markdown, {
  components: { If },
  data: {
    user: { role: 'member' },
    age: 42,
  },
})

Use value by itself for a truthy check:

::if{:value="data.user"}
You are signed in.
::

Combine value with one or more comparison props. Every supplied comparison must pass:

::if{:value="data.user.role" neq="guest"}
This content is available to members.
::

::if{:value="data.age" :gte="18" :lt="65" as="section"}
This content is wrapped in a section.
::
PropBehavior
valueChecked for truthiness when no comparison props are present
eqRequires strict equality
neqRequires strict inequality
gtRequires value to be greater than the comparison value
gteRequires value to be greater than or equal to the value
ltRequires value to be less than the comparison value
lteRequires value to be less than or equal to the value
asWraps visible content in an allowlisted semantic HTML element

A comparison never passes when value or its comparison value resolves to undefined. Use : on numeric, boolean, or other JSON values so Comark preserves their type. For example, :eq="false" compares against the boolean false, while eq="false" compares against the string "false".

The as prop accepts div, span, p, section, article, aside, header, footer, main, or nav. ANSI output validates the prop but renders no wrapper.

To require checks on different values, nest If blocks:

::if{:value="data.isLoggedIn"}
:::if{:value="data.age" :gte="18"}
Adult member content.
:::
::

Else branches

Use the #else slot to show fallback content when the condition or any comparison fails. No blank line is required before #else:

::if{:value="data.isHappy"}
I am happy.
#else
I am NOT happy.
::

Nest another If in the else slot to check a second condition:

::if{:value="data.isHappy"}
I am happy.
#else
:::if{:value="data.isFine"}
I am fine.
#else
I am NOT fine and NOT happy.
:::
::

Each #else belongs to its enclosing component. Only the selected branch renders, and as wraps whichever branch is selected. Without an else slot, a failed condition produces no output. This works with all renderer-specific If exports.

Repeating content

Register For alongside Binding to render Markdown once per array item:

render.ts
import { renderHtml } from '@comark/html'
import binding, { Binding, For } from '@comark/html/plugins/binding'

const markdown = `
::for{:each="data.posts" item="post" key="id"}
### {{ props.post.title }}

{{ props.post.description }}
#empty
No posts published yet.
::
`

const html = await renderHtml(markdown, {
  plugins: [binding()],
  components: { Binding, For },
  data: {
    posts: [{ id: 'hello', title: 'Hello', description: 'Our first post.' }],
  },
})

Import from your renderer's binding entry point (html, ansi, vue, react, svelte, angular, or nuxt). Register For directly in the component map. Like If, the block syntax uses the default component parser; binding() is only needed for {{ … }} interpolation.

Item and index aliases

item names the current value in props; its default name is item. Add an index alias to expose the zero-based array position:

::for{:each="data.posts" item="post" index="position" key="id"}
{{ props.position }}: {{ props.post.title }}
::

Arrays can contain objects or primitive values. Loop aliases remain available inside headings, attributed elements, and nested components. An inner loop can reference outer aliases; reusing an alias shadows it only within the inner loop. Aliases take precedence over component props of the same name and do not leak into following siblings.

::for{:each="data.posts" item="post" key="id"}
:::for{:each="props.post.tags" item="tag"}
{{ props.post.title }}: {{ props.tag }}
:::
::

Register If too when combining loops with conditional content:

::for{:each="data.posts" item="post" key="id"}
:::if{:value="props.post.published"}
{{ props.post.title }}
#else
Draft: {{ props.post.title }}
:::
::

Empty lists

#empty renders once when each is an empty array, null, or undefined. Without it, an empty list produces no output. Other non-array values are invalid. The default branch is not evaluated for an empty list, and the empty branch is not evaluated for a populated list. An explicit #default slot is also supported.

Stable keys

key="id" reads each item's id property; nested paths such as key="metadata.id" work too. Values must be unique strings or finite numbers. Missing, invalid, or duplicate keys throw an error. Without key, iterations use their array positions.

Vue, React, and Svelte use these keys to preserve an item's rendered components and uncontrolled input state when the array is reordered. Nuxt uses the Vue implementation. HTML and ANSI have no persistent DOM state. Angular currently rebuilds rendered descendants when its inputs change, so keys do not preserve component state there.

For adds no wrapper element. Put wrapper elements inside its default slot when needed.

Markdown round-trip

When you re-serialize the AST with renderMarkdown, you can pass the core Binding handler to preserve the original {{ … }} shorthand:

render-markdown.ts
import { parseMarkdown } from 'comark'
import { renderMarkdown } from 'comark/render'
import binding, { Binding } from 'comark/plugins/binding'

const document = await parseMarkdown('Hi {{ user.name }}!', { plugins: [binding()] })

const source = await renderMarkdown(document, {
  components: { Binding },
})
// → "Hi {{ user.name }}!\n"

Resolution scope

A binding value ({{ path }}) is resolved as a dot-path against the same render context used by :prefix component bindings:

NamespaceSource
frontmatterThe document's YAML frontmatter
metaPlugin-populated metadata on the parsed tree
dataRuntime values passed via the renderer's data prop
propsThe enclosing component's own props (useful for nested components)

See Data Binding for the full contract and additional examples.

Default values

Use || default to specify a fallback that's emitted when the dot-path does not resolve:

Hello {{ data.user.name || guest }}!
  • If data.user.name resolves, its value is rendered.
  • Otherwise the literal text after || is rendered (trim and quote as you see fit; YAML rules don't apply here).

Custom tag name

You can swap the emitted element tag via the plugin's tag option. This is handy if you already use binding as a custom component name:

import binding from 'comark/plugins/binding'

const tree = await parseMarkdown('{{ x }}', {
  plugins: [binding({ tag: 'prop' })],
})

// AST: ['p', {}, ['prop', { ':value': 'x' }]]

Pair this with a components: { prop: Binding } mapping to preserve the render behavior.

API reference

binding(options?: MdcInlineBindingOptions): ComarkPlugin

Register the inline-binding parser.

OptionTypeDefaultDescription
tagstring"binding"Tag name used for the emitted inline element

Binding

Every first-party package exports a Binding handler/component tailored to its rendering target. Each one:

  • Prefers the already-resolved value prop supplied by the data-binding layer
  • Falls back to defaultValue when the path does not resolve
  • Emits an empty string when neither is available (the ANSI variant shows a dimmed {{ path }} placeholder for debuggability)
// markdown (source shorthand)
import { Binding } from 'comark/plugins/binding'

// HTML
import { Binding } from '@comark/html/plugins/binding'

// ANSI
import { Binding } from '@comark/ansi/plugins/binding'

// React
import { Binding } from '@comark/react/plugins/binding'

// Svelte
import { Binding } from '@comark/svelte/plugins/binding'

// Vue
import { Binding } from '@comark/vue/plugins/binding'

// Angular
import { Binding } from '@comark/angular/plugins/binding'

// Nuxt (Vue implementation)
import { Binding } from '@comark/nuxt/plugins/binding'

If

Every renderer-specific binding entry point exports an If adapter. Register it under components to enable ::if blocks:

import { If } from '@comark/html/plugins/binding'

const components = { If }

Replace html with ansi, vue, react, svelte, angular, or nuxt for the corresponding renderer. Hidden Angular branches are structural: their descendants aren't instantiated.

See Conditional content for Markdown examples of truthiness checks, comparisons, wrappers, and nested #else branches.

The core entry point also exports the shared IfProps, IfComparisonOperator, and IfWrapperTag types, plus helpers for custom renderer adapters:

  • shouldRenderIf(props) checks the resolved value for truthiness or evaluates the supplied comparisons.
  • selectIfBranch(children, matches) selects default or else AST children. It returns undefined when the condition fails and no else slot exists.
  • resolveIfWrapper(value) validates the optional wrapper tag.
import {
  resolveIfWrapper,
  selectIfBranch,
  shouldRenderIf,
  type IfProps,
} from 'comark/plugins/binding'

For

Every renderer-specific binding entry point exports For. Register it with components: { Binding, For } when using interpolation inside loops.

PropTypeDefaultDescription
eachunknown[], null, or undefinedundefinedArray to iterate; nullish values select #empty
itemstring"item"Alias for the current item under props
indexstringOptional alias for the zero-based array position
keystringItem property path used for stable identity

item and index must be distinct, non-empty names without dots or prototype keys. Slots: default content repeats per item; #empty renders once for an empty collection. See Repeating content for Markdown examples and renderer-specific key behavior.

The core entry point exports ForProps, ForIteration, resolveForIterations(props, renderData), and selectForBranch(children, empty) for renderer adapters. Each iteration contains a key and a render context with lexical aliases in scope; attribute resolution makes those aliases available under props.

Use cases

  1. Personalized content: greet users by name from frontmatter or runtime data:

    Hello {{ data.user.name || friend }}!
  2. Documentation templates: interpolate configuration or versioned values:

    ---
    version: 2.5.1
    ---
    
    You are reading the docs for **v{{ frontmatter.version }}**.
  3. Dynamic tables: combine with frontmatter-driven rows:

    ---
    stats:
      users: 1200
      uptime: 99.9%
    ---
    
    | Metric | Value                       |
    | ------ | --------------------------- |
    | Users  | {{ frontmatter.stats.users }} |
    | Uptime | {{ frontmatter.stats.uptime }} |
  4. Component props: reference an enclosing component's resolved attributes:

    ::card{title="Hello"}
      Title is {{ props.title }}.
    ::

See also