Skip to content

Commit

Permalink
feat(compiler): add defineCustomElements method & signature typedef (
Browse files Browse the repository at this point in the history
…#3619)

`bundle` is added as a `customElementsExportBehavior` option. This behavior will mirror that of `dist-custom-elements-bundle` in exporting a `defineCustomElements` function. This is intended to be used as a quick migration for users currently building with `dist-custom-elements-bundle`.
  • Loading branch information
tanner-reits authored and rwaskiewicz committed Jan 25, 2023
1 parent 509869c commit 1cac95d
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 32 deletions.
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 @@ -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'));
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, 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;
};

/**
Expand Down
68 changes: 68 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 @@ -169,4 +169,72 @@ 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;',
'',
'/**',
" * 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',
' * (<meta name="csp-nonce" content="{ nonce value here }" />) 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;',
'',
'/**',
` * 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,30 @@ 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, setNonce, 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, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
`);
});
});

Expand All @@ -87,9 +104,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 +173,10 @@ describe('Custom Elements output target', () => {
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`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();
`
);
Expand Down Expand Up @@ -190,12 +206,14 @@ globalScripts();
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`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();
`
);
});

Expand All @@ -215,11 +233,59 @@ export { MyBestComponent, defineCustomElement as defineCustomElementMyBestCompon
);
addCustomElementInputs(buildCtx, bundleOptions, config.outputTargets[0] as OutputTargetDistCustomElements);
expect(bundleOptions.loader['\0core']).toEqual(
`export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
`import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
export { setAssetPath, setNonce, setPlatformOptions } from '${STENCIL_INTERNAL_CLIENT_ID}';
export * from '${USER_INDEX_ENTRY_ID}';
export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';
globalScripts();
`
);
});
});

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}';
import { globalScripts } from '${STENCIL_APP_GLOBALS_ID}';
globalScripts();
export { ComponentWithJsx, defineCustomElement as defineCustomElementComponentWithJsx } from '\0ComponentWithJsx';`
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
Loading

0 comments on commit 1cac95d

Please sign in to comment.