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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ packages/comark/
│ ├── frontmatter.ts # parseFrontmatter() / renderFrontmatter()
│ ├── heading-id.ts # Auto heading id generation (shared by parser + stringifier)
│ ├── yaml.ts # YAML helpers
│ ├── props-validation.ts # Component props validation
│ ├── props-validation.ts # Component props validation (incl. anchored `{{ }}` URL templates)
│ ├── template.ts # `{{ path || default }}` grammar shared by the binding plugin and the resolver
│ ├── parse/ # Parsing pipeline
│ │ ├── token-processor.ts # markdown-exit tokens → document nodes
│ │ ├── auto-close/ # Self-healing for incomplete markdown
Expand All @@ -105,7 +106,7 @@ packages/comark/
│ │ ├── indent.ts # Dedent of outdented component children
│ │ ├── unwrap.ts # `unwrap` option
│ │ └── auto-unwrap.ts # `autoUnwrap` option
│ └── stringify/ # AST → markdown string rendering (handlers/ per tag)
│ └── stringify/ # AST → markdown string rendering (handlers/ per tag; interpolate.ts resolves `:prop` templates)
├── SPEC/ # Behavioral spec fixtures (CommonMark, GFM, HTML, COMARK, auto-close.md)
├── test/ # Vitest test files
├── package.json
Expand Down Expand Up @@ -700,6 +701,20 @@ Example:
}
```

## Templates in `:` props

With `binding()`, a `:` prop may mix text and placeholders: `:href="/docs/{{ data.slug }}"`.
The plugin's `post` hook marks each element whose `:` attrs hold a well-formed template with
`$.bind: 1` (grammar in `src/internal/template.ts`). `resolveAttributes` / `resolveAttribute`
interpolate only marked nodes (`src/internal/stringify/interpolate.ts`), so without the plugin
the values stay as written. Plain props are never interpolated. `$` is parser metadata:
`comarkAttributes()` never prints it.

Security: the interpolated string goes through `validateProp` and the binding hard floor. In URL
props, fixed text and data must not join into another host (`/{{ x }}`). With `security()`, the
literal prefix before the first `{{` must fix the scheme and host and pass the allowlists
(`validateUrlTemplate` in `src/internal/props-validation.ts`).

## Indentation Inside Components

A block component's children may be indented (aligned under the `::` marker) or
Expand Down
38 changes: 38 additions & 0 deletions docs/content/2.syntax/2.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,44 @@ Nested components can read the enclosing component's resolved props through the
Need to interpolate a value directly into text rather than a prop? The [Binding plugin](/plugins/built-in/binding) adds a `{{ path || default }}` inline shorthand that resolves against the same render context.
::

### String interpolation

With the [Binding plugin](/plugins/built-in/binding) enabled, a `:` prop can mix fixed text with data by wrapping a path in `{{ }}`. The renderer resolves each placeholder against the same render context and passes the result as a string:

```mdc
:a[Read more]{:href="/docs/{{ data.slug }}/overview"}
```

With `data` set to `{ slug: 'getting-started' }`, the link renders with `href="/docs/getting-started/overview"`. It works in YAML block props and in HTML attributes too:

```mdc
::card
---
:title: Item {{ frontmatter.id }}
---
::

<a :href="/docs/{{ data.slug }}">Docs</a>
```

Add a fallback with `||`. Like text bindings, the default is plain text, so quotes are kept:

```mdc
:badge{:label="Hello {{ data.user.name || friend }}"}
```

#### Key rules

- Interpolation needs the `binding()` plugin. Without it, `{{ }}` in a `:` prop stays as written.
- Only `:` props are interpolated. A plain prop such as `title="Hi {{ data.x }}"` or `<a href="{{ data.url }}">` is always literal.
- Paths use the same namespaces as above.
- The result is always a string. Use the path form (`:count="data.n"`, `:value="data.off"`) for numbers and booleans, because `false` becomes the text `false`, which is truthy.
- A missing value uses the default, or an empty string. If the whole prop is one placeholder with nothing to show, the prop is omitted.
- Values are inserted as is, without URL encoding. Unsafe URL schemes such as `javascript:` and unsafe prop names (`on*`, `srcdoc`) omit the prop.
- A URL prop is also omitted when fixed text and data join into another host, such as `/{{ data.path }}` with `/evil.com`. See [Interpolated props](/plugins/built-in/security#interpolated-props) for the URL rules.
- Quote inline attribute values, and quote YAML values that start with `{{`.
- The `key`, `ref`, `is`, `as`, and `style` props, malformed placeholders, and JSON values are left as written.

## Slots

Block components support slots for passing structured content to components.
Expand Down
18 changes: 18 additions & 0 deletions docs/content/4.plugins/1.built-in/binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,24 @@ A binding value (`{{ path }}`) is resolved as a dot-path against the same render

See [Data Binding](/syntax/components#data-binding) for the full contract and additional examples.

## Interpolation in props

The plugin also enables `{{ path || default }}` inside `:` props, resolved against the same render context:

```mdc
:a[Docs]{:href="/docs/{{ data.slug }}"}

::card
---
:title: Version {{ frontmatter.version || 1.0 }}
---
::

<a :href="/docs/{{ data.slug }}">Docs</a>
```

The parser marks the elements whose `:` props hold a well-formed template, and the renderers interpolate them. Without the plugin, those values stay as written. Plain props without `:` are never interpolated. The interpolated value is always a string. See [String interpolation](/syntax/components#string-interpolation) for the full rules.

## Default values

Use `|| default` to specify a fallback that's emitted when the dot-path does not resolve:
Expand Down
20 changes: 20 additions & 0 deletions docs/content/4.plugins/1.built-in/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ The same check applies to `:href` and `:src` bindings twice: on the JSON-decoded

::

### Interpolated props

With the `binding()` plugin, a `:` prop can contain `{{ path }}` placeholders (see [String interpolation](/syntax/components#string-interpolation)). Comark checks the final string after it inserts the values, even without the `security()` plugin. Unsafe prop names and unsafe URL schemes are omitted, including a scheme split between fixed text and data, such as `:to="javascri{{ frontmatter.rest }}"`.

In a URL prop, data can only fill what comes after the fixed text, so the fixed text before the first placeholder must set the scheme and host:

| Template | Result |
| --- | --- |
| `/docs/{{ data.slug }}`, `?q={{ data.q }}`, `https://myapp.com/{{ data.slug }}` | Kept |
| `{{ data.url }}` | Kept: the data sets the whole URL, and the result still gets the scheme check |
| `/{{ data.path }}` | Omitted: data such as `/evil.com` would make `//evil.com` |
| `https://myapp.com{{ data.host }}` | Omitted with `security()`: data such as `.evil.com` would change the host |
| `java{{ data.rest }}` | Omitted with `security()`: data could finish the scheme |

With `security()`, `allowedLinkPrefixes`, `allowedImagePrefixes`, and `allowedProtocols` are checked against that fixed text at parse time. A template that needs a `defaultOrigin` rewrite is omitted. A template that starts with a placeholder is treated like a path binding.

Values are inserted without URL encoding: `?q={{ data.q }}` passes `&` and `#` through. Only interpolate trusted values, or values you encoded yourself.

Path bindings (`:href="data.url"`) are not checked against the allowlists at render time. They only get the render-time URL check for `href`, `src`, `xlink:href`, and `to`.

---

## API
Expand Down
18 changes: 17 additions & 1 deletion examples/3.plugins/vue-vite-binding/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const previewPlugins = [binding()]
// Runtime data exposed to bindings via the `data.` namespace.
const initialData = {
user: {
name: 'Ada',
name: 'Adam',
role: 'admin',
age: 28,
},
Expand Down Expand Up @@ -87,6 +87,22 @@ I am NOT fine and NOT happy 😩
::card{title="Component props"}
Inside the card the binding below pulls the card's title via \`props\`:

{{ props.title }}
::

## Dynamic props

Build a prop from fixed text and data. Edit the inputs to update it.

- :a[Open {{ data.user.name || friend }}'s profile]{:href="/users/{{ data.user.name }}/overview"}
- :a[Release notes]{:href="/releases/{{ frontmatter.release.version }}"}

::card
---
:title: Release {{ frontmatter.release.codename }} (v{{ frontmatter.release.version }})
---
The title above is built from the frontmatter:

{{ props.title }}
::
`
Expand Down
16 changes: 16 additions & 0 deletions packages/comark-ansi/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, it, expect, vi } from 'vitest'
import { parseMarkdown } from 'comark'
import shiki from 'comark/plugins/shiki'
import binding from 'comark/plugins/binding'
import githubDark from 'shiki/dist/themes/github-dark.mjs'
import {
createAnsiPrinter,
Expand Down Expand Up @@ -404,6 +405,21 @@ home: https://example.com
expect(out).toContain('[image: Photo of Ada]')
})

it('interpolates placeholders in :href and :alt', async () => {
const link = await parseMarkdown('[Doc](x){:href="/d/{{ data.id }}"}', { plugins: [binding()] })
expect(await renderAnsiFromDocument(link, { colors: false, data: { id: 7 } })).toContain('Doc (/d/7)')
const img = await parseMarkdown('![x](/x.png){:alt="Alt {{ data.id }}"}', { plugins: [binding()] })
expect(await renderAnsiFromDocument(img, { colors: false, data: { id: 7 } })).toContain('[image: Alt 7]')
})

it('prints the literal fallback when an interpolated URL is unsafe', async () => {
const link = await parseMarkdown('[Doc](x){:href="{{ data.bad }}"}', { plugins: [binding()] })
const out = await renderAnsiFromDocument(link, { colors: false, data: { bad: 'javascript:alert(1)' } })
expect(out).toBe('Doc (x)\n')
expect(out).not.toContain('javascript:')
expect(await renderAnsiFromDocument(link, { colors: false, data: { bad: '/ok' } })).toContain('Doc (/ok)')
})

it('exposes parent props for custom handlers via resolveAttribute', async () => {
const { resolveAttribute } = await import('comark/render')
const tree = await parseMarkdown(`---
Expand Down
8 changes: 8 additions & 0 deletions packages/comark-html/test/plugin-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ describe('@comark/html plugins/binding — If handler', () => {
expect(hidden).toBe('')
})

it('interpolates `{{ }}` in If values to a truthy string even for false', async () => {
const data = { off: false }
const interpolated = await parseWithBinding('::if{:value="{{ data.off }}"}\nShown\n::')
expect(await renderHtmlFromDocument(interpolated, { components: { If }, data })).toBe('Shown')
const plain = await parseMarkdown('::if{:value="data.off"}\nShown\n::')
expect(await renderHtmlFromDocument(plain, { components: { If }, data })).toBe('')
})

it('exposes normalized If props to bindings in its children', async () => {
const doc = await parseWithBinding('::if{:value="true" :enabled="false"}\nEnabled {{ props.enabled }}\n::')
const html = await renderHtmlFromDocument(doc, { components: { Binding, If } })
Expand Down
32 changes: 32 additions & 0 deletions packages/comark-html/test/string.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import { parseMarkdown } from 'comark'
import { renderHtmlFromDocument } from '../src/index'
import binding from '../src/plugins/binding'

describe('renderHtmlFromDocument', () => {
it('renders without options (backward compatible)', async () => {
Expand Down Expand Up @@ -206,5 +207,36 @@ name: Ada
const html = await renderHtmlFromDocument(doc)
expect(html).toContain('title="frontmatter.name"')
})

it('interpolates `{{ path }}` placeholders inside :prefixed props', async () => {
const doc = await parseMarkdown('Go: :a[Read more]{:href="/docs/{{ data.slug }}/overview"}', {
plugins: [binding()],
})
const html = await renderHtmlFromDocument(doc, { data: { slug: 'getting-started' } })
expect(html).toContain('<a href="/docs/getting-started/overview">')
})

it('interpolates placeholders in block YAML props', async () => {
const doc = await parseMarkdown('::card\n---\n:title: Item {{ data.id }}\n---\nBody\n::', {
plugins: [binding()],
})
const html = await renderHtmlFromDocument(doc, { data: { id: 7 } })
expect(html).toContain('title="Item 7"')
})

it('renders a link without href when the interpolated URL is unsafe', async () => {
const doc = await parseMarkdown(':a[x]{:href="{{ data.url }}"}', { plugins: [binding()] })
const bad = await renderHtmlFromDocument(doc, { data: { url: 'javascript:alert(1)' } })
expect(bad).toBe('<a>x</a>')
const ok = await renderHtmlFromDocument(doc, { data: { url: '/ok' } })
expect(ok).toBe('<a href="/ok">x</a>')
})

it('escapes interpolated values', async () => {
const doc = await parseMarkdown(':card{:title="T {{ data.t }}"}', { plugins: [binding()] })
const html = await renderHtmlFromDocument(doc, { data: { t: 'a"b<c' } })
expect(html).toContain('title="T a&quot;b&lt;c"')
expect(html).not.toContain('a"b<c')
})
})
})
27 changes: 25 additions & 2 deletions packages/comark-react/test/renderer-data-binding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react'
import { renderToString } from 'react-dom/server'
import { parseMarkdown } from 'comark'
import { MarkdownDocument } from '../src/components/MarkdownDocument'
import binding from '../src/plugins/binding'

interface BadgeProps {
label?: string
Expand Down Expand Up @@ -37,8 +38,8 @@ function Card({ title = '', variant = '', children }: CardProps) {
)
}

async function renderMarkdown(markdown: string, props: Record<string, any> = {}) {
const tree = await parseMarkdown(markdown)
async function renderMarkdown(markdown: string, { plugins, ...props }: Record<string, any> = {}) {
const tree = await parseMarkdown(markdown, { plugins })
return renderToString(
<MarkdownDocument
value={tree}
Expand Down Expand Up @@ -114,6 +115,28 @@ site:
expect(html).not.toContain('frontmatter.missing')
})

it('interpolates placeholders into string props', async () => {
const html = await renderMarkdown(
`::badge{:label="Hi {{ data.name }}"}
::

:a[Docs]{:href="/d/{{ data.id }}"}`,
{ components: { badge: Badge }, data: { name: 'Ada', id: 7 }, plugins: [binding()] }
)
expect(html).toContain('Hi Ada')
expect(html).toContain('href="/d/7"')
})

it('omits a rejected URL while keeping the link text', async () => {
const md = ':a[x]{:href="{{ data.url }}"}'
const bad = await renderMarkdown(md, { data: { url: 'javascript:alert(1)' }, plugins: [binding()] })
expect(bad).not.toContain('javascript:')
expect(bad).not.toContain('href')
expect(bad).toContain('<a>x</a>')
const ok = await renderMarkdown(md, { data: { url: '/ok' }, plugins: [binding()] })
expect(ok).toContain('href="/ok"')
})

it('leaves non-:prefix attributes untouched', async () => {
const html = await renderMarkdown(
`---
Expand Down
20 changes: 19 additions & 1 deletion packages/comark-svelte/test/plugin-binding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { render } from 'svelte/server'
import { parseMarkdown } from 'comark'
import MarkdownDocument from '../src/components/MarkdownDocument.svelte'
import { If } from '../src/plugins/binding'
import binding, { If } from '../src/plugins/binding'
import { nestedIfCases, nestedIfMarkdown } from '../../../test/fixtures/if'

describe('@comark/svelte plugins/binding — If SSR', () => {
Expand All @@ -12,3 +12,21 @@ describe('@comark/svelte plugins/binding — If SSR', () => {
expect(body.replace(/<[^>]*>/g, '').trim()).toBe(expected)
})
})

describe('@comark/svelte prop interpolation SSR', () => {
it('resolves `{{ path }}` placeholders in :href', async () => {
const value = await parseMarkdown('Go :a[Docs]{:href="/d/{{ data.id }}"}', { plugins: [binding()] })
const { body } = render(MarkdownDocument, { props: { value, data: { id: 7 } } })
expect(body).toContain('href="/d/7"')
})

it('omits a rejected URL while keeping the link text', async () => {
const value = await parseMarkdown(':a[x]{:href="{{ data.url }}"}', { plugins: [binding()] })
const bad = render(MarkdownDocument, { props: { value, data: { url: 'javascript:alert(1)' } } }).body
expect(bad).not.toContain('javascript:')
expect(bad).not.toContain('href')
expect(bad.replace(/<!--.*?-->/g, '')).toContain('<a>x</a>')
const ok = render(MarkdownDocument, { props: { value, data: { url: '/ok' } } }).body
expect(ok).toContain('href="/ok"')
})
})
Loading
Loading