Skip to content

Commit

Permalink
feat(compiler): generate export maps on build (#5809)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tanner-reits committed Jun 12, 2024
1 parent 3da736d commit b6d2404
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 6 deletions.
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];
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"`);
}
});
}
};
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) =>
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 @@ -85,6 +85,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

0 comments on commit b6d2404

Please sign in to comment.