import path from 'node:path'; import type { GetModuleInfo, Plugin } from 'rollup'; import { Path } from '@boost/common'; import type { TSESTree } from '@typescript-eslint/types'; import type { InputMap } from '../../types'; export interface AddMjsWrapperOptions { inputs: InputMap; packageRoot: Path; } export type ExternalExport = | { type: 'export-all'; namespace?: string; source: string; } | { type: 'export-named'; names: string[]; source: string; }; export interface ExtractedExports { externalExports: ExternalExport[]; namedExports: string[]; defaultExport: boolean; } function isExternalSource(source?: { value: string } | undefined): boolean { return !!source && !source.value.startsWith('.'); } function extractName(node: TSESTree.Node): string[] | string { switch (node.type) { case 'Identifier': return node.name; case 'Literal': return String(node.value); // class Foo case 'ClassDeclaration': if (node.id) { return extractName(node.id); } break; // function foo case 'FunctionDeclaration': if (node.id) { return extractName(node.id); } break; // const foo = ... case 'VariableDeclaration': return node.declarations.flatMap((decl) => extractName(decl.id)).filter(Boolean); // const [foo, bar] = ... case 'ArrayPattern': return node.elements.flatMap((el) => (el ? extractName(el) : '')).filter(Boolean); // const { foo, bar } = ... case 'ObjectPattern': return node.properties .flatMap((prop) => (prop.type === 'Property' ? extractName(prop.value) : '')) .filter(Boolean); default: break; } return ''; } function extractScopedIdentifiers(node: TSESTree.Program) { // id -> source const imports: Record = {}; // id const types: Set = new Set(); node.body.forEach((item) => { if ( item.type === 'TSTypeAliasDeclaration' || item.type === 'TSInterfaceDeclaration' || item.type === 'TSEnumDeclaration' ) { types.add(item.id.name); } if (item.type === 'ImportDeclaration') { item.specifiers.forEach((spec) => { const { name } = spec.local; imports[name] = item.source.value; if (item.importKind === 'type' || ('importKind' in spec && spec.importKind === 'type')) { types.add(name); } }); } }); return { imports, types }; } export function extractExports(id: string, getModuleInfo: GetModuleInfo): ExtractedExports { const info = getModuleInfo(id); if (!info?.ast) { throw new Error(`Cannot get module info for ID: ${id}`); } const ast = info.ast as unknown as TSESTree.Program; const { imports, types } = extractScopedIdentifiers(ast); const externalExports: ExternalExport[] = []; const namedExports: string[] = []; let defaultExport = false; const mapNamed = (name: string[] | string | undefined) => { if (Array.isArray(name)) { name.forEach((n) => void mapNamed(n)); } else if (name === 'default') { defaultExport = true; } else if (name && !types.has(name)) { namedExports.push(name); } }; const filterType = (value: string) => !!value && !types.has(value); const namedExportsBySource: Record = {}; // eslint-disable-next-line complexity ast.body.forEach((item) => { if (item.type === 'ExportNamedDeclaration' && item.exportKind !== 'type') { // export class Foo {} // export function foo() {} // export const foo = {}; if (item.declaration) { mapNamed(extractName(item.declaration)); } if (item.specifiers.length > 0) { const names = item.specifiers .flatMap((spec) => extractName(spec.exported)) .filter(filterType); // export { foo } from 'source'; // export foo from 'source'; // export * as foo from 'source'; if (item.source) { if (isExternalSource(item.source)) { (namedExportsBySource[item.source.value] ??= []).push(...names); } else { mapNamed(names); } } // export { foo }; if (!item.source) { names.forEach((name) => { const maybeSource = imports[name]; if (maybeSource && isExternalSource({ value: maybeSource })) { (namedExportsBySource[maybeSource] ??= []).push(name); } else { mapNamed(name); } }); } } } // export default ... if (item.type === 'ExportDefaultDeclaration') { defaultExport = !!item.declaration; } if (item.type === 'ExportAllDeclaration' && item.source) { // export * from 'node-module' // export * as ns from 'node-module' if (isExternalSource(item.source)) { externalExports.push({ namespace: item.exported?.name, source: item.source.value, type: 'export-all', }); // export * as ns from './relative/file' } else if (item.exported) { mapNamed(extractName(item.exported)); // export * from './relative/file' } else if (item.source) { const importId = info.importedIds.find((file) => file.startsWith(path.normalize(path.join(path.dirname(id), item.source.value))), ); if (importId) { namedExports.push(...extractExports(importId, getModuleInfo).namedExports); } } } }); Object.entries(namedExportsBySource).forEach(([source, names]) => { externalExports.push({ names, source, type: 'export-named', }); }); return { externalExports, namedExports, defaultExport }; } function createMjsFileFromExports( input: string, { externalExports, namedExports, defaultExport }: ExtractedExports, ) { const mjs = []; // Support re-exports from externals (node modules) if (externalExports.length > 0) { externalExports.forEach((external) => { switch (external.type) { case 'export-all': mjs.push( `export *${external.namespace ? ` as ${external.namespace}` : ''} from '${ external.source }';`, ); break; case 'export-named': mjs.push(`export { ${external.names.join(', ')} } from '${external.source}';`); break; default: break; } }); } namedExports.sort(); // Nothing exported, so must have side-effects (bin files for example) if (namedExports.length === 0 && !defaultExport) { mjs.unshift(`import './${input}.cjs';`, ''); // Otherwise, define explicit named and default exports } else { mjs.unshift(`import data from './${input}.cjs';`, ''); if (namedExports.length > 0) { mjs.push(`export const { ${namedExports.join(', ')} } = data;`); } if (defaultExport) { mjs.push(namedExports.length > 0 ? `export default data.default;` : `export default data;`); } } return [ '// Bundled with Packemon: https://packemon.dev', '// This is an MJS wrapper for a sibling CJS file', '', ...mjs, ] .join('\n') .trim(); } export function addMjsWrapperForCjs({ inputs, packageRoot }: AddMjsWrapperOptions): Plugin { return { name: 'packemon-add-mjs-wrapper-for-cjs', buildEnd(error) { if (error) { return; } Object.entries(inputs).forEach(([input, inputPath]) => { this.emitFile({ type: 'asset', fileName: `${input}-wrapper.mjs`, source: createMjsFileFromExports( input, extractExports(packageRoot.append(inputPath).path(), this.getModuleInfo), ), }); }); }, }; }