Repository: mdx-js/mdx-analyzer (js)

Issue:
[typescript-plugin] Doesn't understand component map
### Initial checklist

- [X] I read the [support docs](https://mdxjs.com/community/support/)
- [X] I read the [contributing guide](https://mdxjs.com/community/contribute/)
- [X] I agree to follow the [code of conduct](https://github.com/mdx-js/.github/blob/HEAD/code-of-conduct.md)
- [X] I searched [issues](https://github.com/search?q=user%3Amdx-js&type=Issues) and couldn’t find anything (or linked relevant results below)

### Affected packages and versions

MDX extension v1.8.9

### Link to runnable example

https://codesandbox.io/p/github/aaronadamsCA/mdx-issues/main?file=/src/mdx.mdx

### Steps to reproduce

You'll have to run the reproduction in an environment that supports Visual Studio Code extensions.

The TypeScript plugin doesn't seem to understand a component map in a `.mdx` file:

![image](https://github.com/user-attachments/assets/8246ea6c-66cd-4d43-a6e9-e62cb9cd6125)

### Expected behavior

No errors or warnings.

### Actual behavior

The plugin isn't recognizing the destructured `Component` and the JSX `<Component />` as the same object, whereas the actual MDX parser has no problem with this syntax.

### Runtime

_No response_

### Package manager

_No response_

### OS

_No response_

### Build and bundle tools

_No response_

Pull request description:
Handle components defined in a local scope
### Initial checklist

* [x] I read the support docs <!-- https://mdxjs.com/community/support/ -->
* [x] I read the contributing guide <!-- https://mdxjs.com/community/contribute/ -->
* [x] I agree to follow the code of conduct <!-- https://github.com/mdx-js/.github/blob/main/code-of-conduct.md -->
* [x] I searched issues and discussions and couldn’t find anything or linked relevant results below <!-- https://github.com/search?q=user%3Amdx-js&type=issues and https://github.com/orgs/mdx-js/discussions -->
* [x] I made sure the docs are up to date
* [x] I included tests (or that’s not needed)

### Description of changes

This handles components used in a local scope the same as components defined in the global scope. This means local components used in JSX no longer yield errors in the editor. However, autocompletions are not yet supported.

Closes #467

<!--do not edit: pr-->


Patch:
diff --git a/.changeset/curvy-cups-guess.md b/.changeset/curvy-cups-guess.md
new file mode 100644
index 0000000..90fe761
--- /dev/null
+++ b/.changeset/curvy-cups-guess.md
@@ -0,0 +1,5 @@
+---
+'@mdx-js/language-service': patch
+---
+
+Handle components defined in a local scope
diff --git a/packages/language-service/lib/jsx-utils.js b/packages/language-service/lib/jsx-utils.js
index c4a3368..0ec9b2f 100644
--- a/packages/language-service/lib/jsx-utils.js
+++ b/packages/language-service/lib/jsx-utils.js
@@ -1,7 +1,21 @@
 /**
+ * @import {JSXIdentifier, Node} from 'estree-jsx'
  * @import {Scope} from 'estree-util-scope'
  */
 
+/**
+ * @param {string | null} name
+ * @returns {name is Capitalize<string>}
+ */
+function isJsxReference(name) {
+  if (!name) {
+    return false
+  }
+
+  const char = name.charAt(0)
+  return char === char.toUpperCase()
+}
+
 /**
  * Check if a name belongs to a JSX component that can be injected.
  *
@@ -16,14 +30,33 @@
  *   Whether or not the given name is that of an injectable JSX component.
  */
 export function isInjectableComponent(name, scope) {
-  if (!name) {
+  if (!isJsxReference(name)) {
     return false
   }
 
-  const char = name.charAt(0)
-  if (char !== char.toUpperCase()) {
+  return !scope.defined.includes(name)
+}
+
+/**
+ * @param {JSXIdentifier} node
+ * @param {Map<Node, Scope | undefined>} scopes
+ * @param {Map<Node, Node | null>} parents
+ */
+export function isInjectableEstree(node, scopes, parents) {
+  if (!isJsxReference(node.name)) {
     return false
   }
 
-  return !scope.defined.includes(name)
+  /** @type {Node | null | undefined} */
+  let current = node
+  while (current) {
+    const scope = scopes.get(current)
+    if (scope?.defined.includes(node.name)) {
+      return false
+    }
+
+    current = parents.get(current)
+  }
+
+  return true
 }
diff --git a/packages/language-service/lib/virtual-code.js b/packages/language-service/lib/virtual-code.js
index 9a60663..dc2840e 100644
--- a/packages/language-service/lib/virtual-code.js
+++ b/packages/language-service/lib/virtual-code.js
@@ -1,6 +1,6 @@
 /**
  * @import {CodeMapping, VirtualCode} from '@volar/language-service'
- * @import {ExportDefaultDeclaration, JSXClosingElement, JSXOpeningElement, Program} from 'estree-jsx'
+ * @import {ExportDefaultDeclaration, JSXClosingElement, JSXOpeningElement, Node, Program} from 'estree-jsx'
  * @import {Scope} from 'estree-util-scope'
  * @import {Nodes, Root} from 'mdast'
  * @import {MdxjsEsm} from 'mdast-util-mdxjs-esm'
@@ -13,7 +13,7 @@ import {createVisitors} from 'estree-util-scope'
 import {walk} from 'estree-walker'
 import {getNodeEndOffset, getNodeStartOffset} from './mdast-utils.js'
 import {ScriptSnapshot} from './script-snapshot.js'
-import {isInjectableComponent} from './jsx-utils.js'
+import {isInjectableComponent, isInjectableEstree} from './jsx-utils.js'
 
 /**
  * Render the content that should be prefixed to the embedded JavaScript file.
@@ -458,6 +458,10 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
    * @returns {number}
    */
   function processJsxExpression(program, lastIndex) {
+    /** @type {Map<Node, Scope | undefined>} */
+    const localScopes = new Map()
+    /** @type {Map<Node, Node | null>} */
+    const parents = new Map()
     let newIndex = lastIndex
     let functionNesting = 0
 
@@ -472,7 +476,7 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
         return
       }
 
-      if (!isInjectableComponent(name.name, programScope)) {
+      if (!isInjectableEstree(name, localScopes, parents)) {
         return
       }
 
@@ -481,6 +485,25 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
       newIndex = name.start
     }
 
+    walk(program, {
+      enter(node, parent) {
+        if (node.type === 'Program') {
+          return
+        }
+
+        visitors.enter(node)
+        localScopes.set(node, visitors.scopes.at(-1))
+        parents.set(node, parent)
+      },
+      leave(node) {
+        if (node.type === 'Program') {
+          return
+        }
+
+        visitors.exit(node)
+      }
+    })
+
     walk(program, {
       enter(node) {
         switch (node.type) {


Test patch:
diff --git a/packages/language-service/test/language-plugin.js b/packages/language-service/test/language-plugin.js
index 8042476..37c968c 100644
--- a/packages/language-service/test/language-plugin.js
+++ b/packages/language-service/test/language-plugin.js
@@ -2931,7 +2931,7 @@ test('ignore async functions in props or expressions', () => {
     '',
     '{async () => { await Promise.resolve(42) }}',
     '{async function() { await Promise.resolve(42) }}',
-    '{async function named() { await Promise.resolve(42) }}',
+    '{async function local() { await Promise.resolve(42) }}',
     ''
   )
 
@@ -2978,7 +2978,7 @@ test('ignore async functions in props or expressions', () => {
           }
         },
         {
-          generatedOffsets: [1102, 1150, 1203],
+          generatedOffsets: [1138, 1186, 1239],
           sourceOffsets: [205, 249, 298],
           lengths: [43, 48, 54],
           data: {
@@ -3030,13 +3030,15 @@ test('ignore async functions in props or expressions', () => {
         '    /** {@link expression} */',
         '    expression,',
         '    /** {@link named} */',
-        '    named',
+        '    named,',
+        '    /** {@link local} */',
+        '    local',
         '  }',
         '  _components',
         '  return <>',
         '    {async () => { await Promise.resolve(42) }}',
         '    {async function() { await Promise.resolve(42) }}',
-        '    {async function named() { await Promise.resolve(42) }}',
+        '    {async function local() { await Promise.resolve(42) }}',
         '  </>',
         '}',
         '',
@@ -3089,6 +3091,121 @@ test('ignore async functions in props or expressions', () => {
   ])
 })
 
+test('support locally scoped components', () => {
+  const plugin = createMdxLanguagePlugin()
+
+  const snapshot = snapshotFromLines('{(Component) => <Component />}', '')
+
+  const code = plugin.createVirtualCode?.('/test.mdx', 'mdx', snapshot, {
+    getAssociatedScript: () => undefined
+  })
+
+  assert.ok(code instanceof VirtualMdxCode)
+  assert.equal(code.id, 'mdx')
+  assert.equal(code.languageId, 'mdx')
+  assert.ifError(code.error)
+  assert.equal(code.snapshot, snapshot)
+  assert.deepEqual(code.mappings, [
+    {
+      sourceOffsets: [0],
+      generatedOffsets: [0],
+      lengths: [snapshot.getLength()],
+      data: {
+        completion: true,
+        format: true,
+        navigation: true,
+        semantic: true,
+        structure: true,
+        verification: true
+      }
+    }
+  ])
+  assert.deepEqual(code.embeddedCodes, [
+    {
+      id: 'jsx',
+      languageId: 'javascriptreact',
+      mappings: [
+        {
+          generatedOffsets: [779],
+          sourceOffsets: [0],
+          lengths: [30],
+          data: {
+            completion: true,
+            format: false,
+            navigation: true,
+            semantic: true,
+            structure: true,
+            verification: true
+          }
+        }
+      ],
+      snapshot: snapshotFromLines(
+        '/* @jsxRuntime automatic',
+        '@jsxImportSource react */',
+        '',
+        '/**',
+        ' * @internal',
+        ' *   **Do not use.** This function is generated by MDX for internal use.',
+        ' *',
+        ' * @param {{readonly [K in keyof MDXContentProps]: MDXContentProps[K]}} props',
+        ' *   The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component.',
+        ' */',
+        'function _createMdxContent(props) {',
+        '  /**',
+        '   * @internal',
+        '   *   **Do not use.** This variable is generated by MDX for internal use.',
+        '   */',
+        '  const _components = {',
+        '    // @ts-ignore',
+        '    .../** @type {0 extends 1 & MDXProvidedComponents ? {} : MDXProvidedComponents} */ ({}),',
+        '    ...props.components,',
+        '    /** The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component. */',
+        '    props',
+        '  }',
+        '  _components',
+        '  return <>',
+        '    {(Component) => <Component />}',
+        '  </>',
+        '}',
+        '',
+        '/**',
+        ' * Render the MDX contents.',
+        ' *',
+        ' * @param {{readonly [K in keyof MDXContentProps]: MDXContentProps[K]}} props',
+        ' *   The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component.',
+        ' */',
+        'export default function MDXContent(props) {',
+        '  return <_createMdxContent {...props} />',
+        '}',
+        '',
+        '// @ts-ignore',
+        '/** @typedef {(void extends Props ? {} : Props) & {components?: {}}} MDXContentProps */',
+        ''
+      )
+    },
+    {
+      id: 'md',
+      languageId: 'markdown',
+      mappings: [
+        {
+          sourceOffsets: [30],
+          generatedOffsets: [0],
+          lengths: [1],
+          data: {
+            completion: true,
+            format: false,
+            navigation: true,
+            semantic: true,
+            structure: true,
+            verification: true
+          }
+        }
+      ],
+      snapshot: snapshotFromLines('', '')
+    }
+  ])
+})
+
 test('create virtual code w/ dedented markdown content', () => {
   const plugin = createMdxLanguagePlugin()