Skip to content
19 changes: 19 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions messages/lightningEmbedding.md
Original file line number Diff line number Diff line change
@@ -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 <lightning-embedding> 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 <lightning-embedding> "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 <lightning-embedding>. 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 <lightning-embedding> and used as the iframe's accessible name (announced by screen readers).
87 changes: 87 additions & 0 deletions src/commands/template/generate/lightning/embedding.ts
Original file line number Diff line number Diff line change
@@ -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<CreateOutput> {
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({
Comment thread
iowillhoit marked this conversation as resolved.
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<CreateOutput> {
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),
});
}
}
152 changes: 152 additions & 0 deletions test/commands/template/generate/lightning/embedding.nut.ts
Original file line number Diff line number Diff line change
@@ -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 <lightning-embedding> 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`), '<lightning-embedding');
});

it('should join multiple --sandbox tokens into a single space-separated attribute', () => {
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);
});
});
});
Loading