604 lines
20 KiB
JavaScript
604 lines
20 KiB
JavaScript
/**
|
||
* @typedef {import('estree-jsx').Directive} Directive
|
||
* @typedef {import('estree-jsx').ExportAllDeclaration} ExportAllDeclaration
|
||
* @typedef {import('estree-jsx').ExportDefaultDeclaration} ExportDefaultDeclaration
|
||
* @typedef {import('estree-jsx').ExportNamedDeclaration} ExportNamedDeclaration
|
||
* @typedef {import('estree-jsx').ExportSpecifier} ExportSpecifier
|
||
* @typedef {import('estree-jsx').Expression} Expression
|
||
* @typedef {import('estree-jsx').FunctionDeclaration} FunctionDeclaration
|
||
* @typedef {import('estree-jsx').ImportDeclaration} ImportDeclaration
|
||
* @typedef {import('estree-jsx').ImportDefaultSpecifier} ImportDefaultSpecifier
|
||
* @typedef {import('estree-jsx').ImportExpression} ImportExpression
|
||
* @typedef {import('estree-jsx').ImportSpecifier} ImportSpecifier
|
||
* @typedef {import('estree-jsx').Literal} Literal
|
||
* @typedef {import('estree-jsx').JSXElement} JSXElement
|
||
* @typedef {import('estree-jsx').ModuleDeclaration} ModuleDeclaration
|
||
* @typedef {import('estree-jsx').Node} Node
|
||
* @typedef {import('estree-jsx').Program} Program
|
||
* @typedef {import('estree-jsx').Property} Property
|
||
* @typedef {import('estree-jsx').SimpleLiteral} SimpleLiteral
|
||
* @typedef {import('estree-jsx').SpreadElement} SpreadElement
|
||
* @typedef {import('estree-jsx').Statement} Statement
|
||
* @typedef {import('estree-jsx').VariableDeclarator} VariableDeclarator
|
||
*/
|
||
|
||
/**
|
||
* @typedef RecmaDocumentOptions
|
||
* Configuration for internal plugin `recma-document`.
|
||
* @property {'function-body' | 'program' | null | undefined} [outputFormat='program']
|
||
* Whether to use either `import` and `export` statements to get the runtime
|
||
* (and optionally provider) and export the content, or get values from
|
||
* `arguments` and return things.
|
||
* @property {boolean | null | undefined} [useDynamicImport=false]
|
||
* Whether to keep `import` (and `export … from`) statements or compile them
|
||
* to dynamic `import()` instead.
|
||
* @property {string | null | undefined} [baseUrl]
|
||
* Resolve `import`s (and `export … from`, and `import.meta.url`) relative to
|
||
* this URL.
|
||
* @property {string | null | undefined} [pragma='React.createElement']
|
||
* Pragma for JSX (used in classic runtime).
|
||
* @property {string | null | undefined} [pragmaFrag='React.Fragment']
|
||
* Pragma for JSX fragments (used in classic runtime).
|
||
* @property {string | null | undefined} [pragmaImportSource='react']
|
||
* Where to import the identifier of `pragma` from (used in classic runtime).
|
||
* @property {string | null | undefined} [jsxImportSource='react']
|
||
* Place to import automatic JSX runtimes from (used in automatic runtime).
|
||
* @property {'automatic' | 'classic' | null | undefined} [jsxRuntime='automatic']
|
||
* JSX runtime to use.
|
||
*/
|
||
|
||
import {analyze} from 'periscopic'
|
||
import {stringifyPosition} from 'unist-util-stringify-position'
|
||
import {positionFromEstree} from 'unist-util-position-from-estree'
|
||
import {walk} from 'estree-walker'
|
||
import {create} from '../util/estree-util-create.js'
|
||
import {specifiersToDeclarations} from '../util/estree-util-specifiers-to-declarations.js'
|
||
import {declarationToExpression} from '../util/estree-util-declaration-to-expression.js'
|
||
import {isDeclaration} from '../util/estree-util-is-declaration.js'
|
||
|
||
/**
|
||
* A plugin to wrap the estree in `MDXContent`.
|
||
*
|
||
* @type {import('unified').Plugin<[RecmaDocumentOptions | null | undefined] | [], Program>}
|
||
*/
|
||
export function recmaDocument(options) {
|
||
// Always given inside `@mdx-js/mdx`
|
||
/* c8 ignore next */
|
||
const options_ = options || {}
|
||
const baseUrl = options_.baseUrl || undefined
|
||
const useDynamicImport = options_.useDynamicImport || undefined
|
||
const outputFormat = options_.outputFormat || 'program'
|
||
const pragma =
|
||
options_.pragma === undefined ? 'React.createElement' : options_.pragma
|
||
const pragmaFrag =
|
||
options_.pragmaFrag === undefined ? 'React.Fragment' : options_.pragmaFrag
|
||
const pragmaImportSource = options_.pragmaImportSource || 'react'
|
||
const jsxImportSource = options_.jsxImportSource || 'react'
|
||
const jsxRuntime = options_.jsxRuntime || 'automatic'
|
||
|
||
return (tree, file) => {
|
||
/** @type {Array<[string, string] | string>} */
|
||
const exportedIdentifiers = []
|
||
/** @type {Array<Directive | ModuleDeclaration | Statement>} */
|
||
const replacement = []
|
||
/** @type {Array<string>} */
|
||
const pragmas = []
|
||
let exportAllCount = 0
|
||
/** @type {ExportDefaultDeclaration | ExportSpecifier | undefined} */
|
||
let layout
|
||
/** @type {boolean | undefined} */
|
||
let content
|
||
/** @type {Node} */
|
||
let child
|
||
|
||
// Patch missing comments, which types say could occur.
|
||
/* c8 ignore next */
|
||
if (!tree.comments) tree.comments = []
|
||
|
||
if (jsxRuntime) {
|
||
pragmas.push('@jsxRuntime ' + jsxRuntime)
|
||
}
|
||
|
||
if (jsxRuntime === 'automatic' && jsxImportSource) {
|
||
pragmas.push('@jsxImportSource ' + jsxImportSource)
|
||
}
|
||
|
||
if (jsxRuntime === 'classic' && pragma) {
|
||
pragmas.push('@jsx ' + pragma)
|
||
}
|
||
|
||
if (jsxRuntime === 'classic' && pragmaFrag) {
|
||
pragmas.push('@jsxFrag ' + pragmaFrag)
|
||
}
|
||
|
||
if (pragmas.length > 0) {
|
||
tree.comments.unshift({type: 'Block', value: pragmas.join(' ')})
|
||
}
|
||
|
||
if (jsxRuntime === 'classic' && pragmaImportSource) {
|
||
if (!pragma) {
|
||
throw new Error(
|
||
'Missing `pragma` in classic runtime with `pragmaImportSource`'
|
||
)
|
||
}
|
||
|
||
handleEsm({
|
||
type: 'ImportDeclaration',
|
||
specifiers: [
|
||
{
|
||
type: 'ImportDefaultSpecifier',
|
||
local: {type: 'Identifier', name: pragma.split('.')[0]}
|
||
}
|
||
],
|
||
source: {type: 'Literal', value: pragmaImportSource}
|
||
})
|
||
}
|
||
|
||
// Find the `export default`, the JSX expression, and leave the rest
|
||
// (import/exports) as they are.
|
||
for (child of tree.body) {
|
||
// ```js
|
||
// export default props => <>{props.children}</>
|
||
// ```
|
||
//
|
||
// Treat it as an inline layout declaration.
|
||
if (child.type === 'ExportDefaultDeclaration') {
|
||
if (layout) {
|
||
file.fail(
|
||
'Cannot specify multiple layouts (previous: ' +
|
||
stringifyPosition(positionFromEstree(layout)) +
|
||
')',
|
||
positionFromEstree(child),
|
||
'recma-document:duplicate-layout'
|
||
)
|
||
}
|
||
|
||
layout = child
|
||
replacement.push({
|
||
type: 'VariableDeclaration',
|
||
kind: 'const',
|
||
declarations: [
|
||
{
|
||
type: 'VariableDeclarator',
|
||
id: {type: 'Identifier', name: 'MDXLayout'},
|
||
init: isDeclaration(child.declaration)
|
||
? declarationToExpression(child.declaration)
|
||
: child.declaration
|
||
}
|
||
]
|
||
})
|
||
}
|
||
// ```js
|
||
// export {a, b as c} from 'd'
|
||
// ```
|
||
else if (child.type === 'ExportNamedDeclaration' && child.source) {
|
||
const source = /** @type {SimpleLiteral} */ (child.source)
|
||
|
||
// Remove `default` or `as default`, but not `default as`, specifier.
|
||
child.specifiers = child.specifiers.filter((specifier) => {
|
||
if (specifier.exported.name === 'default') {
|
||
if (layout) {
|
||
file.fail(
|
||
'Cannot specify multiple layouts (previous: ' +
|
||
stringifyPosition(positionFromEstree(layout)) +
|
||
')',
|
||
positionFromEstree(child),
|
||
'recma-document:duplicate-layout'
|
||
)
|
||
}
|
||
|
||
layout = specifier
|
||
|
||
// Make it just an import: `import MDXLayout from '…'`.
|
||
/** @type {Array<ImportDefaultSpecifier | ImportSpecifier>} */
|
||
const specifiers = []
|
||
|
||
// Default as default / something else as default.
|
||
if (specifier.local.name === 'default') {
|
||
specifiers.push({
|
||
type: 'ImportDefaultSpecifier',
|
||
local: {type: 'Identifier', name: 'MDXLayout'}
|
||
})
|
||
} else {
|
||
/** @type {ImportSpecifier} */
|
||
const importSpecifier = {
|
||
type: 'ImportSpecifier',
|
||
imported: specifier.local,
|
||
local: {type: 'Identifier', name: 'MDXLayout'}
|
||
}
|
||
create(specifier.local, importSpecifier)
|
||
specifiers.push(importSpecifier)
|
||
}
|
||
|
||
/** @type {Literal} */
|
||
const from = {type: 'Literal', value: source.value}
|
||
create(source, from)
|
||
|
||
/** @type {ImportDeclaration} */
|
||
const declaration = {
|
||
type: 'ImportDeclaration',
|
||
specifiers,
|
||
source: from
|
||
}
|
||
create(specifier, declaration)
|
||
handleEsm(declaration)
|
||
|
||
return false
|
||
}
|
||
|
||
return true
|
||
})
|
||
|
||
// If there are other things imported, keep it.
|
||
if (child.specifiers.length > 0) {
|
||
handleExport(child)
|
||
}
|
||
}
|
||
// ```js
|
||
// export {a, b as c}
|
||
// export * from 'a'
|
||
// ```
|
||
else if (
|
||
child.type === 'ExportNamedDeclaration' ||
|
||
child.type === 'ExportAllDeclaration'
|
||
) {
|
||
handleExport(child)
|
||
} else if (child.type === 'ImportDeclaration') {
|
||
handleEsm(child)
|
||
} else if (
|
||
child.type === 'ExpressionStatement' &&
|
||
// @ts-expect-error types are wrong: `JSXFragment` is an `Expression`.
|
||
(child.expression.type === 'JSXFragment' ||
|
||
child.expression.type === 'JSXElement')
|
||
) {
|
||
content = true
|
||
replacement.push(...createMdxContent(child.expression, Boolean(layout)))
|
||
// The following catch-all branch is because plugins might’ve added
|
||
// other things.
|
||
// Normally, we only have import/export/jsx, but just add whatever’s
|
||
// there.
|
||
/* c8 ignore next 3 */
|
||
} else {
|
||
replacement.push(child)
|
||
}
|
||
}
|
||
|
||
// If there was no JSX content at all, add an empty function.
|
||
if (!content) {
|
||
replacement.push(...createMdxContent(undefined, Boolean(layout)))
|
||
}
|
||
|
||
exportedIdentifiers.push(['MDXContent', 'default'])
|
||
|
||
if (outputFormat === 'function-body') {
|
||
replacement.push({
|
||
type: 'ReturnStatement',
|
||
argument: {
|
||
type: 'ObjectExpression',
|
||
properties: [
|
||
...Array.from({length: exportAllCount}).map(
|
||
/**
|
||
* @param {undefined} _
|
||
* @param {number} index
|
||
* @returns {SpreadElement}
|
||
*/
|
||
(_, index) => ({
|
||
type: 'SpreadElement',
|
||
argument: {type: 'Identifier', name: '_exportAll' + (index + 1)}
|
||
})
|
||
),
|
||
...exportedIdentifiers.map((d) => {
|
||
/** @type {Property} */
|
||
const prop = {
|
||
type: 'Property',
|
||
kind: 'init',
|
||
method: false,
|
||
computed: false,
|
||
shorthand: typeof d === 'string',
|
||
key: {
|
||
type: 'Identifier',
|
||
name: typeof d === 'string' ? d : d[1]
|
||
},
|
||
value: {
|
||
type: 'Identifier',
|
||
name: typeof d === 'string' ? d : d[0]
|
||
}
|
||
}
|
||
|
||
return prop
|
||
})
|
||
]
|
||
}
|
||
})
|
||
} else {
|
||
replacement.push({
|
||
type: 'ExportDefaultDeclaration',
|
||
declaration: {type: 'Identifier', name: 'MDXContent'}
|
||
})
|
||
}
|
||
|
||
tree.body = replacement
|
||
|
||
if (baseUrl) {
|
||
walk(tree, {
|
||
enter(node) {
|
||
if (
|
||
node.type === 'MemberExpression' &&
|
||
'object' in node &&
|
||
node.object.type === 'MetaProperty' &&
|
||
node.property.type === 'Identifier' &&
|
||
node.object.meta.name === 'import' &&
|
||
node.object.property.name === 'meta' &&
|
||
node.property.name === 'url'
|
||
) {
|
||
/** @type {SimpleLiteral} */
|
||
const replacement = {type: 'Literal', value: baseUrl}
|
||
this.replace(replacement)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* @param {ExportAllDeclaration | ExportNamedDeclaration} node
|
||
* @returns {void}
|
||
*/
|
||
function handleExport(node) {
|
||
if (node.type === 'ExportNamedDeclaration') {
|
||
// ```js
|
||
// export function a() {}
|
||
// export class A {}
|
||
// export var a = 1
|
||
// ```
|
||
if (node.declaration) {
|
||
exportedIdentifiers.push(
|
||
...analyze(node.declaration).scope.declarations.keys()
|
||
)
|
||
}
|
||
|
||
// ```js
|
||
// export {a, b as c}
|
||
// export {a, b as c} from 'd'
|
||
// ```
|
||
for (child of node.specifiers) {
|
||
exportedIdentifiers.push(child.exported.name)
|
||
}
|
||
}
|
||
|
||
handleEsm(node)
|
||
}
|
||
|
||
/**
|
||
* @param {ExportAllDeclaration | ExportNamedDeclaration | ImportDeclaration} node
|
||
* @returns {void}
|
||
*/
|
||
function handleEsm(node) {
|
||
// Rewrite the source of the `import` / `export … from`.
|
||
// See: <https://html.spec.whatwg.org/multipage/webappapis.html#resolve-a-module-specifier>
|
||
if (baseUrl && node.source) {
|
||
let value = String(node.source.value)
|
||
|
||
try {
|
||
// A full valid URL.
|
||
value = String(new URL(value))
|
||
} catch {
|
||
// Relative: `/example.js`, `./example.js`, and `../example.js`.
|
||
if (/^\.{0,2}\//.test(value)) {
|
||
value = String(new URL(value, baseUrl))
|
||
}
|
||
// Otherwise, it’s a bare specifiers.
|
||
// For example `some-package`, `@some-package`, and
|
||
// `some-package/path`.
|
||
// These are supported in Node and browsers plan to support them
|
||
// with import maps (<https://github.com/WICG/import-maps>).
|
||
}
|
||
|
||
/** @type {Literal} */
|
||
const literal = {type: 'Literal', value}
|
||
create(node.source, literal)
|
||
node.source = literal
|
||
}
|
||
|
||
/** @type {ModuleDeclaration | Statement | undefined} */
|
||
let replace
|
||
/** @type {Expression} */
|
||
let init
|
||
|
||
if (outputFormat === 'function-body') {
|
||
if (
|
||
// Always have a source:
|
||
node.type === 'ImportDeclaration' ||
|
||
node.type === 'ExportAllDeclaration' ||
|
||
// Source optional:
|
||
(node.type === 'ExportNamedDeclaration' && node.source)
|
||
) {
|
||
if (!useDynamicImport) {
|
||
file.fail(
|
||
'Cannot use `import` or `export … from` in `evaluate` (outputting a function body) by default: please set `useDynamicImport: true` (and probably specify a `baseUrl`)',
|
||
positionFromEstree(node),
|
||
'recma-document:invalid-esm-statement'
|
||
)
|
||
}
|
||
|
||
// Just for types.
|
||
/* c8 ignore next 3 */
|
||
if (!node.source) {
|
||
throw new Error('Expected `node.source` to be defined')
|
||
}
|
||
|
||
// ```
|
||
// import 'a'
|
||
// //=> await import('a')
|
||
// import a from 'b'
|
||
// //=> const {default: a} = await import('b')
|
||
// export {a, b as c} from 'd'
|
||
// //=> const {a, c: b} = await import('d')
|
||
// export * from 'a'
|
||
// //=> const _exportAll0 = await import('a')
|
||
// ```
|
||
/** @type {ImportExpression} */
|
||
const argument = {type: 'ImportExpression', source: node.source}
|
||
create(node, argument)
|
||
init = {type: 'AwaitExpression', argument}
|
||
|
||
if (
|
||
(node.type === 'ImportDeclaration' ||
|
||
node.type === 'ExportNamedDeclaration') &&
|
||
node.specifiers.length === 0
|
||
) {
|
||
replace = {type: 'ExpressionStatement', expression: init}
|
||
} else {
|
||
replace = {
|
||
type: 'VariableDeclaration',
|
||
kind: 'const',
|
||
declarations:
|
||
node.type === 'ExportAllDeclaration'
|
||
? [
|
||
{
|
||
type: 'VariableDeclarator',
|
||
id: {
|
||
type: 'Identifier',
|
||
name: '_exportAll' + ++exportAllCount
|
||
},
|
||
init
|
||
}
|
||
]
|
||
: specifiersToDeclarations(node.specifiers, init)
|
||
}
|
||
}
|
||
} else if (node.declaration) {
|
||
replace = node.declaration
|
||
} else {
|
||
/** @type {Array<VariableDeclarator>} */
|
||
const declarators = node.specifiers
|
||
.filter(
|
||
(specifier) => specifier.local.name !== specifier.exported.name
|
||
)
|
||
.map((specifier) => ({
|
||
type: 'VariableDeclarator',
|
||
id: specifier.exported,
|
||
init: specifier.local
|
||
}))
|
||
|
||
if (declarators.length > 0) {
|
||
replace = {
|
||
type: 'VariableDeclaration',
|
||
kind: 'const',
|
||
declarations: declarators
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
replace = node
|
||
}
|
||
|
||
if (replace) {
|
||
replacement.push(replace)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {Expression | undefined} [content]
|
||
* @param {boolean | undefined} [hasInternalLayout]
|
||
* @returns {Array<FunctionDeclaration>}
|
||
*/
|
||
function createMdxContent(content, hasInternalLayout) {
|
||
/** @type {JSXElement} */
|
||
const element = {
|
||
type: 'JSXElement',
|
||
openingElement: {
|
||
type: 'JSXOpeningElement',
|
||
name: {type: 'JSXIdentifier', name: 'MDXLayout'},
|
||
attributes: [
|
||
{
|
||
type: 'JSXSpreadAttribute',
|
||
argument: {type: 'Identifier', name: 'props'}
|
||
}
|
||
],
|
||
selfClosing: false
|
||
},
|
||
closingElement: {
|
||
type: 'JSXClosingElement',
|
||
name: {type: 'JSXIdentifier', name: 'MDXLayout'}
|
||
},
|
||
children: [
|
||
{
|
||
type: 'JSXElement',
|
||
openingElement: {
|
||
type: 'JSXOpeningElement',
|
||
name: {type: 'JSXIdentifier', name: '_createMdxContent'},
|
||
attributes: [
|
||
{
|
||
type: 'JSXSpreadAttribute',
|
||
argument: {type: 'Identifier', name: 'props'}
|
||
}
|
||
],
|
||
selfClosing: true
|
||
},
|
||
closingElement: null,
|
||
children: []
|
||
}
|
||
]
|
||
}
|
||
|
||
let result = /** @type {Expression} */ (element)
|
||
|
||
if (!hasInternalLayout) {
|
||
result = {
|
||
type: 'ConditionalExpression',
|
||
test: {type: 'Identifier', name: 'MDXLayout'},
|
||
consequent: result,
|
||
alternate: {
|
||
type: 'CallExpression',
|
||
callee: {type: 'Identifier', name: '_createMdxContent'},
|
||
arguments: [{type: 'Identifier', name: 'props'}],
|
||
optional: false
|
||
}
|
||
}
|
||
}
|
||
|
||
let argument = content || {type: 'Literal', value: null}
|
||
|
||
// Unwrap a fragment of a single element.
|
||
if (
|
||
argument &&
|
||
// @ts-expect-error: fine.
|
||
argument.type === 'JSXFragment' &&
|
||
// @ts-expect-error: fine.
|
||
argument.children.length === 1 &&
|
||
// @ts-expect-error: fine.
|
||
argument.children[0].type === 'JSXElement'
|
||
) {
|
||
// @ts-expect-error: fine.
|
||
argument = argument.children[0]
|
||
}
|
||
|
||
return [
|
||
{
|
||
type: 'FunctionDeclaration',
|
||
id: {type: 'Identifier', name: '_createMdxContent'},
|
||
params: [{type: 'Identifier', name: 'props'}],
|
||
body: {
|
||
type: 'BlockStatement',
|
||
body: [{type: 'ReturnStatement', argument}]
|
||
}
|
||
},
|
||
{
|
||
type: 'FunctionDeclaration',
|
||
id: {type: 'Identifier', name: 'MDXContent'},
|
||
params: [
|
||
{
|
||
type: 'AssignmentPattern',
|
||
left: {type: 'Identifier', name: 'props'},
|
||
right: {type: 'ObjectExpression', properties: []}
|
||
}
|
||
],
|
||
body: {
|
||
type: 'BlockStatement',
|
||
body: [{type: 'ReturnStatement', argument: result}]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|