import type * as estree from 'estree' import { name as isIdentifierName } from 'estree-util-is-identifier-name' import { createVisitors } from 'estree-util-scope' import { walk } from 'estree-walker' import type * as hast from 'hast' import type * as mdast from 'mdast' import { type VFile } from 'vfile' export namespace define { /** * A mapping of variables to define. They keys are the names. The values are the ESTree expression * to represent them. */ export type Variables = Record /** * Options for {@link define} */ export interface Options { /** * If and how to export the variable. * * - `'module'`: Export the value using an ESM const export declaration. * - `'namespace'`: Attach the value as a property on `MDXContent`. * - `false`: Define the variable locally, but don’t export it. * * @default 'module' */ export?: 'module' | 'namespace' | false | undefined /** * What to do if there’s a name conflict. * * - `'skip'`: Don’t insert the variable if there’s a name conflict. * - `'throw'`: Throw if there’s a name conflict. * - `'warn'`: Emit a vfile warning, but don’t throw. * * @default 'throw' */ conflict?: 'skip' | 'throw' | 'warn' | undefined } } /** * @param program * The ESTree program to scan. * @param file * The {@link VFile} to emit warnings to. * @param variables * The variables that should be injected. * @param options * {@link define}.options * @returns * The position in the body where the export may be injected. */ function scan( program: estree.Program, file: VFile, variables: Map, options: define.Options | undefined ): number { const visitors = createVisitors() const [scope] = visitors.scopes const identifiers = new Map() let injectIndex = 0 walk(program, { enter(node, parent) { visitors.enter(node) switch (node.type) { case 'Identifier': if (scope.defined.includes(node.name) && !identifiers.has(node.name)) { identifiers.set(node.name, node) } break case 'ArrowFunctionExpression': case 'ClassDeclaration': case 'ClassExpression': case 'FunctionExpression': case 'FunctionDeclaration': this.skip() break // Don’t insert before directives. case 'ExpressionStatement': if ( parent === program && node.expression.type === 'Literal' && typeof node.expression.value === 'string' ) { injectIndex = program.body.indexOf(node) + 1 } break default: } }, leave: visitors.exit }) for (const name of scope.defined) { if (variables.has(name)) { if (options?.conflict !== 'skip') { const identifier = identifiers.get(name) const message = file.message(`Variable name conflict: ${name}`, { place: identifier?.loc, ruleId: 'conflict', source: 'unist-util-mdx-define' }) message.url = 'https://github.com/remcohaszing/unist-util-mdx-define' if (options?.conflict !== 'warn') { message.fatal = true throw message } } variables.delete(name) } } return injectIndex } /** * Generate an export named declaration. * * @param variables * The variables for which to generate an declaration. * @param options * {@link define} options * @param returnStatement * The return statement of the program to inject into. * @returns * The export named declaration. */ function generate( variables: Map, options: define.Options | undefined, returnStatement?: estree.ReturnStatement | undefined ): (estree.ModuleDeclaration | estree.Statement)[] { if (options?.export === 'namespace') { const statements: estree.Statement[] = [] for (const [name, right] of variables) { const isIdentifier = isIdentifierName(name) statements.push({ type: 'ExpressionStatement', expression: { type: 'AssignmentExpression', left: { type: 'MemberExpression', computed: !isIdentifier, object: { type: 'Identifier', name: 'MDXContent' }, optional: false, property: isIdentifier ? { type: 'Identifier', name } : { type: 'Literal', value: name } }, operator: '=', right } }) } return statements } const declarations: estree.Declaration[] = [] for (const [name, init] of variables) { declarations.push({ type: 'VariableDeclaration', kind: 'const', declarations: [ { type: 'VariableDeclarator', id: { type: 'Identifier', name }, init } ] }) } if (options?.export === false) { return declarations } if (!returnStatement) { return declarations.map((declaration) => ({ type: 'ExportNamedDeclaration', declaration, specifiers: [] })) } if (returnStatement.argument?.type === 'ObjectExpression') { returnStatement.argument.properties.splice( -1, 0, ...Array.from( variables.keys(), (name): estree.Property => ({ type: 'Property', computed: false, kind: 'init', method: false, shorthand: true, key: { type: 'Identifier', name }, value: { type: 'Identifier', name } }) ) ) } return declarations } /** * Define variables in an MDX related AST. * * @param ast * The AST in which to define an export * @param file * The {@link VFile} to emit warnings to. * @param variables * A mapping of variables to define. They keys are the names. The values are the ESTree expression * to represent them. * @param options * Additional options to configure behaviour. */ export function define( ast: estree.Program | hast.Root | mdast.Root, file: VFile, variables: define.Variables, options?: define.Options | undefined ): undefined { const map = new Map(Object.entries(variables)) if (options?.export !== 'namespace') { for (const name of map.keys()) { if ( name === '_createMdxContent' || name === '_Fragment' || name === '_jsx' || name === '_jsxs' || name === '_missingMdxReference' || name === 'MDXContent' ) { const message = file.message(`MDX internal name conflict: ${name}`, { ruleId: 'internal', source: 'unist-util-mdx-define' }) message.url = 'https://github.com/remcohaszing/unist-util-mdx-define' message.fatal = true throw message } if (!isIdentifierName(name)) { const message = file.message(`Invalid identifier name: ${name}`, { ruleId: 'invalid-identifier', source: 'unist-util-mdx-define' }) message.url = 'https://github.com/remcohaszing/unist-util-mdx-define' message.fatal = true throw message } } } if (ast.type === 'root') { for (const child of ast.children) { if (child.type !== 'mdxjsEsm') { continue } const program = child.data?.estree /* c8 ignore start */ if (!program) { continue } /* c8 ignore stop */ scan(program, file, map, options) } if (map.size) { ast.children.unshift({ type: 'mdxjsEsm', value: '', data: { estree: { type: 'Program', sourceType: 'module', body: generate(map, options) } } }) } } else { const returnStatement = ast.body.find((node) => node.type === 'ReturnStatement') const injectIndex = scan(ast, file, map, options) if (map.size) { ast.body.splice(injectIndex, 0, ...generate(map, options, returnStatement)) } } }