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 8c8cbd8bafb..ee48cfc88a7 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 @@ -44,6 +44,7 @@ const generateCustomElementsTypesOutput = async ( outputTarget: d.OutputTargetDistCustomElements ) => { const isBarrelExport = outputTarget.customElementsExportBehavior === 'single-export-module'; + const isBundleExport = outputTarget.customElementsExportBehavior === 'bundle'; // 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'); @@ -106,6 +107,22 @@ const generateCustomElementsTypesOutput = async ( ` rel?: (el: EventTarget, eventName: string, listener: EventListenerOrEventListenerObject, options: boolean | AddEventListenerOptions) => void;`, `}`, `export declare const setPlatformOptions: (opts: SetPlatformOptions) => void;`, + ...(isBundleExport + ? [ + ``, + `/**`, + ` * Utility to define all custom elements within this package using the tag name provided in the component's source.`, + ` * When defining each custom element, it will also check it's safe to define by:`, + ` *`, + ` * 1. Ensuring the "customElements" registry is available in the global context (window).`, + ` * 2. Ensuring that the component tag name is not already defined.`, + ` *`, + ` * Use the standard [customElements.define()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define)`, + ` * method instead to define custom elements individually, or to provide a different tag name.`, + ` */`, + `export declare const defineCustomElements: (opts?: any) => void;`, + ] + : []), ]; const componentsDtsRelPath = relDts(outputTarget.dir!, join(typesDir, 'components.d.ts')); diff --git a/src/compiler/output-targets/dist-custom-elements/index.ts b/src/compiler/output-targets/dist-custom-elements/index.ts index 40302c79ce0..21686989d97 100644 --- a/src/compiler/output-targets/dist-custom-elements/index.ts +++ b/src/compiler/output-targets/dist-custom-elements/index.ts @@ -87,9 +87,7 @@ export const getBundleOptions = ( // @see {@link https://rollupjs.org/guide/en/#conventions} for more info. index: '\0core', }, - loader: { - '\0core': generateEntryPoint(outputTarget), - }, + loader: {}, inlineDynamicImports: outputTarget.inlineDynamicImports, preserveEntrySignatures: 'allow-extension', }); @@ -189,8 +187,13 @@ export const addCustomElementInputs = ( 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 + // An array to store the imports of these modules that we're going to add to our entry chunk const indexImports: string[] = []; + // An array to store the export declarations that we're going to add to our entry chunk + const indexExports: string[] = []; + // An array to store the exported component names that will be used for the `defineCustomElements` + // function on the `bundle` export behavior option + const exportNames: string[] = []; components.forEach((cmp) => { const exp: string[] = []; @@ -201,7 +204,7 @@ export const addCustomElementInputs = ( if (cmp.isPlain) { exp.push(`export { ${importName} as ${exportName} } from '${cmp.sourceFilePath}';`); - indexImports.push(`export { {${exportName} } from '${coreKey}';`); + indexExports.push(`export { {${exportName} } from '${coreKey}';`); } else { // the `importName` may collide with the `exportName`, alias it just in case it does with `importAs` exp.push( @@ -216,39 +219,88 @@ export const addCustomElementInputs = ( // correct virtual module, if we instead referenced, for instance, // `cmp.sourceFilePath`, we would end up with duplicated modules in our // output. - indexImports.push( + indexExports.push( `export { ${exportName}, defineCustomElement as defineCustomElement${exportName} } from '${coreKey}';` ); } + indexImports.push(`import { ${exportName} } from '${coreKey}';`); + exportNames.push(exportName); + bundleOpts.inputs[cmp.tagName] = coreKey; bundleOpts.loader![coreKey] = exp.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'); - } + // Generate the contents of the entry file to be created by the bundler + bundleOpts.loader!['\0core'] = generateEntryPoint(outputTarget, indexImports, indexExports, exportNames); }; /** * Generate the entrypoint (`index.ts` file) contents for the `dist-custom-elements` output target * @param outputTarget the output target's configuration + * @param cmpImports The import declarations for local component modules. + * @param cmpExports The export declarations for local component modules. + * @param cmpNames The exported component names (could be aliased) from local component modules. * @returns the stringified contents to be placed in the entrypoint */ -export const generateEntryPoint = (outputTarget: d.OutputTargetDistCustomElements): string => { - const imp: string[] = []; +export const generateEntryPoint = ( + outputTarget: d.OutputTargetDistCustomElements, + cmpImports: string[] = [], + cmpExports: string[] = [], + cmpNames: string[] = [] +): string => { + const body: string[] = []; + const imports: string[] = []; + const exports: string[] = []; - imp.push( + // Exports that are always present + exports.push( `export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`, `export * from '${USER_INDEX_ENTRY_ID}';` ); + // Content related to global scripts if (outputTarget.includeGlobalScripts !== false) { - imp.push(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';`, `globalScripts();`); + imports.push(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';`); + body.push(`globalScripts();`); + } + + // Content related to the `bundle` export behavior + if (outputTarget.customElementsExportBehavior === 'bundle') { + imports.push(...cmpImports); + body.push( + 'export const defineCustomElements = (opts) => {', + " if (typeof customElements !== 'undefined') {", + ' [', + ...cmpNames.map((cmp) => ` ${cmp},`), + ' ].forEach(cmp => {', + ' if (!customElements.get(cmp.is)) {', + ' customElements.define(cmp.is, cmp, opts);', + ' }', + ' });', + ' }', + '};' + ); } - return imp.join('\n') + '\n'; + // Content related to the `single-export-module` export behavior + if (outputTarget.customElementsExportBehavior === 'single-export-module') { + exports.push(...cmpExports); + } + + // Generate the contents of the file based on the parts + // defined above. This keeps the file structure consistent as + // new export behaviors may be added + let content = ''; + + // Add imports to file content + content += imports.length ? imports.join('\n') + '\n' : ''; + // Add exports to file content + content += exports.length ? exports.join('\n') + '\n' : ''; + // Add body to file content + content += body.length ? '\n' + body.join('\n') + '\n' : ''; + + return content; }; /** 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 731939c803b..332a2092706 100644 --- a/src/compiler/output-targets/test/custom-elements-types.spec.ts +++ b/src/compiler/output-targets/test/custom-elements-types.spec.ts @@ -160,4 +160,63 @@ describe('Custom Elements Typedef generation', () => { writeFileSpy.mockRestore(); }); + + it('should generate a type signature for the `defineCustomElements` function when `bundle` export behavior is set', async () => { + const componentOne = stubComponentCompilerMeta({ + tagName: 'my-component', + sourceFilePath: '/src/components/my-component/my-component.tsx', + }); + 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(); + (config.outputTargets[0] as d.OutputTargetDistCustomElements).customElementsExportBehavior = 'bundle'; + 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;', + '', + '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;', + '', + '/**', + ` * Utility to define all custom elements within this package using the tag name provided in the component's source.`, + ` * When defining each custom element, it will also check it's safe to define by:`, + ' *', + ' * 1. Ensuring the "customElements" registry is available in the global context (window).', + ' * 2. Ensuring that the component tag name is not already defined.', + ' *', + ' * Use the standard [customElements.define()](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define)', + ' * method instead to define custom elements individually, or to provide a different tag name.', + ' */', + 'export declare const defineCustomElements: (opts?: any) => 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 48e504ab2c3..fd677c247cf 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 @@ -11,6 +11,7 @@ import type * as d from '../../../declarations'; import { OutputTargetDistCustomElements } from '../../../declarations'; import { STENCIL_APP_GLOBALS_ID, STENCIL_INTERNAL_CLIENT_ID, USER_INDEX_ENTRY_ID } from '../../bundle/entry-alias-ids'; import { stubComponentCompilerMeta } from '../../types/tests/ComponentCompilerMeta.stub'; +import * as outputCustomElementsMod from '../dist-custom-elements'; import { addCustomElementInputs, bundleCustomElements, @@ -18,7 +19,6 @@ import { getBundleOptions, outputCustomElements, } from '../dist-custom-elements'; -import * as outputCustomElementsMod from '../dist-custom-elements'; // TODO(STENCIL-561): fully delete dist-custom-elements-bundle code import { DIST_CUSTOM_ELEMENTS, DIST_CUSTOM_ELEMENTS_BUNDLE } from '../output-utils'; @@ -67,13 +67,29 @@ describe('Custom Elements output target', () => { }); describe('generateEntryPoint', () => { - it.each([true, false])('should include globalScripts if the right option is set', (includeGlobalScripts) => { + it('should include global scripts when flag is `true`', () => { const entryPoint = generateEntryPoint({ type: DIST_CUSTOM_ELEMENTS, - includeGlobalScripts, + includeGlobalScripts: true, }); - const globalScriptsBoilerplate = `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';\nglobalScripts();`; - expect(entryPoint.includes(globalScriptsBoilerplate)).toBe(includeGlobalScripts); + + expect(entryPoint).toEqual(`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; +export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; +export * from '${USER_INDEX_ENTRY_ID}'; + +globalScripts(); +`); + }); + + it('should not include global scripts when flag is `false`', () => { + const entryPoint = generateEntryPoint({ + type: DIST_CUSTOM_ELEMENTS, + includeGlobalScripts: false, + }); + + expect(entryPoint).toEqual(`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; +export * from '${USER_INDEX_ENTRY_ID}'; +`); }); }); @@ -87,9 +103,7 @@ describe('Custom Elements output target', () => { expect(options.inputs).toEqual({ index: '\0core', }); - expect(options.loader).toEqual({ - '\0core': generateEntryPoint({ type: DIST_CUSTOM_ELEMENTS }), - }); + expect(options.loader).toEqual({}); expect(options.preserveEntrySignatures).toEqual('allow-extension'); }); @@ -194,10 +208,11 @@ globalScripts(); `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; 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';` +export { MyBestComponent, defineCustomElement as defineCustomElementMyBestComponent } from '\0MyBestComponent'; + +globalScripts(); +` ); }); @@ -220,9 +235,56 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; export * from '${USER_INDEX_ENTRY_ID}'; -import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; +export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx'; + globalScripts(); -export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';` +` + ); + }); + }); + + describe('CustomElementsExportBehavior.BUNDLE', () => { + beforeEach(() => { + (config.outputTargets[0] as OutputTargetDistCustomElements).customElementsExportBehavior = 'bundle'; + }); + + it('should add a `defineCustomElements` function to the index.js 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( + `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}'; +import { StubCmp } from '\0StubCmp'; +import { MyBestComponent } from '\0MyBestComponent'; +export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}'; +export * from '${USER_INDEX_ENTRY_ID}'; + +globalScripts(); +export const defineCustomElements = (opts) => { + if (typeof customElements !== 'undefined') { + [ + StubCmp, + MyBestComponent, + ].forEach(cmp => { + if (!customElements.get(cmp.is)) { + customElements.define(cmp.is, cmp, opts); + } + }); + } +}; +` ); }); }); diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index a9100419d5b..e043cceecc4 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -2035,11 +2035,14 @@ export interface OutputTargetBaseNext { * - `auto-define-custom-elements`: Enables the auto-definition of a component and its children (recursively) in the custom elements registry. This * functionality allows consumers to bypass the explicit call to define a component, its children, its children's * children, etc. Users of this flag should be aware that enabling this functionality may increase bundle size. + * - `bundle`: A `defineCustomElements` function will be exported from the distribution directory. This behavior was added to allow easy migration + * from `dist-custom-elements-bundle` to `dist-custom-elements`. * - `single-export-module`: All components will be re-exported from the specified directory's root `index.js` file. */ export const CustomElementsExportBehaviorOptions = [ 'default', 'auto-define-custom-elements', + 'bundle', 'single-export-module', ] as const;