= {}, name = 'title') =>
+ interpolateProp(name, value, withData(data))
+
+describe('interpolateProp (resolution)', () => {
+ it.each([
+ ['/docs/{{ data.slug }}', '/docs/intro'],
+ ['/docs/{{ data.slug }}/overview', '/docs/intro/overview'],
+ ['{{ data.slug }}/overview', 'intro/overview'],
+ ['{{data.slug}}-{{ data.slug }}', 'intro-intro'],
+ ['{{ data.items.0.name }}', 'first'],
+ ])('replaces placeholders in %s', (value, expected) => {
+ expect(run(value, { slug: 'intro', items: [{ name: 'first' }] })).toBe(expected)
+ })
+
+ it('reads all four roots', () => {
+ const renderData = { frontmatter: { a: 'A' }, meta: { b: 'B' }, data: { c: 'C' }, props: { d: 'D' } }
+ expect(interpolateProp('title', '{{ frontmatter.a }}{{ meta.b }}{{ data.c }}{{ props.d }}', renderData)).toBe(
+ 'ABCD'
+ )
+ })
+
+ it.each([
+ ['x-{{ data.missing || fallback }}', 'x-fallback'],
+ ['x-{{ data.missing || }}', 'x-'],
+ ["x-{{ data.missing || 'q' }}", "x-'q'"],
+ ['x-{{ data.missing || a || b }}', 'x-a || b'],
+ ['x-{{ data.slug || fallback }}', 'x-intro'],
+ ['x-{{ data.missing }}', 'x-'],
+ ])('handles defaults in %s', (value, expected) => {
+ expect(run(value, { slug: 'intro' })).toBe(expected)
+ })
+
+ it('inserts 0, false and empty strings without using the default', () => {
+ expect(run('{{ data.n || d }}|{{ data.f || d }}|{{ data.s || d }}', { n: 0, f: false, s: '' })).toBe('0|false|')
+ })
+
+ it('stringifies numbers and booleans', () => {
+ expect(run('a{{ data.n }}{{ data.t }}', { n: 1.5, t: true })).toBe('a1.5true')
+ })
+
+ it.each([
+ ['object', {}],
+ ['array', []],
+ ['null', null],
+ ['undefined', undefined],
+ ['NaN', Number.NaN],
+ ['Infinity', Number.POSITIVE_INFINITY],
+ ['function', () => 'x'],
+ ])('treats a %s value as unresolved', (_, value) => {
+ expect(run('a{{ data.v || d }}', { v: value })).toBe('ad')
+ expect(run('a{{ data.v }}', { v: value })).toBe('a')
+ })
+
+ it('returns UNRESOLVED only for a sole placeholder without a default', () => {
+ expect(run('{{ data.x }}')).toBe(UNRESOLVED)
+ expect(run('{{ data.x || d }}')).toBe('d')
+ expect(run('{{ data.x || }}')).toBe('')
+ expect(run('{{ data.x }}!')).toBe('!')
+ expect(run('{{ data.x }}{{ data.y }}')).toBe('')
+ })
+
+ it('never calls functions or reads inherited methods', () => {
+ const fn = vi.fn(() => 'called')
+ expect(run('{{ data.fn || d }}', { fn })).toBe('d')
+ expect(run('{{ data.toString || d }}')).toBe('d')
+ expect(fn).not.toHaveBeenCalled()
+ })
+
+ it('treats a throwing getter as unresolved', () => {
+ const data = {
+ get boom(): string {
+ throw new Error('boom')
+ },
+ }
+ expect(run('a{{ data.boom || d }}', data)).toBe('ad')
+ })
+
+ it('does not expand placeholders inside inserted values', () => {
+ expect(run('{{ data.a }}', { a: '{{ data.secret }}', secret: 's' })).toBe('{{ data.secret }}')
+ })
+
+ it('collapses line breaks and removes control characters', () => {
+ expect(run('{{ data.a }}', { a: 'a\r\n\t b' })).toBe('a b')
+ expect(run('{{ data.a }}', { a: 'a\n\n\nb' })).toBe('a b')
+ expect(run('{{ data.a }}', { a: 'a\u0000b\u0085c\u202Ed\u2066e\u007Ff' })).toBe('abcdef')
+ expect(run('{{ data.x || d\u0000e }}')).toBe('de')
+ })
+
+ it('returns UNRESOLVED when the output is too long, without building it', () => {
+ const start = performance.now()
+ expect(run('a{{ data.a }}', { a: 'x'.repeat(5_000_000) })).toBe(UNRESOLVED)
+ expect(run('{{ data.a }}{{ data.a }}', { a: 'x'.repeat(3000) })).toBe(UNRESOLVED)
+ expect(performance.now() - start).toBeLessThan(200)
+ })
+})
+
+describe('interpolateProp (fall through)', () => {
+ it.each([
+ 'plain text',
+ 'only {{ open',
+ 'only close }}',
+ '{{ x }}',
+ '{{ other.x }}',
+ '{{ a | b }}',
+ '{{ data.a b }}',
+ '{{ }}',
+ '{{ || d }}',
+ '{{ data.a..b }}',
+ '{{ data.items[0] }}',
+ '{{ data.a"b }}',
+ "{{ data.a'b }}",
+ '{{ {{ data.x }} }}',
+ '{{ data.__proto__ }}',
+ '{{ data.constructor }}',
+ '{{ data.a.prototype }}',
+ '"{{ data.x }}"',
+ '{{ data.x }} {{ unterminated',
+ `${'x'.repeat(4090)}{{ data.x }}`,
+ ])('returns undefined for %s', (value) => {
+ expect(run(value, { x: 'v' })).toBeUndefined()
+ })
+
+ it.each(['key', 'ref', 'is', 'as', 'style', '.style', '^style', 'STYLE', ':href'])(
+ 'returns undefined for the name %s',
+ (name) => {
+ expect(interpolateProp(name, 'a{{ data.x }}', withData({ x: 'v' }))).toBeUndefined()
+ }
+ )
+
+ it('returns quickly for inputs over the length gate', () => {
+ const start = performance.now()
+ expect(run(`{{${' '.repeat(100000)}`)).toBeUndefined()
+ expect(run('{{ '.repeat(1500))).toBeUndefined()
+ expect(performance.now() - start).toBeLessThan(200)
+ })
+
+ it('returns quickly for hostile shapes under the length gate', () => {
+ const start = performance.now()
+ expect(run('{{ data.x }}'.repeat(340), { x: 'v' })).toBe('v'.repeat(340))
+ expect(run('{{ '.repeat(1300))).toBeUndefined()
+ expect(run('{{ data.x }}{{ '.repeat(300))).toBeUndefined()
+ expect(run(`{{ data.x || ${'a'.repeat(3900)} }}`)).toBe('a'.repeat(3900))
+ expect(run('}} {{ '.repeat(700))).toBeUndefined()
+ expect(run('{{ data.x }}'.repeat(300) + '}}'.repeat(200), { x: 'v' })).toBe('v'.repeat(300) + '}}'.repeat(200))
+ expect(performance.now() - start).toBeLessThan(200)
+ })
+})
+
+describe('interpolateProp (validation)', () => {
+ const urlNames = ['href', 'to', 'src', 'action', 'poster']
+ const dangerous = [
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ ' javascript:alert(1)',
+ 'javascript:alert(1)',
+ 'javascript%3Aalert(1)',
+ 'data:text/html,x',
+ ]
+
+ it.each(urlNames.flatMap((name) => dangerous.map((value) => [name, value])))('rejects %s=%s', (name, value) => {
+ expect(run('{{ data.u }}', { u: value }, name)).toBe(REJECTED_PROP)
+ expect(run('{{ data.u || d }}', { u: value }, name)).toBe(REJECTED_PROP)
+ })
+
+ it('rejects schemes built from several pieces', () => {
+ expect(run('{{ data.a }}{{ data.b }}', { a: 'java', b: 'script:alert(1)' }, 'href')).toBe(REJECTED_PROP)
+ expect(run('java{{ data.x }}script:alert(1)', {}, 'href')).toBe(REJECTED_PROP)
+ expect(run('java{{ data.x }}script:alert(1)', { x: '' }, 'href')).toBe(REJECTED_PROP)
+ })
+
+ it('does not emit a tab split scheme as javascript', () => {
+ const result = run('{{ data.a }}script:alert(1)', { a: 'java\t' }, 'href')
+ expect(result).toBe('java script:alert(1)')
+ expect(String(result).startsWith('javascript:')).toBe(false)
+ })
+
+ // The checks match path bindings such as `:href="data.x"`
+ it.each([
+ 'https://a/b',
+ '/p?x=1',
+ '#frag',
+ 'mailto:a@b.c',
+ 'tel:+1',
+ '//cdn.example/x',
+ '/a:b',
+ '?q=a:b',
+ 'data:image/png;base64,AAA',
+ ])('accepts %s for href', (value) => {
+ expect(run('{{ data.u }}', { u: value }, 'href')).toBe(value)
+ })
+
+ it('accepts dangerous looking text for a non-URL name', () => {
+ expect(run('{{ data.t }}', { t: 'JavaScript: The Good Parts' })).toBe('JavaScript: The Good Parts')
+ expect(run('{{ data.t }}', { t: 'javascript:alert(1)' }, 'title')).toBe('javascript:alert(1)')
+ })
+
+ it.each(['onclick', 'srcdoc', 'formaction', 'innerHTML', 'onlyfoo'])('rejects the unsafe name %s', (name) => {
+ expect(run('a{{ data.x }}', { x: 'safe' }, name)).toBe(REJECTED_PROP)
+ })
+
+ it('does not throw on malformed percent encoding', () => {
+ expect(run('/a/{{ data.x }}', { x: '100%' }, 'href')).toBe('/a/100%')
+ expect(run('{{ data.x }}', { x: '%E0%A4%A' }, 'href')).toBe('%E0%A4%A')
+ })
+})
diff --git a/packages/comark/test/plugins/binding.test.ts b/packages/comark/test/plugins/binding.test.ts
index 6e938994..e5a7d4e5 100644
--- a/packages/comark/test/plugins/binding.test.ts
+++ b/packages/comark/test/plugins/binding.test.ts
@@ -182,3 +182,69 @@ Intro text.
expect(html).not.toContain(' {
+ const bindOf = (node: unknown) => (node as [string, { $?: { bind?: 1 } }])[1].$?.bind
+
+ it('marks inline attributes, YAML props and raw HTML attributes', async () => {
+ const inline = await parseWithBinding('Go :a[x]{:href="/docs/{{ data.slug }}"}')
+ expect(bindOf((inline.nodes[0] as any[])[3])).toBe(1)
+ const yaml = await parseWithBinding('::card\n---\n:title: Version {{ data.v }}\n---\n::')
+ expect(bindOf(yaml.nodes[0])).toBe(1)
+ const html = await parseWithBinding('See x')
+ expect((html.nodes[0] as any[])[3][1].$).toEqual({ html: 1, block: 0, bind: 1 })
+ })
+
+ it('does not mark plain props, malformed templates or unknown roots', async () => {
+ for (const md of [
+ 'Go :a[x]{title="Hi {{ data.x }}"}',
+ 'Go :a[x]{:title="Hi {{ data.x"}',
+ 'Go :a[x]{:title="Hi {{ world }}"}',
+ 'Go :a[x]{:title="data.x"}',
+ ]) {
+ const doc = await parseWithBinding(md)
+ const link = (doc.nodes[0] as any[])[3]
+ expect(link[0], md).toBe('a')
+ expect(bindOf(link), md).toBeUndefined()
+ }
+ })
+
+ it('does not mark anything without the plugin', async () => {
+ const doc = await parseMarkdown('Go :a[x]{:href="/docs/{{ data.slug }}"}')
+ expect((doc.nodes[0] as any[])[3][0]).toBe('a')
+ expect(bindOf((doc.nodes[0] as any[])[3])).toBeUndefined()
+ })
+
+ it('marks nested components', async () => {
+ const depth = 40
+ const md =
+ Array.from({ length: depth }, (_, i) => `${':'.repeat(depth + 2 - i)}box`).join('\n') +
+ '\n:a[deep]{:href="/{{ data.x }}/y"}\n' +
+ Array.from({ length: depth }, (_, i) => ':'.repeat(i + 3)).join('\n')
+ const doc = await parseWithBinding(md)
+ let node: any = doc.nodes[0]
+ let levels = 0
+ while (Array.isArray(node) && node[0] === 'box') {
+ levels++
+ node = node.slice(2).find((child: unknown) => Array.isArray(child))
+ }
+ expect(levels).toBe(depth)
+ const link = node[0] === 'a' ? node : node.slice(2).find((c: any) => Array.isArray(c) && c[0] === 'a')
+ expect(bindOf(link)).toBe(1)
+ })
+
+ it('renders the template only with the plugin', async () => {
+ const md = ':a[Read]{:href="/docs/{{ data.slug }}/overview"}'
+ const data = { slug: 'intro' }
+ expect(await renderHtmlForTest(await parseWithBinding(md), { data })).toContain('href="/docs/intro/overview"')
+ expect(await renderHtmlForTest(await parseMarkdown(md), { data })).not.toContain('/docs/intro/overview')
+ })
+
+ it('round-trips the template source through renderMarkdown, without the marker', async () => {
+ const md = '::card{:title="Version {{ data.v }}" :link="/docs/{{ data.slug }}"}\n::'
+ const doc = await parseWithBinding(md)
+ expect((await renderMarkdown(doc)).trim()).toBe(md)
+ const link = await parseWithBinding('[Read](/x){:title="Hi {{ data.name }}"}')
+ expect((await renderMarkdown(link)).trim()).toBe('[Read](/x){:title="Hi {{ data.name }}"}')
+ })
+})
diff --git a/packages/comark/test/props-validation.test.ts b/packages/comark/test/props-validation.test.ts
index c33c140e..f3bdceff 100644
--- a/packages/comark/test/props-validation.test.ts
+++ b/packages/comark/test/props-validation.test.ts
@@ -541,6 +541,67 @@ describe('validateProp', () => {
})
})
})
+describe('validateProp — `{{ }}` URL templates', () => {
+ const allowlist = { allowedLinkPrefixes: ['https://myapp.com'] }
+
+ it.each([
+ '/docs/{{ data.slug }}',
+ './{{ data.slug }}',
+ '../{{ data.slug }}',
+ '?q={{ data.q }}',
+ '#{{ data.id }}',
+ 'docs/{{ data.slug }}',
+ 'https://myapp.com/{{ data.slug }}',
+ 'https://myapp.com/docs?q={{ data.q }}',
+ '{{ data.url }}',
+ '{{ data.base }}/{{ data.slug }}',
+ ])('keeps %s, whose literal prefix fixes the scheme and host', (value) => {
+ expect(validateProp(':href', value, allowlist)).toBe(value)
+ })
+
+ it.each([
+ ['javascri{{ frontmatter.foo }}', 'a scheme the data can finish'],
+ ['java{{ data.x }}', 'a relative prefix the data can turn into a scheme'],
+ ['/{{ data.host }}', 'a lone slash the data can turn into //host'],
+ ['https://myapp.com{{ data.host }}', 'a host the data can extend'],
+ ['//myapp.com{{ data.host }}', 'a scheme-relative host the data can extend'],
+ ['https://evil.com/{{ data.slug }}', 'a host outside the allowlist'],
+ ['javascript:{{ data.code }}', 'an unsafe scheme'],
+ ['https:/{{ data.path }}', 'a single slash browsers read as //host'],
+ ['https:{{ data.path }}', 'a scheme without a host'],
+ ['https:///{{ data.host }}', 'extra slashes before an empty host'],
+ ['HTTPS:\\\\{{ data.host }}', 'backslashes browsers read as //'],
+ ['///{{ data.host }}', 'a scheme-relative URL with an empty host'],
+ ])('rejects %s (%s)', (value) => {
+ expect(validateProp(':href', value, allowlist)).toBe(REJECTED_PROP)
+ })
+
+ it('keeps a scheme-relative prefix with a complete, allowed host', () => {
+ expect(validateProp(':src', '//cdn.example.com/{{ data.file }}')).toBe('//cdn.example.com/{{ data.file }}')
+ expect(validateProp(':href', 'https:/myapp.com/{{ data.slug }}', allowlist)).toBe(
+ 'https:/myapp.com/{{ data.slug }}'
+ )
+ })
+
+ it('applies to every URL prop, but not to text props', () => {
+ expect(validateProp(':to', 'javascri{{ frontmatter.foo }}')).toBe(REJECTED_PROP)
+ expect(validateProp(':link', '/{{ data.host }}')).toBe(REJECTED_PROP)
+ expect(validateProp(':title', 'java{{ data.x }}')).toBe('java{{ data.x }}')
+ })
+
+ it('applies allowedProtocols to the literal scheme', () => {
+ expect(validateProp(':href', 'mailto:{{ data.email }}', { allowedProtocols: ['https'] })).toBe(REJECTED_PROP)
+ expect(validateProp(':href', 'mailto:{{ data.email }}', { allowedProtocols: ['mailto'] })).toBe(
+ 'mailto:{{ data.email }}'
+ )
+ })
+
+ it('leaves a malformed template and a JSON string to the usual checks', () => {
+ expect(validateProp(':href', '/{{ data.host')).toBe('/{{ data.host')
+ expect(validateProp(':href', '"/{{ data.host }}"')).toBe('"/{{ data.host }}"')
+ })
+})
+
// ─── validateProps ────────────────────────────────────────────────────────────
describe('validateProps', () => {
diff --git a/packages/comark/test/resolve-attributes.test.ts b/packages/comark/test/resolve-attributes.test.ts
index e234dd12..b87162a2 100644
--- a/packages/comark/test/resolve-attributes.test.ts
+++ b/packages/comark/test/resolve-attributes.test.ts
@@ -287,3 +287,200 @@ describe('resolveAttribute (single-attr lookup)', () => {
expect(value).toBe(config)
})
})
+
+/** Attributes as the `binding()` plugin marks them, so their templates are interpolated. */
+const bound = (attrs: Record): Record => ({ ...attrs, $: { bind: 1 } })
+
+describe('resolveAttributes (interpolation)', () => {
+ const renderData = makeRenderData({
+ frontmatter: { slug: 'getting-started', n: 7, foo: "pt: alert('xss')" },
+ data: {
+ x: 'X',
+ secret: 'S',
+ a: 'java',
+ b: 'script:alert(1)',
+ bad: 'javascript:alert(1)',
+ flag: true,
+ host: '/evil.com',
+ },
+ props: { title: 'Ada' },
+ })
+ const modes = [
+ ['default', {}],
+ ['parseJson', { parseJson: true }],
+ ] as const
+
+ describe.each(modes)('%s mode', (_name, options) => {
+ it('resolves a mixed template and drops the : prefix', () => {
+ const result = resolveAttributes(bound({ ':href': '/docs/{{ frontmatter.slug }}/overview' }), renderData, options)
+ expect(result).toEqual({ href: '/docs/getting-started/overview' })
+ })
+
+ it('inserts numbers and booleans as text', () => {
+ const result = resolveAttributes(
+ bound({ ':title': 'Item {{ frontmatter.n }} {{ data.flag }}' }),
+ renderData,
+ options
+ )
+ expect(result).toEqual({ title: 'Item 7 true' })
+ })
+
+ it('uses the default when the path is missing', () => {
+ const result = resolveAttributes(bound({ ':title': 'Hi {{ data.nope || friend }}' }), renderData, options)
+ expect(result).toEqual({ title: 'Hi friend' })
+ })
+
+ it('omits the prop when a sole placeholder has nothing available', () => {
+ const result = resolveAttributes(bound({ ':title': '{{ data.nope }}', id: 'keep' }), renderData, options)
+ expect(result).toEqual({ id: 'keep' })
+ expect(result).not.toHaveProperty('title')
+ expect(result).not.toHaveProperty(':title')
+ })
+
+ it('resolves the props scope', () => {
+ const result = resolveAttributes(bound({ ':label': 'Hi {{ props.title }}' }), renderData, options)
+ expect(result).toEqual({ label: 'Hi Ada' })
+ })
+
+ it('leaves a non-: prop with a placeholder literal', () => {
+ const result = resolveAttributes(bound({ title: 'Hi {{ data.x }}' }), renderData, options)
+ expect(result).toEqual({ title: 'Hi {{ data.x }}' })
+ })
+
+ it('omits :href, :to and :src when the result has an unsafe scheme', () => {
+ const result = resolveAttributes(
+ bound({ ':href': '{{ data.bad }}', ':to': '{{ data.bad }}', ':src': '{{ data.bad }}', id: 'keep' }),
+ renderData,
+ options
+ )
+ expect(result).toEqual({ id: 'keep' })
+ })
+
+ it('omits a URL whose scheme is split across placeholders', () => {
+ const result = resolveAttributes(bound({ ':href': '{{ data.a }}{{ data.b }}' }), renderData, options)
+ expect(result).toEqual({})
+ })
+
+ it('omits a URL whose scheme is split between literal text and data', () => {
+ const result = resolveAttributes(
+ bound({ ':to': 'javascri{{ frontmatter.foo }}', ':link': 'java{{ data.b }}', id: 'keep' }),
+ renderData,
+ options
+ )
+ expect(result).toEqual({ id: 'keep' })
+ })
+
+ it('omits a URL where literal text and data join into another host', () => {
+ expect(resolveAttributes(bound({ ':href': '/{{ data.host }}' }), renderData, options)).toEqual({})
+ expect(resolveAttributes(bound({ ':href': '///{{ frontmatter.slug }}' }), renderData, options)).toEqual({})
+ expect(resolveAttributes(bound({ ':href': ' /{{ data.host }}' }), renderData, options)).toEqual({})
+ expect(
+ resolveAttributes(bound({ ':src': '//cdn.example.com/{{ frontmatter.slug }}' }), renderData, options)
+ ).toEqual({
+ src: '//cdn.example.com/getting-started',
+ })
+ expect(resolveAttributes(bound({ ':href': '/docs/{{ data.host }}' }), renderData, options)).toEqual({
+ href: '/docs//evil.com',
+ })
+ })
+
+ it('keeps a non-URL prop whose text looks like a scheme', () => {
+ const result = resolveAttributes(bound({ ':title': 'JavaScript: {{ data.x }}' }), renderData, options)
+ expect(result).toEqual({ title: 'JavaScript: X' })
+ })
+
+ it('omits event handler props', () => {
+ const result = resolveAttributes(bound({ ':onclick': '{{ data.x }}', id: 'keep' }), renderData, options)
+ expect(result).toEqual({ id: 'keep' })
+ })
+
+ it('leaves a nested object value untouched', () => {
+ const result = resolveAttributes(bound({ ':config': { k: '{{ data.x }}' } }), renderData, options)
+ expect(Object.values(result)).toEqual([{ k: '{{ data.x }}' }])
+ })
+
+ it('is stable when resolved twice and never expands inserted text', () => {
+ const data = makeRenderData({ data: { x: '{{ data.secret }}', secret: 'S' } })
+ const once = resolveAttributes(bound({ ':title': 'A {{ data.x }}' }), data, options)
+ expect(once).toEqual({ title: 'A {{ data.secret }}' })
+ expect(resolveAttributes(bound(once), data, options)).toEqual(once)
+ })
+ })
+
+ it('does not interpolate without the binding() marker (default mode)', () => {
+ const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }}' }, renderData)
+ expect(result).toEqual({ ':title': 'Item {{ frontmatter.n }}' })
+ })
+
+ it('does not interpolate without the binding() marker (parseJson mode)', () => {
+ const result = resolveAttributes({ ':title': 'Item {{ frontmatter.n }}' }, renderData, { parseJson: true })
+ expect(result).toHaveProperty('title', undefined)
+ })
+
+ it('keeps today behavior for a malformed template (default mode)', () => {
+ const result = resolveAttributes(bound({ ':title': 'Hi {{ data.x' }), renderData)
+ expect(result).toEqual({ ':title': 'Hi {{ data.x' })
+ })
+
+ it('keeps today behavior for a malformed template (parseJson mode)', () => {
+ const result = resolveAttributes(bound({ ':title': 'Hi {{ data.x' }), renderData, { parseJson: true })
+ expect(result).toHaveProperty('title', undefined)
+ })
+
+ it('keeps today behavior for an unknown root (default mode)', () => {
+ const result = resolveAttributes(bound({ ':title': 'Hi {{ item.name }}' }), renderData)
+ expect(result).toEqual({ ':title': 'Hi {{ item.name }}' })
+ })
+
+ it('keeps today behavior for an unknown root (parseJson mode)', () => {
+ const result = resolveAttributes(bound({ ':title': 'Hi {{ item.name }}' }), renderData, { parseJson: true })
+ expect(result).toHaveProperty('title', undefined)
+ })
+
+ it('keeps today behavior for a JSON string literal', () => {
+ const value = '"{{ data.x }}"'
+ expect(resolveAttributes(bound({ ':title': value }), renderData)).toEqual({ ':title': value })
+ expect(resolveAttributes(bound({ ':title': value }), renderData, { parseJson: true })).toEqual({
+ title: '{{ data.x }}',
+ })
+ })
+
+ it.each([':style', ':key', ':is', ':as'])('keeps today behavior for reserved prop %s', (key) => {
+ const value = 'a {{ data.x }}'
+ expect(resolveAttributes(bound({ [key]: value }), renderData)).toEqual({ [key]: value })
+ expect(resolveAttributes(bound({ [key]: value }), renderData, { parseJson: true })).toHaveProperty(
+ key.slice(1),
+ undefined
+ )
+ })
+})
+
+describe('resolveAttribute (interpolation)', () => {
+ const renderData = makeRenderData({ frontmatter: { slug: 'docs' }, data: { bad: 'javascript:alert(1)' } })
+
+ it('returns the interpolated string', () => {
+ expect(resolveAttribute(bound({ ':href': '/{{ frontmatter.slug }}/x' }), renderData, 'href')).toBe('/docs/x')
+ })
+
+ it('returns the literal attribute when the template is rejected, as resolveAttributes keeps it', () => {
+ expect(resolveAttribute(bound({ ':href': '{{ data.bad }}' }), renderData, 'href')).toBeUndefined()
+ expect(resolveAttribute(bound({ ':href': '{{ data.bad }}', href: '/safe' }), renderData, 'href')).toBe('/safe')
+ expect(resolveAttributes(bound({ ':href': '{{ data.bad }}', href: '/safe' }), renderData)).toEqual({
+ href: '/safe',
+ })
+ })
+
+ it('returns the literal attribute when a sole placeholder is unresolved', () => {
+ expect(resolveAttribute(bound({ ':href': '{{ data.nope }}' }), renderData, 'href')).toBeUndefined()
+ })
+
+ it('keeps today behavior for a malformed template', () => {
+ expect(resolveAttribute(bound({ ':href': '/x/{{ data.y' }), renderData, 'href')).toBe('/x/{{ data.y')
+ })
+
+ it('does not interpolate without the binding() marker', () => {
+ expect(resolveAttribute({ ':href': '/{{ frontmatter.slug }}/x' }, renderData, 'href')).toBe(
+ '/{{ frontmatter.slug }}/x'
+ )
+ })
+})