From 33fca756b2b859c449cbe24ae66d5df8ff39d211 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 21 Apr 2026 13:20:22 +0200 Subject: [PATCH 1/4] feat: data binding in components --- .../comark-react/src/components/Comark.tsx | 9 ++++ .../src/components/ComarkClient.tsx | 2 + .../src/components/ComarkRenderer.tsx | 37 ++++++++++++----- .../src/async/ComarkAsync.svelte | 3 ++ .../src/components/Comark.svelte | 3 ++ .../src/components/ComarkNode.svelte | 20 ++++++--- .../src/components/ComarkRenderer.svelte | 10 +++++ packages/comark-vue/src/components/Comark.ts | 14 +++++++ .../src/components/ComarkRenderer.ts | 41 ++++++++++++++----- packages/comark/src/types.ts | 20 +++++++++ packages/comark/src/utils/index.ts | 22 ++++++++++ 11 files changed, 153 insertions(+), 28 deletions(-) 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..09e4673a 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, get } from 'comark/utils' import { findLastTextNodeAndAppendNode, getCaret } from '../utils/caret' /** @@ -42,7 +42,7 @@ function getProps(node: ComarkNode): Record { return {} } -function parsePropValue(value: string): any { +function parsePropValue(value: string, data: NodeRenderData): any { if (value === 'true') return true if (value === 'false') return false if (value === 'null') return null @@ -50,9 +50,8 @@ function parsePropValue(value: string): any { return JSON.parse(value) } catch { - // noop + return get(data, value) } - return value } /** @@ -101,6 +100,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') { @@ -145,7 +145,7 @@ function renderNode( props.tabIndex = nodeProps[k] } else if (k.charCodeAt(0) === 58 /* ':' */) { - props[k.substring(1)] = parsePropValue(nodeProps[k]) + props[k.substring(1)] = parsePropValue(nodeProps[k], renderData) } else { props[k] = nodeProps[k] @@ -174,7 +174,7 @@ function renderNode( } else { if (propKey.startsWith(':')) { - props[propKey.substring(1)] = parsePropValue(value) + props[propKey.substring(1)] = parsePropValue(value, renderData) Reflect.deleteProperty(props, propKey) } } @@ -189,6 +189,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 +224,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 +307,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 +346,7 @@ export const ComarkRenderer: React.FC = ({ componentsManifest, streaming = false, caret: caretProp = false, + data, className, }) => { const caret = useMemo(() => getCaret(caretProp), [caretProp]) @@ -354,10 +362,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-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..d3032336 100644 --- a/packages/comark-svelte/src/components/ComarkNode.svelte +++ b/packages/comark-svelte/src/components/ComarkNode.svelte @@ -15,27 +15,31 @@ naturally appears inline after the deepest trailing text node. ``` --> {#if isText} @@ -118,6 +124,7 @@ naturally appears inline after the deepest trailing text node. {components} {componentsManifest} caretClass={i === children.length - 1 ? caretClass : null} + renderData={childrenRenderData} /> {/each} @@ -131,6 +138,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-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..a9a6490c 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 { get, pascalCase } 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,7 +27,7 @@ function getProps(node: ComarkNode): Record { return {} } -function parsePropValue(value: string): any { +function parsePropValue(value: string, data: NodeRenderData): any { if (value === 'true') return true if (value === 'false') return false if (value === 'null') return null @@ -38,7 +35,7 @@ function parsePropValue(value: string): any { return JSON.parse(value) } catch { - // noop + return get(data, value) } return value @@ -90,6 +87,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') { @@ -129,7 +127,7 @@ function renderNode( props.class = nodeProps[k] } else if (k.charCodeAt(0) === 58 /* ':' */) { - props[k.substring(1)] = parsePropValue(nodeProps[k]) + props[k.substring(1)] = parsePropValue(nodeProps[k], renderData) } else { props[k] = nodeProps[k] @@ -150,6 +148,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 +183,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 +240,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 +321,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 +374,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/src/types.ts b/packages/comark/src/types.ts index 89eff37f..0f8d0f05 100644 --- a/packages/comark/src/types.ts +++ b/packages/comark/src/types.ts @@ -243,6 +243,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 +296,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..cc56630b 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -167,3 +167,25 @@ 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 From bf1250a3cd4e54344d98f82a2c5a6fefcc77e44d Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 21 Apr 2026 13:31:34 +0200 Subject: [PATCH 2/4] add data binding docs --- docs/content/2.syntax/2.components.md | 75 +++++++++++++++++++++++++++ docs/content/3.rendering/3.vue.md | 21 ++++++++ docs/content/3.rendering/5.react.md | 17 ++++++ docs/content/3.rendering/6.svelte.md | 17 ++++++ 4 files changed, 130 insertions(+) diff --git a/docs/content/2.syntax/2.components.md b/docs/content/2.syntax/2.components.md index 84f07924..f5a61fde 100644 --- a/docs/content/2.syntax/2.components.md +++ b/docs/content/2.syntax/2.components.md @@ -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] + +``` + +```tsx [React] + +``` + +```svelte [Svelte] + +``` +:: + +```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. diff --git a/docs/content/3.rendering/3.vue.md b/docs/content/3.rendering/3.vue.md index 93920ccb..d3d87cb7 100644 --- a/docs/content/3.rendering/3.vue.md +++ b/docs/content/3.rendering/3.vue.md @@ -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 `` | | [`caret`](#caret) | `boolean \| { class: string }` | `false` | Append caret to last text node | +| [`data`](#code-data) | `Record` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` | #### `options` @@ -245,6 +246,25 @@ const manifest = (name: string) => { ``` +#### `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] + + + +``` + ### `defineComarkComponent` Creates a pre-configured `` component with default options, plugins, and components baked in. @@ -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` | `{}` | Runtime values referenced from markdown via `:prop="data.path"` | ### `defineComarkRendererComponent` diff --git a/docs/content/3.rendering/5.react.md b/docs/content/3.rendering/5.react.md index 48d8569e..54f365f0 100644 --- a/docs/content/3.rendering/5.react.md +++ b/docs/content/3.rendering/5.react.md @@ -90,6 +90,7 @@ Pass markdown content via `children` or the `markdown` prop: | [`componentsManifest`](#code-componentsmanifest) | `(name: string) => Promise` | `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` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` | | `className` | `string` | `undefined` | CSS class for wrapper element | #### `options` @@ -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 +} +``` + ### `defineComarkComponent` Creates a pre-configured `` component with default options, plugins, and components baked in. @@ -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` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` | | `className` | `string` | `undefined` | CSS class for the wrapper `
` | ### `defineComarkRendererComponent` diff --git a/docs/content/3.rendering/6.svelte.md b/docs/content/3.rendering/6.svelte.md index 24ca6902..3ec4e828 100644 --- a/docs/content/3.rendering/6.svelte.md +++ b/docs/content/3.rendering/6.svelte.md @@ -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` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` | | `class` | `string` | `''` | CSS class for wrapper element | #### `options` @@ -180,6 +181,21 @@ For lazy-loading components on demand: ``` +#### `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] + + + +``` + --- ## `` (experimental) @@ -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` | `undefined` | Runtime values referenced from markdown via `:prop="data.path"` | | `class` | `string` | `''` | CSS class for wrapper element | ### `componentsManifest` From 9e92fdf4d077f1718f6e52eca48d74947f61572a Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 21 Apr 2026 14:54:14 +0200 Subject: [PATCH 3/4] add support for HTML and ANSI --- packages/comark-ansi/src/handlers/a.ts | 3 +- packages/comark-ansi/src/handlers/img.ts | 3 +- packages/comark-ansi/test/index.test.ts | 42 ++++++ packages/comark-html/test/string.test.ts | 60 ++++++++ .../src/components/ComarkRenderer.tsx | 63 ++------- .../src/components/ComarkNode.svelte | 27 +--- .../src/components/ComarkRenderer.ts | 33 +---- .../src/internal/stringify/attributes.ts | 128 ++++++++++++++++-- .../src/internal/stringify/handlers/html.ts | 10 +- .../comark/src/internal/stringify/state.ts | 58 +++++--- packages/comark/src/render.ts | 5 +- packages/comark/src/types.ts | 8 ++ packages/comark/src/utils/index.ts | 5 + 13 files changed, 315 insertions(+), 130 deletions(-) diff --git a/packages/comark-ansi/src/handlers/a.ts b/packages/comark-ansi/src/handlers/a.ts index 192f1248..02c29045 100644 --- a/packages/comark-ansi/src/handlers/a.ts +++ b/packages/comark-ansi/src/handlers/a.ts @@ -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) { diff --git a/packages/comark-ansi/src/handlers/img.ts b/packages/comark-ansi/src/handlers/img.ts index e5dbb0ab..12d1bcd0 100644 --- a/packages/comark-ansi/src/handlers/img.ts +++ b/packages/comark-ansi/src/handlers/img.ts @@ -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)) } diff --git a/packages/comark-ansi/test/index.test.ts b/packages/comark-ansi/test/index.test.ts index bcb1a0e9..e6911c01 100644 --- a/packages/comark-ansi/test/index.test.ts +++ b/packages/comark-ansi/test/index.test.ts @@ -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', () => { diff --git a/packages/comark-html/test/string.test.ts b/packages/comark-html/test/string.test.ts index e3822ff9..a6f2f708 100644 --- a/packages/comark-html/test/string.test.ts +++ b/packages/comark-html/test/string.test.ts @@ -145,4 +145,64 @@ More content expect(html).toContain('
') 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/ComarkRenderer.tsx b/packages/comark-react/src/components/ComarkRenderer.tsx index 09e4673a..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, NodeRenderData } from 'comark' import React, { lazy, Suspense, useMemo } from 'react' -import { pascalCase, camelCase, get } from 'comark/utils' +import { pascalCase, camelCase, resolveAttributes } from 'comark/utils' import { findLastTextNodeAndAppendNode, getCaret } from '../utils/caret' /** @@ -42,18 +42,6 @@ function getProps(node: ComarkNode): Record { return {} } -function parsePropValue(value: string, data: NodeRenderData): any { - if (value === 'true') return true - if (value === 'false') return false - if (value === 'null') return null - try { - return JSON.parse(value) - } - catch { - return get(data, value) - } -} - /** * Helper to get children from a ComarkNode */ @@ -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] + for (const k in resolved) { + const v = resolved[k] + if (k === 'className' || k === 'class') { + props.className = v } - else if (k === 'class') { - props.className = nodeProps[k] - } - 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], renderData) + 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, renderData) - Reflect.deleteProperty(props, propKey) - } - } - } // Add key if provided if (key !== undefined) { props.key = key diff --git a/packages/comark-svelte/src/components/ComarkNode.svelte b/packages/comark-svelte/src/components/ComarkNode.svelte index d3032336..bff084b6 100644 --- a/packages/comark-svelte/src/components/ComarkNode.svelte +++ b/packages/comark-svelte/src/components/ComarkNode.svelte @@ -17,7 +17,7 @@ naturally appears inline after the deepest trailing text node. + + + {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/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 e7f0a8cc..57f98cfd 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -41,9 +41,11 @@ export function resolveAttributes( if (key === '$') continue const value = attrs[key] + const isBinding = key.charCodeAt(0) === 58 /* ':' */ - if (key.charCodeAt(0) === 58 /* ':' */ && typeof value === 'string') { - if (options.parseJson) { + 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 @@ -54,7 +56,13 @@ export function resolveAttributes( 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 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) + }) +})