Skip to content

Commit

Permalink
Add minimal support for the components prop (#396)
Browse files Browse the repository at this point in the history
This defines the `_components` variable in `_createMdxContent`. This
variable contains all components injected through the `components` prop,
a reference to `props`, and all local variables. All MDX JSX tags are
prefixed with `_components.` in the virtual code.

As a result, components declared in the `components` prop are allowed.
Components from both the `components` prop and local variables are
displayed in the autocomplete.

A downside of this approach is that documentation is lost for local
components. To mitigate this, only unknown JSX tags are prefixed.

Only MDX JSX tags are handled yet. JSX from estree not yet.

Refs #260
  • Loading branch information
remcohaszing committed Feb 12, 2024
1 parent dca9433 commit 3b6c5a8
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 57 deletions.
7 changes: 7 additions & 0 deletions .changeset/large-dolls-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mdx-js/language-service": patch
"@mdx-js/language-server": patch
"vscode-mdx": patch
---

Support the `components` prop for MDX JSX tags.
2 changes: 1 addition & 1 deletion packages/language-server/test/diagnostics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ test('type errors', async () => {
version: 1
},
message:
"Property 'counter' may not exist on type '{ readonly count: number; }'. Did you mean 'count'?",
"Property 'counter' may not exist on type '{ readonly count: number; readonly components?: {}; }'. Did you mean 'count'?",
range: {
start: {line: 14, character: 51},
end: {line: 14, character: 58}
Expand Down
25 changes: 25 additions & 0 deletions packages/language-service/lib/jsx-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Check if a name belongs to a JSX component that can be injected.
*
* These are components whose name start with an upper case character. They may
* also not be defined in the scope.
*
* @param {string | null} name
* The name of the component to check.
* @param {string[]} scope
* The variable names available in the scope.
* @returns {boolean}
* Whether or not the given name is that of an injectable JSX component.
*/
export function isInjectableComponent(name, scope) {
if (!name) {
return false
}

const char = name.charAt(0)
if (char !== char.toUpperCase()) {
return false
}

return !scope.includes(name)
}
72 changes: 66 additions & 6 deletions packages/language-service/lib/virtual-code.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
*/

import {walk} from 'estree-walker'
import {analyze} from 'periscopic'
import {getNodeEndOffset, getNodeStartOffset} from './mdast-utils.js'
import {ScriptSnapshot} from './script-snapshot.js'
import {isInjectableComponent} from './jsx-utils.js'

/**
* Render the content that should be prefixed to the embedded JavaScript file.
Expand Down Expand Up @@ -51,8 +53,9 @@ const layoutJsDoc = (propsName) => `
/**
* @param {boolean} isAsync
* Whether or not the `_createMdxContent` should be async
* @param {string[]} variables
*/
const componentStart = (isAsync) => `
const componentStart = (isAsync, variables) => `
/**
* @deprecated
* Do not use.
Expand All @@ -61,6 +64,15 @@ const componentStart = (isAsync) => `
* The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component.
*/
${isAsync ? 'async ' : ''}function _createMdxContent(props) {
/**
* @internal
* **Do not use.** This is an MDX internal.
*/
const _components = {
...props.components,
/** The [props](https://mdxjs.com/docs/using-mdx/#props) that have been passed to the MDX component. */
props${Array.from(variables, (name) => ',\n /** {@link ' + name + '} */\n ' + name).join('')}
}
return `

const componentEnd = `
Expand All @@ -77,11 +89,11 @@ export default function MDXContent(props) {
}
// @ts-ignore
/** @typedef {0 extends 1 & Props ? {} : Props} MDXContentProps */
/** @typedef {(0 extends 1 & Props ? {} : Props) & {components?: {}}} MDXContentProps */
`

const fallback =
jsPrefix(false, 'react') + componentStart(false) + '<></>' + componentEnd
jsPrefix(false, 'react') + componentStart(false, []) + '<></>' + componentEnd

/**
* Visit an mdast tree with and enter and exit callback.
Expand Down Expand Up @@ -379,6 +391,29 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
let markdown = ''
let nextMarkdownSourceStart = 0

/** @type {Program} */
const esmProgram = {
type: 'Program',
sourceType: 'module',
start: 0,
end: 0,
body: []
}

for (const child of ast.children) {
if (child.type !== 'mdxjsEsm') {
continue
}

const estree = child.data?.estree

if (estree) {
esmProgram.body.push(...estree.body)
}
}

const variables = [...analyze(esmProgram).scope.declarations.keys()].sort()

/**
* Update the **markdown** mappings from a start and end offset of a **JavaScript** chunk.
*
Expand Down Expand Up @@ -487,7 +522,20 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
}

updateMarkdownFromOffsets(start, end)
jsx = addOffset(jsxMapping, mdx, jsx, start, end)
if (isInjectableComponent(node.name, variables)) {
const openingStart = start + 1
jsx = addOffset(
jsxMapping,
mdx,
addOffset(jsxMapping, mdx, jsx, start, openingStart) +
'_components.',
openingStart,
end
)
} else {
jsx = addOffset(jsxMapping, mdx, jsx, start, end)
}

break
}

Expand Down Expand Up @@ -533,7 +581,19 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
const end = getNodeEndOffset(node)

updateMarkdownFromOffsets(start, end)
jsx = addOffset(jsxMapping, mdx, jsx, start, end)
if (isInjectableComponent(node.name, variables)) {
const closingStart = start + 2
jsx = addOffset(
jsxMapping,
mdx,
addOffset(jsxMapping, mdx, jsx, start, closingStart) +
'_components.',
closingStart,
end
)
} else {
jsx = addOffset(jsxMapping, mdx, jsx, start, end)
}
}

break
Expand All @@ -557,7 +617,7 @@ function getEmbeddedCodes(mdx, ast, checkMdx, jsxImportSource) {
)

updateMarkdownFromOffsets(mdx.length, mdx.length)
esm += componentStart(hasAwait)
esm += componentStart(hasAwait, variables)

for (let i = 0; i < jsxMapping.generatedOffsets.length; i++) {
jsxMapping.generatedOffsets[i] += esm.length
Expand Down
1 change: 1 addition & 0 deletions packages/language-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"estree-walker": "^3.0.0",
"mdast-util-mdxjs-esm": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"periscopic": "^3.0.0",
"remark-mdx": "^3.0.0",
"remark-parse": "^11.0.0",
"unified": "^11.0.0",
Expand Down

0 comments on commit 3b6c5a8

Please sign in to comment.