Skip to content

Commit b6d2404

Browse files
authored
feat(compiler): generate export maps on build (#5809)
* generate export maps in package.json on build * npm cli commands * add config flag & generation conditionals * wip(tests) * add tests * remove log * account for primary output target * type comment & SNCs
1 parent 3da736d commit b6d2404

6 files changed

+265
-6
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { mockBuildCtx, mockValidatedConfig } from '@stencil/core/testing';
2+
import childProcess from 'child_process';
3+
4+
import * as d from '../../../declarations';
5+
import { stubComponentCompilerMeta } from '../../types/tests/ComponentCompilerMeta.stub';
6+
import { writeExportMaps } from '../write-export-maps';
7+
8+
describe('writeExportMaps', () => {
9+
let config: d.ValidatedConfig;
10+
let buildCtx: d.BuildCtx;
11+
let execSyncSpy: jest.SpyInstance;
12+
13+
beforeEach(() => {
14+
config = mockValidatedConfig();
15+
buildCtx = mockBuildCtx(config);
16+
17+
execSyncSpy = jest.spyOn(childProcess, 'execSync').mockImplementation(() => '');
18+
});
19+
20+
afterEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
it('should not generate any exports if there are no output targets', () => {
25+
writeExportMaps(config, buildCtx);
26+
27+
expect(execSyncSpy).toHaveBeenCalledTimes(0);
28+
});
29+
30+
it('should generate the default exports for the lazy build if present', () => {
31+
config.outputTargets = [
32+
{
33+
type: 'dist',
34+
dir: '/dist',
35+
typesDir: '/dist/types',
36+
},
37+
];
38+
39+
writeExportMaps(config, buildCtx);
40+
41+
expect(execSyncSpy).toHaveBeenCalledTimes(3);
42+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][import]"="./dist/index.js"`);
43+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][require]"="./dist/index.cjs.js"`);
44+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][types]"="./dist/types/index.d.ts"`);
45+
});
46+
47+
it('should generate the default exports for the custom elements build if present', () => {
48+
config.outputTargets = [
49+
{
50+
type: 'dist-custom-elements',
51+
dir: '/dist/components',
52+
generateTypeDeclarations: true,
53+
},
54+
];
55+
56+
writeExportMaps(config, buildCtx);
57+
58+
expect(execSyncSpy).toHaveBeenCalledTimes(2);
59+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][import]"="./dist/components/index.js"`);
60+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[.][types]"="./dist/components/index.d.ts"`);
61+
});
62+
63+
it('should generate the lazy loader exports if the output target is present', () => {
64+
config.rootDir = '/';
65+
config.outputTargets.push({
66+
type: 'dist-lazy-loader',
67+
dir: '/dist/lazy-loader',
68+
empty: true,
69+
esmDir: '/dist/esm',
70+
cjsDir: '/dist/cjs',
71+
componentDts: '/dist/components.d.ts',
72+
});
73+
74+
writeExportMaps(config, buildCtx);
75+
76+
expect(execSyncSpy).toHaveBeenCalledTimes(3);
77+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][import]"="./dist/lazy-loader/index.js"`);
78+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][require]"="./dist/lazy-loader/index.cjs"`);
79+
expect(execSyncSpy).toHaveBeenCalledWith(`npm pkg set "exports[./loader][types]"="./dist/lazy-loader/index.d.ts"`);
80+
});
81+
82+
it('should generate the custom elements exports if the output target is present', () => {
83+
config.rootDir = '/';
84+
config.outputTargets.push({
85+
type: 'dist-custom-elements',
86+
dir: '/dist/components',
87+
generateTypeDeclarations: true,
88+
});
89+
90+
buildCtx.components = [
91+
stubComponentCompilerMeta({
92+
tagName: 'my-component',
93+
componentClassName: 'MyComponent',
94+
}),
95+
];
96+
97+
writeExportMaps(config, buildCtx);
98+
99+
expect(execSyncSpy).toHaveBeenCalledTimes(4);
100+
expect(execSyncSpy).toHaveBeenCalledWith(
101+
`npm pkg set "exports[./my-component][import]"="./dist/components/my-component.js"`,
102+
);
103+
expect(execSyncSpy).toHaveBeenCalledWith(
104+
`npm pkg set "exports[./my-component][types]"="./dist/components/my-component.d.ts"`,
105+
);
106+
});
107+
108+
it('should generate the custom elements exports for multiple components', () => {
109+
config.rootDir = '/';
110+
config.outputTargets.push({
111+
type: 'dist-custom-elements',
112+
dir: '/dist/components',
113+
generateTypeDeclarations: true,
114+
});
115+
116+
buildCtx.components = [
117+
stubComponentCompilerMeta({
118+
tagName: 'my-component',
119+
componentClassName: 'MyComponent',
120+
}),
121+
stubComponentCompilerMeta({
122+
tagName: 'my-other-component',
123+
componentClassName: 'MyOtherComponent',
124+
}),
125+
];
126+
127+
writeExportMaps(config, buildCtx);
128+
129+
expect(execSyncSpy).toHaveBeenCalledTimes(6);
130+
expect(execSyncSpy).toHaveBeenCalledWith(
131+
`npm pkg set "exports[./my-component][import]"="./dist/components/my-component.js"`,
132+
);
133+
expect(execSyncSpy).toHaveBeenCalledWith(
134+
`npm pkg set "exports[./my-component][types]"="./dist/components/my-component.d.ts"`,
135+
);
136+
expect(execSyncSpy).toHaveBeenCalledWith(
137+
`npm pkg set "exports[./my-other-component][import]"="./dist/components/my-other-component.js"`,
138+
);
139+
expect(execSyncSpy).toHaveBeenCalledWith(
140+
`npm pkg set "exports[./my-other-component][types]"="./dist/components/my-other-component.d.ts"`,
141+
);
142+
});
143+
});

