diff --git a/command-snapshot.json b/command-snapshot.json index 2d0b1663..fa7af857 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -79,6 +79,25 @@ "flags": ["api-version", "flags-dir", "internal", "json", "loglevel", "name", "output-dir", "template", "type"], "plugin": "@salesforce/plugin-templates" }, + { + "alias": [], + "command": "template:generate:lightning:embedding", + "flagAliases": ["apiversion", "outputdir"], + "flagChars": ["d", "i", "n", "s"], + "flags": [ + "api-version", + "flags-dir", + "internal", + "json", + "loglevel", + "name", + "output-dir", + "sandbox", + "shell-title", + "src" + ], + "plugin": "@salesforce/plugin-templates" + }, { "alias": ["force:lightning:event:create", "lightning:generate:event"], "command": "template:generate:lightning:event", diff --git a/messages/lightningEmbedding.md b/messages/lightningEmbedding.md new file mode 100644 index 00000000..e549668b --- /dev/null +++ b/messages/lightningEmbedding.md @@ -0,0 +1,55 @@ +# examples + +- Generate an embedding wrapper LWC in the current directory: + + <%= config.bin %> <%= command.id %> --name MyEmbeddingWrapper --src https://app.example.com --sandbox allow-forms --shell-title "Expense Report Widget" + +- Generate an embedding wrapper LWC in the "force-app/main/default/lwc" directory with multiple sandbox tokens: + + <%= config.bin %> <%= command.id %> --name MyEmbeddingWrapper --src https://app.example.com --sandbox allow-forms --sandbox allow-scripts --shell-title "Expense Report Widget" --output-dir force-app/main/default/lwc + +# summary + +Generate a Lightning Web Component (LWC) bundle that wraps the lightning-embedding base component. + +# description + +The generated LWC bundle consumes the first-party component, which is pre-wired with the three required attributes: the widget URL (src), iframe sandbox tokens, and an accessible iframe title (shell-title). + +The generated LWC bundle contains four files (.html, .js, .js-meta.xml, .css) in a directory named with the camelCased component name. The bundle must live under a parent folder named "lwc". + +# flags.name.summary + +Name of the generated component; must be in PascalCase format. + +# flags.name.description + +The component name is also used (camelCased) as the LWC folder name and file stem. Must contain only alphanumeric characters and start with a letter. + +# flags.src.summary + +Absolute HTTPS URL that the iframe will load. + +# flags.src.description + +The URL is bound to the "src" attribute as a reactive property in the generated LWC. Must use HTTPS; plain HTTP is allowed only for localhost or 127.0.0.1 (for local development). + +# flags.src.error + +The --src flag must be an absolute HTTPS URL, such as https://app.example.com. Plain HTTP is allowed only for localhost or 127.0.0.1. + +# flags.sandbox.summary + +Iframe sandbox token. Specify this flag multiple times to set more than one token. + +# flags.sandbox.description + +Each token is written into the space-separated "sandbox" attribute on . Only W3C-defined sandbox tokens are accepted. + +# flags.shell-title.summary + +Accessible title for the embedded iframe. + +# flags.shell-title.description + +Written to the "shell-title" attribute on and used as the iframe's accessible name (announced by screen readers). diff --git a/src/commands/template/generate/lightning/embedding.ts b/src/commands/template/generate/lightning/embedding.ts new file mode 100644 index 00000000..2a206395 --- /dev/null +++ b/src/commands/template/generate/lightning/embedding.ts @@ -0,0 +1,87 @@ +/* + * 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 { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand, Ux } from '@salesforce/sf-plugins-core'; +import { + CreateOutput, + isAllowedLightningEmbeddingSrcUrl, + LIGHTNING_EMBEDDING_SANDBOX_TOKENS, + LightningEmbeddingOptions, + TemplateType, +} from '@salesforce/templates'; +import { Messages } from '@salesforce/core'; +import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; +import { internalFlag, outputDirFlagLightning } from '../../../../utils/flags.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-templates', 'lightningEmbedding'); + +export default class LightningEmbedding extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + public static readonly hidden = true; + + public static readonly flags = { + name: Flags.string({ + char: 'n', + summary: messages.getMessage('flags.name.summary'), + description: messages.getMessage('flags.name.description'), + required: true, + }), + src: Flags.string({ + char: 's', + summary: messages.getMessage('flags.src.summary'), + description: messages.getMessage('flags.src.description'), + required: true, + parse: (input: string) => { + if (!isAllowedLightningEmbeddingSrcUrl(input)) { + throw new Error(messages.getMessage('flags.src.error')); + } + return Promise.resolve(input); + }, + }), + sandbox: Flags.option({ + summary: messages.getMessage('flags.sandbox.summary'), + description: messages.getMessage('flags.sandbox.description'), + options: LIGHTNING_EMBEDDING_SANDBOX_TOKENS, + multiple: true, + required: true, + })(), + 'shell-title': Flags.string({ + summary: messages.getMessage('flags.shell-title.summary'), + description: messages.getMessage('flags.shell-title.description'), + required: true, + }), + 'output-dir': outputDirFlagLightning, + 'api-version': orgApiVersionFlagWithDeprecations, + internal: internalFlag, + loglevel, + }; + + public async run(): Promise { + const { flags } = await this.parse(LightningEmbedding); + + const flagsAsOptions: LightningEmbeddingOptions = { + componentname: flags.name, + src: flags.src, + sandbox: flags.sandbox.join(' '), + shellTitle: flags['shell-title'], + outputdir: flags['output-dir'], + apiversion: flags['api-version'], + internal: flags.internal, + }; + + return runGenerator({ + templateType: TemplateType.LightningEmbedding, + opts: flagsAsOptions, + ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + templates: getCustomTemplates(this.configAggregator), + }); + } +} diff --git a/test/commands/template/generate/lightning/embedding.nut.ts b/test/commands/template/generate/lightning/embedding.nut.ts new file mode 100644 index 00000000..61935abf --- /dev/null +++ b/test/commands/template/generate/lightning/embedding.nut.ts @@ -0,0 +1,152 @@ +/* + * 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 path from 'node:path'; +import { expect, config } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import assert from 'yeoman-assert'; + +config.truncateThreshold = 0; + +describe('template generate lightning embedding:', () => { + let session: TestSession; + before(async () => { + session = await TestSession.create({ + project: {}, + devhubAuthStrategy: 'NONE', + }); + }); + after(async () => { + await session?.clean(); + }); + + const lwcDir = (): string => path.join(session.project.dir, 'lwc'); + const bundleFiles = (componentName: string): string[] => { + const camel = componentName.charAt(0).toLowerCase() + componentName.slice(1); + return ['.html', '.js', '.css', '.js-meta.xml'].map((suffix) => path.join(lwcDir(), camel, camel + suffix)); + }; + + describe('Check lightning embedding creation', () => { + const name = 'MyEmbedding'; + const src = 'https://app.example.com'; + const shellTitle = 'Demo Embedding'; + + it('should scaffold an embedding LWC bundle with all four files', () => { + execCmd( + `template generate lightning embedding --name ${name} --src ${src} --sandbox allow-forms --shell-title "${shellTitle}" --output-dir ${lwcDir()}`, + { ensureExitCode: 0 } + ); + assert.file(bundleFiles(name)); + }); + + it('should emit a element in the generated html', () => { + execCmd( + `template generate lightning embedding --name ${name} --src ${src} --sandbox allow-forms --shell-title "${shellTitle}" --output-dir ${lwcDir()}`, + { ensureExitCode: 0 } + ); + const camel = name.charAt(0).toLowerCase() + name.slice(1); + assert.fileContent(path.join(lwcDir(), camel, `${camel}.html`), ' { + execCmd( + `template generate lightning embedding --name MultiSandbox --src ${src} --sandbox allow-forms --sandbox allow-scripts --shell-title "${shellTitle}" --output-dir ${lwcDir()}`, + { ensureExitCode: 0 } + ); + assert.fileContent( + path.join(lwcDir(), 'multiSandbox', 'multiSandbox.html'), + 'sandbox="allow-forms allow-scripts"' + ); + }); + + it('should bind the src URL into the generated js as a reactive property', () => { + execCmd( + `template generate lightning embedding --name SrcBinding --src ${src} --sandbox allow-forms --shell-title "${shellTitle}" --output-dir ${lwcDir()}`, + { ensureExitCode: 0 } + ); + assert.fileContent(path.join(lwcDir(), 'srcBinding', 'srcBinding.js'), src); + }); + + it('should accept http URLs on localhost for local development', () => { + execCmd( + `template generate lightning embedding --name LocalDev --src http://localhost:3000 --sandbox allow-forms --shell-title "${shellTitle}" --output-dir ${lwcDir()}`, + { ensureExitCode: 0 } + ); + assert.fileContent(path.join(lwcDir(), 'localDev', 'localDev.js'), 'http://localhost:3000'); + }); + }); + + describe('lightning embedding failures', () => { + const baseFlags = '--name Foo --sandbox allow-forms --shell-title "Demo"'; + + it('should throw missing --name error', () => { + const stderr = execCmd( + 'template generate lightning embedding --src https://app.example.com --sandbox allow-forms --shell-title "Demo"' + ).shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should throw missing --src error', () => { + const stderr = execCmd( + 'template generate lightning embedding --name Foo --sandbox allow-forms --shell-title "Demo"' + ).shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should throw missing --sandbox error', () => { + const stderr = execCmd( + 'template generate lightning embedding --name Foo --src https://app.example.com --shell-title "Demo"' + ).shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should throw missing --shell-title error (no fallback to --name)', () => { + const stderr = execCmd( + 'template generate lightning embedding --name Foo --src https://app.example.com --sandbox allow-forms' + ).shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should reject http src on a non-localhost host', () => { + const stderr = execCmd( + `template generate lightning embedding --name Foo --src http://attacker.com --sandbox allow-forms --shell-title "Demo" --output-dir ${lwcDir()}` + ).shellOutput.stderr; + expect(stderr).to.contain('HTTPS URL'); + }); + + it('should reject non-http(s) protocols', () => { + const stderr = execCmd( + `template generate lightning embedding --name Foo --src ftp://example.com --sandbox allow-forms --shell-title "Demo" --output-dir ${lwcDir()}` + ).shellOutput.stderr; + expect(stderr).to.contain('HTTPS URL'); + }); + + it('should reject malformed --src input', () => { + const stderr = execCmd( + `template generate lightning embedding --name Foo --src not-a-url --sandbox allow-forms --shell-title "Demo" --output-dir ${lwcDir()}` + ).shellOutput.stderr; + expect(stderr).to.contain('HTTPS URL'); + }); + + it('should reject an unknown sandbox token', () => { + const stderr = execCmd( + `template generate lightning embedding ${baseFlags} --src https://app.example.com --sandbox allow-everything --output-dir ${lwcDir()}`, + { ensureExitCode: 'nonZero' } + ).shellOutput.stderr; + expect(stderr).to.contain('Expected --sandbox'); + }); + + it('should throw missing lwc parent folder error when output-dir is not under lwc/', () => { + const stderr = execCmd( + `template generate lightning embedding --name Foo --src https://app.example.com --sandbox allow-forms --shell-title "Demo" --output-dir ${path.join( + session.project.dir, + 'somewhere-else' + )}` + ).shellOutput.stderr; + expect(stderr).to.match(/lwc/i); + }); + }); +});