mirror of
https://github.com/Snigdha-OS/documentation.git
synced 2025-09-07 19:25:13 +02:00
648 lines
21 KiB
JavaScript
648 lines
21 KiB
JavaScript
/**
|
||
* @typedef {import('estree-jsx').Expression} Expression
|
||
* @typedef {import('estree-jsx').Function} EstreeFunction
|
||
* @typedef {import('estree-jsx').Identifier} Identifier
|
||
* @typedef {import('estree-jsx').ImportSpecifier} ImportSpecifier
|
||
* @typedef {import('estree-jsx').JSXElement} JSXElement
|
||
* @typedef {import('estree-jsx').ModuleDeclaration} ModuleDeclaration
|
||
* @typedef {import('estree-jsx').Node} Node
|
||
* @typedef {import('estree-jsx').ObjectPattern} ObjectPattern
|
||
* @typedef {import('estree-jsx').Program} Program
|
||
* @typedef {import('estree-jsx').Property} Property
|
||
* @typedef {import('estree-jsx').SpreadElement} SpreadElement
|
||
* @typedef {import('estree-jsx').Statement} Statement
|
||
* @typedef {import('estree-jsx').VariableDeclarator} VariableDeclarator
|
||
*
|
||
* @typedef {import('periscopic').Scope} PeriscopicScope
|
||
*
|
||
* @typedef {import('vfile').VFile} VFile
|
||
*
|
||
* @typedef {import('../core.js').ProcessorOptions} ProcessorOptions
|
||
*/
|
||
|
||
/**
|
||
* @typedef {PeriscopicScope & {node: Node}} Scope
|
||
* Scope (with a `node`).
|
||
*
|
||
* @typedef StackEntry
|
||
* Entry.
|
||
* @property {Array<string>} components
|
||
* Used components.
|
||
* @property {Map<string, string>} idToInvalidComponentName
|
||
* Map of JSX identifiers which cannot be used as JS identifiers, to valid JS identifiers.
|
||
* @property {Readonly<EstreeFunction>} node
|
||
* Function.
|
||
* @property {Array<string>} objects
|
||
* Identifiers of used objects (such as `x` in `x.y`).
|
||
* @property {Record<string, {node: Readonly<JSXElement>, component: boolean}>} references
|
||
* Map of JSX identifiers for components and objects, to where they were first used.
|
||
* @property {Array<string>} tags
|
||
* Tag names.
|
||
*/
|
||
|
||
import {name as isIdentifierName} from 'estree-util-is-identifier-name'
|
||
import {walk} from 'estree-walker'
|
||
import {analyze} from 'periscopic'
|
||
import {stringifyPosition} from 'unist-util-stringify-position'
|
||
import {positionFromEstree} from 'unist-util-position-from-estree'
|
||
import {specifiersToDeclarations} from '../util/estree-util-specifiers-to-declarations.js'
|
||
import {toBinaryAddition} from '../util/estree-util-to-binary-addition.js'
|
||
import {
|
||
toIdOrMemberExpression,
|
||
toJsxIdOrMemberExpression
|
||
} from '../util/estree-util-to-id-or-member-expression.js'
|
||
|
||
/**
|
||
* A plugin that rewrites JSX in functions to accept components as
|
||
* `props.components` (when the function is called `_createMdxContent`), or from
|
||
* a provider (if there is one).
|
||
* It also makes sure that any undefined components are defined: either from
|
||
* received components or as a function that throws an error.
|
||
*
|
||
* @param {Readonly<ProcessorOptions>} options
|
||
* Configuration (optional).
|
||
* @returns
|
||
* Transform.
|
||
*/
|
||
export function recmaJsxRewrite(options) {
|
||
const {development, outputFormat, providerImportSource} = options
|
||
|
||
/**
|
||
* @param {Program} tree
|
||
* Tree.
|
||
* @param {VFile} file
|
||
* File.
|
||
* @returns {undefined}
|
||
* Nothing.
|
||
*/
|
||
return function (tree, file) {
|
||
// Find everything that’s defined in the top-level scope.
|
||
const scopeInfo = analyze(tree)
|
||
/** @type {Array<StackEntry>} */
|
||
const functionStack = []
|
||
let importProvider = false
|
||
let createErrorHelper = false
|
||
/** @type {Scope | undefined} */
|
||
let currentScope
|
||
|
||
walk(tree, {
|
||
enter(node) {
|
||
// Cast because we match `node`.
|
||
const newScope = /** @type {Scope | undefined} */ (
|
||
scopeInfo.map.get(node)
|
||
)
|
||
|
||
if (
|
||
node.type === 'FunctionDeclaration' ||
|
||
node.type === 'FunctionExpression' ||
|
||
node.type === 'ArrowFunctionExpression'
|
||
) {
|
||
functionStack.push({
|
||
components: [],
|
||
idToInvalidComponentName: new Map(),
|
||
node,
|
||
objects: [],
|
||
references: {},
|
||
tags: []
|
||
})
|
||
|
||
// MDXContent only ever contains MDXLayout
|
||
if (
|
||
isNamedFunction(node, 'MDXContent') &&
|
||
newScope &&
|
||
!inScope(newScope, 'MDXLayout')
|
||
) {
|
||
functionStack[0].components.push('MDXLayout')
|
||
}
|
||
}
|
||
|
||
const functionScope = functionStack[0]
|
||
if (
|
||
!functionScope ||
|
||
(!isNamedFunction(functionScope.node, '_createMdxContent') &&
|
||
!providerImportSource)
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (newScope) {
|
||
newScope.node = node
|
||
currentScope = newScope
|
||
}
|
||
|
||
if (currentScope && node.type === 'JSXElement') {
|
||
let name = node.openingElement.name
|
||
|
||
// `<x.y>`, `<Foo.Bar>`, `<x.y.z>`.
|
||
if (name.type === 'JSXMemberExpression') {
|
||
/** @type {Array<string>} */
|
||
const ids = []
|
||
|
||
// Find the left-most identifier.
|
||
while (name.type === 'JSXMemberExpression') {
|
||
ids.unshift(name.property.name)
|
||
name = name.object
|
||
}
|
||
|
||
ids.unshift(name.name)
|
||
const fullId = ids.join('.')
|
||
const id = name.name
|
||
|
||
const isInScope = inScope(currentScope, id)
|
||
|
||
if (!Object.hasOwn(functionScope.references, fullId)) {
|
||
// Cast because we match `node`.
|
||
const parentScope = /** @type {Scope | undefined} */ (
|
||
currentScope.parent
|
||
)
|
||
if (
|
||
!isInScope ||
|
||
// If the parent scope is `_createMdxContent`, then this
|
||
// references a component we can add a check statement for.
|
||
(parentScope &&
|
||
parentScope.node.type === 'FunctionDeclaration' &&
|
||
isNamedFunction(parentScope.node, '_createMdxContent'))
|
||
) {
|
||
functionScope.references[fullId] = {component: true, node}
|
||
}
|
||
}
|
||
|
||
if (!functionScope.objects.includes(id) && !isInScope) {
|
||
functionScope.objects.push(id)
|
||
}
|
||
}
|
||
// `<xml:thing>`.
|
||
else if (name.type === 'JSXNamespacedName') {
|
||
// Ignore namespaces.
|
||
}
|
||
// If the name is a valid ES identifier, and it doesn’t start with a
|
||
// lowercase letter, it’s a component.
|
||
// For example, `$foo`, `_bar`, `Baz` are all component names.
|
||
// But `foo` and `b-ar` are tag names.
|
||
else if (isIdentifierName(name.name) && !/^[a-z]/.test(name.name)) {
|
||
const id = name.name
|
||
|
||
if (!inScope(currentScope, id)) {
|
||
// No need to add an error for an undefined layout — we use an
|
||
// `if` later.
|
||
if (
|
||
id !== 'MDXLayout' &&
|
||
!Object.hasOwn(functionScope.references, id)
|
||
) {
|
||
functionScope.references[id] = {component: true, node}
|
||
}
|
||
|
||
if (!functionScope.components.includes(id)) {
|
||
functionScope.components.push(id)
|
||
}
|
||
}
|
||
} else if (node.data && node.data._mdxExplicitJsx) {
|
||
// Do not turn explicit JSX into components from `_components`.
|
||
// As in, a given `h1` component is used for `# heading` (next case),
|
||
// but not for `<h1>heading</h1>`.
|
||
} else {
|
||
const id = name.name
|
||
|
||
if (!functionScope.tags.includes(id)) {
|
||
functionScope.tags.push(id)
|
||
}
|
||
|
||
/** @type {Array<number | string>} */
|
||
let jsxIdExpression = ['_components', id]
|
||
if (isIdentifierName(id) === false) {
|
||
let invalidComponentName =
|
||
functionScope.idToInvalidComponentName.get(id)
|
||
if (invalidComponentName === undefined) {
|
||
invalidComponentName = `_component${functionScope.idToInvalidComponentName.size}`
|
||
functionScope.idToInvalidComponentName.set(
|
||
id,
|
||
invalidComponentName
|
||
)
|
||
}
|
||
|
||
jsxIdExpression = [invalidComponentName]
|
||
}
|
||
|
||
node.openingElement.name =
|
||
toJsxIdOrMemberExpression(jsxIdExpression)
|
||
|
||
if (node.closingElement) {
|
||
node.closingElement.name =
|
||
toJsxIdOrMemberExpression(jsxIdExpression)
|
||
}
|
||
}
|
||
}
|
||
},
|
||
leave(node) {
|
||
/** @type {Array<Property | SpreadElement>} */
|
||
const defaults = []
|
||
/** @type {Array<string>} */
|
||
const actual = []
|
||
/** @type {Array<Expression>} */
|
||
const parameters = []
|
||
/** @type {Array<VariableDeclarator>} */
|
||
const declarations = []
|
||
|
||
if (currentScope && currentScope.node === node) {
|
||
// Cast to patch our `node`.
|
||
currentScope = /** @type {Scope} */ (currentScope.parent)
|
||
}
|
||
|
||
if (
|
||
node.type === 'FunctionDeclaration' ||
|
||
node.type === 'FunctionExpression' ||
|
||
node.type === 'ArrowFunctionExpression'
|
||
) {
|
||
const scopeNode = node
|
||
const scope = functionStack[functionStack.length - 1]
|
||
/** @type {string} */
|
||
let name
|
||
|
||
for (name of scope.tags.sort()) {
|
||
defaults.push({
|
||
type: 'Property',
|
||
kind: 'init',
|
||
key: isIdentifierName(name)
|
||
? {type: 'Identifier', name}
|
||
: {type: 'Literal', value: name},
|
||
value: {type: 'Literal', value: name},
|
||
method: false,
|
||
shorthand: false,
|
||
computed: false
|
||
})
|
||
}
|
||
|
||
actual.push(...scope.components)
|
||
|
||
for (name of scope.objects) {
|
||
// In some cases, a component is used directly (`<X>`) but it’s also
|
||
// used as an object (`<X.Y>`).
|
||
if (!actual.includes(name)) {
|
||
actual.push(name)
|
||
}
|
||
}
|
||
|
||
actual.sort()
|
||
|
||
/** @type {Array<Statement>} */
|
||
const statements = []
|
||
|
||
if (
|
||
defaults.length > 0 ||
|
||
actual.length > 0 ||
|
||
scope.idToInvalidComponentName.size > 0
|
||
) {
|
||
if (providerImportSource) {
|
||
importProvider = true
|
||
parameters.push({
|
||
type: 'CallExpression',
|
||
callee: {type: 'Identifier', name: '_provideComponents'},
|
||
arguments: [],
|
||
optional: false
|
||
})
|
||
}
|
||
|
||
// Accept `components` as a prop if this is the `MDXContent` or
|
||
// `_createMdxContent` function.
|
||
if (
|
||
isNamedFunction(scope.node, 'MDXContent') ||
|
||
isNamedFunction(scope.node, '_createMdxContent')
|
||
) {
|
||
parameters.push(toIdOrMemberExpression(['props', 'components']))
|
||
}
|
||
|
||
if (defaults.length > 0 || parameters.length > 1) {
|
||
for (const parameter of parameters) {
|
||
defaults.push({type: 'SpreadElement', argument: parameter})
|
||
}
|
||
}
|
||
|
||
// If we’re getting components from several sources, merge them.
|
||
/** @type {Expression} */
|
||
let componentsInit =
|
||
defaults.length > 0
|
||
? {type: 'ObjectExpression', properties: defaults}
|
||
: // If we’re only getting components from `props.components`,
|
||
// make sure it’s defined.
|
||
{
|
||
type: 'LogicalExpression',
|
||
operator: '||',
|
||
left: parameters[0],
|
||
right: {type: 'ObjectExpression', properties: []}
|
||
}
|
||
|
||
/** @type {ObjectPattern | undefined} */
|
||
let componentsPattern
|
||
|
||
// Add components to scope.
|
||
// For `['MyComponent', 'MDXLayout']` this generates:
|
||
// ```tsx
|
||
// const {MyComponent, wrapper: MDXLayout} = _components
|
||
// ```
|
||
// Note that MDXLayout is special as it’s taken from
|
||
// `_components.wrapper`.
|
||
if (actual.length > 0) {
|
||
componentsPattern = {
|
||
type: 'ObjectPattern',
|
||
properties: actual.map(function (name) {
|
||
return {
|
||
type: 'Property',
|
||
kind: 'init',
|
||
key: {
|
||
type: 'Identifier',
|
||
name: name === 'MDXLayout' ? 'wrapper' : name
|
||
},
|
||
value: {type: 'Identifier', name},
|
||
method: false,
|
||
shorthand: name !== 'MDXLayout',
|
||
computed: false
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
if (scope.tags.length > 0) {
|
||
declarations.push({
|
||
type: 'VariableDeclarator',
|
||
id: {type: 'Identifier', name: '_components'},
|
||
init: componentsInit
|
||
})
|
||
componentsInit = {type: 'Identifier', name: '_components'}
|
||
}
|
||
|
||
if (isNamedFunction(scope.node, '_createMdxContent')) {
|
||
for (const [id, componentName] of [
|
||
...scope.idToInvalidComponentName
|
||
].sort(function ([a], [b]) {
|
||
return a.localeCompare(b)
|
||
})) {
|
||
// For JSX IDs that can’t be represented as JavaScript IDs (as in,
|
||
// those with dashes, such as `custom-element`), generate a
|
||
// separate variable that is a valid JS ID (such as `_component0`),
|
||
// and takes it from components:
|
||
// `const _component0 = _components['custom-element']`
|
||
declarations.push({
|
||
type: 'VariableDeclarator',
|
||
id: {
|
||
type: 'Identifier',
|
||
name: componentName
|
||
},
|
||
init: {
|
||
type: 'MemberExpression',
|
||
object: {type: 'Identifier', name: '_components'},
|
||
property: {type: 'Literal', value: id},
|
||
computed: true,
|
||
optional: false
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
if (componentsPattern) {
|
||
declarations.push({
|
||
type: 'VariableDeclarator',
|
||
id: componentsPattern,
|
||
init: componentsInit
|
||
})
|
||
}
|
||
|
||
if (declarations.length > 0) {
|
||
statements.push({
|
||
type: 'VariableDeclaration',
|
||
kind: 'const',
|
||
declarations
|
||
})
|
||
}
|
||
}
|
||
|
||
/** @type {string} */
|
||
let key
|
||
|
||
// Add partials (so for `x.y.z` it’d generate `x` and `x.y` too).
|
||
for (key in scope.references) {
|
||
if (Object.hasOwn(scope.references, key)) {
|
||
const parts = key.split('.')
|
||
let index = 0
|
||
while (++index < parts.length) {
|
||
const partial = parts.slice(0, index).join('.')
|
||
if (!Object.hasOwn(scope.references, partial)) {
|
||
scope.references[partial] = {
|
||
component: false,
|
||
node: scope.references[key].node
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const references = Object.keys(scope.references).sort()
|
||
let index = -1
|
||
while (++index < references.length) {
|
||
const id = references[index]
|
||
const info = scope.references[id]
|
||
const place = stringifyPosition(positionFromEstree(info.node))
|
||
/** @type {Array<Expression>} */
|
||
const parameters = [
|
||
{type: 'Literal', value: id},
|
||
{type: 'Literal', value: info.component}
|
||
]
|
||
|
||
createErrorHelper = true
|
||
|
||
if (development && place) {
|
||
parameters.push({type: 'Literal', value: place})
|
||
}
|
||
|
||
statements.push({
|
||
type: 'IfStatement',
|
||
test: {
|
||
type: 'UnaryExpression',
|
||
operator: '!',
|
||
prefix: true,
|
||
argument: toIdOrMemberExpression(id.split('.'))
|
||
},
|
||
consequent: {
|
||
type: 'ExpressionStatement',
|
||
expression: {
|
||
type: 'CallExpression',
|
||
callee: {type: 'Identifier', name: '_missingMdxReference'},
|
||
arguments: parameters,
|
||
optional: false
|
||
}
|
||
},
|
||
alternate: undefined
|
||
})
|
||
}
|
||
|
||
if (statements.length > 0) {
|
||
// Arrow functions with an implied return:
|
||
if (scopeNode.body.type !== 'BlockStatement') {
|
||
scopeNode.body = {
|
||
type: 'BlockStatement',
|
||
body: [{type: 'ReturnStatement', argument: scopeNode.body}]
|
||
}
|
||
}
|
||
|
||
scopeNode.body.body.unshift(...statements)
|
||
}
|
||
|
||
functionStack.pop()
|
||
}
|
||
}
|
||
})
|
||
|
||
// If a provider is used (and can be used), import it.
|
||
if (importProvider && providerImportSource) {
|
||
tree.body.unshift(
|
||
createImportProvider(providerImportSource, outputFormat)
|
||
)
|
||
}
|
||
|
||
// If potentially missing components are used.
|
||
if (createErrorHelper) {
|
||
/** @type {Array<Expression>} */
|
||
const message = [
|
||
{type: 'Literal', value: 'Expected '},
|
||
{
|
||
type: 'ConditionalExpression',
|
||
test: {type: 'Identifier', name: 'component'},
|
||
consequent: {type: 'Literal', value: 'component'},
|
||
alternate: {type: 'Literal', value: 'object'}
|
||
},
|
||
{type: 'Literal', value: ' `'},
|
||
{type: 'Identifier', name: 'id'},
|
||
{
|
||
type: 'Literal',
|
||
value:
|
||
'` to be defined: you likely forgot to import, pass, or provide it.'
|
||
}
|
||
]
|
||
|
||
/** @type {Array<Identifier>} */
|
||
const parameters = [
|
||
{type: 'Identifier', name: 'id'},
|
||
{type: 'Identifier', name: 'component'}
|
||
]
|
||
|
||
if (development) {
|
||
message.push({
|
||
type: 'ConditionalExpression',
|
||
test: {type: 'Identifier', name: 'place'},
|
||
consequent: toBinaryAddition([
|
||
{type: 'Literal', value: '\nIt’s referenced in your code at `'},
|
||
{type: 'Identifier', name: 'place'},
|
||
{
|
||
type: 'Literal',
|
||
value: (file.path ? '` in `' + file.path : '') + '`'
|
||
}
|
||
]),
|
||
alternate: {type: 'Literal', value: ''}
|
||
})
|
||
|
||
parameters.push({type: 'Identifier', name: 'place'})
|
||
}
|
||
|
||
tree.body.push({
|
||
type: 'FunctionDeclaration',
|
||
id: {type: 'Identifier', name: '_missingMdxReference'},
|
||
generator: false,
|
||
async: false,
|
||
params: parameters,
|
||
body: {
|
||
type: 'BlockStatement',
|
||
body: [
|
||
{
|
||
type: 'ThrowStatement',
|
||
argument: {
|
||
type: 'NewExpression',
|
||
callee: {type: 'Identifier', name: 'Error'},
|
||
arguments: [toBinaryAddition(message)]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
})
|
||
}
|
||
|
||
if (outputFormat === 'function-body') {
|
||
tree.body.unshift({
|
||
type: 'ExpressionStatement',
|
||
expression: {type: 'Literal', value: 'use strict'},
|
||
directive: 'use strict'
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} providerImportSource
|
||
* Provider source.
|
||
* @param {'function-body' | 'program' | null | undefined} outputFormat
|
||
* Format.
|
||
* @returns {ModuleDeclaration | Statement}
|
||
* Node.
|
||
*/
|
||
function createImportProvider(providerImportSource, outputFormat) {
|
||
/** @type {Array<ImportSpecifier>} */
|
||
const specifiers = [
|
||
{
|
||
type: 'ImportSpecifier',
|
||
imported: {type: 'Identifier', name: 'useMDXComponents'},
|
||
local: {type: 'Identifier', name: '_provideComponents'}
|
||
}
|
||
]
|
||
|
||
return outputFormat === 'function-body'
|
||
? {
|
||
type: 'VariableDeclaration',
|
||
kind: 'const',
|
||
declarations: specifiersToDeclarations(
|
||
specifiers,
|
||
toIdOrMemberExpression(['arguments', 0])
|
||
)
|
||
}
|
||
: {
|
||
type: 'ImportDeclaration',
|
||
specifiers,
|
||
source: {type: 'Literal', value: providerImportSource}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {Readonly<EstreeFunction>} node
|
||
* Node.
|
||
* @param {string} name
|
||
* Name.
|
||
* @returns {boolean}
|
||
* Whether `node` is a named function with `name`.
|
||
*/
|
||
function isNamedFunction(node, name) {
|
||
return Boolean(node && 'id' in node && node.id && node.id.name === name)
|
||
}
|
||
|
||
/**
|
||
* @param {Readonly<Scope>} scope
|
||
* Scope.
|
||
* @param {string} id
|
||
* Identifier.
|
||
* @returns {boolean}
|
||
* Whether `id` is in `scope`.
|
||
*/
|
||
function inScope(scope, id) {
|
||
/** @type {Scope | undefined} */
|
||
let currentScope = scope
|
||
|
||
while (currentScope) {
|
||
if (currentScope.declarations.has(id)) {
|
||
return true
|
||
}
|
||
|
||
// Cast to patch our `node`.
|
||
currentScope = /** @type {Scope | undefined} */ (
|
||
currentScope.parent || undefined
|
||
)
|
||
}
|
||
|
||
return false
|
||
}
|