diff --git a/src/compiler/config/outputs/validate-custom-element.ts b/src/compiler/config/outputs/validate-custom-element.ts index 9f3c4ad6035..3c83beefba7 100644 --- a/src/compiler/config/outputs/validate-custom-element.ts +++ b/src/compiler/config/outputs/validate-custom-element.ts @@ -8,6 +8,7 @@ import type { OutputTargetDistTypes, ValidatedConfig, } from '../../../declarations'; +import { CustomElementsExportBehaviorOptions } from '../../../declarations'; import { COPY, DIST_TYPES, isOutputTargetDistCustomElements } from '../../output-targets/output-utils'; import { getAbsolutePath } from '../config-utils'; import { validateCopy } from '../validate-copy'; @@ -40,6 +41,14 @@ export const validateCustomElement = ( if (!isBoolean(outputTarget.generateTypeDeclarations)) { outputTarget.generateTypeDeclarations = true; } + // Export behavior must be defined on the validated target config and must + // be one of the export behavior valid values + if ( + outputTarget.customElementsExportBehavior == null || + !CustomElementsExportBehaviorOptions.includes(outputTarget.customElementsExportBehavior) + ) { + outputTarget.customElementsExportBehavior = 'default'; + } // unlike other output targets, Stencil does not allow users to define the output location of types at this time if (outputTarget.generateTypeDeclarations) { diff --git a/src/compiler/config/test/validate-output-dist-custom-element.spec.ts b/src/compiler/config/test/validate-output-dist-custom-element.spec.ts index 4ab2f0ec95b..8ca58c3cfce 100644 --- a/src/compiler/config/test/validate-output-dist-custom-element.spec.ts +++ b/src/compiler/config/test/validate-output-dist-custom-element.spec.ts @@ -36,6 +36,59 @@ describe('validate-output-dist-custom-element', () => { empty: true, externalRuntime: true, generateTypeDeclarations: true, + customElementsExportBehavior: 'default', + }, + ]); + }); + + it('uses a provided export behavior over the default value', () => { + const outputTarget: d.OutputTargetDistCustomElements = { + type: DIST_CUSTOM_ELEMENTS, + customElementsExportBehavior: 'single-export-module', + }; + userConfig.outputTargets = [outputTarget]; + + const { config } = validateConfig(userConfig, mockLoadConfigInit()); + expect(config.outputTargets).toEqual([ + { + type: DIST_TYPES, + dir: defaultDistDir, + typesDir: path.join(rootDir, 'dist', 'types'), + }, + { + type: DIST_CUSTOM_ELEMENTS, + copy: [], + dir: defaultDistDir, + empty: true, + externalRuntime: true, + generateTypeDeclarations: true, + customElementsExportBehavior: 'single-export-module', + }, + ]); + }); + + it('uses the default export behavior if the specified value is invalid', () => { + const outputTarget: d.OutputTargetDistCustomElements = { + type: DIST_CUSTOM_ELEMENTS, + customElementsExportBehavior: 'not-a-valid-option' as d.CustomElementsExportBehavior, + }; + userConfig.outputTargets = [outputTarget]; + + const { config } = validateConfig(userConfig, mockLoadConfigInit()); + expect(config.outputTargets).toEqual([ + { + type: DIST_TYPES, + dir: defaultDistDir, + typesDir: path.join(rootDir, 'dist', 'types'), + }, + { + type: DIST_CUSTOM_ELEMENTS, + copy: [], + dir: defaultDistDir, + empty: true, + externalRuntime: true, + generateTypeDeclarations: true, + customElementsExportBehavior: 'default', }, ]); }); @@ -57,6 +110,7 @@ describe('validate-output-dist-custom-element', () => { empty: true, externalRuntime: true, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -79,6 +133,7 @@ describe('validate-output-dist-custom-element', () => { empty: true, externalRuntime: false, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -101,6 +156,7 @@ describe('validate-output-dist-custom-element', () => { empty: true, externalRuntime: false, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -124,6 +180,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: true, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -146,6 +203,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: true, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -173,6 +231,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: true, generateTypeDeclarations: true, + customElementsExportBehavior: 'default', }, ]); }); @@ -199,6 +258,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: true, generateTypeDeclarations: true, + customElementsExportBehavior: 'default', }, ]); }); @@ -226,6 +286,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: false, generateTypeDeclarations: true, + customElementsExportBehavior: 'default', }, ]); }); @@ -254,6 +315,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: false, generateTypeDeclarations: true, + customElementsExportBehavior: 'default', }, ]); }); @@ -276,6 +338,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: false, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); @@ -316,6 +379,7 @@ describe('validate-output-dist-custom-element', () => { empty: false, externalRuntime: false, generateTypeDeclarations: false, + customElementsExportBehavior: 'default', }, ]); }); diff --git a/src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts b/src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts index 38f03173f91..ac359d9e09e 100644 --- a/src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts +++ b/src/compiler/output-targets/dist-custom-elements/custom-elements-types.ts @@ -43,6 +43,8 @@ const generateCustomElementsTypesOutput = async ( typesDir: string, outputTarget: d.OutputTargetDistCustomElements ) => { + const isBarrelExport = outputTarget.customElementsExportBehavior === 'single-export-module'; + // the path where we're going to write the typedef for the whole dist-custom-elements output const customElementsDtsPath = join(outputTarget.dir!, 'index.d.ts'); // the directory where types for the individual components are written @@ -51,24 +53,32 @@ const generateCustomElementsTypesOutput = async ( const components = buildCtx.components.filter((m) => !m.isCollectionDependency); const code = [ - `/* ${config.namespace} custom elements */`, - ...components.map((component) => { - const exportName = dashToPascalCase(component.tagName); - const importName = component.componentClassName; - // typedefs for individual components can be found under paths like - // $TYPES_DIR/components/my-component/my-component.d.ts - // - // To construct this path we: - // - // - get the relative path to the component's source file from the source directory - // - join that relative path to the relative path from the `index.d.ts` file to the - // directory where typedefs are saved - const componentSourceRelPath = relative(config.srcDir, component.sourceFilePath).replace('.tsx', ''); - const componentDTSPath = join(componentsTypeDirectoryRelPath, componentSourceRelPath); - - return `export { ${importName} as ${exportName} } from '${componentDTSPath}';`; - }), - ``, + // To mirror the index.js file and only export the typedefs for the + // entities exported there, we will re-export the typedefs iff + // the `customElementsExportBehavior` is set to barrel component exports + ...(isBarrelExport + ? [ + `/* ${config.namespace} custom elements */`, + ...components.map((component) => { + const exportName = dashToPascalCase(component.tagName); + const importName = component.componentClassName; + + // typedefs for individual components can be found under paths like + // $TYPES_DIR/components/my-component/my-component.d.ts + // + // To construct this path we: + // + // - get the relative path to the component's source file from the source directory + // - join that relative path to the relative path from the `index.d.ts` file to the + // directory where typedefs are saved + const componentSourceRelPath = relative(config.srcDir, component.sourceFilePath).replace('.tsx', ''); + const componentDTSPath = join(componentsTypeDirectoryRelPath, componentSourceRelPath); + + return `export { ${importName} as ${exportName} } from '${componentDTSPath}';`; + }), + ``, + ] + : []), `/**`, ` * Used to manually set the base path where assets can be found.`, ` * If the script is used as "module", it's recommended to use "import.meta.url",`, @@ -100,13 +110,18 @@ const generateCustomElementsTypesOutput = async ( const componentsDtsRelPath = relDts(outputTarget.dir!, join(typesDir, 'components.d.ts')); - const usersIndexJsPath = join(config.srcDir, 'index.ts'); - const hasUserIndex = await compilerCtx.fs.access(usersIndexJsPath); - if (hasUserIndex) { - const userIndexRelPath = normalizePath(dirname(componentsDtsRelPath)); - code.push(`export * from '${userIndexRelPath}';`); - } else { - code.push(`export * from '${componentsDtsRelPath}';`); + // To mirror the index.js file and only export the typedefs for the + // entities exported there, we will re-export the typedefs iff + // the `customElementsExportBehavior` is set to barrel component exports + if (isBarrelExport) { + const usersIndexJsPath = join(config.srcDir, 'index.ts'); + const hasUserIndex = await compilerCtx.fs.access(usersIndexJsPath); + if (hasUserIndex) { + const userIndexRelPath = normalizePath(dirname(componentsDtsRelPath)); + code.push(`export * from '${userIndexRelPath}';`); + } else { + code.push(`export * from '${componentsDtsRelPath}';`); + } } await compilerCtx.fs.writeFile(customElementsDtsPath, code.join('\n') + `\n`, { diff --git a/src/compiler/output-targets/dist-custom-elements/index.ts b/src/compiler/output-targets/dist-custom-elements/index.ts index 1211a061737..40302c79ce0 100644 --- a/src/compiler/output-targets/dist-custom-elements/index.ts +++ b/src/compiler/output-targets/dist-custom-elements/index.ts @@ -113,7 +113,7 @@ export const bundleCustomElements = async ( try { const bundleOpts = getBundleOptions(config, buildCtx, compilerCtx, outputTarget); - addCustomElementInputs(buildCtx, bundleOpts); + addCustomElementInputs(buildCtx, bundleOpts, outputTarget); const build = await bundleOutput(config, compilerCtx, buildCtx, bundleOpts); @@ -181,8 +181,13 @@ export const bundleCustomElements = async ( * Create the virtual modules/input modules for the `dist-custom-elements` output target. * @param buildCtx the context for the current build * @param bundleOpts the bundle options to store the virtual modules under. acts as an output parameter + * @param outputTarget the configuration for the custom element output target */ -export const addCustomElementInputs = (buildCtx: d.BuildCtx, bundleOpts: BundleOptions): void => { +export const addCustomElementInputs = ( + buildCtx: d.BuildCtx, + bundleOpts: BundleOptions, + outputTarget: d.OutputTargetDistCustomElements +): void => { const components = buildCtx.components; // an array to store the imports of these modules that we're going to add to our entry chunk const indexImports: string[] = []; @@ -220,7 +225,10 @@ export const addCustomElementInputs = (buildCtx: d.BuildCtx, bundleOpts: BundleO bundleOpts.loader![coreKey] = exp.join('\n'); }); - bundleOpts.loader!['\0core'] += indexImports.join('\n'); + // Only re-export component definitions if the barrel export behavior is set + if (outputTarget.customElementsExportBehavior === 'single-export-module') { + bundleOpts.loader!['\0core'] += indexImports.join('\n'); + } }; /** diff --git a/src/compiler/output-targets/test/custom-elements-types.spec.ts b/src/compiler/output-targets/test/custom-elements-types.spec.ts index 73dd690d50e..0d957e80191 100644 --- a/src/compiler/output-targets/test/custom-elements-types.spec.ts +++ b/src/compiler/output-targets/test/custom-elements-types.spec.ts @@ -40,7 +40,7 @@ const setup = () => { }; describe('Custom Elements Typedef generation', () => { - it('should generate an index.d.ts file corresponding to the index.js file', async () => { + it('should generate an index.d.ts file corresponding to the index.js file when barrel export behavior is enabled', async () => { // this component tests the 'happy path' of a component's filename coinciding with its // tag name const componentOne = stubComponentCompilerMeta({ @@ -55,6 +55,7 @@ describe('Custom Elements Typedef generation', () => { tagName: 'my-best-component', }); const { config, compilerCtx, buildCtx } = setup(); + (config.outputTargets[0] as d.OutputTargetDistCustomElements).customElementsExportBehavior = 'single-export-module'; buildCtx.components = [componentOne, componentTwo]; const writeFileSpy = jest.spyOn(compilerCtx.fs, 'writeFile'); @@ -109,4 +110,63 @@ describe('Custom Elements Typedef generation', () => { writeFileSpy.mockRestore(); }); + + it('should generate an index.d.ts file corresponding to the index.js file when barrel export behavior is disabled', async () => { + // this component tests the 'happy path' of a component's filename coinciding with its + // tag name + const componentOne = stubComponentCompilerMeta({ + tagName: 'my-component', + sourceFilePath: '/src/components/my-component/my-component.tsx', + }); + // this component tests that we correctly resolve its path when the component tag does + // not match its filename + const componentTwo = stubComponentCompilerMeta({ + sourceFilePath: '/src/components/the-other-component/my-real-best-component.tsx', + componentClassName: 'MyBestComponent', + tagName: 'my-best-component', + }); + const { config, compilerCtx, buildCtx } = setup(); + buildCtx.components = [componentOne, componentTwo]; + + const writeFileSpy = jest.spyOn(compilerCtx.fs, 'writeFile'); + + await generateCustomElementsTypes(config, compilerCtx, buildCtx, 'types_dir'); + + const expectedTypedefOutput = [ + '/**', + ' * Used to manually set the base path where assets can be found.', + ' * If the script is used as "module", it\'s recommended to use "import.meta.url",', + ' * such as "setAssetPath(import.meta.url)". Other options include', + ' * "setAssetPath(document.currentScript.src)", or using a bundler\'s replace plugin to', + ' * dynamically set the path at build time, such as "setAssetPath(process.env.ASSET_PATH)".', + ' * But do note that this configuration depends on how your script is bundled, or lack of', + ' * bundling, and where your assets can be loaded from. Additionally custom bundling', + ' * will have to ensure the static assets are copied to its build directory.', + ' */', + 'export declare const setAssetPath: (path: string) => void;', + '', + '/**', + " * Used to specify a nonce value that corresponds with an application's CSP.", + ' * When set, the nonce will be added to all dynamically created script and style tags at runtime.', + ' * Alternatively, the nonce value can be set on a meta tag in the DOM head', + ' * () which', + ' * will result in the same behavior.', + ' */', + 'export declare const setNonce: (nonce: string) => void', + '', + 'export interface SetPlatformOptions {', + ' raf?: (c: FrameRequestCallback) => number;', + ' ael?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;', + ' rel?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;', + '}', + 'export declare const setPlatformOptions: (opts: SetPlatformOptions) => void;', + '', + ].join('\n'); + + expect(compilerCtx.fs.writeFile).toHaveBeenCalledWith(join('my-best-dir', 'index.d.ts'), expectedTypedefOutput, { + outputTargetType: DIST_CUSTOM_ELEMENTS, + }); + + writeFileSpy.mockRestore(); + }); }); diff --git a/src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts b/src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts index 514cce5d53c..0524047211d 100644 --- a/src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts +++ b/src/compiler/output-targets/test/output-targets-dist-custom-elements.spec.ts @@ -132,55 +132,96 @@ describe('Custom Elements output target', () => { }); describe('addCustomElementInputs', () => { - it('should add imports to index.js for all included components', () => { - const componentOne = stubComponentCompilerMeta(); - const componentTwo = stubComponentCompilerMeta({ - componentClassName: 'MyBestComponent', - tagName: 'my-best-component', + let config: d.ValidatedConfig; + let compilerCtx: d.CompilerCtx; + let buildCtx: d.BuildCtx; + + beforeEach(() => { + ({ config, compilerCtx, buildCtx } = setup()); + }); + + describe('no defined CustomElementsExportBehavior', () => { + it("doesn't re-export components from the index.js barrel file", () => { + const componentOne = stubComponentCompilerMeta(); + const componentTwo = stubComponentCompilerMeta({ + componentClassName: 'MyBestComponent', + tagName: 'my-best-component', + }); + + buildCtx.components = [componentOne, componentTwo]; + + const bundleOptions = getBundleOptions( + config, + buildCtx, + compilerCtx, + config.outputTargets[0] as OutputTargetDistCustomElements + ); + addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements); + expect(bundleOptions.loader['\0core']).toEqual( + `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; +export * from '${USER_INDEX_ENTRY_ID}'; +import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; +globalScripts(); +` + ); }); - const { config, compilerCtx, buildCtx } = setup(); - buildCtx.components = [componentOne, componentTwo]; - - const bundleOptions = getBundleOptions( - config, - buildCtx, - compilerCtx, - config.outputTargets[0] as OutputTargetDistCustomElements - ); - addCustomElementInputs(buildCtx, bundleOptions); - expect(bundleOptions.loader['\0core']).toEqual( - `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; + }); + + describe('CustomElementsExportBehavior.SINGLE_EXPORT_MODULE', () => { + beforeEach(() => { + (config.outputTargets[0] as OutputTargetDistCustomElements).customElementsExportBehavior = + 'single-export-module'; + }); + + it('should add imports to index.js for all included components', () => { + const componentOne = stubComponentCompilerMeta(); + const componentTwo = stubComponentCompilerMeta({ + componentClassName: 'MyBestComponent', + tagName: 'my-best-component', + }); + + buildCtx.components = [componentOne, componentTwo]; + + const bundleOptions = getBundleOptions( + config, + buildCtx, + compilerCtx, + config.outputTargets[0] as OutputTargetDistCustomElements + ); + addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements); + expect(bundleOptions.loader['\0core']).toEqual( + `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; export * from '${USER_INDEX_ENTRY_ID}'; import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; globalScripts(); export { StubCmp, defineCustomElement as defineCustomElementStubCmp } from '\0StubCmp'; export { MyBestComponent, defineCustomElement as defineCustomElementMyBestComponent } from '\0MyBestComponent';` - ); - }); - - it('should correctly handle capitalization edge-cases', () => { - const component = stubComponentCompilerMeta({ - componentClassName: 'ComponentWithJSX', - tagName: 'component-with-jsx', + ); }); - const { config, compilerCtx, buildCtx } = setup(); - buildCtx.components = [component]; - - const bundleOptions = getBundleOptions( - config, - buildCtx, - compilerCtx, - config.outputTargets[0] as OutputTargetDistCustomElements - ); - addCustomElementInputs(buildCtx, bundleOptions); - expect(bundleOptions.loader['\0core']).toEqual( - `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; + it('should correctly handle capitalization edge-cases', () => { + const component = stubComponentCompilerMeta({ + componentClassName: 'ComponentWithJSX', + tagName: 'component-with-jsx', + }); + + buildCtx.components = [component]; + + const bundleOptions = getBundleOptions( + config, + buildCtx, + compilerCtx, + config.outputTargets[0] as OutputTargetDistCustomElements + ); + addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements); + expect(bundleOptions.loader['\0core']).toEqual( + `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; export * from '${USER_INDEX_ENTRY_ID}'; import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; globalScripts(); export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';` - ); + ); + }); }); }); }); diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index a7543fdc67e..5707e8d751d 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -2023,6 +2023,25 @@ export interface OutputTargetBaseNext { dir?: string; } +/** + * The collection of valid export behaviors. + * Used to generate a type for typed configs as well as output target validation + * for the `dist-custom-elements` output target. + * + * Adding a value to this const array will automatically add it as a valid option on the + * output target configuration for `customElementsExportBehavior`. + * + * - `default`: No additional export or definition behavior will happen. + * - `single-export-module`: All components will be re-exported from the specified directory's root `index.js` file. + */ +export const CustomElementsExportBehaviorOptions = ['default', 'single-export-module'] as const; + +/** + * This type is auto-generated based on the values in `CustomElementsExportBehaviorOptions` array. + * This is used on the output target config for intellisense in typed configs. + */ +export type CustomElementsExportBehavior = typeof CustomElementsExportBehaviorOptions[number]; + export interface OutputTargetDistCustomElements extends OutputTargetBaseNext { type: 'dist-custom-elements'; empty?: boolean; @@ -2047,6 +2066,12 @@ export interface OutputTargetDistCustomElements extends OutputTargetBaseNext { * Enables the generation of type definition files for the output target. */ generateTypeDeclarations?: boolean; + /** + * Define the export/definition behavior for the output target's generated output. + * This controls if/how custom elements will be defined or where components will be exported from. + * If omitted, no auto-definition behavior or re-exporting will happen. + */ + customElementsExportBehavior?: CustomElementsExportBehavior; } export interface OutputTargetDistCustomElementsBundle extends OutputTargetBaseNext {