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` 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/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 ( +
+

{title}

+
{children}
+
+ ) +} + +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) + }) +})