safe-mdx 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -8
- package/dist/dynamic-esm-component.d.ts +1 -1
- package/dist/dynamic-esm-component.d.ts.map +1 -1
- package/dist/dynamic-esm-component.js +9 -1
- package/dist/dynamic-esm-component.js.map +1 -1
- package/dist/esm-parser.d.ts +1 -1
- package/dist/esm-parser.d.ts.map +1 -1
- package/dist/esm-parser.js +3 -3
- package/dist/esm-parser.js.map +1 -1
- package/dist/esm-parser.test.js +2 -2
- package/dist/html/html-and-md.test.js.map +1 -1
- package/dist/html/html-to-mdx-ast.d.ts +1 -1
- package/dist/html/html-to-mdx-ast.js +4 -4
- package/dist/html/html-to-mdx-ast.js.map +1 -1
- package/dist/html/html-to-mdx-ast.test.js +3 -3
- package/dist/html/html-to-mdx-ast.test.js.map +1 -1
- package/dist/parse.d.ts +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +5 -1
- package/dist/parse.js.map +1 -1
- package/dist/safe-mdx.bench.js +2 -2
- package/dist/safe-mdx.bench.js.map +1 -1
- package/dist/safe-mdx.d.ts +48 -3
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +219 -26
- package/dist/safe-mdx.js.map +1 -1
- package/dist/safe-mdx.test.js +420 -5
- package/dist/safe-mdx.test.js.map +1 -1
- package/dist/streaming.d.ts.map +1 -1
- package/dist/streaming.js +3 -1
- package/dist/streaming.js.map +1 -1
- package/package.json +30 -7
- package/src/esm-parser.test.ts +3 -3
- package/src/esm-parser.ts +4 -4
- package/src/html/html-and-md.test.ts +2 -2
- package/src/html/html-to-mdx-ast.test.ts +3 -3
- package/src/html/html-to-mdx-ast.ts +4 -4
- package/src/parse.ts +3 -1
- package/src/safe-mdx.bench.tsx +2 -2
- package/src/safe-mdx.test.tsx +519 -11
- package/src/safe-mdx.tsx +315 -28
- package/src/streaming.tsx +2 -1
package/src/safe-mdx.test.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import dedent from 'dedent'
|
|
2
|
+
import { generate } from 'escodegen'
|
|
2
3
|
import React from 'react'
|
|
3
4
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
4
5
|
import { expect, test } from 'vitest'
|
|
5
6
|
import { z } from 'zod'
|
|
6
|
-
import { mdxParse } from './parse.
|
|
7
|
-
import { MdastToJsx, mdastBfs, type ComponentPropsSchema } from './safe-mdx.
|
|
8
|
-
import { completeJsxTags } from './streaming.
|
|
7
|
+
import { mdxParse } from './parse.ts'
|
|
8
|
+
import { MdastToJsx, mdastBfs, type ComponentPropsSchema, type EvaluateOptions } from './safe-mdx.tsx'
|
|
9
|
+
import { completeJsxTags } from './streaming.tsx'
|
|
9
10
|
|
|
10
11
|
const components = {
|
|
11
12
|
Heading({ level, children, ...props }) {
|
|
@@ -19,9 +20,9 @@ const components = {
|
|
|
19
20
|
},
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
function render(code, componentPropsSchema?: ComponentPropsSchema, allowClientEsmImports?: boolean, addMarkdownLineNumbers?: boolean) {
|
|
23
|
+
function render(code, componentPropsSchema?: ComponentPropsSchema, allowClientEsmImports?: boolean, addMarkdownLineNumbers?: boolean, scope?: Record<string, any>, evaluateOptions?: EvaluateOptions) {
|
|
23
24
|
const mdast = mdxParse(code)
|
|
24
|
-
const visitor = new MdastToJsx({ markdown: code, mdast, components, componentPropsSchema, allowClientEsmImports, addMarkdownLineNumbers })
|
|
25
|
+
const visitor = new MdastToJsx({ markdown: code, mdast, components, componentPropsSchema, allowClientEsmImports, addMarkdownLineNumbers, scope, evaluateOptions })
|
|
25
26
|
const result = visitor.run()
|
|
26
27
|
const html = renderToStaticMarkup(result)
|
|
27
28
|
// console.log(JSON.stringify(result, null, 2))
|
|
@@ -531,6 +532,7 @@ test('props parsing', () => {
|
|
|
531
532
|
{
|
|
532
533
|
"line": 9,
|
|
533
534
|
"message": "Unsupported jsx component SomeComponent in attribute",
|
|
535
|
+
"type": "missing-component",
|
|
534
536
|
},
|
|
535
537
|
{
|
|
536
538
|
"line": 9,
|
|
@@ -2297,8 +2299,8 @@ test('mdx jsx with unknown components are ignored', () => {
|
|
|
2297
2299
|
|
|
2298
2300
|
// Check that errors were generated for unknown components
|
|
2299
2301
|
expect(errors).toHaveLength(2)
|
|
2300
|
-
expect(errors[0]
|
|
2301
|
-
expect(errors[1]
|
|
2302
|
+
expect(errors[0]!.message).toContain('Unsupported jsx component CustomElement')
|
|
2303
|
+
expect(errors[1]!.message).toContain('Unsupported jsx component AnotherUnknown')
|
|
2302
2304
|
|
|
2303
2305
|
expect(result).toMatchInlineSnapshot(`
|
|
2304
2306
|
<React.Fragment>
|
|
@@ -3053,12 +3055,12 @@ test('ESM imports error handling', () => {
|
|
|
3053
3055
|
expect(visitor.errors.length).toBe(4)
|
|
3054
3056
|
|
|
3055
3057
|
// First two errors are for invalid imports
|
|
3056
|
-
expect(visitor.errors[0]
|
|
3057
|
-
expect(visitor.errors[1]
|
|
3058
|
+
expect(visitor.errors[0]!.message).toContain('Invalid import URL')
|
|
3059
|
+
expect(visitor.errors[1]!.message).toContain('Invalid import URL')
|
|
3058
3060
|
|
|
3059
3061
|
// Last two errors are for unsupported components
|
|
3060
|
-
expect(visitor.errors[2]
|
|
3061
|
-
expect(visitor.errors[3]
|
|
3062
|
+
expect(visitor.errors[2]!.message).toContain('Unsupported jsx component Button')
|
|
3063
|
+
expect(visitor.errors[3]!.message).toContain('Unsupported jsx component Component')
|
|
3062
3064
|
})
|
|
3063
3065
|
|
|
3064
3066
|
test('jsx components in attributes', () => {
|
|
@@ -3216,6 +3218,7 @@ test("jsx components in attributes error handling", () => {
|
|
|
3216
3218
|
{
|
|
3217
3219
|
"line": 3,
|
|
3218
3220
|
"message": "Unsupported jsx component UnsupportedComponent in attribute",
|
|
3221
|
+
"type": "missing-component",
|
|
3219
3222
|
},
|
|
3220
3223
|
{
|
|
3221
3224
|
"line": 3,
|
|
@@ -3689,3 +3692,508 @@ test('modules prop: unresolved import produces error', () => {
|
|
|
3689
3692
|
]
|
|
3690
3693
|
`)
|
|
3691
3694
|
})
|
|
3695
|
+
|
|
3696
|
+
test('modules prop: rendering a subset of mdast with import nodes prepended resolves components', () => {
|
|
3697
|
+
// Simulates the pattern used by holocron: the full mdast is split into
|
|
3698
|
+
// sections, and import nodes (mdxjsEsm) are prepended to each section's
|
|
3699
|
+
// nodes so SafeMdxRenderer can resolve imported components.
|
|
3700
|
+
function CustomHero({ title }: { title: string }) {
|
|
3701
|
+
return <div data-testid="hero">{title}</div>
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
const code = dedent`
|
|
3705
|
+
import { CustomHero } from './components/hero'
|
|
3706
|
+
|
|
3707
|
+
# Main content
|
|
3708
|
+
|
|
3709
|
+
<CustomHero title="Hello" />
|
|
3710
|
+
`
|
|
3711
|
+
const mdast = mdxParse(code)
|
|
3712
|
+
|
|
3713
|
+
// Split: extract import nodes and content nodes separately
|
|
3714
|
+
const importNodes = mdast.children.filter((node) => node.type === 'mdxjsEsm')
|
|
3715
|
+
const contentNodes = mdast.children.filter((node) => node.type !== 'mdxjsEsm')
|
|
3716
|
+
|
|
3717
|
+
// Render only content nodes WITH import nodes prepended
|
|
3718
|
+
const syntheticRoot = { type: 'root' as const, children: [...importNodes, ...contentNodes] }
|
|
3719
|
+
const visitor = new MdastToJsx({
|
|
3720
|
+
markdown: code,
|
|
3721
|
+
mdast: syntheticRoot,
|
|
3722
|
+
components,
|
|
3723
|
+
modules: {
|
|
3724
|
+
'./components/hero.tsx': { CustomHero },
|
|
3725
|
+
},
|
|
3726
|
+
baseUrl: './',
|
|
3727
|
+
})
|
|
3728
|
+
const result = visitor.run()
|
|
3729
|
+
const html = renderToStaticMarkup(result)
|
|
3730
|
+
expect(html).toContain('data-testid="hero"')
|
|
3731
|
+
expect(html).toContain('Hello')
|
|
3732
|
+
expect(visitor.errors).toMatchInlineSnapshot(`[]`)
|
|
3733
|
+
})
|
|
3734
|
+
|
|
3735
|
+
test('modules prop: rendering subset WITHOUT import nodes fails to resolve components', () => {
|
|
3736
|
+
// Demonstrates the bug: if import nodes are NOT prepended, the component
|
|
3737
|
+
// is not found and a missing-component error is produced.
|
|
3738
|
+
function CustomHero({ title }: { title: string }) {
|
|
3739
|
+
return <div data-testid="hero">{title}</div>
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
const code = dedent`
|
|
3743
|
+
import { CustomHero } from './components/hero'
|
|
3744
|
+
|
|
3745
|
+
<CustomHero title="Hello" />
|
|
3746
|
+
`
|
|
3747
|
+
const mdast = mdxParse(code)
|
|
3748
|
+
|
|
3749
|
+
// Only render the JSX node WITHOUT the import node
|
|
3750
|
+
const contentNodes = mdast.children.filter((node) => node.type !== 'mdxjsEsm')
|
|
3751
|
+
const syntheticRoot = { type: 'root' as const, children: contentNodes }
|
|
3752
|
+
const visitor = new MdastToJsx({
|
|
3753
|
+
markdown: code,
|
|
3754
|
+
mdast: syntheticRoot,
|
|
3755
|
+
components,
|
|
3756
|
+
modules: {
|
|
3757
|
+
'./components/hero.tsx': { CustomHero },
|
|
3758
|
+
},
|
|
3759
|
+
baseUrl: './',
|
|
3760
|
+
})
|
|
3761
|
+
const result = visitor.run()
|
|
3762
|
+
const html = renderToStaticMarkup(result)
|
|
3763
|
+
// Without import nodes, the component is not resolved
|
|
3764
|
+
expect(html).not.toContain('data-testid="hero"')
|
|
3765
|
+
expect(visitor.errors).toMatchInlineSnapshot(`
|
|
3766
|
+
[
|
|
3767
|
+
{
|
|
3768
|
+
"line": 3,
|
|
3769
|
+
"message": "Unsupported jsx component CustomHero",
|
|
3770
|
+
"type": "missing-component",
|
|
3771
|
+
},
|
|
3772
|
+
]
|
|
3773
|
+
`)
|
|
3774
|
+
})
|
|
3775
|
+
|
|
3776
|
+
test('scope with function in jsx prop receiving object arg', () => {
|
|
3777
|
+
const scope = {
|
|
3778
|
+
formatTitle: (opts: { text: string; uppercase?: boolean }) => {
|
|
3779
|
+
return opts.uppercase ? opts.text.toUpperCase() : opts.text
|
|
3780
|
+
},
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
const code = dedent`
|
|
3784
|
+
<Heading level={1} title={formatTitle({ text: "hello world", uppercase: true })}>Content</Heading>
|
|
3785
|
+
`
|
|
3786
|
+
|
|
3787
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3788
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3789
|
+
expect(html).toMatchInlineSnapshot(`"<h1 title="HELLO WORLD">Content</h1>"`)
|
|
3790
|
+
})
|
|
3791
|
+
|
|
3792
|
+
test('scope with variables and functions in inline expressions', () => {
|
|
3793
|
+
const scope = {
|
|
3794
|
+
greeting: 'Hello',
|
|
3795
|
+
getName: (user: { first: string; last: string }) => `${user.first} ${user.last}`,
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
const code = dedent`
|
|
3799
|
+
{greeting} {getName({ first: "John", last: "Doe" })}
|
|
3800
|
+
`
|
|
3801
|
+
|
|
3802
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3803
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3804
|
+
expect(html).toMatchInlineSnapshot(`"HelloJohn Doe"`)
|
|
3805
|
+
})
|
|
3806
|
+
|
|
3807
|
+
test('scope with function in spread attribute', () => {
|
|
3808
|
+
const scope = {
|
|
3809
|
+
getProps: (config: { level: number }) => ({ level: config.level }),
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
const code = dedent`
|
|
3813
|
+
<Heading {...getProps({ level: 2 })}>Spread test</Heading>
|
|
3814
|
+
`
|
|
3815
|
+
|
|
3816
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3817
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3818
|
+
expect(html).toMatchInlineSnapshot(`"<h1>Spread test</h1>"`)
|
|
3819
|
+
})
|
|
3820
|
+
|
|
3821
|
+
test('scope with .map and arrow function callback works without generate (safe interpreter)', () => {
|
|
3822
|
+
const scope = {
|
|
3823
|
+
items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }],
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
const code = dedent`
|
|
3827
|
+
{items.map(item => item.name).join(", ")}
|
|
3828
|
+
`
|
|
3829
|
+
|
|
3830
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3831
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3832
|
+
expect(html).toMatchInlineSnapshot(`"Alice, Bob, Charlie"`)
|
|
3833
|
+
})
|
|
3834
|
+
|
|
3835
|
+
test('scope with .map and arrow function callback works with generate', () => {
|
|
3836
|
+
const scope = {
|
|
3837
|
+
items: [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }],
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
const code = dedent`
|
|
3841
|
+
{items.map(item => item.name).join(", ")}
|
|
3842
|
+
`
|
|
3843
|
+
|
|
3844
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope, { generate })
|
|
3845
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3846
|
+
expect(html).toMatchInlineSnapshot(`"Alice, Bob, Charlie"`)
|
|
3847
|
+
})
|
|
3848
|
+
|
|
3849
|
+
test('safe interpreter: arrow with block body and return', () => {
|
|
3850
|
+
const scope = {
|
|
3851
|
+
items: [1, 2, 3],
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
const code = dedent`
|
|
3855
|
+
{items.map(x => { return x * 2 }).join(", ")}
|
|
3856
|
+
`
|
|
3857
|
+
|
|
3858
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3859
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3860
|
+
expect(html).toMatchInlineSnapshot(`"2, 4, 6"`)
|
|
3861
|
+
})
|
|
3862
|
+
|
|
3863
|
+
test('safe interpreter: arrow with multiple params', () => {
|
|
3864
|
+
const scope = {
|
|
3865
|
+
items: ['a', 'b', 'c'],
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
const code = dedent`
|
|
3869
|
+
{items.map((item, i) => i + ":" + item).join(", ")}
|
|
3870
|
+
`
|
|
3871
|
+
|
|
3872
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3873
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3874
|
+
expect(html).toMatchInlineSnapshot(`"0:a, 1:b, 2:c"`)
|
|
3875
|
+
})
|
|
3876
|
+
|
|
3877
|
+
test('safe interpreter: arrow with object destructuring', () => {
|
|
3878
|
+
const scope = {
|
|
3879
|
+
items: [
|
|
3880
|
+
{ name: 'Alice', age: 30 },
|
|
3881
|
+
{ name: 'Bob', age: 25 },
|
|
3882
|
+
],
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
const code = dedent`
|
|
3886
|
+
{items.map(({ name, age }) => name + "(" + age + ")").join(", ")}
|
|
3887
|
+
`
|
|
3888
|
+
|
|
3889
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3890
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3891
|
+
expect(html).toMatchInlineSnapshot(`"Alice(30), Bob(25)"`)
|
|
3892
|
+
})
|
|
3893
|
+
|
|
3894
|
+
test('safe interpreter: arrow with ternary expression', () => {
|
|
3895
|
+
const scope = {
|
|
3896
|
+
items: [
|
|
3897
|
+
{ name: 'Alice', active: true },
|
|
3898
|
+
{ name: 'Bob', active: false },
|
|
3899
|
+
],
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
const code = dedent`
|
|
3903
|
+
{items.map(item => item.active ? item.name : "inactive").join(", ")}
|
|
3904
|
+
`
|
|
3905
|
+
|
|
3906
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3907
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3908
|
+
expect(html).toMatchInlineSnapshot(`"Alice, inactive"`)
|
|
3909
|
+
})
|
|
3910
|
+
|
|
3911
|
+
test('safe interpreter: .filter with arrow function', () => {
|
|
3912
|
+
const scope = {
|
|
3913
|
+
items: [1, 2, 3, 4, 5, 6],
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3916
|
+
const code = dedent`
|
|
3917
|
+
{items.filter(x => x > 3).join(", ")}
|
|
3918
|
+
`
|
|
3919
|
+
|
|
3920
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3921
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3922
|
+
expect(html).toMatchInlineSnapshot(`"4, 5, 6"`)
|
|
3923
|
+
})
|
|
3924
|
+
|
|
3925
|
+
test('safe interpreter: .reduce with arrow function', () => {
|
|
3926
|
+
const scope = {
|
|
3927
|
+
items: [1, 2, 3, 4],
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
const code = dedent`
|
|
3931
|
+
{items.reduce((acc, x) => acc + x, 0)}
|
|
3932
|
+
`
|
|
3933
|
+
|
|
3934
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3935
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3936
|
+
expect(html).toMatchInlineSnapshot(`"10"`)
|
|
3937
|
+
})
|
|
3938
|
+
|
|
3939
|
+
test('safe interpreter: chained .filter.map', () => {
|
|
3940
|
+
const scope = {
|
|
3941
|
+
users: [
|
|
3942
|
+
{ name: 'Alice', role: 'admin' },
|
|
3943
|
+
{ name: 'Bob', role: 'user' },
|
|
3944
|
+
{ name: 'Charlie', role: 'admin' },
|
|
3945
|
+
],
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
const code = dedent`
|
|
3949
|
+
{users.filter(u => u.role === "admin").map(u => u.name).join(", ")}
|
|
3950
|
+
`
|
|
3951
|
+
|
|
3952
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3953
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3954
|
+
expect(html).toMatchInlineSnapshot(`"Alice, Charlie"`)
|
|
3955
|
+
})
|
|
3956
|
+
|
|
3957
|
+
test('safe interpreter: .find with arrow function', () => {
|
|
3958
|
+
const scope = {
|
|
3959
|
+
items: [
|
|
3960
|
+
{ id: 1, name: 'Alice' },
|
|
3961
|
+
{ id: 2, name: 'Bob' },
|
|
3962
|
+
],
|
|
3963
|
+
}
|
|
3964
|
+
|
|
3965
|
+
const code = dedent`
|
|
3966
|
+
{items.find(item => item.id === 2).name}
|
|
3967
|
+
`
|
|
3968
|
+
|
|
3969
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3970
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3971
|
+
expect(html).toMatchInlineSnapshot(`"Bob"`)
|
|
3972
|
+
})
|
|
3973
|
+
|
|
3974
|
+
test('safe interpreter: .some and .every with arrow functions', () => {
|
|
3975
|
+
const scope = {
|
|
3976
|
+
nums: [2, 4, 6],
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
const code = dedent`
|
|
3980
|
+
{nums.every(n => n > 0) ? "all positive" : "nope"}
|
|
3981
|
+
`
|
|
3982
|
+
|
|
3983
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3984
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3985
|
+
expect(html).toMatchInlineSnapshot(`"all positive"`)
|
|
3986
|
+
})
|
|
3987
|
+
|
|
3988
|
+
test('safe interpreter: nested arrow functions', () => {
|
|
3989
|
+
const scope = {
|
|
3990
|
+
matrix: [[1, 2], [3, 4], [5, 6]],
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
const code = dedent`
|
|
3994
|
+
{matrix.map(row => row.map(x => x * 10).join("-")).join(", ")}
|
|
3995
|
+
`
|
|
3996
|
+
|
|
3997
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
3998
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
3999
|
+
expect(html).toMatchInlineSnapshot(`"10-20, 30-40, 50-60"`)
|
|
4000
|
+
})
|
|
4001
|
+
|
|
4002
|
+
test('safe interpreter: arrow accessing outer scope variables', () => {
|
|
4003
|
+
const scope = {
|
|
4004
|
+
items: [1, 2, 3],
|
|
4005
|
+
multiplier: 5,
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
const code = dedent`
|
|
4009
|
+
{items.map(x => x * multiplier).join(", ")}
|
|
4010
|
+
`
|
|
4011
|
+
|
|
4012
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4013
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4014
|
+
expect(html).toMatchInlineSnapshot(`"5, 10, 15"`)
|
|
4015
|
+
})
|
|
4016
|
+
|
|
4017
|
+
test('safe interpreter: arrow with block body and variable declaration', () => {
|
|
4018
|
+
const scope = {
|
|
4019
|
+
items: [{ first: 'John', last: 'Doe' }, { first: 'Jane', last: 'Smith' }],
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
const code = dedent`
|
|
4023
|
+
{items.map(item => { const full = item.first + " " + item.last; return full }).join(", ")}
|
|
4024
|
+
`
|
|
4025
|
+
|
|
4026
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4027
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4028
|
+
expect(html).toMatchInlineSnapshot(`"John Doe, Jane Smith"`)
|
|
4029
|
+
})
|
|
4030
|
+
|
|
4031
|
+
test('safe interpreter: arrow with if/else in block body', () => {
|
|
4032
|
+
const scope = {
|
|
4033
|
+
items: [1, 2, 3, 4, 5],
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
const code = dedent`
|
|
4037
|
+
{items.map(x => { if (x > 3) { return "big" } else { return "small" } }).join(", ")}
|
|
4038
|
+
`
|
|
4039
|
+
|
|
4040
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4041
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4042
|
+
expect(html).toMatchInlineSnapshot(`"small, small, small, big, big"`)
|
|
4043
|
+
})
|
|
4044
|
+
|
|
4045
|
+
test('safe interpreter: .sort with comparator arrow', () => {
|
|
4046
|
+
const scope = {
|
|
4047
|
+
items: [3, 1, 4, 1, 5],
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
const code = dedent`
|
|
4051
|
+
{items.sort((a, b) => a - b).join(", ")}
|
|
4052
|
+
`
|
|
4053
|
+
|
|
4054
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4055
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4056
|
+
expect(html).toMatchInlineSnapshot(`"1, 1, 3, 4, 5"`)
|
|
4057
|
+
})
|
|
4058
|
+
|
|
4059
|
+
test('safe interpreter: arrow in JSX attribute', () => {
|
|
4060
|
+
const scope = {
|
|
4061
|
+
items: [{ name: 'Alice' }, { name: 'Bob' }],
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
const code = dedent`
|
|
4065
|
+
<Heading level={items.map(i => i.name).join(", ")}>Title</Heading>
|
|
4066
|
+
`
|
|
4067
|
+
|
|
4068
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4069
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4070
|
+
expect(html).toMatchInlineSnapshot(`"<h1>Title</h1>"`)
|
|
4071
|
+
})
|
|
4072
|
+
|
|
4073
|
+
test('safe interpreter: arrow with array destructuring', () => {
|
|
4074
|
+
const scope = {
|
|
4075
|
+
pairs: [['a', 1], ['b', 2], ['c', 3]],
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
const code = dedent`
|
|
4079
|
+
{pairs.map(([letter, num]) => letter + num).join(", ")}
|
|
4080
|
+
`
|
|
4081
|
+
|
|
4082
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4083
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4084
|
+
expect(html).toMatchInlineSnapshot(`"a1, b2, c3"`)
|
|
4085
|
+
})
|
|
4086
|
+
|
|
4087
|
+
test('safe interpreter: calling scope functions inside arrow callback', () => {
|
|
4088
|
+
const scope = {
|
|
4089
|
+
items: [{ name: 'alice' }, { name: 'bob' }],
|
|
4090
|
+
formatName: (s: string) => s.toUpperCase(),
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
const code = dedent`
|
|
4094
|
+
{items.map(item => formatName(item.name)).join(", ")}
|
|
4095
|
+
`
|
|
4096
|
+
|
|
4097
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4098
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4099
|
+
expect(html).toMatchInlineSnapshot(`"ALICE, BOB"`)
|
|
4100
|
+
})
|
|
4101
|
+
|
|
4102
|
+
test('safe interpreter: calling scope function with multiple args inside arrow', () => {
|
|
4103
|
+
const scope = {
|
|
4104
|
+
items: [1, 2, 3],
|
|
4105
|
+
add: (a: number, b: number) => a + b,
|
|
4106
|
+
base: 10,
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
const code = dedent`
|
|
4110
|
+
{items.map(x => add(x, base)).join(", ")}
|
|
4111
|
+
`
|
|
4112
|
+
|
|
4113
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4114
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4115
|
+
expect(html).toMatchInlineSnapshot(`"11, 12, 13"`)
|
|
4116
|
+
})
|
|
4117
|
+
|
|
4118
|
+
test('safe interpreter: scope function returning object used in arrow', () => {
|
|
4119
|
+
const scope = {
|
|
4120
|
+
ids: [1, 2, 3],
|
|
4121
|
+
getUser: (id: number) => ({ id, name: 'User' + id }),
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
const code = dedent`
|
|
4125
|
+
{ids.map(id => getUser(id).name).join(", ")}
|
|
4126
|
+
`
|
|
4127
|
+
|
|
4128
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4129
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4130
|
+
expect(html).toMatchInlineSnapshot(`"User1, User2, User3"`)
|
|
4131
|
+
})
|
|
4132
|
+
|
|
4133
|
+
test('safe interpreter: arrow with default parameter', () => {
|
|
4134
|
+
const scope = {
|
|
4135
|
+
items: [undefined, 'hello', undefined],
|
|
4136
|
+
fallback: 'default',
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
const code = dedent`
|
|
4140
|
+
{items.map((x = fallback) => x).join(", ")}
|
|
4141
|
+
`
|
|
4142
|
+
|
|
4143
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4144
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4145
|
+
expect(html).toMatchInlineSnapshot(`"default, hello, default"`)
|
|
4146
|
+
})
|
|
4147
|
+
|
|
4148
|
+
test('scope with template literal in expression', () => {
|
|
4149
|
+
const scope = {
|
|
4150
|
+
name: 'World',
|
|
4151
|
+
count: 3,
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
const code = dedent`
|
|
4155
|
+
{${'`'}Hello ${'${'}name${'}'}, you have ${'${'}count${'}'} items${'`'}}
|
|
4156
|
+
`
|
|
4157
|
+
|
|
4158
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4159
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4160
|
+
expect(html).toMatchInlineSnapshot(`"Hello World, you have 3 items"`)
|
|
4161
|
+
})
|
|
4162
|
+
|
|
4163
|
+
test('scope with tagged template literal function', () => {
|
|
4164
|
+
const myTag = (strings: TemplateStringsArray, ...values: any[]) => {
|
|
4165
|
+
return strings.reduce((result, str, i) => {
|
|
4166
|
+
return result + str + (values[i] !== undefined ? String(values[i]).toUpperCase() : '')
|
|
4167
|
+
}, '')
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
const scope = {
|
|
4171
|
+
myTag,
|
|
4172
|
+
name: 'world',
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
const code = `{myTag${'`'}hello ${'${'}name${'}'}${'`'}}`
|
|
4176
|
+
|
|
4177
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope, { generate })
|
|
4178
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4179
|
+
expect(html).toMatchInlineSnapshot(`"hello WORLD"`)
|
|
4180
|
+
})
|
|
4181
|
+
|
|
4182
|
+
test('scope with tagged template literal without generate', () => {
|
|
4183
|
+
const myTag = (strings: TemplateStringsArray, ...values: any[]) => {
|
|
4184
|
+
return strings.reduce((result, str, i) => {
|
|
4185
|
+
return result + str + (values[i] !== undefined ? String(values[i]).toUpperCase() : '')
|
|
4186
|
+
}, '')
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
const scope = {
|
|
4190
|
+
myTag,
|
|
4191
|
+
name: 'world',
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
const code = `{myTag${'`'}hello ${'${'}name${'}'}${'`'}}`
|
|
4195
|
+
|
|
4196
|
+
const { html, errors } = render(code, undefined, undefined, undefined, scope)
|
|
4197
|
+
expect(errors).toMatchInlineSnapshot(`[]`)
|
|
4198
|
+
expect(html).toMatchInlineSnapshot(`"hello WORLD"`)
|
|
4199
|
+
})
|