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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,57 @@ const { data: ast } = await useAsyncData('markdown', () => parseMarkdown('::aler
</template>
```

#### Dynamic props with interpolation

Props with a `:` or `v-bind:` prefix can mix fixed text with `{{ }}` placeholders. MDC resolves each placeholder from your document data and passes the result to the component as a string.

```md
---
snippet:
api:
id: abc123
---

::button
---
:to: https://apiconsole.example.com/repository/{{ snippet.api.id }}
:title: Version {{ snippet.api.version || '1.0' }}
---
Open the API console
::

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

A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key.

- **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string.
- **Whitespace:** line breaks and tabs in inserted values become one space. Other control characters are removed.
- **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`.
- **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`.
- **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale.
- **Quoting:** in YAML, quote values that start with `{{`, or that contain `: ` or ` #`. Inline attributes must always be quoted.

##### Left as written

MDC does not interpolate these values. They keep their original behavior, so a typo stays visible as literal text:

- Props without the `:` prefix, and the props `key`, `ref`, `is` and `style`.
- Malformed templates, such as `{{ a | upper }}` or `{{ a + b }}`.
- Templates over 4096 characters, 16 placeholders, or 256 characters per placeholder.
- JSON arrays and objects. MDC passes YAML arrays and objects as JSON, so `items: ["{{ x }}"]` stays an array.

##### Removed with a warning

MDC checks the final string and removes the prop when it is unsafe. The console warning names the prop only. This happens when:

- the result is over 4096 characters.
- the prop name is unsafe, such as a name that starts with `on`, or `srcdoc`.
- the value starts with an unsafe link prefix in any prop, such as `javascript:`, `vbscript:` or `data:text/html`.
- an `href`, `src`, `action` or `xlinkHref` value has a malformed `%` sequence.

**Existing content:** a `:` prop that already contained a well-formed `{{ }}` now interpolates. For literal braces, use a prop without the `:` prefix.

### `<MDCSlot>`

This component is a replacement for Vue's `<slot/>` component, specifically designed for MDC. Using this component, you can render a component's children while removing one or multiple wrapping elements. In the below example, the Alert component receives text and its default slot (children). But if the component renders this slot using the normal `<slot/>`, it will render a `<p>` element around the text.
Expand Down
51 changes: 51 additions & 0 deletions docs/0.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,57 @@ const ast = await parseMarkdown(props.md)
</script>
```

#### Dynamic props with interpolation

Props with a `:` or `v-bind:` prefix can mix fixed text with `{{ }}` placeholders. MDC resolves each placeholder from your document data and passes the result to the component as a string.

```md
---
snippet:
api:
id: abc123
---

::button
---
:to: https://apiconsole.example.com/repository/{{ snippet.api.id }}
:title: Version {{ snippet.api.version || '1.0' }}
---
Open the API console
::

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

A placeholder is a dotted path, with optional indexes (`items[0].name`) and an optional default (`|| 'text'`). It reads document data, ancestor props and slot props, but not sibling props. Use `$doc.key` when an ancestor prop hides a document key.

- **Values:** strings, finite numbers and booleans are inserted as text. Anything else uses the default, or an empty string.
- **Whitespace:** line breaks and tabs in inserted values become one space. Other control characters are removed.
- **No encoding:** values are not URL-encoded or escaped. Only insert data you trust, especially for props like `id` or `name`.
- **Safe lookups:** placeholders read own properties only and never call functions. They cannot read `__proto__`, `constructor`, `prototype`, `$route`, `runtimeData`, `updateRuntimeData` or a root `tags`.
- **Stale values:** like `{{ }}` in text, placeholders read the data passed to the renderer. A value changed only through `v-model` can look stale.
- **Quoting:** in YAML, quote values that start with `{{`, or that contain `: ` or ` #`. Inline attributes must always be quoted.

##### Left as written

MDC does not interpolate these values. They keep their original behavior, so a typo stays visible as literal text:

- Props without the `:` prefix, and the props `key`, `ref`, `is` and `style`.
- Malformed templates, such as `{{ a | upper }}` or `{{ a + b }}`.
- Templates over 4096 characters, 16 placeholders, or 256 characters per placeholder.
- JSON arrays and objects. MDC passes YAML arrays and objects as JSON, so `items: ["{{ x }}"]` stays an array.

##### Removed with a warning

MDC checks the final string and removes the prop when it is unsafe. The console warning names the prop only. This happens when:

- the result is over 4096 characters.
- the prop name is unsafe, such as a name that starts with `on`, or `srcdoc`.
- the value starts with an unsafe link prefix in any prop, such as `javascript:`, `vbscript:` or `data:text/html`.
- an `href`, `src`, `action` or `xlinkHref` value has a malformed `%` sequence.

**Existing content:** a `:` prop that already contained a well-formed `{{ }}` now interpolates. For literal braces, use a prop without the `:` prefix.

### `<MDCSlot>`

This component is a replacement for Vue's `<slot/>` component, specifically designed for MDC. Using this component, you can render a component's children while removing one or multiple wrapping elements. In the below example, the Alert component receives text and its default slot (children). But if the component renders this slot using the normal `<slot/>`, it will render a `<p>` element around the text.
Expand Down
28 changes: 24 additions & 4 deletions src/runtime/components/MDCRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import htmlTags from '../parser/utils/html-tags-list'
import { flatUnwrap, nodeTextContent } from '../utils/node'
import { pick } from '../utils'
import { validateProps } from '../parser/utils/props'
import { isInterpolatedProp, resolvePropTemplate } from '../utils/template'

type CreateElement = typeof h

Expand Down Expand Up @@ -233,7 +234,7 @@ function _renderNode(node: MDCNode, h: CreateElement, options: MDCRenderOptions,
component.tag = originalTag
}

const props = propsToData(node, documentMeta)
const props = propsToData(node, documentMeta, parentScope)
if (keyInParent) {
props.key = keyInParent
}
Expand Down Expand Up @@ -337,8 +338,16 @@ function renderBinding(node: MDCElement, h: CreateElement, documentMeta: MDCData
/**
* Create component data from node props.
*/
function propsToData(node: MDCElement, documentMeta: MDCData) {
function propsToData(node: MDCElement, documentMeta: MDCData, parentScope: Record<string, unknown> = {}) {
const { tag = '', props = {} } = node
// Same scope as `renderBinding`. Built once per node, and only when a template needs it.
let scope: Record<string, unknown> | undefined
const getScope = () => scope ??= {
...documentMeta.runtimeData,
...parentScope,
$document: documentMeta,
$doc: documentMeta,
}
return Object.keys(props).reduce(function (data, key) {
// Ignore internal `__ignoreMap` prop.
if (key === '__ignoreMap') {
Expand All @@ -364,7 +373,7 @@ function propsToData(node: MDCElement, documentMeta: MDCData) {

// `:foo="bar"`, `v-bind:foo="bar"`
if (rxBind.test(key)) {
return propsToDataRxBind(key, value, data, documentMeta)
return propsToDataRxBind(key, value, data, documentMeta, getScope)
}

const { attribute } = find(html, key)
Expand Down Expand Up @@ -421,8 +430,19 @@ function propsToDataRxOn(key: string, value: any, data: any, documentMeta: MDCDa
/**
* Handle single binding `v-bind:` and `:`
*/
function propsToDataRxBind(key: string, value: any, data: any, documentMeta: MDCData) {
function propsToDataRxBind(key: string, value: any, data: any, documentMeta: MDCData, getScope: () => Record<string, unknown>) {
key = key.replace(rxBind, '')
if (isInterpolatedProp(key, value)) {
const result = resolvePropTemplate(value, getScope(), { attribute: key })
// Remove the key, so an earlier static value does not show.
if (result === undefined) {
Reflect.deleteProperty(data, key)
}
else {
data[key] = result
}
return data
}
data[key] = evalInContext(value, documentMeta)
return data
}
Expand Down
39 changes: 35 additions & 4 deletions src/runtime/parser/utils/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,39 @@ export const unsafeLinkPrefix = [
'data:text/css',
'data:text/plain',
'data:text/xml',
'data:application/xhtml+xml',
'data:application/xml',
]

function isAnchorLinkAllowed(value: string) {
const decodedUrl = decodeURIComponent(value)
/**
* Options for `isAnchorLinkAllowed`.
* @internal
*/
export interface AnchorLinkOptions {
/** Accept values that are not decodable or not a valid URL. Only unsafe schemes fail. Use it for props that are not links. */
lenient?: boolean
}

/**
* Check if a value is a safe link target.
*
* @param value - Link value to check.
* @param options - Check options.
* @internal
*/
export function isAnchorLinkAllowed(value: string, options: AnchorLinkOptions = {}) {
const { lenient = false } = options
let decodedUrl = value
try {
decodedUrl = decodeURIComponent(value)
}
catch {
// Malformed encoding is unsafe in a link.
if (!lenient) {
return false
}
}

const urlSanitized = decodedUrl.replace(/&#x([0-9a-f]+);?/gi, '')
.replace(/&#(\d+);?/g, '')
.replace(/&[a-z]+;?/gi, '')
Expand All @@ -32,12 +61,14 @@ function isAnchorLinkAllowed(value: string) {
return true
}

if (unsafeLinkPrefix.some(prefix => url.href.toLowerCase().startsWith(prefix))) {
// The media type parser ignores spaces after `data:`, so `data: text/html` is still HTML.
const href = url.href.toLowerCase().replace(/^data:\s+/, 'data:')
if (unsafeLinkPrefix.some(prefix => href.startsWith(prefix))) {
return false
}
}
catch {
return false
return lenient
}

return true
Expand Down
Loading
Loading