Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@
"flags": ["api-version", "event", "flags-dir", "json", "loglevel", "name", "output-dir", "sobject", "template"],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": [],
"command": "template:generate:digital-experience:site",
"flagAliases": [],
"flagChars": ["d", "n", "p", "e", "o", "t"],
"flags": ["flags-dir", "json", "name", "output-dir", "url-path-prefix", "admin-email", "target-org", "template"],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": ["force:visualforce:component:create"],
"command": "visualforce:generate:component",
Expand Down
47 changes: 47 additions & 0 deletions messages/digitalExperienceSite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# summary

Generate an Experience Cloud site.

# description

Creates an Experience Cloud site with the specified template, name, and URL path prefix. The site includes all necessary metadata files, including DigitalExperienceConfig, DigitalExperienceBundle, Network, and CustomSite.

# examples

- Generate an Experience Cloud site using the BuildYourOwnLWR template. The site is called "mysite" and has the URL path prefix "mysite":

<%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite

- Generate an Experience Cloud site like the last example, but generate the files into the specified output directory:

<%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite --output-dir force-app/main/default

# flags.name.summary

Name of the Experience Cloud site to generate.

# flags.template.summary

Template to use when generating the site.

# flags.template.description

Supported templates:

- BuildYourOwnLWR - Creates blazing-fast digital experiences, such as websites, microsites, and portals, using the Lightning Web Components programming model. Powered by Lightning Web Runtime (LWR), this customizable template delivers unparalleled site performance. For additional details, see this Salesforce Help topic: https://help.salesforce.com/s/articleView?id=experience.rss_build_your_own_lwr.htm.

# flags.url-path-prefix.summary

URL path prefix for the site; must contain only alphanumeric characters.

# flags.admin-email.summary

Email address for the site administrator. Defaults to the username of the currently authenticated user.

# flags.output-dir.summary

Directory to generate the site files in.

# flags.output-dir.description

