Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(compiler): generate export maps on build #5809

Merged
merged 8 commits into from
Jun 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/compiler/build/test/write-export-maps.spec.ts
Original file line number Diff line number Diff line change
@@ -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"`,
);
});
});
7 changes: 6 additions & 1 deletion src/compiler/build/write-build.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions src/compiler/build/write-export-maps.ts
Original file line number Diff line number Diff line change
@@ -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];
christian-bromann marked this conversation as resolved.
Show resolved Hide resolved
const outputTargetConfig = PRIMARY_PACKAGE_TARGET_CONFIGS[primaryTarget.type];

if (outputTargetConfig.getModulePath) {
christian-bromann marked this conversation as resolved.
Show resolved Hide resolved
const importPath = outputTargetConfig.getModulePath(config.rootDir, primaryTarget.dir!);

if (importPath) {
execSync(`npm pkg set "exports[.][import]"="${importPath}"`);
}
}

if (outputTargetConfig.getMainPath) {
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
const requirePath = outputTargetConfig.getMainPath(config.rootDir, primaryTarget.dir!);

if (requirePath) {
execSync(`npm pkg set "exports[.][require]"="${requirePath}"`);
}
}

if (outputTargetConfig.getTypesPath) {
tanner-reits marked this conversation as resolved.
Show resolved Hide resolved
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"`);
}
});
}
};
1 change: 1 addition & 0 deletions src/compiler/config/validate-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 26 additions & 5 deletions src/compiler/types/validate-primary-package-output-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason we have to type this as any?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was intentional. I'll add a comment

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<d.EligiblePrimaryPackageOutputTarget['type'], PrimaryPackageOutputTargetRecommendedConfig>;

Expand Down
8 changes: 8 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down