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.
Files changed (42) hide show
  1. package/README.md +94 -8
  2. package/dist/dynamic-esm-component.d.ts +1 -1
  3. package/dist/dynamic-esm-component.d.ts.map +1 -1
  4. package/dist/dynamic-esm-component.js +9 -1
  5. package/dist/dynamic-esm-component.js.map +1 -1
  6. package/dist/esm-parser.d.ts +1 -1
  7. package/dist/esm-parser.d.ts.map +1 -1
  8. package/dist/esm-parser.js +3 -3
  9. package/dist/esm-parser.js.map +1 -1
  10. package/dist/esm-parser.test.js +2 -2
  11. package/dist/html/html-and-md.test.js.map +1 -1
  12. package/dist/html/html-to-mdx-ast.d.ts +1 -1
  13. package/dist/html/html-to-mdx-ast.js +4 -4
  14. package/dist/html/html-to-mdx-ast.js.map +1 -1
  15. package/dist/html/html-to-mdx-ast.test.js +3 -3
  16. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  17. package/dist/parse.d.ts +1 -1
  18. package/dist/parse.d.ts.map +1 -1
  19. package/dist/parse.js +5 -1
  20. package/dist/parse.js.map +1 -1
  21. package/dist/safe-mdx.bench.js +2 -2
  22. package/dist/safe-mdx.bench.js.map +1 -1
  23. package/dist/safe-mdx.d.ts +48 -3
  24. package/dist/safe-mdx.d.ts.map +1 -1
  25. package/dist/safe-mdx.js +219 -26
  26. package/dist/safe-mdx.js.map +1 -1
  27. package/dist/safe-mdx.test.js +420 -5
  28. package/dist/safe-mdx.test.js.map +1 -1
  29. package/dist/streaming.d.ts.map +1 -1
  30. package/dist/streaming.js +3 -1
  31. package/dist/streaming.js.map +1 -1
  32. package/package.json +30 -7
  33. package/src/esm-parser.test.ts +3 -3
  34. package/src/esm-parser.ts +4 -4
  35. package/src/html/html-and-md.test.ts +2 -2
  36. package/src/html/html-to-mdx-ast.test.ts +3 -3
  37. package/src/html/html-to-mdx-ast.ts +4 -4
  38. package/src/parse.ts +3 -1
  39. package/src/safe-mdx.bench.tsx +2 -2
  40. package/src/safe-mdx.test.tsx +519 -11
  41. package/src/safe-mdx.tsx +315 -28
  42. package/src/streaming.tsx +2 -1
@@ -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.js'
7
- import { MdastToJsx, mdastBfs, type ComponentPropsSchema } from './safe-mdx.js'
8
- import { completeJsxTags } from './streaming.js'
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].message).toContain('Unsupported jsx component CustomElement')
2301
- expect(errors[1].message).toContain('Unsupported jsx component AnotherUnknown')
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].message).toContain('Invalid import URL')
3057
- expect(visitor.errors[1].message).toContain('Invalid import URL')
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].message).toContain('Unsupported jsx component Button')
3061
- expect(visitor.errors[3].message).toContain('Unsupported jsx component Component')
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
+ })