The location can be an absolute path or relative to the current working directory. If not specified, the command reads your sfdx-project.json file and uses the default package directory. When running outside a Salesforce DX project, defaults to the current directory.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dependencies": {
"@salesforce/core": "^8.25.0",
"@salesforce/sf-plugins-core": "^12",
"@salesforce/templates": "^65.5.2"
"@salesforce/templates": "^65.5.3"
},
"devDependencies": {
"@oclif/plugin-command-snapshot": "^5.3.8",
Expand Down Expand Up @@ -92,6 +92,9 @@
"subtopics": {
"apex": {
"description": "Create an apex class or trigger."
},
"digital-experience": {
"description": "Create a Digital Experience site."
}
}
}
Expand Down
96 changes: 96 additions & 0 deletions src/commands/template/generate/digital-experience/site.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* 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 { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core';
import { CreateOutput, DigitalExperienceSiteOptions, TemplateType } from '@salesforce/templates';
import { Messages, SfProject } from '@salesforce/core';
import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-templates', 'digitalExperienceSite');

export default class GenerateSite extends SfCommand<CreateOutput> {
public static readonly state = 'preview';
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');
public static readonly flags = {
'target-org': Flags.optionalOrg(),
name: Flags.string({
char: 'n',
summary: messages.getMessage('flags.name.summary'),
required: true,
}),
template: Flags.string({
char: 't',
summary: messages.getMessage('flags.template.summary'),
options: ['BuildYourOwnLWR'] as const,
required: true,
}),
'url-path-prefix': Flags.string({
char: 'p',
summary: messages.getMessage('flags.url-path-prefix.summary'),
// each site must have a unique url path prefix, if not provided assume it's empty
// to mimic UI's behavior
default: '',
}),
'admin-email': Flags.string({
char: 'e',
summary: messages.getMessage('flags.admin-email.summary'),
}),
'output-dir': Flags.directory({
char: 'd',
summary: messages.getMessage('flags.output-dir.summary'),
description: messages.getMessage('flags.output-dir.description'),
}),
};

/**
* Resolves the default output directory by reading the project's sfdx-project.json.
* Returns the path to the default package directory,
* or falls back to the current directory if not in a project context.
*/
private static async getDefaultOutputDir(): Promise<string> {
try {
const project = await SfProject.resolve();
const defaultPackage = project.getDefaultPackage();
return path.join(defaultPackage.path, 'main', 'default');
} catch {
return '.';
}
}

public async run(): Promise<CreateOutput> {
const { flags } = await this.parse(GenerateSite);

let adminEmail = flags['admin-email'];
if (!adminEmail) {
const org = flags['target-org'];
// If this ever fails to return a username, the default value will be appeneded ".invalid"
// in admin workspace with a note asking the admin to set a valid email and verify it.
adminEmail = org?.getConnection()?.getUsername() ?? 'senderEmail@example.com';
}

const outputDir = flags['output-dir'] ?? (await GenerateSite.getDefaultOutputDir());

const flagsAsOptions: DigitalExperienceSiteOptions = {
sitename: flags.name,
urlpathprefix: flags['url-path-prefix'],
adminemail: adminEmail,
template: flags.template,
outputdir: outputDir,
};

return runGenerator({
templateType: TemplateType.DigitalExperienceSite,
opts: flagsAsOptions,
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
templates: getCustomTemplates(this.configAggregator),
});
}
}
151 changes: 151 additions & 0 deletions test/commands/template/generate/digital-experience/site/create.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* 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 fs from 'node:fs';
import { expect } from 'chai';
import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit';
import assert from 'yeoman-assert';

const COMMAND = 'template generate digital-experience site';

describe(COMMAND, () => {
let session: TestSession;
before(async () => {
session = await TestSession.create({
project: {},
devhubAuthStrategy: 'NONE',
});
});
after(async () => {
await session?.clean();
});

describe('--template BuildYourOwnLWR', () => {
it('should create with all required files', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
execCmd(
`${COMMAND} --template BuildYourOwnLWR --name "123 @ Test Site" --url-path-prefix 123testsite --output-dir "${outputDir}"`,
{
ensureExitCode: 0,
}
);

const bundlePath = path.join(outputDir, 'digitalExperiences', 'site', 'X123_Test_Site1');

// Check top-level metadata files
assert.file([
path.join(outputDir, 'networks', '123 %40 Test Site.network-meta.xml'),
path.join(outputDir, 'sites', 'X123_Test_Site.site-meta.xml'),
path.join(outputDir, 'digitalExperienceConfigs', 'X123_Test_Site1.digitalExperienceConfig-meta.xml'),
path.join(bundlePath, 'X123_Test_Site1.digitalExperience-meta.xml'),
]);

// Check DEB components
assert.file([
path.join(bundlePath, 'sfdc_cms__appPage', 'mainAppPage', 'content.json'),
path.join(bundlePath, 'sfdc_cms__appPage', 'mainAppPage', '_meta.json'),
path.join(bundlePath, 'sfdc_cms__brandingSet', 'Build_Your_Own_LWR', 'content.json'),
path.join(bundlePath, 'sfdc_cms__brandingSet', 'Build_Your_Own_LWR', '_meta.json'),
path.join(bundlePath, 'sfdc_cms__languageSettings', 'languages', 'content.json'),
path.join(bundlePath, 'sfdc_cms__languageSettings', 'languages', '_meta.json'),
path.join(bundlePath, 'sfdc_cms__mobilePublisherConfig', 'mobilePublisherConfig', 'content.json'),
path.join(bundlePath, 'sfdc_cms__mobilePublisherConfig', 'mobilePublisherConfig', '_meta.json'),
path.join(bundlePath, 'sfdc_cms__theme', 'Build_Your_Own_LWR', 'content.json'),
path.join(bundlePath, 'sfdc_cms__theme', 'Build_Your_Own_LWR', '_meta.json'),
path.join(bundlePath, 'sfdc_cms__site', 'X123_Test_Site1', 'content.json'),
path.join(bundlePath, 'sfdc_cms__site', 'X123_Test_Site1', '_meta.json'),
]);

// Check routes
const routes = [
'Check_Password',
'Error',
'Forgot_Password',
'Home',
'Login',
'News_Detail__c',
'Register',
'Service_Not_Available',
'Too_Many_Requests',
];
for (const route of routes) {
assert.file([
path.join(bundlePath, 'sfdc_cms__route', route, 'content.json'),
path.join(bundlePath, 'sfdc_cms__route', route, '_meta.json'),
]);
}

// Check theme layouts
const layouts = ['scopedHeaderAndFooter', 'snaThemeLayout'];
for (const layout of layouts) {
assert.file([
path.join(bundlePath, 'sfdc_cms__themeLayout', layout, 'content.json'),
path.join(bundlePath, 'sfdc_cms__themeLayout', layout, '_meta.json'),
]);
}

// Check views
const views = [
'checkPasswordResetEmail',
'error',
'forgotPassword',
'home',
'login',
'newsDetail',
'register',
'serviceNotAvailable',
'tooManyRequests',
];
for (const view of views) {
assert.file([
path.join(bundlePath, 'sfdc_cms__view', view, 'content.json'),
path.join(bundlePath, 'sfdc_cms__view', view, '_meta.json'),
]);
}
});
});

describe('parameter validation', () => {
it('should throw error if missing site name', () => {
const stderr = execCmd(`${COMMAND} --template BuildYourOwnLWR --url-path-prefix test`).shellOutput.stderr;
expect(stderr).to.contain('Missing required flag');
});

it('should throw error if missing template', () => {
const stderr = execCmd(`${COMMAND} --name test --url-path-prefix test`).shellOutput.stderr;
expect(stderr).to.contain('Missing required flag');
});

it('should default to empty string if url-path-prefix is not provided', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
execCmd(`${COMMAND} --template BuildYourOwnLWR --name "DefaultPrefixSite" --output-dir "${outputDir}"`, {
ensureExitCode: 0,
});

const networkPath = path.join(outputDir, 'networks', 'DefaultPrefixSite.network-meta.xml');
const networkContent = fs.readFileSync(networkPath, 'utf8');
expect(networkContent).to.include('<urlPathPrefix>vforcesite</urlPathPrefix>');

const configPath = path.join(
outputDir,
'digitalExperienceConfigs',
'DefaultPrefixSite1.digitalExperienceConfig-meta.xml'
);
const configContent = fs.readFileSync(configPath, 'utf8');
expect(configContent).to.include('<urlPathPrefix></urlPathPrefix>');
});

it('should default to force/main/default if output-dir is not provided', () => {
execCmd(`${COMMAND} --template BuildYourOwnLWR --name "DefaultDirSite" --url-path-prefix defaultdir`, {
ensureExitCode: 0,
});
const defaultOutputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
assert.file(path.join(defaultOutputDir, 'networks', 'DefaultDirSite.network-meta.xml'));
assert.file(path.join(defaultOutputDir, 'sites', 'DefaultDirSite.site-meta.xml'));
});
});
});
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2009,10 +2009,10 @@
cli-progress "^3.12.0"
terminal-link "^3.0.0"

"@salesforce/templates@^65.5.2":
version "65.5.2"
resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-65.5.2.tgz#33dc497908c94cfb1b8ebd93f790e24d4775794b"
integrity sha512-SBlyOPmzc3/+/LmeN7q62/cRTBeLI3g3O6I4JQPzvXuyzdUHs71NYLopQN35lV75BceF3WYKnV3+p/eTIp8Okg==
"@salesforce/templates@^65.5.3":
version "65.5.3"
resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-65.5.3.tgz#8d6fcb4bfabfbe0b7b79e129f7ed26f6fb7357aa"
integrity sha512-ocnHa3dyHNfhDjljHNuxDUfQ0SHA+blswilLELNswvdyaKmNFDpmF3kKz75gsibuQ6MT32FA0TlEbsVlWWZZpQ==
dependencies:
"@salesforce/kit" "^3.2.4"
"@salesforce/webapp-template-base-react-app-experimental" "^1.3.5"
Expand Down