safe-mdx 1.10.0 → 1.11.1

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.
@@ -3684,6 +3684,11 @@ test('modules prop: unresolved import produces error', () => {
3684
3684
  expect(html).toMatchInlineSnapshot(`""`)
3685
3685
  expect(visitor.errors).toMatchInlineSnapshot(`
3686
3686
  [
3687
+ {
3688
+ "line": 1,
3689
+ "message": "Unresolved import "Missing" from "./nonexistent". The imported module could not be resolved, so these names are not available in the document.",
3690
+ "type": "expression",
3691
+ },
3687
3692
  {
3688
3693
  "line": 3,
3689
3694
  "message": "Unsupported jsx component Missing",
@@ -4292,4 +4297,648 @@ test('scope with tagged template literal without generate', () => {
4292
4297
  expect(html).toMatchInlineSnapshot(`"hello WORLD"`)
4293
4298
  })
4294
4299
 
4300
+ /* ── Error readability tests for agents ──────────────────────────────── */
4301
+ // These tests validate that safe-mdx produces clear, actionable error messages
4302
+ // with line numbers so AI agents can diagnose and fix MDX issues.
4303
+
4304
+ test('error: export function in MDX', () => {
4305
+ const code = dedent`
4306
+ export function MyHelper() {
4307
+ return "hello"
4308
+ }
4309
+
4310
+ # Hello
4311
+
4312
+ <MyHelper />
4313
+ `
4314
+
4315
+ const { errors, html } = render(code)
4316
+ expect(errors).toMatchInlineSnapshot(`
4317
+ [
4318
+ {
4319
+ "line": 1,
4320
+ "message": "Unsupported named export "MyHelper". Export declarations are not evaluated, so exported values and components are not available in the document.",
4321
+ "type": "expression",
4322
+ },
4323
+ {
4324
+ "line": 7,
4325
+ "message": "Unsupported jsx component MyHelper",
4326
+ "type": "missing-component",
4327
+ },
4328
+ ]
4329
+ `)
4330
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1>"`)
4331
+ })
4332
+
4333
+ test('error: export const in MDX', () => {
4334
+ const code = dedent`
4335
+ export const title = "My Page"
4336
+ export const items = [1, 2, 3]
4337
+
4338
+ # {title}
4339
+
4340
+ Items: {items.join(", ")}
4341
+ `
4342
+
4343
+ const { errors, html } = render(code)
4344
+ expect(errors).toMatchInlineSnapshot(`
4345
+ [
4346
+ {
4347
+ "line": 1,
4348
+ "message": "Unsupported named export "title". Export declarations are not evaluated, so exported values and components are not available in the document.",
4349
+ "type": "expression",
4350
+ },
4351
+ {
4352
+ "line": 2,
4353
+ "message": "Unsupported named export "items". Export declarations are not evaluated, so exported values and components are not available in the document.",
4354
+ "type": "expression",
4355
+ },
4356
+ {
4357
+ "line": 6,
4358
+ "message": "Failed to evaluate expression: items.join(", "). Functions are not supported",
4359
+ "type": "expression",
4360
+ },
4361
+ ]
4362
+ `)
4363
+ expect(html).toMatchInlineSnapshot(`"<h1></h1><p>Items: </p>"`)
4364
+ })
4365
+
4366
+ test('error: import from non-existing file without modules prop', () => {
4367
+ const code = dedent`
4368
+ import { Card } from './components/card'
4369
+ import { Badge } from '../ui/badge'
4370
+
4371
+ # Hello
4372
+
4373
+ <Card title="test">content</Card>
4374
+ <Badge label="new" />
4375
+ `
4376
+
4377
+ const { errors, html } = render(code)
4378
+ expect(errors).toMatchInlineSnapshot(`
4379
+ [
4380
+ {
4381
+ "line": 1,
4382
+ "message": "Unresolved import "Card" from "./components/card". The imported module could not be resolved, so these names are not available in the document.",
4383
+ "type": "expression",
4384
+ },
4385
+ {
4386
+ "line": 2,
4387
+ "message": "Unresolved import "Badge" from "../ui/badge". The imported module could not be resolved, so these names are not available in the document.",
4388
+ "type": "expression",
4389
+ },
4390
+ {
4391
+ "line": 6,
4392
+ "message": "Unsupported jsx component Card",
4393
+ "type": "missing-component",
4394
+ },
4395
+ {
4396
+ "line": 7,
4397
+ "message": "Unsupported jsx component Badge",
4398
+ "type": "missing-component",
4399
+ },
4400
+ ]
4401
+ `)
4402
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1>"`)
4403
+ })
4404
+
4405
+ test('error: import from non-existing file with modules prop (unresolved)', () => {
4406
+ const code = dedent`
4407
+ import { Card } from './components/card'
4408
+
4409
+ # Hello
4410
+
4411
+ <Card title="test">content</Card>
4412
+ `
4413
+
4414
+ const mdast = mdxParse(code)
4415
+ const visitor = new MdastToJsx({
4416
+ markdown: code,
4417
+ mdast,
4418
+ components,
4419
+ modules: {
4420
+ './components/button.tsx': { Button: () => null },
4421
+ },
4422
+ baseUrl: './',
4423
+ })
4424
+ const result = visitor.run()
4425
+ const html = renderToStaticMarkup(result)
4426
+ expect(visitor.errors).toMatchInlineSnapshot(`
4427
+ [
4428
+ {
4429
+ "line": 1,
4430
+ "message": "Unresolved import "Card" from "./components/card". The imported module could not be resolved, so these names are not available in the document.",
4431
+ "type": "expression",
4432
+ },
4433
+ {
4434
+ "line": 5,
4435
+ "message": "Unsupported jsx component Card",
4436
+ "type": "missing-component",
4437
+ },
4438
+ ]
4439
+ `)
4440
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1>"`)
4441
+ })
4442
+
4443
+ test('error: calling non-existing function in expression (no scope)', () => {
4444
+ const code = dedent`
4445
+ # Title
4446
+
4447
+ Result: {formatDate("2024-01-01")}
4448
+
4449
+ Value: {calculateTotal(100, 0.2)}
4450
+ `
4451
+
4452
+ const { errors, html } = render(code)
4453
+ expect(errors).toMatchInlineSnapshot(`
4454
+ [
4455
+ {
4456
+ "line": 3,
4457
+ "message": "Failed to evaluate expression: formatDate("2024-01-01"). Functions are not supported",
4458
+ "type": "expression",
4459
+ },
4460
+ {
4461
+ "line": 5,
4462
+ "message": "Failed to evaluate expression: calculateTotal(100, 0.2). Functions are not supported",
4463
+ "type": "expression",
4464
+ },
4465
+ ]
4466
+ `)
4467
+ expect(html).toMatchInlineSnapshot(`"<h1>Title</h1><p>Result: </p><p>Value: </p>"`)
4468
+ })
4469
+
4470
+ test('error: calling non-existing function in expression (with scope)', () => {
4471
+ const scope = {
4472
+ greeting: 'hello',
4473
+ }
4474
+
4475
+ const code = dedent`
4476
+ # {greeting}
4477
+
4478
+ Result: {nonExistentFn("test")}
4479
+
4480
+ Upper: {greeting.toUpperCase()}
4481
+ `
4482
+
4483
+ const { errors, html } = render(code, undefined, undefined, undefined, scope)
4484
+ expect(errors).toMatchInlineSnapshot(`
4485
+ [
4486
+ {
4487
+ "line": 3,
4488
+ "message": "Failed to evaluate expression: nonExistentFn("test"). nonExistentFn is not defined. Available variables: greeting",
4489
+ "type": "expression",
4490
+ },
4491
+ ]
4492
+ `)
4493
+ expect(html).toMatchInlineSnapshot(`"<h1>hello</h1><p>Result: </p><p>Upper: HELLO</p>"`)
4494
+ })
4495
+
4496
+ test('error: missing scope variable in expression (no scope at all)', () => {
4497
+ const code = dedent`
4498
+ # Hello
4499
+
4500
+ Name: {userName}
4501
+
4502
+ Count: {itemCount + 1}
4503
+ `
4504
+
4505
+ const { errors, html } = render(code)
4506
+ expect(errors).toMatchInlineSnapshot(`[]`)
4507
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1><p>Name: </p><p>Count: </p>"`)
4508
+ })
4509
+
4510
+ test('error: missing scope variable with strict mode', () => {
4511
+ const scope = {
4512
+ knownVar: 'exists',
4513
+ }
4514
+
4515
+ const code = dedent`
4516
+ Known: {knownVar}
4517
+
4518
+ Missing: {unknownVar}
4519
+ `
4520
+
4521
+ const { errors, html } = render(code, undefined, undefined, undefined, scope, { strict: true })
4522
+ expect(errors).toMatchInlineSnapshot(`
4523
+ [
4524
+ {
4525
+ "line": 3,
4526
+ "message": "Failed to evaluate expression: unknownVar. unknownVar is not defined. Available variables: knownVar",
4527
+ "type": "expression",
4528
+ },
4529
+ ]
4530
+ `)
4531
+ expect(html).toMatchInlineSnapshot(`"<p>Known: exists</p><p>Missing: </p>"`)
4532
+ })
4533
+
4534
+ test('error: multiple error types in single document', () => {
4535
+ const code = dedent`
4536
+ import { Missing } from './not-found'
4537
+
4538
+ # Title
4539
+
4540
+ <NonExistent>content</NonExistent>
4541
+
4542
+ {undefinedVar}
4543
+
4544
+ <Heading level={invalidFn()}>text</Heading>
4545
+ `
4546
+
4547
+ const { errors } = render(code)
4548
+ expect(errors).toMatchInlineSnapshot(`
4549
+ [
4550
+ {
4551
+ "line": 1,
4552
+ "message": "Unresolved import "Missing" from "./not-found". The imported module could not be resolved, so these names are not available in the document.",
4553
+ "type": "expression",
4554
+ },
4555
+ {
4556
+ "line": 5,
4557
+ "message": "Unsupported jsx component NonExistent",
4558
+ "type": "missing-component",
4559
+ },
4560
+ {
4561
+ "line": 9,
4562
+ "message": "Failed to evaluate expression attribute: level={invalidFn()}. Functions are not supported",
4563
+ "type": "expression",
4564
+ },
4565
+ {
4566
+ "line": 9,
4567
+ "message": "Expressions in jsx prop not evaluated: (level={invalidFn()})",
4568
+ "type": "expression",
4569
+ },
4570
+ ]
4571
+ `)
4572
+ })
4573
+
4574
+ test('error: all errors have line numbers for agent debugging', () => {
4575
+ const code = dedent`
4576
+ # Line 1 heading
4577
+
4578
+ <Unknown1 />
4579
+
4580
+ Some text {badVar1}
4581
+
4582
+ <Unknown2 prop={badFn()} />
4583
+
4584
+ {badVar2 + 1}
4585
+ `
4586
+
4587
+ const { errors } = render(code)
4588
+ for (const err of errors) {
4589
+ expect(err.line).toBeDefined()
4590
+ expect(typeof err.line).toBe('number')
4591
+ expect(err.line).toBeGreaterThan(0)
4592
+ }
4593
+ expect(errors).toMatchInlineSnapshot(`
4594
+ [
4595
+ {
4596
+ "line": 3,
4597
+ "message": "Unsupported jsx component Unknown1",
4598
+ "type": "missing-component",
4599
+ },
4600
+ {
4601
+ "line": 7,
4602
+ "message": "Unsupported jsx component Unknown2",
4603
+ "type": "missing-component",
4604
+ },
4605
+ ]
4606
+ `)
4607
+ })
4608
+
4609
+ test('error: export default in MDX', () => {
4610
+ const code = dedent`
4611
+ export default function Layout({ children }) {
4612
+ return children
4613
+ }
4614
+
4615
+ # Hello
4616
+
4617
+ Some content
4618
+ `
4619
+
4620
+ const { errors, html } = render(code)
4621
+ expect(errors).toMatchInlineSnapshot(`
4622
+ [
4623
+ {
4624
+ "line": 1,
4625
+ "message": "Unsupported default export "Layout". Export declarations are not evaluated, so exported values and components are not available in the document.",
4626
+ "type": "expression",
4627
+ },
4628
+ ]
4629
+ `)
4630
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1><p>Some content</p>"`)
4631
+ })
4632
+
4633
+ test('error: using exported const as component', () => {
4634
+ const code = dedent`
4635
+ export const Alert = ({ children }) => <div>{children}</div>
4636
+
4637
+ # Page
4638
+
4639
+ <Alert>This should fail</Alert>
4640
+ `
4641
+
4642
+ const { errors, html } = render(code)
4643
+ expect(errors).toMatchInlineSnapshot(`
4644
+ [
4645
+ {
4646
+ "line": 1,
4647
+ "message": "Unsupported named export "Alert". Export declarations are not evaluated, so exported values and components are not available in the document.",
4648
+ "type": "expression",
4649
+ },
4650
+ {
4651
+ "line": 5,
4652
+ "message": "Unsupported jsx component Alert",
4653
+ "type": "missing-component",
4654
+ },
4655
+ ]
4656
+ `)
4657
+ expect(html).toMatchInlineSnapshot(`"<h1>Page</h1>"`)
4658
+ })
4659
+
4660
+ test('error: invalid JSX expression in attribute', () => {
4661
+ const code = dedent`
4662
+ <Heading level={1 +}>broken expression</Heading>
4663
+ `
4664
+
4665
+ expect(() => mdxParse(code)).toThrow()
4666
+ })
4667
+
4668
+ test('error: nested missing components show correct line numbers', () => {
4669
+ const code = dedent`
4670
+ # Page Title
4671
+
4672
+ <Heading level={1}>
4673
+ Some text with <InlineWidget /> inside
4674
+ </Heading>
4675
+
4676
+ <Tabs items={["a", "b"]}>
4677
+ <TabPanel>
4678
+ <DeepNested>content</DeepNested>
4679
+ </TabPanel>
4680
+ </Tabs>
4681
+ `
4682
+
4683
+ const { errors } = render(code)
4684
+ expect(errors).toMatchInlineSnapshot(`
4685
+ [
4686
+ {
4687
+ "line": 4,
4688
+ "message": "Unsupported jsx component InlineWidget",
4689
+ "type": "missing-component",
4690
+ },
4691
+ {
4692
+ "line": 8,
4693
+ "message": "Unsupported jsx component TabPanel",
4694
+ "type": "missing-component",
4695
+ },
4696
+ ]
4697
+ `)
4698
+ })
4699
+
4700
+ /* ── Syntax error tests ──────────────────────────────────────────────── */
4701
+ // These tests verify that MDX parse errors produce readable messages with
4702
+ // line/column information so agents can locate and fix syntax issues.
4703
+
4704
+ function parseError(code: string) {
4705
+ try {
4706
+ mdxParse(code)
4707
+ return null
4708
+ } catch (e: any) {
4709
+ // remark/mdx errors store position in various places:
4710
+ // e.line/e.column, e.place, e.point, or e.name as "line:column"
4711
+ let line = e.line ?? e.place?.line ?? e.point?.line ?? null
4712
+ let column = e.column ?? e.place?.column ?? e.point?.column ?? null
4713
+ if (line == null && typeof e.name === 'string' && /^\d+:\d+/.test(e.name)) {
4714
+ const [l, c] = e.name.split(':').map(Number)
4715
+ line = l
4716
+ column = c
4717
+ }
4718
+ return {
4719
+ message: (e.reason ?? e.message?.split('\n')[0]) ?? String(e),
4720
+ line,
4721
+ column,
4722
+ }
4723
+ }
4724
+ }
4725
+
4726
+ test('syntax error: unclosed JSX tag', () => {
4727
+ const err = parseError(dedent`
4728
+ # Hello
4729
+
4730
+ <Card title="test">
4731
+ some content
4732
+ `)
4733
+ expect(err).toMatchInlineSnapshot(
4734
+ `
4735
+ {
4736
+ "column": 1,
4737
+ "line": 1,
4738
+ "message": "Expected a closing tag for \`<Card>\` (3:1-3:20)",
4739
+ }
4740
+ `)
4741
+ })
4742
+
4743
+ test('syntax error: mismatched closing tag', () => {
4744
+ const err = parseError(dedent`
4745
+ <Heading>text</Cards>
4746
+ `)
4747
+ expect(err).toMatchInlineSnapshot(
4748
+ `
4749
+ {
4750
+ "column": 14,
4751
+ "line": 1,
4752
+ "message": "Unexpected closing tag \`</Cards>\`, expected corresponding closing tag for \`<Heading>\` (1:1-1:10)",
4753
+ }
4754
+ `)
4755
+ })
4756
+
4757
+ test('syntax error: mismatched closing tag with markdown content inside', () => {
4758
+ const err = parseError(dedent`
4759
+ <Tip>
4760
+ If you like **Playwriter** please
4761
+ [star on GitHub](https://github.com/remorses/playwriter). It helps give more visibility to the project!
4762
+ </Tipx>
4763
+ `)
4764
+ expect(err).toMatchInlineSnapshot(`
4765
+ {
4766
+ "column": 1,
4767
+ "line": 4,
4768
+ "message": "Unexpected closing tag \`</Tipx>\`, expected corresponding closing tag for \`<Tip>\` (1:1-1:6)",
4769
+ }
4770
+ `)
4771
+ })
4772
+
4773
+ test('syntax error: mismatched opening tag with markdown content inside', () => {
4774
+ const err = parseError(dedent`
4775
+ <Tipx>
4776
+ If you like **Playwriter** please
4777
+ [star on GitHub](https://github.com/remorses/playwriter). It helps give more visibility to the project!
4778
+ </Tip>
4779
+ `)
4780
+ expect(err).toMatchInlineSnapshot(`
4781
+ {
4782
+ "column": 1,
4783
+ "line": 4,
4784
+ "message": "Unexpected closing tag \`</Tip>\`, expected corresponding closing tag for \`<Tipx>\` (1:1-1:7)",
4785
+ }
4786
+ `)
4787
+ })
4788
+
4789
+ test('syntax error: unclosed curly brace in expression', () => {
4790
+ const err = parseError(dedent`
4791
+ # Title
4792
+
4793
+ {something
4794
+ `)
4795
+ expect(err).toMatchInlineSnapshot(
4796
+ `
4797
+ {
4798
+ "column": 11,
4799
+ "line": 3,
4800
+ "message": "Unexpected end of file in expression, expected a corresponding closing brace for \`{\`",
4801
+ }
4802
+ `)
4803
+ })
4804
+
4805
+ test('syntax error: invalid expression in attribute', () => {
4806
+ const err = parseError(dedent`
4807
+ <Heading level={1 +}>text</Heading>
4808
+ `)
4809
+ expect(err).toMatchInlineSnapshot(
4810
+ `
4811
+ {
4812
+ "column": 20,
4813
+ "line": 1,
4814
+ "message": "Could not parse expression with acorn",
4815
+ }
4816
+ `)
4817
+ })
4818
+
4819
+ test('syntax error: unclosed string in expression', () => {
4820
+ const err = parseError(dedent`
4821
+ # Title
4822
+
4823
+ {"hello}
4824
+ `)
4825
+ expect(err).toMatchInlineSnapshot(
4826
+ `
4827
+ {
4828
+ "column": 2,
4829
+ "line": 3,
4830
+ "message": "Could not parse expression with acorn",
4831
+ }
4832
+ `)
4833
+ })
4834
+
4835
+ test('syntax error: malformed expression with double operator is actually valid (unary plus)', () => {
4836
+ const err = parseError(dedent`
4837
+ {1 + + + 2}
4838
+ `)
4839
+ // This is valid JS: unary plus operators, so no parse error
4840
+ expect(err).toMatchInlineSnapshot(`null`)
4841
+ })
4842
+
4843
+ test('syntax error: unclosed JSX attribute value', () => {
4844
+ const err = parseError(dedent`
4845
+ <Heading level={>text</Heading>
4846
+ `)
4847
+ expect(err).toMatchInlineSnapshot(`
4848
+ {
4849
+ "column": 32,
4850
+ "line": 1,
4851
+ "message": "Unexpected end of file in expression, expected a corresponding closing brace for \`{\`",
4852
+ }
4853
+ `)
4854
+ })
4855
+
4856
+ test('syntax error: JSX self-closing with wrong syntax', () => {
4857
+ const err = parseError(dedent`
4858
+ <Heading level={1} /
4859
+ `)
4860
+ expect(err).toMatchInlineSnapshot(
4861
+ `
4862
+ {
4863
+ "column": 21,
4864
+ "line": 1,
4865
+ "message": "Unexpected end of file after self-closing slash, expected \`>\` to end the tag",
4866
+ }
4867
+ `)
4868
+ })
4869
+
4870
+ test('syntax error: nested unclosed tags', () => {
4871
+ const err = parseError(dedent`
4872
+ # Title
4873
+
4874
+ <Card>
4875
+ <Badge label="new">
4876
+ </Card>
4877
+ `)
4878
+ expect(err).toMatchInlineSnapshot(
4879
+ `
4880
+ {
4881
+ "column": 1,
4882
+ "line": 5,
4883
+ "message": "Unexpected closing tag \`</Card>\`, expected corresponding closing tag for \`<Badge>\` (4:3-4:22)",
4884
+ }
4885
+ `)
4886
+ })
4887
+
4888
+ test('syntax error: export with invalid syntax', () => {
4889
+ const err = parseError(dedent`
4890
+ export const = "missing name"
4891
+
4892
+ # Title
4893
+ `)
4894
+ expect(err).toMatchInlineSnapshot(
4895
+ `
4896
+ {
4897
+ "column": 14,
4898
+ "line": 1,
4899
+ "message": "Could not parse import/exports with acorn",
4900
+ }
4901
+ `)
4902
+ })
4903
+
4904
+ test('syntax error: import with missing source', () => {
4905
+ const err = parseError(dedent`
4906
+ import { Card } from
4907
+
4908
+ # Title
4909
+ `)
4910
+ expect(err).toMatchInlineSnapshot(
4911
+ `
4912
+ {
4913
+ "column": 2,
4914
+ "line": 3,
4915
+ "message": "Could not parse import/exports with acorn",
4916
+ }
4917
+ `)
4918
+ })
4919
+
4920
+ test('error: ESM import with non-https URL shows esm-import error', () => {
4921
+ const code = dedent`
4922
+ import { Tool } from 'file:///etc/passwd'
4923
+ import { Safe } from 'https://esm.sh/safe-pkg'
4924
+
4925
+ # Test
4926
+
4927
+ <Tool />
4928
+ <Safe />
4929
+ `
4930
+
4931
+ const mdast = mdxParse(code)
4932
+ const visitor = new MdastToJsx({
4933
+ markdown: code,
4934
+ mdast,
4935
+ components,
4936
+ allowClientEsmImports: true,
4937
+ })
4938
+ visitor.run()
4939
+ const esmErrors = visitor.errors.filter(e => e.type === 'esm-import')
4940
+ expect(esmErrors.length).toBeGreaterThan(0)
4941
+ expect(esmErrors[0]!.message).toContain('Invalid import URL')
4942
+ })
4943
+
4295
4944