src/compiler/build/write-build.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { catchError } from '@utils';
22

3-
import type * as d from '../../declarations';
3+
import * as d from '../../declarations';
44
import { outputServiceWorkers } from '../output-targets/output-service-workers';
55
import { validateBuildFiles } from './validate-files';
6+
import { writeExportMaps } from './write-export-maps';
67

78
/**
89
* Writes files to disk as a result of compilation
@@ -36,6 +37,10 @@ export const writeBuild = async (
3637
buildCtx.debug(`in-memory-fs: ${compilerCtx.fs.getMemoryStats()}`);
3738
buildCtx.debug(`cache: ${compilerCtx.cache.getMemoryStats()}`);
3839

40+
if (config.generateExportMaps) {
41+
writeExportMaps(config, buildCtx);
42+
}
43+
3944
await outputServiceWorkers(config, buildCtx);
4045
await validateBuildFiles(config, compilerCtx, buildCtx);
4146
} catch (e: any) {
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {
2+
isEligiblePrimaryPackageOutputTarget,
3+
isOutputTargetDistCustomElements,
4+
isOutputTargetDistLazyLoader,
5+
} from '@utils';
6+
import { relative } from '@utils';
7+
import { execSync } from 'child_process';
8+
9+
import * as d from '../../declarations';
10+
import { PRIMARY_PACKAGE_TARGET_CONFIGS } from '../types/validate-primary-package-output-target';
11+
12+
/**
13+
* Create export map entry point definitions for the `package.json` file using the npm CLI.
14+
* This will generate a root entry point for the package, as well as entry points for each component and
15+
* the lazy loader (if applicable).
16+
*
17+
* @param config The validated Stencil config
18+
* @param buildCtx The build context containing the components to generate export maps for
19+
*/
20+
export const writeExportMaps = (config: d.ValidatedConfig, buildCtx: d.BuildCtx) => {
21+
const eligiblePrimaryTargets = config.outputTargets.filter(isEligiblePrimaryPackageOutputTarget);
22+
if (eligiblePrimaryTargets.length > 0) {
23+
const primaryTarget =
24+
eligiblePrimaryTargets.find((o) => o.isPrimaryPackageOutputTarget) ?? eligiblePrimaryTargets[0];
25+
const outputTargetConfig = PRIMARY_PACKAGE_TARGET_CONFIGS[primaryTarget.type];
26+
27+
if (outputTargetConfig.getModulePath) {
28+
const importPath = outputTargetConfig.getModulePath(config.rootDir, primaryTarget.dir!);
29+
30+
if (importPath) {
31+
execSync(`npm pkg set "exports[.][import]"="${importPath}"`);
32+
}
33+
}
34+
35+
if (outputTargetConfig.getMainPath) {
36+
const requirePath = outputTargetConfig.getMainPath(config.rootDir, primaryTarget.dir!);
37+
38+
if (requirePath) {
39+
execSync(`npm pkg set "exports[.][require]"="${requirePath}"`);
40+
}
41+
}
42+
43+
if (outputTargetConfig.getTypesPath) {
44+
const typesPath = outputTargetConfig.getTypesPath(config.rootDir, primaryTarget);
45+
46+
if (typesPath) {
47+
execSync(`npm pkg set "exports[.][types]"="${typesPath}"`);
48+
}
49+
}
50+
}
51+
52+
const distLazyLoader = config.outputTargets.find(isOutputTargetDistLazyLoader);
53+
if (distLazyLoader != null) {
54+
// Calculate relative path from project root to lazy-loader output directory
55+
let outDir = relative(config.rootDir, distLazyLoader.dir);
56+
if (!outDir.startsWith('.')) {
57+
outDir = './' + outDir;
58+
}
59+
60+
execSync(`npm pkg set "exports[./loader][import]"="${outDir}/index.js"`);
61+
execSync(`npm pkg set "exports[./loader][require]"="${outDir}/index.cjs"`);
62+
execSync(`npm pkg set "exports[./loader][types]"="${outDir}/index.d.ts"`);
63+
}
64+
65+
const distCustomElements = config.outputTargets.find(isOutputTargetDistCustomElements);
66+
if (distCustomElements != null) {
67+
// Calculate relative path from project root to custom elements output directory
68+
let outDir = relative(config.rootDir, distCustomElements.dir!);
69+
if (!outDir.startsWith('.')) {
70+
outDir = './' + outDir;
71+
}
72+
73+
buildCtx.components.forEach((cmp) => {
74+
execSync(`npm pkg set "exports[./${cmp.tagName}][import]"="${outDir}/${cmp.tagName}.js"`);
75+
76+
if (distCustomElements.generateTypeDeclarations) {
77+
execSync(`npm pkg set "exports[./${cmp.tagName}][types]"="${outDir}/${cmp.tagName}.d.ts"`);
78+
}
79+
});
80+
}
81+
};

