Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/content/2.syntax/2.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,81 @@ response:
::
~~~

## Data Binding

Props prefixed with `:` are JSON-parsed at render time. When the value isn't valid JSON, the renderer looks it up as a dot-path against an ambient **render context**, letting authors reference runtime data, frontmatter, meta, or the enclosing component's props without hardcoding them.

### Scope

The render context exposes four namespaces:

| Namespace | Source |
| ------------- | ---------------------------------------------------- |
| `frontmatter` | The document's YAML frontmatter |
| `meta` | Plugin-populated metadata on the parsed tree |
| `data` | Runtime values passed via the renderer's `data` prop |
| `props` | The enclosing component's own props |

### Frontmatter

Reference any value declared in the document's frontmatter:

```mdc
---
theAnswer: 42
user:
name: Ada
---

::question{:answer="frontmatter.theAnswer"}
::

Hello, :badge{:label="frontmatter.user.name"}
```

### Runtime data

Pass values from your app via the renderer's `data` prop and reference them under the `data.` namespace:

::code-group
```vue [Vue]
<Comark :markdown="content" :data="{ user: { name: 'Ada' } }" />
```

```tsx [React]
<Comark markdown={content} data={{ user: { name: 'Ada' } }} />
```

```svelte [Svelte]
<Comark markdown={content} data={{ user: { name: 'Ada' } }} />
```
::

```mdc
Welcome, :badge{:label="data.user.name"}!
```

### Parent component props

Nested components can read the enclosing component's resolved props through the `props.` namespace. This is useful when a child should mirror something declared once on its parent:

```mdc
::card{title="Hello" variant="primary"}
:::badge{:color="props.variant" :text="props.title"}
:::
::
```

### Resolution rules

- If the `:prefixed` value is a valid JSON literal (e.g. `5`, `true`, `"foo"`, `{"a":1}`), it's used as-is.
- Otherwise, the value is treated as a dot-path and resolved against `{ frontmatter, meta, data, props }`.
- Unknown paths resolve to `undefined` — the prop is passed as `undefined` rather than the raw string.

::callout{color="info" icon="i-lucide-info"}
Only props authored with the `:` prefix participate in data binding. Plain `prop="value"` attributes are always passed as literal strings.
::

## Slots

Block components support slots for passing structured content to components.
Expand Down
21 changes: 21 additions & 0 deletions docs/content/3.rendering/3.vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ Pass markdown content via the default slot or the `markdown` prop:
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| `summary` | `boolean` | `false` | Only render content before `<!-- more -->` |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append caret to last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` |

#### `options`

Expand Down Expand Up @@ -245,6 +246,25 @@ const manifest = (name: string) => {
</template>
```

#### `data`

Expose runtime values to markdown authors. Any prop written with a `:` prefix is resolved against the render context `{ frontmatter, meta, data, props }` when its value isn't valid JSON — see [Data Binding](/syntax/components#data-binding) for the full scope.

```vue [App.vue]
<script setup lang="ts">
import { Comark } from '@comark/vue'

const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
</script>

<template>
<Suspense>
<Comark :markdown="content" :data="{ user }" />
</Suspense>
</template>
```

### `defineComarkComponent`

