')
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"')
+ })
+ })
})
diff --git a/packages/comark-react/src/components/Comark.tsx b/packages/comark-react/src/components/Comark.tsx
index fac37492..5c036000 100644
--- a/packages/comark-react/src/components/Comark.tsx
+++ b/packages/comark-react/src/components/Comark.tsx
@@ -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
+
/**
* Additional className for the wrapper div
*/
@@ -96,6 +102,7 @@ export async function Comark({
componentsManifest,
streaming = false,
caret = false,
+ data,
className,
}: ComarkProps) {
const source = children ? String(children) : markdown
@@ -110,6 +117,7 @@ export async function Comark({
componentsManifest={componentsManifest}
streaming={streaming}
caret={caret}
+ data={data}
className={className}
/>
)
@@ -125,6 +133,7 @@ export async function Comark({
streaming={streaming}
className={className}
caret={caret}
+ data={data}
/>
)
}
diff --git a/packages/comark-react/src/components/ComarkClient.tsx b/packages/comark-react/src/components/ComarkClient.tsx
index 0a96f363..59bb095c 100644
--- a/packages/comark-react/src/components/ComarkClient.tsx
+++ b/packages/comark-react/src/components/ComarkClient.tsx
@@ -16,6 +16,7 @@ function ComarkContent({
componentsManifest,
streaming = false,
caret = false,
+ data,
className,
}: ComarkContentProps) {
const parsed = use(parsePromise)
@@ -28,6 +29,7 @@ function ComarkContent({
streaming={streaming}
className={className}
caret={caret}
+ data={data}
/>
)
}
diff --git a/packages/comark-react/src/components/ComarkRenderer.tsx b/packages/comark-react/src/components/ComarkRenderer.tsx
index 49779c6e..13a0341a 100644
--- a/packages/comark-react/src/components/ComarkRenderer.tsx
+++ b/packages/comark-react/src/components/ComarkRenderer.tsx
@@ -1,6 +1,6 @@
-import type { ComarkElement, ComarkNode, ComarkTree, ComponentManifest } from 'comark'
+import type { ComarkElement, ComarkNode, ComarkTree, ComponentManifest, NodeRenderData } from 'comark'
import React, { lazy, Suspense, useMemo } from 'react'
-import { pascalCase, camelCase } from 'comark/utils'
+import { pascalCase, camelCase, resolveAttributes } from 'comark/utils'
import { findLastTextNodeAndAppendNode, getCaret } from '../utils/caret'
/**
@@ -42,19 +42,6 @@ function getProps(node: ComarkNode): Record {
return {}
}
-function parsePropValue(value: string): any {
- if (value === 'true') return true
- if (value === 'false') return false
- if (value === 'null') return null
- try {
- return JSON.parse(value)
- }
- catch {
- // noop
- }
- return value
-}
-
/**
* Helper to get children from a ComarkNode
*/
@@ -101,6 +88,7 @@ function renderNode(
key?: string | number,
componentsManifest?: ComponentManifest,
parent?: ComarkNode,
+ renderData: NodeRenderData = { frontmatter: {}, meta: {}, data: {}, props: {} },
): React.ReactNode {
// Handle text nodes (strings)
if (typeof node === 'string') {
@@ -129,26 +117,24 @@ function renderNode(
const Component = customComponent || tag
- // Prepare props — use for...in instead of Object.entries() to avoid intermediate array allocation
+ // Resolve `:prefix` bindings, then apply React-specific attribute
+ // remapping (`class` → `className`, string `style` → object, `tabindex`
+ // → `tabIndex`).
+ const resolved = resolveAttributes(nodeProps, renderData, { parseJson: true })
const props: Record = {}
- for (const k in nodeProps) {
- if (k === 'className') {
- props.className = nodeProps[k]
- }
- else if (k === 'class') {
- props.className = nodeProps[k]
+ for (const k in resolved) {
+ const v = resolved[k]
+ if (k === 'className' || k === 'class') {
+ props.className = v
}
- else if (k === 'style' && typeof nodeProps[k] === 'string') {
- props.style = cssStringToObject(nodeProps[k])
+ else if (k === 'style' && typeof v === 'string') {
+ props.style = cssStringToObject(v)
}
else if (k === 'tabindex') {
- props.tabIndex = nodeProps[k]
- }
- else if (k.charCodeAt(0) === 58 /* ':' */) {
- props[k.substring(1)] = parsePropValue(nodeProps[k])
+ props.tabIndex = v
}
else {
- props[k] = nodeProps[k]
+ props[k] = v
}
}
@@ -156,29 +142,6 @@ function renderNode(
props.__node = node
}
- // Parse special prop values (props starting with :)
- for (const [propKey, value] of Object.entries(nodeProps)) {
- if (propKey === '$') {
- Reflect.deleteProperty(props, propKey)
- }
- if (propKey === 'style') {
- props.style = cssStringToObject(value)
- }
- else if (propKey === 'tabindex') {
- props.tabIndex = value
- Reflect.deleteProperty(props, propKey)
- }
- if (propKey === 'class') {
- props.className = value
- Reflect.deleteProperty(props, propKey)
- }
- else {
- if (propKey.startsWith(':')) {
- props[propKey.substring(1)] = parsePropValue(value)
- Reflect.deleteProperty(props, propKey)
- }
- }
- }
// Add key if provided
if (key !== undefined) {
props.key = key
@@ -189,6 +152,7 @@ function renderNode(
return React.createElement(Component, props)
}
+ const childrenRenderData: NodeRenderData = { ...renderData, props }
// Separate template elements (slots) from regular children
const slots: Record = {}
const regularChildren: React.ReactNode[] = []
@@ -223,13 +187,13 @@ function renderNode(
if (slotName) {
const slotChildren = getChildren(child)
slots[slotName] = slotChildren
- .map((slotChild: ComarkNode, idx: number) => renderNode(slotChild, components, idx, componentsManifest, node))
+ .map((slotChild: ComarkNode, idx: number) => renderNode(slotChild, components, idx, componentsManifest, node, childrenRenderData))
.filter((slotChild): slotChild is React.ReactNode => slotChild !== null)
continue
}
}
- const rendered = renderNode(child, components, i, componentsManifest, node)
+ const rendered = renderNode(child, components, i, componentsManifest, node, childrenRenderData)
if (rendered !== null) {
regularChildren.push(rendered)
}
@@ -306,6 +270,12 @@ export interface ComarkRendererProps {
*/
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
+
/**
* Additional className for the wrapper div
*/
@@ -339,6 +309,7 @@ export const ComarkRenderer: React.FC = ({
componentsManifest,
streaming = false,
caret: caretProp = false,
+ data,
className,
}) => {
const caret = useMemo(() => getCaret(caretProp), [caretProp])
@@ -354,10 +325,17 @@ export const ComarkRenderer: React.FC = ({
}
}
+ const renderData: NodeRenderData = {
+ frontmatter: tree.frontmatter,
+ meta: tree.meta,
+ data: data || {},
+ props: {},
+ }
+
return nodes
- .map((node, index) => renderNode(node, customComponents, index, componentsManifest))
+ .map((node, index) => renderNode(node, customComponents, index, componentsManifest, undefined, renderData))
.filter((child): child is React.ReactNode => child !== null)
- }, [tree, customComponents, componentsManifest, streaming, caret])
+ }, [tree, customComponents, componentsManifest, streaming, caret, data])
// Wrap in a fragment
return (
diff --git a/packages/comark-react/test/renderer-data-binding.test.tsx b/packages/comark-react/test/renderer-data-binding.test.tsx
new file mode 100644
index 00000000..16951b93
--- /dev/null
+++ b/packages/comark-react/test/renderer-data-binding.test.tsx
@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest'
+import React from 'react'
+import { renderToString } from 'react-dom/server'
+import { parse } from 'comark'
+import { ComarkRenderer } from '../src/components/ComarkRenderer'
+
+interface BadgeProps {
+ label?: string
+ count?: number
+ config?: Record
+}
+
+function Badge({ label = '', count = 0, config = {} }: BadgeProps) {
+ return (
+
+ {label}
+ {Object.keys(config).length > 0 ? JSON.stringify(config) : null}
+
+ )
+}
+
+interface CardProps {
+ title?: string
+ variant?: string
+ children?: React.ReactNode
+}
+
+function Card({ title = '', variant = '', children }: CardProps) {
+ return (
+
+ )
+}
+
+async function renderMarkdown(markdown: string, props: Record = {}) {
+ const tree = await parse(markdown)
+ return renderToString()
+}
+
+describe('ComarkRenderer — data binding', () => {
+ it('resolves :prefix bindings from frontmatter onto component props', async () => {
+ const html = await renderMarkdown(
+ `---
+site:
+ name: My Blog
+---
+
+::badge{:label="frontmatter.site.name"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('class="badge"')
+ expect(html).toContain('My Blog')
+ })
+
+ it('JSON-parses numeric literals so components receive real numbers', async () => {
+ const html = await renderMarkdown(
+ `::badge{:count="42"}
+::`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('data-count="42"')
+ })
+
+ it('JSON-parses object literals into real objects', async () => {
+ const html = await renderMarkdown(
+ `::badge{:config='{"k":"v"}'}
+::`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('{"k":"v"}')
+ })
+
+ it('resolves bindings from the renderer `data` prop', async () => {
+ const html = await renderMarkdown(
+ `::badge{:label="data.user.name"}
+::`,
+ { components: { badge: Badge }, data: { user: { name: 'Ada' } } },
+ )
+ expect(html).toContain('Ada')
+ })
+
+ it('exposes parent component props to nested :prefix bindings', async () => {
+ const html = await renderMarkdown(
+ `::card{title="Hello" variant="primary"}
+:::badge{:label="props.title"}
+:::
+::
+`,
+ { components: { card: Card, badge: Badge } },
+ )
+ expect(html).toContain('class="card card-primary"')
+ expect(html).toContain('Hello')
+ })
+
+ it('yields undefined for unresolved paths (component uses its default)', async () => {
+ const html = await renderMarkdown(
+ `::badge{:label="frontmatter.missing"}
+::`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('class="badge"')
+ expect(html).not.toContain('frontmatter.missing')
+ })
+
+ it('leaves non-:prefix attributes untouched', async () => {
+ const html = await renderMarkdown(
+ `---
+site: Blog
+---
+
+::badge{label="frontmatter.site"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('frontmatter.site')
+ })
+})
diff --git a/packages/comark-svelte/src/async/ComarkAsync.svelte b/packages/comark-svelte/src/async/ComarkAsync.svelte
index ce3bf33d..53f3532d 100644
--- a/packages/comark-svelte/src/async/ComarkAsync.svelte
+++ b/packages/comark-svelte/src/async/ComarkAsync.svelte
@@ -40,6 +40,7 @@ and wrap this component in a `` for pending/error states.
componentsManifest,
streaming = false,
caret = false,
+ data,
class: className = '',
}: {
markdown?: string
@@ -49,6 +50,7 @@ and wrap this component in a `` for pending/error states.
componentsManifest?: ComponentManifest
streaming?: boolean
caret?: boolean | { class: string }
+ data?: Record
class?: string
} = $props()
@@ -67,5 +69,6 @@ and wrap this component in a `` for pending/error states.
{componentsManifest}
{streaming}
{caret}
+ {data}
class={className}
/>
diff --git a/packages/comark-svelte/src/components/Comark.svelte b/packages/comark-svelte/src/components/Comark.svelte
index f463f978..3f5a0d25 100644
--- a/packages/comark-svelte/src/components/Comark.svelte
+++ b/packages/comark-svelte/src/components/Comark.svelte
@@ -36,6 +36,7 @@ This is an alert component
componentsManifest,
streaming = false,
caret = false,
+ data,
class: className = '',
}: {
markdown?: string
@@ -45,6 +46,7 @@ This is an alert component
componentsManifest?: ComponentManifest
streaming?: boolean
caret?: boolean | { class: string }
+ data?: Record
class?: string
} = $props()
@@ -75,6 +77,7 @@ This is an alert component
{componentsManifest}
{streaming}
{caret}
+ {data}
class={className}
/>
{/if}
diff --git a/packages/comark-svelte/src/components/ComarkNode.svelte b/packages/comark-svelte/src/components/ComarkNode.svelte
index cfebc57f..bff084b6 100644
--- a/packages/comark-svelte/src/components/ComarkNode.svelte
+++ b/packages/comark-svelte/src/components/ComarkNode.svelte
@@ -15,38 +15,30 @@ naturally appears inline after the deepest trailing text node.
```
-->
{#if isText}
@@ -118,6 +111,7 @@ naturally appears inline after the deepest trailing text node.
{components}
{componentsManifest}
caretClass={i === children.length - 1 ? caretClass : null}
+ renderData={childrenRenderData}
/>
{/each}
@@ -131,6 +125,7 @@ naturally appears inline after the deepest trailing text node.
{components}
{componentsManifest}
caretClass={i === children.length - 1 ? caretClass : null}
+ renderData={childrenRenderData}
/>
{/each}
diff --git a/packages/comark-svelte/src/components/ComarkRenderer.svelte b/packages/comark-svelte/src/components/ComarkRenderer.svelte
index a1fddfd7..2fc4c5d6 100644
--- a/packages/comark-svelte/src/components/ComarkRenderer.svelte
+++ b/packages/comark-svelte/src/components/ComarkRenderer.svelte
@@ -27,6 +27,7 @@ Supports custom component mappings and a streaming caret indicator.
componentsManifest,
streaming = false,
caret: caretProp = false,
+ data,
class: className = '',
}: {
tree: ComarkTree
@@ -34,6 +35,7 @@ Supports custom component mappings and a streaming caret indicator.
componentsManifest?: ComponentManifest
streaming?: boolean
caret?: boolean | { class: string }
+ data?: Record
class?: string
} = $props()
@@ -42,6 +44,13 @@ Supports custom component mappings and a streaming caret indicator.
? (typeof caretProp === 'object' && caretProp.class) || ''
: null,
)
+
+ let renderData = $derived({
+ frontmatter: tree.frontmatter,
+ meta: tree.meta,
+ data: data || {},
+ props: {},
+ })
@@ -51,6 +60,7 @@ Supports custom component mappings and a streaming caret indicator.
{components}
{componentsManifest}
caretClass={i === tree.nodes.length - 1 ? caretClass : null}
+ {renderData}
/>
{/each}
diff --git a/packages/comark-svelte/test/renderer-data-binding.svelte.test.ts b/packages/comark-svelte/test/renderer-data-binding.svelte.test.ts
new file mode 100644
index 00000000..4b1749f7
--- /dev/null
+++ b/packages/comark-svelte/test/renderer-data-binding.svelte.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest'
+import { render } from 'vitest-browser-svelte'
+import { parse } from 'comark'
+import ComarkRenderer from '../src/components/ComarkRenderer.svelte'
+import Badge from './test-components/Badge.svelte'
+import Card from './test-components/Card.svelte'
+
+async function renderMarkdown(markdown: string, props: Record = {}) {
+ const tree = await parse(markdown)
+ return render(ComarkRenderer, { tree, ...props })
+}
+
+describe('ComarkRenderer — data binding', () => {
+ it('resolves :prefix bindings from frontmatter onto component props', async () => {
+ const screen = await renderMarkdown(
+ `---
+site:
+ name: My Blog
+---
+
+::badge{:label="frontmatter.site.name"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge).not.toBeNull()
+ expect(badge.textContent?.trim()).toBe('My Blog')
+ })
+
+ it('JSON-parses numeric literals so components receive real numbers', async () => {
+ const screen = await renderMarkdown(
+ `::badge{:count="42"}
+::`,
+ { components: { badge: Badge } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge.getAttribute('data-count')).toBe('42')
+ })
+
+ it('JSON-parses object literals into real objects', async () => {
+ const screen = await renderMarkdown(
+ `::badge{:config='{"k":"v"}'}
+::`,
+ { components: { badge: Badge } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge.getAttribute('data-config')).toBe('{"k":"v"}')
+ })
+
+ it('resolves bindings from the renderer `data` prop', async () => {
+ const screen = await renderMarkdown(
+ `::badge{:label="data.user.name"}
+::`,
+ { components: { badge: Badge }, data: { user: { name: 'Ada' } } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge.textContent?.trim()).toBe('Ada')
+ })
+
+ it('exposes parent component props to nested :prefix bindings', async () => {
+ const screen = await renderMarkdown(
+ `::card{title="Hello" variant="primary"}
+:::badge{:label="props.title"}
+:::
+::
+`,
+ { components: { card: Card, badge: Badge } },
+ )
+ const card = screen.container.querySelector('.card')!
+ expect(card).toHaveClass('card-primary')
+ const badge = card.querySelector('.badge')!
+ expect(badge).not.toBeNull()
+ expect(badge.textContent?.trim()).toBe('Hello')
+ })
+
+ it('yields undefined for unresolved paths (component uses its default)', async () => {
+ const screen = await renderMarkdown(
+ `::badge{:label="frontmatter.missing"}
+::`,
+ { components: { badge: Badge } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge).not.toBeNull()
+ // default label is '' — dot-path that doesn't resolve must not leak the
+ // raw string into the DOM.
+ expect(badge.textContent).not.toContain('frontmatter.missing')
+ })
+
+ it('leaves non-:prefix attributes untouched', async () => {
+ const screen = await renderMarkdown(
+ `---
+site: Blog
+---
+
+::badge{label="frontmatter.site"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ const badge = screen.container.querySelector('.badge')!
+ expect(badge.textContent?.trim()).toBe('frontmatter.site')
+ })
+})
diff --git a/packages/comark-svelte/test/test-components/Badge.svelte b/packages/comark-svelte/test/test-components/Badge.svelte
new file mode 100644
index 00000000..9ce45b3b
--- /dev/null
+++ b/packages/comark-svelte/test/test-components/Badge.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {label}
+
diff --git a/packages/comark-svelte/test/test-components/Card.svelte b/packages/comark-svelte/test/test-components/Card.svelte
new file mode 100644
index 00000000..dbc2de45
--- /dev/null
+++ b/packages/comark-svelte/test/test-components/Card.svelte
@@ -0,0 +1,20 @@
+
+
+
+
{title}
+
+ {@render children?.()}
+
+
diff --git a/packages/comark-vue/src/components/Comark.ts b/packages/comark-vue/src/components/Comark.ts
index 3ae8587e..eba0b49f 100644
--- a/packages/comark-vue/src/components/Comark.ts
+++ b/packages/comark-vue/src/components/Comark.ts
@@ -47,6 +47,11 @@ export interface ComarkProps {
* If caret is true, a caret will be appended to the last text node in the tree
*/
caret?: boolean | { class: string }
+
+ /**
+ * Additional data to pass to the renderer
+ */
+ data?: Record
}
type ComarkComponent = ReturnType>
@@ -154,6 +159,14 @@ export const Comark: ComarkComponent = defineComponent({
type: [Boolean, Object] as PropType,
default: false,
},
+
+ /**
+ * Additional data to pass to the renderer
+ */
+ data: {
+ type: Object as PropType>,
+ default: () => ({}),
+ },
},
async setup(props, ctx) {
@@ -190,6 +203,7 @@ export const Comark: ComarkComponent = defineComponent({
componentsManifest: props.componentsManifest,
class: props.streaming ? 'comark-stream' : '',
caret: props.caret,
+ data: props.data,
})
}
},
diff --git a/packages/comark-vue/src/components/ComarkRenderer.ts b/packages/comark-vue/src/components/ComarkRenderer.ts
index fe87f9c6..fbd5a061 100644
--- a/packages/comark-vue/src/components/ComarkRenderer.ts
+++ b/packages/comark-vue/src/components/ComarkRenderer.ts
@@ -1,15 +1,12 @@
import type { PropType, VNode } from 'vue'
-import type { ComponentManifest, ComarkContextProvider, ComarkElement, ComarkNode, ComarkTree } from 'comark'
+import type { ComponentManifest, ComarkContextProvider, ComarkElement, ComarkNode, ComarkTree, NodeRenderData } from 'comark'
import { computed, defineAsyncComponent, defineComponent, getCurrentInstance, h, inject, onErrorCaptured, ref, toRaw } from 'vue'
import { findLastTextNodeAndAppendNode, getCaret } from '../utils/caret.ts'
+import { pascalCase, resolveAttributes } from 'comark/utils'
// Cache for dynamically resolved components
const asyncComponentCache = new Map()
-const camelize = (s: string) => s.replace(/-(\w)/g, (_, c: string) => c ? c.toUpperCase() : '')
-const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
-const pascalCase = (str: string) => capitalize(camelize(str))
-
/**
* Helper to get tag from a ComarkNode
*/
@@ -30,20 +27,6 @@ function getProps(node: ComarkNode): Record {
return {}
}
-function parsePropValue(value: string): any {
- if (value === 'true') return true
- if (value === 'false') return false
- if (value === 'null') return null
- try {
- return JSON.parse(value)
- }
- catch {
- // noop
- }
-
- return value
-}
-
/**
* Helper to get children from a ComarkNode
*/
@@ -90,6 +73,7 @@ function renderNode(
key?: string | number,
componentsManifest?: ComponentManifest,
parent?: ComarkNode,
+ renderData: NodeRenderData = { frontmatter: {}, meta: {}, data: {}, props: {} },
): VNode | string | null {
// Handle text nodes (strings)
if (typeof node === 'string') {
@@ -118,21 +102,16 @@ function renderNode(
const component = customComponent || tag
- // Prepare props
- // Prepare props — use for...in instead of Object.entries() to avoid intermediate array allocation
+ // Resolve `:prefix` bindings and let Vue-specific attribute mapping run
+ // on top (e.g. `className` → `class`).
+ const resolved = resolveAttributes(nodeProps, renderData, { parseJson: true })
const props: Record = {}
- for (const k in nodeProps) {
- if (k === '$') {
- continue
- }
+ for (const k in resolved) {
if (k === 'className') {
- props.class = nodeProps[k]
- }
- else if (k.charCodeAt(0) === 58 /* ':' */) {
- props[k.substring(1)] = parsePropValue(nodeProps[k])
+ props.class = resolved[k]
}
else {
- props[k] = nodeProps[k]
+ props[k] = resolved[k]
}
}
@@ -150,6 +129,7 @@ function renderNode(
return h(component, props)
}
+ const childrenRenderData = { ...renderData, props }
// Separate template elements (slots) from regular children
const slots: Record (VNode | string)[]> = {}
const regularChildren: (VNode | string)[] = []
@@ -184,13 +164,13 @@ function renderNode(
if (slotName) {
const slotChildren = getChildren(child)
slots[slotName] = () => slotChildren
- .map((slotChild: ComarkNode, idx: number) => renderNode(slotChild, components, idx, componentsManifest, node))
+ .map((slotChild: ComarkNode, idx: number) => renderNode(slotChild, components, idx, componentsManifest, node, childrenRenderData))
.filter((slotChild): slotChild is VNode | string => slotChild !== null)
continue
}
}
- const rendered = renderNode(child, components, i, componentsManifest, node)
+ const rendered = renderNode(child, components, i, componentsManifest, node, childrenRenderData)
if (rendered !== null) {
regularChildren.push(rendered)
}
@@ -241,6 +221,11 @@ export interface ComarkRendererProps {
* If caret is true, a caret will be appended to the last text node in the tree
*/
caret?: boolean | { class: string }
+
+ /**
+ * Additional data to pass to the renderer
+ */
+ data?: Record
}
type ComarkRendererComponent = ReturnType>
@@ -317,6 +302,14 @@ export const ComarkRenderer: ComarkRendererComponent = defineComponent({
type: [Boolean, Object] as PropType,
default: false,
},
+
+ /**
+ * Additional data to pass to the renderer
+ */
+ data: {
+ type: Object as PropType>,
+ default: () => ({}),
+ },
},
async setup(props) {
@@ -362,8 +355,15 @@ export const ComarkRenderer: ComarkRendererComponent = defineComponent({
}
}
+ const renderData: NodeRenderData = {
+ frontmatter: props.tree.frontmatter,
+ meta: props.tree.meta,
+ data: props.data || {},
+ props: {},
+ }
+
const children = nodes
- .map((node, index) => renderNode(node, components.value, index, componentManifest))
+ .map((node, index) => renderNode(node, components.value, index, componentManifest, undefined, renderData))
.filter((child): child is VNode | string => child !== null)
// Wrap in a fragment
diff --git a/packages/comark-vue/test/renderer-data-binding.test.ts b/packages/comark-vue/test/renderer-data-binding.test.ts
new file mode 100644
index 00000000..23e18a36
--- /dev/null
+++ b/packages/comark-vue/test/renderer-data-binding.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from 'vitest'
+import { createSSRApp, defineComponent, h } from 'vue'
+import { renderToString } from '@vue/server-renderer'
+import { parse } from 'comark'
+import { ComarkRenderer } from '../src/components/ComarkRenderer'
+
+const Badge = defineComponent({
+ name: 'Badge',
+ props: {
+ label: { type: String, default: '' },
+ count: { type: Number, default: 0 },
+ config: { type: Object, default: () => ({}) },
+ },
+ setup(props) {
+ return () => h('span', { 'class': 'badge', 'data-count': props.count }, [
+ props.label,
+ props.config && typeof props.config === 'object' ? JSON.stringify(props.config) : '',
+ ])
+ },
+})
+
+const Card = defineComponent({
+ name: 'Card',
+ props: {
+ title: { type: String, default: '' },
+ variant: { type: String, default: '' },
+ },
+ setup(_, { slots }) {
+ return () => h('div', { class: 'card' }, [
+ h('h3', {}, slots.title?.()),
+ h('div', { class: 'body' }, slots.default?.()),
+ ])
+ },
+})
+
+async function renderMarkdown(markdown: string, props: Record = {}) {
+ const tree = await parse(markdown)
+ const app = createSSRApp({
+ setup: () => () => h(ComarkRenderer, { tree, ...props }),
+ })
+ return renderToString(app as any)
+}
+
+describe('ComarkRenderer — data binding', () => {
+ it('resolves :prefix bindings from frontmatter onto custom component props', async () => {
+ const html = await renderMarkdown(
+ `---
+site:
+ name: My Blog
+---
+
+::badge{:label="frontmatter.site.name"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('class="badge"')
+ expect(html).toContain('My Blog')
+ })
+
+ it('JSON-parses numeric literals so components receive real numbers', async () => {
+ const html = await renderMarkdown(
+ `::badge{:count="42"}
+::`,
+ { components: { badge: Badge } },
+ )
+ // data-count attribute on a number prop is serialised without quotes in
+ // the VNode, but string conversion is fine — what matters is that the
+ // prop reached the component as 42 (not "42").
+ expect(html).toContain('data-count="42"')
+ })
+
+ it('JSON-parses object literals into real objects', async () => {
+ const html = await renderMarkdown(
+ `::badge{:config='{"k":"v"}'}
+::`,
+ { components: { badge: Badge } },
+ )
+ expect(html).toContain('{"k":"v"}')
+ })
+
+ it('resolves bindings from the renderer `data` prop', async () => {
+ const html = await renderMarkdown(
+ `::badge{:label="data.user.name"}
+::`,
+ { components: { badge: Badge }, data: { user: { name: 'Ada' } } },
+ )
+ expect(html).toContain('Ada')
+ })
+
+ it('exposes parent component props to nested :prefix bindings', async () => {
+ const html = await renderMarkdown(
+ `::card{title="Hello" variant="primary"}
+:::badge{:label="props.title" :count="props.variant"}
+:::
+::
+`,
+ { components: { card: Card, badge: Badge } },
+ )
+ // The badge is rendered inside the card's default slot with the card's
+ // title surfacing through props.title.
+ expect(html).toContain('Hello')
+ })
+
+ it('yields undefined for unresolved paths (uses component default)', async () => {
+ const html = await renderMarkdown(
+ `::badge{:label="frontmatter.missing"}
+::`,
+ { components: { badge: Badge } },
+ )
+ // label default is '' — resolved undefined becomes the empty default.
+ expect(html).toContain('class="badge"')
+ expect(html).not.toContain('frontmatter.missing')
+ })
+
+ it('leaves non-:prefix attributes untouched', async () => {
+ const html = await renderMarkdown(
+ `---
+site: Blog
+---
+
+::badge{label="frontmatter.site"}
+::
+`,
+ { components: { badge: Badge } },
+ )
+ // literal string, not resolved because there's no colon prefix
+ expect(html).toContain('frontmatter.site')
+ })
+})
diff --git a/packages/comark/SPEC/COMARK/data-binding-frontmatter.md b/packages/comark/SPEC/COMARK/data-binding-frontmatter.md
new file mode 100644
index 00000000..9cffa1df
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/data-binding-frontmatter.md
@@ -0,0 +1,56 @@
+## Input
+
+```md
+---
+site:
+ name: My Blog
+---
+
+::alert{:title="frontmatter.site.name" type="info"}
+Hello
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {
+ "site": {
+ "name": "My Blog"
+ }
+ },
+ "meta": {},
+ "nodes": [
+ [
+ "alert",
+ {
+ ":title": "frontmatter.site.name",
+ "type": "info"
+ },
+ "Hello"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Hello
+
+```
+
+## Markdown
+
+```md
+---
+site:
+ name: My Blog
+---
+
+::alert{:title="frontmatter.site.name" type="info"}
+Hello
+::
+```
diff --git a/packages/comark/SPEC/COMARK/data-binding-inline.md b/packages/comark/SPEC/COMARK/data-binding-inline.md
new file mode 100644
index 00000000..a86bb1a7
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/data-binding-inline.md
@@ -0,0 +1,56 @@
+## Input
+
+```md
+---
+user:
+ name: Ada
+---
+
+Hello :badge{:label="frontmatter.user.name"}!
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {
+ "user": {
+ "name": "Ada"
+ }
+ },
+ "meta": {},
+ "nodes": [
+ [
+ "p",
+ {},
+ "Hello ",
+ [
+ "badge",
+ {
+ ":label": "frontmatter.user.name"
+ }
+ ],
+ "!"
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Hello !
+
+```
+
+## Markdown
+
+```md
+---
+user:
+ name: Ada
+---
+
+Hello :badge{:label="frontmatter.user.name"}!
+```
diff --git a/packages/comark/SPEC/COMARK/data-binding-props.md b/packages/comark/SPEC/COMARK/data-binding-props.md
new file mode 100644
index 00000000..675e34c3
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/data-binding-props.md
@@ -0,0 +1,50 @@
+## Input
+
+```md
+::card{title="Hello" variant="primary"}
+:::badge{:label="props.title" :color="props.variant"}
+:::
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "card",
+ {
+ "title": "Hello",
+ "variant": "primary"
+ },
+ [
+ "badge",
+ {
+ ":label": "props.title",
+ ":color": "props.variant"
+ }
+ ]
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+
+
+```
+
+## Markdown
+
+```md
+::card{title="Hello" variant="primary"}
+ :::badge{:label="props.title" :color="props.variant"}
+ :::
+::
+```
diff --git a/packages/comark/SPEC/COMARK/data-binding-unresolved.md b/packages/comark/SPEC/COMARK/data-binding-unresolved.md
new file mode 100644
index 00000000..84816db2
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/data-binding-unresolved.md
@@ -0,0 +1,36 @@
+## Input
+
+```md
+::card{:to="$doc.snippet.link"}
+::
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "card",
+ {
+ ":to": "$doc.snippet.link"
+ }
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+```
+
+## Markdown
+
+```md
+::card{:to="$doc.snippet.link"}
+::
+```
diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts
index 8cc230ee..57f98cfd 100644
--- a/packages/comark/src/internal/stringify/attributes.ts
+++ b/packages/comark/src/internal/stringify/attributes.ts
@@ -1,4 +1,101 @@
import { stringifyYaml } from '../yaml.ts'
+import { get } from '../../utils/index.ts'
+import type { NodeRenderData } from '../../types.ts'
+
+export interface ResolveAttributesOptions {
+ /**
+ * When true, every `:prefixed` string value is JSON-parsed first and the
+ * `:` prefix is always stripped. Non-JSON strings fall back to a dot-path
+ * lookup in `renderData`; unresolved paths yield `undefined`.
+ *
+ * This matches the Vue/React/Svelte renderer semantics, which always
+ * normalize bindings into real JS values suitable for typed component props.
+ *
+ * When false (default) only dot-path lookups are applied — literals and
+ * unresolved paths are preserved verbatim so string-based serializers
+ * (like HTML attribute emitters) can apply their own `:prefix` handling.
+ */
+ parseJson?: boolean
+}
+
+/**
+ * Resolve `:prefixed` attributes against the render context.
+ *
+ * Default behavior: a `:prefixed` string value that matches a dot-path in
+ * `{ frontmatter, meta, data, props }` is replaced with the resolved value
+ * (and the `:` prefix is stripped). Anything that doesn't resolve — literals
+ * like `"5"` / `"true"`, unknown paths, or already-parsed object values — is
+ * left untouched and keeps its `:` prefix.
+ *
+ * With `parseJson: true`, every `:prefixed` string is JSON-parsed first and
+ * the `:` prefix is always stripped, falling back to the dot-path lookup.
+ * The `$` metadata key is never forwarded.
+ */
+export function resolveAttributes(
+ attrs: Record,
+ renderData: NodeRenderData,
+ options: ResolveAttributesOptions = {},
+): Record {
+ const result: Record = {}
+ for (const key in attrs) {
+ if (key === '$') continue
+
+ const value = attrs[key]
+ const isBinding = key.charCodeAt(0) === 58 /* ':' */
+
+ if (options.parseJson && isBinding) {
+ // Framework mode: always strip `:` and hand components real JS values.
+ if (typeof value === 'string') {
+ try {
+ result[key.slice(1)] = JSON.parse(value)
+ continue
+ }
+ catch {
+ // not JSON — fall through to dot-path lookup
+ }
+ result[key.slice(1)] = get(renderData, value)
+ continue
+ }
+ // Non-string binding value (e.g. an object literal the parser already
+ // decoded) — pass through with the prefix stripped.
+ result[key.slice(1)] = value
+ continue
+ }
+
+ if (isBinding && typeof value === 'string') {
+ const resolved = get(renderData, value)
+ if (resolved !== undefined) {
+ result[key.slice(1)] = resolved
+ continue
+ }
+ }
+
+ result[key] = value
+ }
+ return result
+}
+
+/**
+ * Read a named attribute, preferring its `:prefixed` binding (resolved against
+ * `renderData`) over the literal `key`. Falls back to the raw value when the
+ * binding doesn't resolve.
+ */
+export function resolveAttribute(
+ attrs: Record,
+ renderData: NodeRenderData,
+ key: string,
+): unknown {
+ const bindKey = `:${key}`
+ if (bindKey in attrs) {
+ const value = attrs[bindKey]
+ if (typeof value === 'string') {
+ const resolved = get(renderData, value)
+ if (resolved !== undefined) return resolved
+ }
+ return value
+ }
+ return attrs[key]
+}
/**
* Convert attributes to a string of Comark attributes
@@ -37,24 +134,35 @@ export function comarkAttributes(attributes: Record) {
* @returns The stringified attributes
*/
export function htmlAttributes(attributes: Record) {
- return Object.entries(attributes)
- .map(([key, value]) => {
- if (key.startsWith(':')) {
- if (value === 'true') {
- return key.slice(1)
- }
- return `${key.slice(1)}="${value}"`
+ const parts: string[] = []
+ for (const [key, value] of Object.entries(attributes)) {
+ if (key.startsWith(':')) {
+ if (value === 'true') {
+ parts.push(key.slice(1))
+ continue
}
+ if (typeof value === 'object' && value !== null) {
+ parts.push(`${key.slice(1)}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
+ continue
+ }
+ parts.push(`${key.slice(1)}="${value}"`)
+ continue
+ }
- if (value === 'true') return key
+ if (value === true || value === 'true') {
+ parts.push(key)
+ continue
+ }
+ if (value === false || value === null || value === undefined) continue
- if (typeof value === 'object') {
- return `${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`
- }
+ if (typeof value === 'object') {
+ parts.push(`${key}="${JSON.stringify(value).replace(/"/g, '\\"')}"`)
+ continue
+ }
- return `${key}="${value}"`
- })
- .join(' ')
+ parts.push(`${key}="${value}"`)
+ }
+ return parts.join(' ')
}
/**
diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts
index 2e07fada..658b30b8 100644
--- a/packages/comark/src/internal/stringify/handlers/html.ts
+++ b/packages/comark/src/internal/stringify/handlers/html.ts
@@ -10,7 +10,15 @@ const blockTags = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul',
export async function html(node: ComarkElement, state: State, parent?: ComarkElement) {
const [tag, attr, ...children] = node
- const { $ = {}, ...attributes } = attr
+ const { $ = {}, ...rawAttributes } = attr
+
+ // In text/html mode, `one()` has already resolved this element's `:prefix`
+ // bindings against the parent's render context and stored the result in
+ // `state.renderData.props`. Use that for serialization; in markdown modes
+ // (e.g. html-source nodes in mdc output) keep the raw attributes.
+ const attributes = state.context.html
+ ? state.renderData.props
+ : rawAttributes
const hasOnlyTextChildren = children.every(child => typeof child === 'string' || inlineTags.has(String(child?.[0])))
const hasTextSibling = children.some(child => typeof child === 'string')
diff --git a/packages/comark/src/internal/stringify/state.ts b/packages/comark/src/internal/stringify/state.ts
index dc3d1c64..f8cb9ac3 100644
--- a/packages/comark/src/internal/stringify/state.ts
+++ b/packages/comark/src/internal/stringify/state.ts
@@ -1,7 +1,8 @@
import { handlers as defaultHandlers } from './handlers/index.ts'
-import type { State, Context } from 'comark/render'
-import type { ComarkElement, ComarkNode, ConditionalNodeHandler, CreateContext, NodeHandler } from 'comark'
+import type { NodeRenderData, State, Context } from 'comark/render'
+import type { ComarkElement, ComarkNode, ComarkTree, ConditionalNodeHandler, CreateContext, NodeHandler } from 'comark'
import { pascalCase } from '../../utils/index.ts'
+import { resolveAttributes } from './attributes.ts'
function findHandler(ctx: Context, node: ComarkElement): NodeHandler | undefined {
const userHandler = ctx.handlers[node[0] as string] || ctx.handlers[pascalCase(node[0] as string)]
@@ -38,24 +39,40 @@ export async function one(node: ComarkNode, state: State, parent?: ComarkElement
return await state.handlers.comment(node as unknown as ComarkElement, state)
}
- const userHandler = findHandler(state.context, node)
- if (userHandler) {
- return await userHandler(node, state, parent)
+ // Scope `renderData.props` to the current element's resolved attributes so
+ // nested bindings like `:prop="props.x"` resolve against the enclosing
+ // element's values, regardless of which handler (html / ansi / user) runs.
+ const prevRenderData = state.renderData
+ if (state.renderData && node[1]) {
+ state.renderData = {
+ ...prevRenderData,
+ props: resolveAttributes(node[1] as Record, prevRenderData),
+ }
}
- if (state.context.html || node[1].$?.html === 1) {
- return await state.handlers.html(node, state, parent)
- }
+ try {
+ const userHandler = findHandler(state.context, node)
+ if (userHandler) {
+ return await userHandler(node, state, parent)
+ }
- // fallback to default handlers
- const nodeHandler = state.handlers[node[0] as string]
- if (nodeHandler) {
- return await nodeHandler(node, state, parent)
- }
+ if (state.context.html || node[1].$?.html === 1) {
+ return await state.handlers.html(node, state, parent)
+ }
- return state.context.format === 'markdown/comark'
- ? await state.handlers.mdc(node, state, parent)
- : await state.handlers.html(node, state, parent)
+ // fallback to default handlers
+ const nodeHandler = state.handlers[node[0] as string]
+ if (nodeHandler) {
+ return await nodeHandler(node, state, parent)
+ }
+
+ return state.context.format === 'markdown/comark'
+ ? await state.handlers.mdc(node, state, parent)
+ : await state.handlers.html(node, state, parent)
+ }
+ finally {
+ state.renderData = prevRenderData
+ }
}
export async function flow(node: ComarkElement, state: State, parent?: ComarkElement): Promise {
@@ -91,12 +108,20 @@ export function createState(ctx: Partial = {}): State {
html: ctx.format === 'text/html',
} as Context
+ const tree = ctx.tree as ComarkTree | undefined
+ const renderData: NodeRenderData = {
+ frontmatter: (tree?.frontmatter || {}) as Record,
+ meta: (tree?.meta || {}) as Record,
+ data: (ctx.data || {}) as Record,
+ props: {} as Record,
+ }
const state = {
handlers: defaultHandlers,
context,
one,
flow,
data: ctx.data || {},
+ renderData,
render: async (input: ComarkNode[] | ComarkElement) => {
if (Array.isArray(input) && typeof input[0] === 'string' && input.length > 1) {
return state.one(input as ComarkElement, state)
@@ -127,6 +152,7 @@ export const state: State = {
handlers: defaultHandlers,
conditionalHandlers: [],
data: {},
+ renderData: { frontmatter: {}, meta: {}, data: {}, props: {} } as NodeRenderData,
context: {
blockSeparator: '\n\n',
format: 'markdown/comark',
diff --git a/packages/comark/src/render.ts b/packages/comark/src/render.ts
index 851896af..5cb5c003 100644
--- a/packages/comark/src/render.ts
+++ b/packages/comark/src/render.ts
@@ -3,11 +3,14 @@ import { renderFrontmatter } from './internal/frontmatter.ts'
import { createState, one } from './internal/stringify/state.ts'
-export type { NodeHandler, State, Context, RenderOptions, RenderMarkdownOptions } from './types.ts'
+export type { NodeHandler, State, Context, RenderOptions, RenderMarkdownOptions, NodeRenderData } from './types.ts'
// Re-export frontmatter renderer
export { renderFrontmatter } from './internal/frontmatter.ts'
+// Re-export attribute resolvers for custom handlers that want to honor `:prefix` bindings
+export { resolveAttributes, resolveAttribute } from './internal/stringify/attributes.ts'
+
/**
* Generate a string from a Comark tree
* @param tree - The Comark tree to render
diff --git a/packages/comark/src/types.ts b/packages/comark/src/types.ts
index 89eff37f..40693cb3 100644
--- a/packages/comark/src/types.ts
+++ b/packages/comark/src/types.ts
@@ -164,6 +164,14 @@ export type State = {
*/
data: Record
+ /**
+ * Render context — `{ frontmatter, meta, data, props }` — used to
+ * resolve `:prefixed` attributes that reference dot-paths in markdown.
+ * `props` is scoped to the nearest enclosing element as it's mutated during
+ * recursion.
+ */
+ renderData: NodeRenderData
+
/**
* The context of the renderer
*/
@@ -243,6 +251,25 @@ export interface RenderMarkdownOptions extends RenderOptions {
*/
frontmatterOptions?: DumpOptions
}
+
+export interface NodeRenderData {
+ /*
+ * Frontmatter data from the markdown file
+ */
+ frontmatter: Record
+ /**
+ * Meta information from Comark Tree
+ */
+ meta: Record
+ /**
+ * Additional data paased to rendere
+ */
+ data: Record
+ /**
+ * Props from parent node
+ */
+ props: Record
+}
// #endregion
export type MarkdownExitPlugin = (md: MarkdownExit) => void
@@ -277,6 +304,7 @@ export interface ComarkContextProvider {
components: Record
componentManifest: ComponentManifest
}
+
export interface ParseOptions {
/**
* Whether to automatically unwrap single paragraphs in container components.
diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts
index fb5f7ebf..ceaf7766 100644
--- a/packages/comark/src/utils/index.ts
+++ b/packages/comark/src/utils/index.ts
@@ -167,3 +167,30 @@ function splitByCase(str: string) {
}
// #endregion
+
+// #region Object Utils
+/**
+ * Retrieves a value from a nested object using a dot-separated key path.
+ * @param data - The object to retrieve the value from.
+ * @param key - The dot-separated key path to the value.
+ * @returns The value at the specified key path, or `undefined` if the key path does not exist.
+ */
+export function get(data: unknown, key: string): unknown {
+ const keys = key.split('.')
+ let value: unknown = data
+ for (const k of keys) {
+ if (value && typeof value === 'object' && k in (value as Record)) {
+ value = (value as Record)[k]
+ }
+ else {
+ return undefined
+ }
+ }
+ return value
+}
+// #endregion
+
+// Re-export the shared attribute resolvers so framework renderers can apply the
+// same `:prefix` semantics as the HTML/ANSI handlers without duplicating logic.
+export { resolveAttributes, resolveAttribute } from '../internal/stringify/attributes.ts'
+export type { ResolveAttributesOptions } from '../internal/stringify/attributes.ts'
diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts
new file mode 100644
index 00000000..72239f60
--- /dev/null
+++ b/packages/comark/test/resolve-attributes.test.ts
@@ -0,0 +1,183 @@
+import { describe, expect, it } from 'vitest'
+import type { NodeRenderData } from '../src/types.ts'
+import { resolveAttribute, resolveAttributes } from '../src/internal/stringify/attributes.ts'
+
+const makeRenderData = (overrides: Partial = {}): NodeRenderData => ({
+ frontmatter: {},
+ meta: {},
+ data: {},
+ props: {},
+ ...overrides,
+})
+
+describe('resolveAttributes (default / preserve mode)', () => {
+ it('resolves a :prefix binding from frontmatter and strips the colon', () => {
+ const result = resolveAttributes(
+ { ':title': 'frontmatter.site.name', 'type': 'info' },
+ makeRenderData({ frontmatter: { site: { name: 'My Blog' } } }),
+ )
+ expect(result).toEqual({ title: 'My Blog', type: 'info' })
+ })
+
+ it('resolves across all namespaces', () => {
+ const result = resolveAttributes(
+ {
+ ':fromFm': 'frontmatter.a',
+ ':fromMeta': 'meta.b',
+ ':fromData': 'data.c',
+ ':fromProps': 'props.d',
+ },
+ makeRenderData({
+ frontmatter: { a: 'A' },
+ meta: { b: 'B' },
+ data: { c: 'C' },
+ props: { d: 'D' },
+ }),
+ )
+ expect(result).toEqual({ fromFm: 'A', fromMeta: 'B', fromData: 'C', fromProps: 'D' })
+ })
+
+ it('preserves unresolved paths verbatim (with the :prefix intact)', () => {
+ const result = resolveAttributes(
+ { ':to': '$doc.snippet.link' },
+ makeRenderData(),
+ )
+ expect(result).toEqual({ ':to': '$doc.snippet.link' })
+ })
+
+ it('preserves string literals verbatim (does not JSON-parse)', () => {
+ const result = resolveAttributes(
+ { ':count': '5', ':active': 'true' },
+ makeRenderData(),
+ )
+ expect(result).toEqual({ ':count': '5', ':active': 'true' })
+ })
+
+ it('passes non-string :prefix values through with the prefix preserved', () => {
+ const result = resolveAttributes(
+ { ':config': { k: 'v' } },
+ makeRenderData(),
+ )
+ expect(result).toEqual({ ':config': { k: 'v' } })
+ })
+
+ it('passes non-:prefix attributes through unchanged', () => {
+ const result = resolveAttributes(
+ { 'id': 'main', 'data-x': '1' },
+ makeRenderData({ frontmatter: { main: 'nope' } }),
+ )
+ expect(result).toEqual({ 'id': 'main', 'data-x': '1' })
+ })
+
+ it('drops the internal $ metadata key', () => {
+ const result = resolveAttributes(
+ { $: { line: 3 }, type: 'info' },
+ makeRenderData(),
+ )
+ expect(result).toEqual({ type: 'info' })
+ })
+})
+
+describe('resolveAttributes (parseJson mode)', () => {
+ const renderData = makeRenderData({ frontmatter: { title: 'Hello' } })
+
+ it('JSON-parses numeric, boolean, and null literals', () => {
+ const result = resolveAttributes(
+ { ':count': '5', ':active': 'true', ':missing': 'null' },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toEqual({ count: 5, active: true, missing: null })
+ })
+
+ it('JSON-parses object values and strips the :prefix', () => {
+ const result = resolveAttributes(
+ { ':config': '{"k":"v"}' },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toEqual({ config: { k: 'v' } })
+ })
+
+ it('resolves dot-paths as a fallback when JSON.parse fails', () => {
+ const result = resolveAttributes(
+ { ':title': 'frontmatter.title' },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toEqual({ title: 'Hello' })
+ })
+
+ it('yields undefined for unresolved dot-paths (prop is present, value undefined)', () => {
+ const result = resolveAttributes(
+ { ':title': 'frontmatter.missing' },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toHaveProperty('title', undefined)
+ })
+
+ it('still drops $ and passes non-:prefix attributes through', () => {
+ const result = resolveAttributes(
+ { '$': { line: 1 }, 'id': 'main', ':count': '5' },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toEqual({ id: 'main', count: 5 })
+ })
+
+ it('strips :prefix for non-string values already decoded by the parser', () => {
+ const result = resolveAttributes(
+ { ':config': { k: 'v' } },
+ renderData,
+ { parseJson: true },
+ )
+ expect(result).toEqual({ config: { k: 'v' } })
+ })
+})
+
+describe('resolveAttribute (single-attr lookup)', () => {
+ const renderData = makeRenderData({ frontmatter: { home: 'https://example.com' } })
+
+ it('prefers the :prefix binding when a dot-path resolves', () => {
+ const value = resolveAttribute(
+ { 'href': 'fallback', ':href': 'frontmatter.home' },
+ renderData,
+ 'href',
+ )
+ expect(value).toBe('https://example.com')
+ })
+
+ it('falls back to the raw :prefix value when the path is unresolved', () => {
+ const value = resolveAttribute(
+ { ':href': 'unknown.path' },
+ renderData,
+ 'href',
+ )
+ expect(value).toBe('unknown.path')
+ })
+
+ it('returns the non-:prefix value when no binding is present', () => {
+ const value = resolveAttribute(
+ { href: '/about' },
+ renderData,
+ 'href',
+ )
+ expect(value).toBe('/about')
+ })
+
+ it('returns undefined when the attribute is missing entirely', () => {
+ const value = resolveAttribute({}, renderData, 'href')
+ expect(value).toBeUndefined()
+ })
+
+ it('passes non-string :prefix values through untouched', () => {
+ const config = { k: 'v' }
+ const value = resolveAttribute(
+ { ':config': config },
+ renderData,
+ 'config',
+ )
+ expect(value).toBe(config)
+ })
+})