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): add defineCustomElements method & signature typedef #3619

Merged
Merged
Show file tree
Hide file tree
Changes from 12 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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -97,6 +98,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'));
Expand Down
82 changes: 67 additions & 15 deletions src/compiler/output-targets/dist-custom-elements/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down Expand Up @@ -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[] = [];
Expand All @@ -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(
Expand All @@ -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, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';`,
`export * from '${USER_INDEX_ENTRY_ID}';`
);

// Content related to global scripts
if (outputTarget.includeGlobalScripts !== false) {
Copy link
Contributor

Choose a reason for hiding this comment

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

is it our intention that the user could set outputTarget.includeGlobalScripts to null and have this condition evaluate to true? Seems off to me...

(just because null !== false evaluates to true)

we don't have to change that in this PR but just calling it out because I noticed it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That does seem off. I'm hesitant to change it just because that was the logic already in place, but I can make note of that and take a look outside the context of this PR (this is going into v3 anyway)

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this setup a lot more 👍 things are cleanly factored and co-located

};

/**
Expand Down
59 changes: 59 additions & 0 deletions src/compiler/output-targets/test/custom-elements-types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ 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,
generateEntryPoint,
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';

Expand Down Expand Up @@ -67,13 +67,28 @@ 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: true,
});

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,
includeGlobalScripts: false,
});
const globalScriptsBoilerplate = `import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';\nglobalScripts();`;
expect(entryPoint.includes(globalScriptsBoilerplate)).toBe(includeGlobalScripts);

expect(entryPoint).toEqual(`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
`);
});
});

Expand All @@ -87,9 +102,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');
});

Expand Down Expand Up @@ -158,9 +171,9 @@ describe('Custom Elements output target', () => {
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
`
);
Expand Down Expand Up @@ -190,12 +203,13 @@ globalScripts();
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, 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();
`
);
});

Expand All @@ -215,11 +229,57 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, 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, 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);
}
});
}
};
`
);
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/declarations/stencil-public-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2034,11 +2034,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;

Expand Down