Creates a pre-configured `<Comark>` component with default options, plugins, and components baked in.
Expand Down Expand Up @@ -414,6 +434,7 @@ const tree = await res.json()
| [`componentsManifest`](#code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver for lazy-loaded components |
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` |

### `defineComarkRendererComponent`

Expand Down
17 changes: 17 additions & 0 deletions docs/content/3.rendering/5.react.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Pass markdown content via `children` or the `markdown` prop:
| [`componentsManifest`](#code-componentsmanifest) | `(name: string) => Promise<Component>` | `undefined` | Dynamic component resolver |
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append caret to last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `className` | `string` | `undefined` | CSS class for wrapper element |

#### `options`
Expand Down Expand Up @@ -208,6 +209,21 @@ export default function App() {
}
```

#### `data`

Expose runtime values to markdown authors. Any prop written with a `:` prefix is resolved against the render context `{ frontmatter, meta, data, props }` when its value isn't valid JSON — see [Data Binding](/syntax/components#data-binding) for the full scope.

```tsx [App.tsx]
import { Comark } from '@comark/react'

const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`

export default function App() {
return <Comark markdown={content} data={{ user }} />
}
```

### `defineComarkComponent`

Creates a pre-configured `<Comark>` component with default options, plugins, and components baked in.
Expand Down Expand Up @@ -386,6 +402,7 @@ export default async function DocsPage({ params }: { params: { slug: string } })
| [`componentsManifest`](#code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver for lazy-loaded components |
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `className` | `string` | `undefined` | CSS class for the wrapper `<div>` |

### `defineComarkRendererComponent`
Expand Down
17 changes: 17 additions & 0 deletions docs/content/3.rendering/6.svelte.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Pass markdown content via the `markdown` prop:
| [`componentsManifest`](#code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver |
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append caret to last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `class` | `string` | `''` | CSS class for wrapper element |

#### `options`
Expand Down Expand Up @@ -180,6 +181,21 @@ For lazy-loading components on demand:
<Comark {markdown} componentsManifest={manifest} />
```

#### `data`

Expose runtime values to markdown authors. Any prop written with a `:` prefix is resolved against the render context `{ frontmatter, meta, data, props }` when its value isn't valid JSON — see [Data Binding](/syntax/components#data-binding) for the full scope.

```svelte [App.svelte]
<script lang="ts">
import { Comark } from '@comark/svelte'

const user = { name: 'Ada', role: 'admin' }
const content = `Hello, :badge{:label="data.user.name"}!`
</script>

<Comark markdown={content} data={{ user }} />
```

---

## `<ComarkAsync>` (experimental)
Expand Down Expand Up @@ -268,6 +284,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
| [`componentsManifest`](#code-componentsmanifest) | `ComponentManifest` | `undefined` | Dynamic component resolver for lazy-loaded components |
| [`streaming`](#streaming) | `boolean` | `false` | Enable streaming mode |
| [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append a blinking caret to the last text node |
| [`data`](#code-data) | `Record<string, unknown>` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` |
| `class` | `string` | `''` | CSS class for wrapper element |

### `componentsManifest`
Expand Down
3 changes: 2 additions & 1 deletion packages/comark-ansi/src/handlers/a.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { NodeHandler } from 'comark/render'
import { resolveAttribute } from 'comark/render'
import { DIM, RESET } from '../utils/escape.ts'

export const a: NodeHandler = async (node, state) => {
const href = String(node[1].href || '')
const href = String(resolveAttribute(node[1], state.renderData, 'href') || '')
const content = await state.flow(node, state)

if (!state.context.colors || !href) {
Expand Down
3 changes: 2 additions & 1 deletion packages/comark-ansi/src/handlers/img.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { NodeHandler } from 'comark/render'
import { resolveAttribute } from 'comark/render'
import { DIM, wrap } from '../utils/escape.ts'

export const img: NodeHandler = (node, state) => {
const alt = String(node[1].alt || 'image')
const alt = String(resolveAttribute(node[1], state.renderData, 'alt') || 'image')
return wrap(DIM, `[image: ${alt}]`, Boolean(state.context.colors))
}
42 changes: 42 additions & 0 deletions packages/comark-ansi/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,48 @@ describe('renderANSI', () => {
expect(out).toContain('Uptime: 99.9%')
})
})

describe('data binding', () => {
it('resolves :href on links from frontmatter', async () => {
const tree = await parse(`---
home: https://example.com
---

[Home](placeholder){:href="frontmatter.home"}
`)
const out = await renderANSI(tree, { colors: false })
expect(out).toContain('Home (https://example.com)')
})

it('resolves :alt on images from data', async () => {
const tree = await parse('![x](/x.png){:alt="data.caption"}')
const out = await renderANSI(tree, { colors: false, data: { caption: 'Photo of Ada' } })
expect(out).toContain('[image: Photo of Ada]')
})

it('exposes parent props for custom handlers via resolveAttribute', async () => {
const { resolveAttribute } = await import('comark/render')
const tree = await parse(`---
user: Ada
---

::callout{:who="frontmatter.user"}
Hello
::
`)
const out = await renderANSI(tree, {
colors: false,
components: {
callout: async ([, attrs, ...children], state) => {
const who = resolveAttribute(attrs, state.renderData, 'who')
const content = await state.render(children as any)
return `[${who}]: ${content.trim()}\n`
},
},
})
expect(out).toContain('[Ada]: Hello')
})
})
})

describe('createLog', () => {
Expand Down
60 changes: 60 additions & 0 deletions packages/comark-html/test/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,64 @@ More content
expect(html).toContain('<div class="info-box">')
expect(html).toContain('Info message')
})

describe('data binding', () => {
it('resolves :prop bindings from frontmatter', async () => {
const tree = await parse(`---
siteName: My Blog
user:
name: Ada
---

::alert{:title="frontmatter.siteName" type="info"}
Hello :badge{:label="frontmatter.user.name"}!
::
`)
const html = await renderHTML(tree)
expect(html).toContain('title="My Blog"')
expect(html).toContain('label="Ada"')
})

it('resolves :prop bindings from the data option', async () => {
const tree = await parse('::alert{:title="data.headline"}\nHi\n::')
const html = await renderHTML(tree, { data: { headline: 'Release notes' } })
expect(html).toContain('title="Release notes"')
})

it('resolves :prop bindings from meta', async () => {
const tree = await parse('::alert{:title="meta.wordCount"}\nHi\n::')
tree.meta = { wordCount: 42 }
const html = await renderHTML(tree)
expect(html).toContain('title="42"')
})

it('exposes the enclosing component\'s props to nested bindings', async () => {
const tree = await parse(`::card{title="Hello" variant="primary"}
:::badge{:color="props.variant" :text="props.title"}
:::
::
`)
const html = await renderHTML(tree)
expect(html).toContain('color="primary"')
expect(html).toContain('text="Hello"')
})

it('preserves unresolved paths as literal string attributes', async () => {
const tree = await parse('::card{:to="$doc.snippet.link"}\n::')
const html = await renderHTML(tree)
expect(html).toContain('to="$doc.snippet.link"')
})

it('leaves attributes without :prefix untouched', async () => {
const tree = await parse(`---
name: Ada
---

::card{title="frontmatter.name"}
::
`)
const html = await renderHTML(tree)
expect(html).toContain('title="frontmatter.name"')
})
})
})
9 changes: 9 additions & 0 deletions packages/comark-react/src/components/Comark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface ComarkProps {
*/
caret?: boolean | { class: string }

/**
* Additional data to pass to the renderer — referenced from markdown
* via `:`-prefixed props using dot paths (e.g. `:foo="data.user.name"`).
*/
data?: Record<string, unknown>

/**
* Additional className for the wrapper div
*/
Expand Down Expand Up @@ -96,6 +102,7 @@ export async function Comark({
componentsManifest,
streaming = false,
caret = false,
data,
className,
}: ComarkProps) {
const source = children ? String(children) : markdown
Expand All @@ -110,6 +117,7 @@ export async function Comark({
componentsManifest={componentsManifest}
streaming={streaming}
caret={caret}
data={data}
className={className}
/>
)
Expand All @@ -125,6 +133,7 @@ export async function Comark({
streaming={streaming}
className={className}
caret={caret}
data={data}
/>
)
}
2 changes: 2 additions & 0 deletions packages/comark-react/src/components/ComarkClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function ComarkContent({
componentsManifest,
streaming = false,
caret = false,
data,
className,
}: ComarkContentProps) {
const parsed = use(parsePromise)
Expand All @@ -28,6 +29,7 @@ function ComarkContent({
streaming={streaming}
className={className}
caret={caret}
data={data}
/>
)
}
Expand Down
Loading
Loading