src/compiler/config/validate-config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const validateConfig = (
124124
devMode,
125125
extras: config.extras || {},
126126
flags,
127+
generateExportMaps: isBoolean(config.generateExportMaps) ? config.generateExportMaps : false,
127128
hashFileNames,
128129
hashedFileNameLength: config.hashedFileNameLength ?? DEFAULT_HASHED_FILENAME_LENGTH,
129130
hydratedFlag: validateHydrated(config),

src/compiler/types/validate-primary-package-output-target.ts

+26-5
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,30 @@ export type PrimaryPackageOutputTargetRecommendedConfig = {
1717
* @param outputTargetDir The output directory for the output target's compiled code.
1818
* @returns The recommended path for the `module` property in a project's `package.json`
1919
*/
20-
getModulePath?: (rootDir: string, outputTargetDir: string) => string | null;
20+
getModulePath: (rootDir: string, outputTargetDir: string) => string | null;
2121
/**
2222
* Generates the recommended path for the `types` property based on the output target type,
2323
* the project's root directory, and the output target's configuration.
2424
*
25+
* `outputTargetConfig` is typed as `any` because downstream consumers may run into type conflicts
26+
* with the `type` property of all the different "eligible" output targets.
27+
*
2528
* @param rootDir The Stencil project's root directory pulled from the validated config.
2629
* @param outputTargetConfig The output target's config.
2730
* @returns The recommended path for the `types` property in a project's `package.json`
2831
*/
29-
getTypesPath?: (rootDir: string, outputTargetConfig: any) => string | null;
32+
getTypesPath: (rootDir: string, outputTargetConfig: any) => string | null;
33+
/**
34+
* Generates the recommended path for the `main` property based on the output target type,
35+
* the project's root directory, and the output target's designated output location.
36+
*
37+
* Only used for generate export maps.
38+
*
39+
* @param rootDir The Stencil project's root directory pulled from the validated config.
40+
* @param outputTargetDir The output directory for the output target's compiled code.
41+
* @returns The recommended path for the `main` property in a project's `package.json`
42+
*/
43+
getMainPath: (rootDir: string, outputTargetDir: string) => string | null;
3044
};
3145

3246
/**
@@ -38,25 +52,32 @@ export const PRIMARY_PACKAGE_TARGET_CONFIGS = {
3852
dist: {
3953
getModulePath: (rootDir: string, outputTargetDir: string) =>
4054
normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))),
41-
getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDist) =>
55+
getTypesPath: (rootDir: string, outputTargetConfig: any) =>
4256
normalizePath(relative(rootDir, join(outputTargetConfig.typesDir!, 'index.d.ts'))),
57+
getMainPath: (rootDir: string, outputTargetDir: string) =>
58+
normalizePath(relative(rootDir, join(outputTargetDir, 'index.cjs.js'))),
4359
},
4460
'dist-collection': {
4561
getModulePath: (rootDir: string, outputTargetDir: string) =>
4662
normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))),
63+
getTypesPath: () => null,
64+
getMainPath: () => null,
4765
},
4866
'dist-custom-elements': {
4967
getModulePath: (rootDir: string, outputTargetDir: string) =>
5068
normalizePath(relative(rootDir, join(outputTargetDir, 'index.js'))),
51-
getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDistCustomElements) => {
69+
getTypesPath: (rootDir: string, outputTargetConfig: any) => {
5270
return outputTargetConfig.generateTypeDeclarations
5371
? normalizePath(relative(rootDir, join(outputTargetConfig.dir!, 'index.d.ts')))
5472
: null;
5573
},
74+
getMainPath: () => null,
5675
},
5776
'dist-types': {
58-
getTypesPath: (rootDir: string, outputTargetConfig: d.OutputTargetDistTypes) =>
77+
getModulePath: () => null,
78+
getTypesPath: (rootDir: string, outputTargetConfig: any) =>
5979
normalizePath(relative(rootDir, join(outputTargetConfig.typesDir, 'index.d.ts'))),
80+
getMainPath: () => null,
6081
},
6182
} satisfies Record<d.EligiblePrimaryPackageOutputTarget['type'], PrimaryPackageOutputTargetRecommendedConfig>;
6283

src/declarations/stencil-public-compiler.ts

+8
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export interface StencilConfig {
8585
*/
8686
globalStyle?: string;
8787

88+
/**
89+
* Will generate {@link https://nodejs.org/api/packages.html#packages_exports export map} entry points
90+
* for each component in the build when `true`.
91+
*
92+
* @default false
93+
*/
94+
generateExportMaps?: boolean;
95+
8896
/**
8997
* When the hashFileNames config is set to true, and it is a production build,
9098
* the hashedFileNameLength config is used to determine how many characters the file name's hash should be.

0 commit comments

Comments
 (0)