diff --git a/src/compiler/build/test/write-export-maps.spec.ts b/src/compiler/build/test/write-export-maps.spec.ts new file mode 100644 index 00000000000..17c49e91435 --- /dev/null +++ b/src/compiler/build/test/write-export-maps.spec.ts @@ -0,0 +1,143 @@ +import { mockBuildCtx, mockValidatedConfig } from '@stencil/core/testing'; +import childProcess from 'child_process'; + +import * as d from '../../../declarations'; +import { stubComponentCompilerMeta } from '../../types/tests/ComponentCompilerMeta.stub'; +import { writeExportMaps } from '../write-export-maps'; + +describe('writeExportMaps', () => { + let config: d.ValidatedConfig; + let buildCtx: d.BuildCtx; + let execSyncSpy: jest.SpyInstance; + + beforeEach(() => { + config = mockValidatedConfig(); + buildCtx = mockBuildCtx(config); + + execSyncSpy = jest.spyOn(childProcess, 'execSync').mockImplementation(() => ''); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not generate any exports if there are no output targets', () => { + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(0); + }); + + it('should generate the default exports for the lazy build if present', () => { + config.outputTargets = [ + { + type: 'dist', + dir: '/dist', + typesDir: '/dist/types', + }, + ]; + + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(3); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][import]"="./dist/index.js"`); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][require]"="./dist/index.cjs.js"`); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][types]"="./dist/types/index.d.ts"`); + }); + + it('should generate the default exports for the custom elements build if present', () => { + config.outputTargets = [ + { + type: 'dist-custom-elements', + dir: '/dist/components', + generateTypeDeclarations: true, + }, + ]; + + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(2); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][import]"="./dist/components/index.js"`); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][types]"="./dist/components/index.d.ts"`); + }); + + it('should generate the lazy loader exports if the output target is present', () => { + config.rootDir = '/'; + config.outputTargets.push({ + type: 'dist-lazy-loader', + dir: '/dist/lazy-loader', + empty: true, + esmDir: '/dist/esm', + cjsDir: '/dist/cjs', + componentDts: '/dist/components.d.ts', + }); + + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(3); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][import]"="./dist/lazy-loader/index.js"`); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][require]"="./dist/lazy-loader/index.cjs"`); + expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][types]"="./dist/lazy-loader/index.d.ts"`); + }); + + it('should generate the custom elements exports if the output target is present', () => { + config.rootDir = '/'; + config.outputTargets.push({ + type: 'dist-custom-elements', + dir: '/dist/components', + generateTypeDeclarations: true, + }); + + buildCtx.components = [ + stubComponentCompilerMeta({ + tagName: 'my-component', + componentClassName: 'MyComponent', + }), + ]; + + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(4); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-component][import]"="./dist/components/my-component.js"`, + ); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-component][types]"="./dist/components/my-component.d.ts"`, + ); + }); + + it('should generate the custom elements exports for multiple components', () => { + config.rootDir = '/'; + config.outputTargets.push({ + type: 'dist-custom-elements', + dir: '/dist/components', + generateTypeDeclarations: true, + }); + + buildCtx.components = [ + stubComponentCompilerMeta({ + tagName: 'my-component', + componentClassName: 'MyComponent', + }), + stubComponentCompilerMeta({ + tagName: 'my-other-component', + componentClassName: 'MyOtherComponent', + }), + ]; + + writeExportMaps(config, buildCtx); + + expect(execSyncSpy).toHaveBeenCalledTimes(6); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-component][import]"="./dist/components/my-component.js"`, + ); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-component][types]"="./dist/components/my-component.d.ts"`, + ); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-other-component][import]"="./dist/components/my-other-component.js"`, + ); + expect(execSyncSpy).toHaveBeenCalledWith( + `npm pkg set "exports[./my-other-component][types]"="./dist/components/my-other-component.d.ts"`, + ); + }); +}); diff --git a/src/compiler/build/write-build.ts b/src/compiler/build/write-build.ts index 076657a7239..447e4940289 100644 --- a/src/compiler/build/write-build.ts +++ b/src/compiler/build/write-build.ts @@ -1,8 +1,9 @@ import { catchError } from '@utils'; -import type * as d from '../../declarations'; +import * as d from '../../declarations'; import { outputServiceWorkers } from '../output-targets/output-service-workers'; import { validateBuildFiles } from './validate-files'; +import { writeExportMaps } from './write-export-maps'; /** * Writes files to disk as a result of compilation @@ -36,6 +37,10 @@ export const writeBuild = async ( buildCtx.debug(`in-memory-fs: ${compilerCtx.fs.getMemoryStats()}`); buildCtx.debug(`cache: ${compilerCtx.cache.getMemoryStats()}`); + if (config.generateExportMaps) { + writeExportMaps(config, buildCtx); + } + await outputServiceWorkers(config, buildCtx); await validateBuildFiles(config, compilerCtx, buildCtx); } catch (e: any) { diff --git a/src/compiler/build/write-export-maps.ts b/src/compiler/build/write-export-maps.ts new file mode 100644 index 00000000000..ab8588a3844 --- /dev/null +++ b/src/compiler/build/write-export-maps.ts @@ -0,0 +1,81 @@ +import { + isEligiblePrimaryPackageOutputTarget, + isOutputTargetDistCustomElements, + isOutputTargetDistLazyLoader, +} from '@utils'; +import { relative } from '@utils'; +import { execSync } from 'child_process'; + +import * as d from '../../declarations'; +import { PRIMARY_PACKAGE_TARGET_CONFIGS } from '../types/validate-primary-package-output-target'; + +/** + * Create export map entry point definitions for the `package.json` file using the npm CLI. + * This will generate a root entry point for the package, as well as entry points for each component and + * the lazy loader (if applicable). + * + * @param config The validated Stencil config + * @param buildCtx The build context containing the components to generate export maps for + */ +export const writeExportMaps = (config: d.ValidatedConfig, buildCtx: d.BuildCtx) => { + const eligiblePrimaryTargets = config.outputTargets.filter(isEligiblePrimaryPackageOutputTarget); + if (eligiblePrimaryTargets.length > 0) { + const primaryTarget = + eligiblePrimaryTargets.find((o) => o.isPrimaryPackageOutputTarget) ?? eligiblePrimaryTargets[0]; + const outputTargetConfig = PRIMARY_PACKAGE_TARGET_CONFIGS[primaryTarget.type]; + + if (outputTargetConfig.getModulePath) { + const importPath = outputTargetConfig.getModulePath(config.rootDir, primaryTarget.dir!); + + if (importPath) { + execSync(`npm pkg set "exports[.][import]"="${importPath}"`); + } + } + + if (outputTargetConfig.getMainPath) { + const requirePath = outputTargetConfig.getMainPath(config.rootDir, primaryTarget.dir!); + + if (requirePath) { + execSync(`npm pkg set "exports[.][require]"="${requirePath}"`); + } + } + + if (outputTargetConfig.getTypesPath) { + const typesPath = outputTargetConfig.getTypesPath(config.rootDir, primaryTarget); + + if (typesPath) { + execSync(`npm pkg set "exports[.][types]"="${typesPath}"`); + } + } + } + + const distLazyLoader = config.outputTargets.find(isOutputTargetDistLazyLoader); + if (distLazyLoader != null) { + // Calculate relative path from project root to lazy-loader output directory + let outDir = relative(config.rootDir, distLazyLoader.dir); + if (!outDir.startsWith('.')) { + outDir = './' + outDir; + } + + execSync(`npm pkg set "exports[./loader][import]"="${outDir}/index.js"`); + execSync(`npm pkg set "exports[./loader][require]"="${outDir}/index.cjs"`); + execSync(`npm pkg set "exports[./loader][types]"="${outDir}/index.d.ts"`); + } + + const distCustomElements = config.outputTargets.find(isOutputTargetDistCustomElements); + if (distCustomElements != null) { + // Calculate relative path from project root to custom elements output directory + let outDir = relative(config.rootDir, distCustomElements.dir!); + if (!outDir.startsWith('.')) { + outDir = './' + outDir; + } + + buildCtx.components.forEach((cmp) => { + execSync(`npm pkg set "exports[./${cmp.tagName}][import]"="${outDir}/${cmp.tagName}.js"`); + + if (distCustomElements.generateTypeDeclarations) { + execSync(`npm pkg set "exports[./${cmp.tagName}][types]"="${outDir}/${cmp.tagName}.d.ts"`); + } + }); + } +}; diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 65f31af59c0..ae2c6314a97 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -124,6 +124,7 @@ export const validateConfig = ( devMode, extras: config.extras || {}, flags, + generateExportMaps: isBoolean(config.generateExportMaps) ? config.generateExportMaps : false, hashFileNames, hashedFileNameLength: config.hashedFileNameLength ?? DEFAULT_HASHED_FILENAME_LENGTH, hydratedFlag: validateHydrated(config), diff --git a/src/compiler/types/validate-primary-package-output-target.ts b/src/compiler/types/validate-primary-package-output-target.ts index 9fa6b97f22d..df6e0609efc 100644 --- a/src/compiler/types/validate-primary-package-output-target.ts +++ b/src/compiler/types/validate-primary-package-output-target.ts @@ -17,16 +17,30 @@ export type PrimaryPackageOutputTargetRecommendedConfig = { * @param outputTargetDir The output directory for the output target's compiled code. * @returns The recommended path for the `module` property in a project's `package.json` */ - getModulePath?: (rootDir: string, outputTargetDir: string) => string | null; + getModulePath: (rootDir: string, outputTargetDir: string) => string | null; /** * Generates the recommended path for the `types` property based on the output target type, * the project's root directory, and the output target's configuration. * + * `outputTargetConfig` is typed as `any` because downstream consumers may run into type conflicts + * with the `type` property of all the different "eligible" output targets. + * * @param rootDir The Stencil project's root directory pulled from the validated config. * @param outputTargetConfig The output target's config. * @returns The recommended path for the `types` property in a project's `package.json` */ - getTypesPath?: (rootDir: string, outputTargetConfig: any) => string | null; + getTypesPath: (rootDir: string, outputTargetConfig: any) => string | null; + /** + * Generates the recommended path for the `main` property based on the output target type, + * the project's root directory, and the output target's designated output location. + * + * Only used for generate export maps. + * + * @param rootDir The Stencil project's root directory pulled from the validated config. + * @param outputTargetDir The output directory for the output target's compiled code. + * @returns The recommended path for the `main` property in a project's `package.json` + */ + getMainPath: (rootDir: string, outputTargetDir: string) => string | null; }; /** @@ -38,25 +52,32 @@ export const PRIMARY_PACKAGE_TARGET_CONFIGS = { dist: { getModulePath: (rootDir: string, outputTargetDir: string) => normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))), - getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDist) => + getTypesPath: (rootDir: string, outputTargetConfig: any) => normalizePath(relative(rootDir, join(outputTargetConfig.typesDir!, 'index.d.ts'))), + getMainPath: (rootDir: string, outputTargetDir: string) => + normalizePath(relative(rootDir, join(outputTargetDir, 'index.cjs.js'))), }, 'dist-collection': { getModulePath: (rootDir: string, outputTargetDir: string) => normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))), + getTypesPath: () => null, + getMainPath: () => null, }, 'dist-custom-elements': { getModulePath: (rootDir: string, outputTargetDir: string) => normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))), - getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDistCustomElements) => { + getTypesPath: (rootDir: string, outputTargetConfig: any) => { return outputTargetConfig.generateTypeDeclarations ? normalizePath(relative(rootDir, join(outputTargetConfig.dir!, 'index.d.ts'))) : null; }, + getMainPath: () => null, }, 'dist-types': { - getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDistTypes) => + getModulePath: () => null, + getTypesPath: (rootDir: string, outputTargetConfig: any) => normalizePath(relative(rootDir, join(outputTargetConfig.typesDir, 'index.d.ts'))), + getMainPath: () => null, }, } satisfies Record; diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index 7d1c3d0cd63..2ad83f44b5e 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -84,6 +84,14 @@ export interface StencilConfig { */ globalStyle?: string; + /** + * Will generate {@link https://nodejs.org/api/packages.html#packages_exports export map} entry points + * for each component in the build when `true`. + * + * @default false + */ + generateExportMaps?: boolean; + /** * When the hashFileNames config is set to true, and it is a production build, * the hashedFileNameLength config is used to determine how many characters the file name's hash should be.