diff --git a/src/generators/microfrontendGenerator.ts b/src/generators/microfrontendGenerator.ts new file mode 100644 index 00000000..c64f8818 --- /dev/null +++ b/src/generators/microfrontendGenerator.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { camelCaseToTitleCase } from '@salesforce/kit'; +import * as path from 'path'; +import { nls } from '../i18n'; +import { CreateUtil } from '../utils'; +import { MicrofrontendOptions } from '../utils/types'; +import { BaseGenerator } from './baseGenerator'; + +function isAllowedSrcUrl(src: string): boolean { + let parsed: URL; + try { + parsed = new URL(src); + } catch { + return false; + } + if (parsed.protocol === 'https:') { + return true; + } + if (parsed.protocol === 'http:') { + return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'; + } + return false; +} + +const VALID_SANDBOX_TOKENS = new Set([ + 'allow-forms', + 'allow-modals', + 'allow-orientation-lock', + 'allow-pointer-lock', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-presentation', + 'allow-same-origin', + 'allow-scripts', + 'allow-storage-access-by-user-activation', + 'allow-top-navigation', + 'allow-top-navigation-by-user-activation', +]); + +export default class MicrofrontendGenerator extends BaseGenerator { + public validateOptions(): void { + CreateUtil.checkInputs(this.options.componentname); + + const fileparts = path.resolve(this.outputdir).split(path.sep); + if (!this.options.internal && !fileparts.includes('lwc')) { + throw new Error(nls.localize('MissingLWCDir')); + } + + if (!isAllowedSrcUrl(this.options.src)) { + throw new Error(nls.localize('InvalidMicrofrontendSrcUrl')); + } + + if (!this.options.shellTitle || !this.options.shellTitle.trim()) { + throw new Error(nls.localize('MissingMicrofrontendShellTitle')); + } + + const tokens = this.options.sandbox.split(/\s+/).filter(Boolean); + const invalid = tokens.filter((t) => !VALID_SANDBOX_TOKENS.has(t)); + if (invalid.length) { + throw new Error( + nls.localize('InvalidMicrofrontendSandboxToken', [ + invalid.join(', '), + [...VALID_SANDBOX_TOKENS].join(', '), + ]) + ); + } + } + + public async generate(): Promise { + const { componentname, src, sandbox, shellTitle, internal } = this.options; + + const pascalCaseComponentName = `${componentname + .substring(0, 1) + .toUpperCase()}${componentname.substring(1)}`; + const camelCaseComponentName = `${componentname + .substring(0, 1) + .toLowerCase()}${componentname.substring(1)}`; + + this.sourceRootWithPartialPath(path.join('microfrontend', 'default')); + + await this.render( + this.templatePath('default.html'), + this.destinationPath( + path.join( + this.outputdir, + camelCaseComponentName, + `${camelCaseComponentName}.html` + ) + ), + { sandbox, shellTitle } + ); + + await this.render( + this.templatePath('default.js'), + this.destinationPath( + path.join( + this.outputdir, + camelCaseComponentName, + `${camelCaseComponentName}.js` + ) + ), + { pascalCaseComponentName, src } + ); + + await this.render( + this.templatePath('default.css'), + this.destinationPath( + path.join( + this.outputdir, + camelCaseComponentName, + `${camelCaseComponentName}.css` + ) + ), + {} + ); + + if (!internal) { + const masterLabel = camelCaseToTitleCase(componentname) + .replace(//g, '>'); + await this.render( + this.templatePath('default.js-meta.xml'), + this.destinationPath( + path.join( + this.outputdir, + camelCaseComponentName, + `${camelCaseComponentName}.js-meta.xml` + ) + ), + { apiVersion: this.apiversion, masterLabel } + ); + } + } +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index 557ab897..855e2ca3 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -61,4 +61,13 @@ export const messages = { 'Failed to load the FlexiPage templates repository. Please verify the URL is correct and accessible.', AlphaNumericValidationError: '%s must contain only alphanumeric characters.', + + InvalidMicrofrontendSrcUrl: + 'The --src flag must be an absolute https URL (e.g., https://app.example.com). Plain http is only allowed for localhost or 127.0.0.1.', + InvalidMicrofrontendSandboxToken: + 'Invalid sandbox tokens: %s. Valid tokens are: %s.', + MissingMicrofrontendShellTitle: + 'The --shell-title flag is required and must be a non-empty string used as the iframe accessible name.', + MicrofrontendBundle: + 'A Lightning Web Component that wraps the lightning-mfe-shell base component.', }; diff --git a/src/templates/microfrontend/default/default.css b/src/templates/microfrontend/default/default.css new file mode 100644 index 00000000..f44ed0da --- /dev/null +++ b/src/templates/microfrontend/default/default.css @@ -0,0 +1,5 @@ +:host { + display: block; + width: 100%; + height: 100%; +} diff --git a/src/templates/microfrontend/default/default.html b/src/templates/microfrontend/default/default.html new file mode 100644 index 00000000..6a3e9563 --- /dev/null +++ b/src/templates/microfrontend/default/default.html @@ -0,0 +1,7 @@ + diff --git a/src/templates/microfrontend/default/default.js b/src/templates/microfrontend/default/default.js new file mode 100644 index 00000000..74fc4387 --- /dev/null +++ b/src/templates/microfrontend/default/default.js @@ -0,0 +1,5 @@ +import { LightningElement } from 'lwc'; + +export default class <%= pascalCaseComponentName %> extends LightningElement { + widgetUrl = '<%= src %>'; +} diff --git a/src/templates/microfrontend/default/default.js-meta.xml b/src/templates/microfrontend/default/default.js-meta.xml new file mode 100644 index 00000000..e078854e --- /dev/null +++ b/src/templates/microfrontend/default/default.js-meta.xml @@ -0,0 +1,12 @@ + + + <%= apiVersion %> + true + <%= masterLabel %> + + lightning__AppPage + lightning__RecordPage + lightning__HomePage + lightningCommunity__Page + + diff --git a/src/utils/types.ts b/src/utils/types.ts index b0d6071d..79ba64cb 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -11,6 +11,7 @@ import ApexTriggerGenerator from '../generators/apexTriggerGenerator'; import FlexipageGenerator from '../generators/flexipageGenerator'; import LightningAppGenerator from '../generators/lightningAppGenerator'; import LightningComponentGenerator from '../generators/lightningComponentGenerator'; +import MicrofrontendGenerator from '../generators/microfrontendGenerator'; import LightningEventGenerator from '../generators/lightningEventGenerator'; import LightningInterfaceGenerator from '../generators/lightningInterfaceGenerator'; import LightningTestGenerator from '../generators/lightningTestGenerator'; @@ -51,6 +52,7 @@ export type Generators = | typeof LightningTestGenerator | typeof LightningInterfaceGenerator | typeof DigitalExperienceSiteGenerator + | typeof MicrofrontendGenerator | typeof ProjectGenerator | typeof StaticResourceGenerator | typeof VisualforceComponentGenerator @@ -75,6 +77,7 @@ export enum TemplateType { LightningInterface, LightningTest, DigitalExperienceSite, + Microfrontend, Project, VisualforceComponent, VisualforcePage, @@ -93,6 +96,7 @@ export const generators = new Map>([ [TemplateType.LightningInterface, LightningInterfaceGenerator], [TemplateType.LightningTest, LightningTestGenerator], [TemplateType.DigitalExperienceSite, DigitalExperienceSiteGenerator], + [TemplateType.Microfrontend, MicrofrontendGenerator], [TemplateType.Project, ProjectGenerator], [TemplateType.StaticResource, StaticResourceGenerator], [TemplateType.VisualforceComponent, VisualforceComponentGenerator], @@ -180,6 +184,14 @@ export interface LightningTestOptions extends TemplateOptions { internal: boolean; } +export interface MicrofrontendOptions extends TemplateOptions { + componentname: string; + src: string; + sandbox: string; + shellTitle: string; + internal?: boolean; +} + export interface ProjectOptions extends TemplateOptions { projectname: string; defaultpackagedir: string; diff --git a/test/generators/microfrontendGenerator.test.ts b/test/generators/microfrontendGenerator.test.ts new file mode 100644 index 00000000..d07dbade --- /dev/null +++ b/test/generators/microfrontendGenerator.test.ts @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import * as chai from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TemplateService, TemplateType } from '../../src'; +import MicrofrontendGenerator from '../../src/generators/microfrontendGenerator'; +import { getDefaultApiVersion } from '../../src/generators/baseGenerator'; + +chai.config.truncateThreshold = 100000; +const { expect } = chai; + +async function remove(file: string) { + await fs.promises.rm(file, { force: true, recursive: true }); +} + +function assertFileExists(file: string) { + expect(fs.existsSync(file), `Expected file to exist: ${file}`).to.be.true; +} + +function assertFileContent(file: string, needle: string | RegExp) { + assertFileExists(file); + const body = fs.readFileSync(file, 'utf8'); + const match = + typeof needle === 'string' ? body.includes(needle) : needle.test(body); + expect(match, `${file} did not match '${needle}'. Contained:\n\n${body}`).to + .be.true; +} + +describe('MicrofrontendGenerator', () => { + const apiVersion = getDefaultApiVersion(); + const lwcOutputDir = path.join('testsoutput', 'lwc'); + const nonLwcOutputDir = path.join('testsoutput', 'mfe'); + + beforeEach(async () => { + await remove(lwcOutputDir); + await remove(nonLwcOutputDir); + }); + + describe('validateOptions', () => { + it('should throw when componentname is empty', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: '', + src: 'https://app.example.com', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(); + }); + + it('should throw when not internal and outputdir is missing lwc parent', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: nonLwcOutputDir, + internal: false, + }) + ).to.throw(/lwc/i); + }); + + it('should accept http src on localhost', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'http://localhost:3000', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.not.throw(); + }); + + it('should accept http src on 127.0.0.1', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'http://127.0.0.1:8080', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.not.throw(); + }); + + it('should reject http src on non-localhost host', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'http://app.example.com', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(/https/i); + }); + + it('should reject non-URL src', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'not a url', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(/https/i); + }); + + it('should reject non-http(s) protocols', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'ftp://example.com', + sandbox: 'allow-scripts', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(/https/i); + }); + + it('should reject empty shellTitle', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts', + shellTitle: ' ', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(/shell-title/i); + }); + + it('should reject invalid sandbox tokens', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts allow-everything', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.throw(/allow-everything/); + }); + + it('should accept multiple valid sandbox tokens', () => { + expect( + () => + new MicrofrontendGenerator({ + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts allow-forms allow-same-origin', + shellTitle: 'Demo', + outputdir: lwcOutputDir, + internal: true, + }) + ).to.not.throw(); + }); + }); + + describe('generate', () => { + it('should create the LWC bundle (internal — no meta xml)', async () => { + const templateService = TemplateService.getInstance(process.cwd()); + const result = await templateService.create(TemplateType.Microfrontend, { + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts allow-forms', + shellTitle: 'Demo Shell', + outputdir: lwcOutputDir, + apiversion: apiVersion, + internal: true, + }); + + const base = path.join(lwcOutputDir, 'mfeShell'); + assertFileExists(path.join(base, 'mfeShell.html')); + assertFileExists(path.join(base, 'mfeShell.js')); + assertFileExists(path.join(base, 'mfeShell.css')); + expect( + fs.existsSync(path.join(base, 'mfeShell.js-meta.xml')), + 'meta xml should not exist for internal' + ).to.be.false; + + assertFileContent(path.join(base, 'mfeShell.html'), 'allow-scripts'); + assertFileContent(path.join(base, 'mfeShell.html'), 'Demo Shell'); + assertFileContent(path.join(base, 'mfeShell.js'), 'MfeShell'); + assertFileContent( + path.join(base, 'mfeShell.js'), + 'https://app.example.com' + ); + + expect(result.created.length).to.be.greaterThan(0); + }); + + it('should generate meta xml when not internal', async () => { + const templateService = TemplateService.getInstance(process.cwd()); + await templateService.create(TemplateType.Microfrontend, { + componentname: 'mfeShell', + src: 'https://app.example.com', + sandbox: 'allow-scripts', + shellTitle: 'Public Shell', + outputdir: lwcOutputDir, + apiversion: apiVersion, + internal: false, + }); + + const meta = path.join(lwcOutputDir, 'mfeShell', 'mfeShell.js-meta.xml'); + assertFileExists(meta); + assertFileContent(meta, 'Mfe Shell'); + assertFileContent(meta, apiVersion); + }); + }); +});