Skip to content

Commit 4aca67f

Browse files
authored
Handle components defined in a local scope (#488)
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
1 parent b2a56a5 commit 4aca67f

4 files changed

Lines changed: 189 additions & 11 deletions

File tree

.changeset/curvy-cups-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mdx-js/language-service': patch
3+
---
4+
5+
Handle components defined in a local scope

packages/language-service/lib/jsx-utils.js

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
/**
2+
* @import {JSXIdentifier, Node} from 'estree-jsx'
23
* @import {Scope} from 'estree-util-scope'
34
*/
45

6+
/**
7+
* @param {string | null} name
8+
* @returns {name is Capitalize<string>}
9+
*/
10+
function isJsxReference(name) {
11+
if (!name) {
12+
return false
13+
}
14+
15+
const char = name.charAt(0)
16+
return char === char.toUpperCase()
17+
}
18+
519
/**
620
* Check if a name belongs to a JSX component that can be injected.
721
*
@@ -16,14 +30,33 @@
1630
* Whether or not the given name is that of an injectable JSX component.
1731
*/
1832
export function isInjectableComponent(name, scope) {
19-
if (!name) {
33+
if (!isJsxReference(name)) {
2034
return false
2135
}
2236

23-
const char = name.charAt(0)
24-
if (char !== char.toUpperCase()) {
37+
return !scope.defined.includes(name)
38+
}
39+
40+
/**
41+
* @param {JSXIdentifier} node
42+
* @param {Map<Node, Scope | undefined>} scopes
43+
* @param {Map<Node, Node | null>} parents
44+
*/
45+
export function isInjectableEstree(node, scopes, parents) {
46+
if (!isJsxReference(node.name)) {
2547
return false
2648
}
2749

28-
return !scope.defined.includes(name)
50+
/** @type {Node | null | undefined} */
51+
let current = node
52+
while (current) {
53+
const scope = scopes.get(current)
54+
if (scope?.defined.includes(node.name)) {
55+
return false
56+
}
57+
58+
current = parents.get(current)
59+
}
60+
61+
return true
2962
}

packages/language-service/lib/virtual-code.js

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* @import {CodeMapping, VirtualCode} from '@volar/language-service'
3-
* @import {ExportDefaultDeclaration, JSXClosingElement, JSXOpeningElement, Program} from 'estree-jsx'
3+
* @import {ExportDefaultDeclaration, JSXClosingElement, JSXOpeningElement, Node, Program} from 'estree-jsx'
44
* @import {Scope} from 'estree-util-scope'
55
* @import {Nodes, Root} from 'mdast'
66
* @import {MdxjsEsm} from 'mdast-util-mdxjs-esm'
@@ -13,7 +13,7 @@ import {createVisitors} from 'estree-util-scope'
1313
import {walk} from 'estree-walker'
1414
import {getNodeEndOffset, getNodeStartOffset} from './mdast-utils.js'
1515
import {ScriptSnapshot} from './script-snapshot.js'
16-
import {isInjectableComponent} from './jsx-utils.js'
16+
import {isInjectableComponent, isInjectableEstree} from './jsx-utils.js'
1717

1818
/**
1919
* Render the content that should be prefixed to the embedded JavaScript file.
@@ -458,6 +458,10 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
458458
* @returns {number}
459459
*/
460460
function processJsxExpression(program, lastIndex) {
461+
/** @type {Map<Node, Scope | undefined>} */
462+
const localScopes = new Map()
463+
/** @type {Map<Node, Node | null>} */
464+
const parents = new Map()
461465
let newIndex = lastIndex
462466
let functionNesting = 0
463467

@@ -472,7 +476,7 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
472476
return
473477
}
474478

475-
if (!isInjectableComponent(name.name, programScope)) {
479+
if (!isInjectableEstree(name, localScopes, parents)) {
476480
return
477481
}
478482

@@ -481,6 +485,25 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
481485
newIndex = name.start
482486
}
483487

488+
walk(program, {
489+
enter(node, parent) {
490+
if (node.type === 'Program') {
491+
return
492+
}
493+
494+
visitors.enter(node)
495+
localScopes.set(node, visitors.scopes.at(-1))
496+
parents.set(node, parent)
497+
},
498+
leave(node) {
499+
if (node.type === 'Program') {
500+
return
501+
}
502+
503+
visitors.exit(node)
504+
}
505+
})
506+
484507
walk(program, {
485508
enter(node) {
486509
switch (node.type) {

packages/language-service/test/language-plugin.js

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,7 +2931,7 @@ test('ignore async functions in props or expressions', () => {
29312931
'',
29322932
'{async () => { await Promise.resolve(42) }}',
29332933
'{async function() { await Promise.resolve(42) }}',
2934-
'{async function named() { await Promise.resolve(42) }}',
2934+
'{async function local() { await Promise.resolve(42) }}',
29352935
''
29362936
)
29372937

@@ -2978,7 +2978,7 @@ test('ignore async functions in props or expressions', () => {
29782978
}
29792979
},
29802980
{
2981-
generatedOffsets: [1102, 1150, 1203],
2981+
generatedOffsets: [1138, 1186, 1239],
29822982
sourceOffsets: [205, 249, 298],
29832983
lengths: [43, 48, 54],
29842984
data: {
@@ -3030,13 +3030,15 @@ test('ignore async functions in props or expressions', () => {
30303030
' /** {@link expression} */',
30313031
' expression,',
30323032
' /** {@link named} */',
3033-
' named',
3033+
' named,',
3034+
' /** {@link local} */',
3035+
' local',
30343036
' }',
30353037
' _components',
30363038
' return <>',
30373039
' {async () => { await Promise.resolve(42) }}',
30383040
' {async function() { await Promise.resolve(42) }}',
3039-
' {async function named() { await Promise.resolve(42) }}',
3041+
' {async function local() { await Promise.resolve(42) }}',
30403042
' </>',
30413043
'}',
30423044
'',
@@ -3089,6 +3091,121 @@ test('ignore async functions in props or expressions', () => {
30893091
])
30903092
})
30913093

3094+
test('support locally scoped components', () => {
3095+
const plugin = createMdxLanguagePlugin()
3096+
3097+
const snapshot = snapshotFromLines('{(Component) => <Component />}', '')
3098+
3099+
const code = plugin.createVirtualCode?.('/test.mdx', 'mdx', snapshot, {
3100+
getAssociatedScript: () => undefined
3101+
})
3102+
3103+
assert.ok(code instanceof VirtualMdxCode)
3104+
assert.equal(code.id, 'mdx')
3105+
assert.equal(code.languageId, 'mdx')
3106+
assert.ifError(code.error)
3107+
assert.equal(code.snapshot, snapshot)
3108+
assert.deepEqual(code.mappings, [
3109+
{
3110+
sourceOffsets: [0],
3111+
generatedOffsets: [0],
3112+
lengths: [snapshot.getLength()],
3113+
data: {
3114+
completion: true,
3115+
format: true,
3116+
navigation: true,
3117+
semantic: true,
3118+
structure: true,
3119+
verification: true
3120+
}
3121+
}
3122+
])
3123+
assert.deepEqual(code.embeddedCodes, [
3124+
{
3125+
id: 'jsx',
3126+
languageId: 'javascriptreact',
3127+
mappings: [
3128+
{
3129+
generatedOffsets: [779],
3130+
sourceOffsets: [0],
3131+
lengths: [30],
3132+
data: {
3133+
completion: true,
3134+
format: false,
3135+
navigation: true,
3136+
semantic: true,
3137+
structure: true,
3138+
verification: true
3139+
}
3140+
}
3141+
],
3142+
snapshot: snapshotFromLines(
3143+
'/* @jsxRuntime automatic',
3144+
'@jsxImportSource react */',
3145+
'',
3146+
'/**',
3147+
' * @internal',
3148+
' * **Do not use.** This function is generated by MDX for internal use.',
3149+
' *',
3150+
' * @param {{readonly [K in keyof MDXContentProps]: MDXContentProps[K]}} props',
3151+
' * The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component.',
3152+
' */',
3153+
'function _createMdxContent(props) {',
3154+
' /**',
3155+
' * @internal',
3156+
' * **Do not use.** This variable is generated by MDX for internal use.',
3157+
' */',
3158+
' const _components = {',
3159+
' // @ts-ignore',
3160+
' .../** @type {0 extends 1 & MDXProvidedComponents ? {} : MDXProvidedComponents} */ ({}),',
3161+
' ...props.components,',
3162+
' /** The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component. */',
3163+
' props',
3164+
' }',
3165+
' _components',
3166+
' return <>',
3167+
' {(Component) => <Component />}',
3168+
' </>',
3169+
'}',
3170+
'',
3171+
'/**',
3172+
' * Render the MDX contents.',
3173+
' *',
3174+
' * @param {{readonly [K in keyof MDXContentProps]: MDXContentProps[K]}} props',
3175+
' * The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component.',
3176+
' */',
3177+
'export default function MDXContent(props) {',
3178+
' return <_createMdxContent {...props} />',
3179+
'}',
3180+
'',
3181+
'// @ts-ignore',
3182+
'/** @typedef {(void extends Props ? {} : Props) & {components?: {}}} MDXContentProps */',
3183+
''
3184+
)
3185+
},
3186+
{
3187+
id: 'md',
3188+
languageId: 'markdown',
3189+
mappings: [
3190+
{
3191+
sourceOffsets: [30],
3192+
generatedOffsets: [0],
3193+
lengths: [1],
3194+
data: {
3195+
completion: true,
3196+
format: false,
3197+
navigation: true,
3198+
semantic: true,
3199+
structure: true,
3200+
verification: true
3201+
}
3202+
}
3203+
],
3204+
snapshot: snapshotFromLines('', '')
3205+
}
3206+
])
3207+
})
3208+
30923209
test('create virtual code w/ dedented markdown content', () => {
30933210
const plugin = createMdxLanguagePlugin()
30943211

0 commit comments

Comments
 (0)