Skip to content

Commit 5f05d4b

Browse files
authored
Merge pull request #829 from scottmo/t/experience-sites-runtime/w-20906837/byotemplate
@W-20906837 feat: add command to use BYO LWR template
2 parents f32be1b + 9f4bd7d commit 5f05d4b

6 files changed

Lines changed: 310 additions & 5 deletions

File tree

command-snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@
9191
"flags": ["api-version", "event", "flags-dir", "json", "loglevel", "name", "output-dir", "sobject", "template"],
9292
"plugin": "@salesforce/plugin-templates"
9393
},
94+
{
95+
"alias": [],
96+
"command": "template:generate:digital-experience:site",
97+
"flagAliases": [],
98+
"flagChars": ["d", "n", "p", "e", "o", "t"],
99+
"flags": ["flags-dir", "json", "name", "output-dir", "url-path-prefix", "admin-email", "target-org", "template"],
100+
"plugin": "@salesforce/plugin-templates"
101+
},
94102
{
95103
"alias": ["force:visualforce:component:create"],
96104
"command": "visualforce:generate:component",

messages/digitalExperienceSite.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# summary
2+
3+
Generate an Experience Cloud site.
4+
5+
# description
6+
7+
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.
8+
9+
# examples
10+
11+
- Generate an Experience Cloud site using the BuildYourOwnLWR template. The site is called "mysite" and has the URL path prefix "mysite":
12+
13+
<%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite
14+
15+
- Generate an Experience Cloud site like the last example, but generate the files into the specified output directory:
16+
17+
<%= config.bin %> <%= command.id %> --template BuildYourOwnLWR --name mysite --url-path-prefix mysite --output-dir force-app/main/default
18+
19+
# flags.name.summary
20+
21+
Name of the Experience Cloud site to generate.
22+
23+
# flags.template.summary
24+
25+
Template to use when generating the site.
26+
27+
# flags.template.description
28+
29+
Supported templates:
30+
31+
- 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.
32+
33+
# flags.url-path-prefix.summary
34+
35+
URL path prefix for the site; must contain only alphanumeric characters.
36+
37+
# flags.admin-email.summary
38+
39+
Email address for the site administrator. Defaults to the username of the currently authenticated user.
40+
41+
# flags.output-dir.summary
42+
43+
Directory to generate the site files in.
44+
45+
# flags.output-dir.description
46+
47+
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.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"dependencies": {
88
"@salesforce/core": "^8.25.1",
99
"@salesforce/sf-plugins-core": "^12",
10-
"@salesforce/templates": "^65.5.2"
10+
"@salesforce/templates": "^65.5.3"
1111
},
1212
"devDependencies": {
1313
"@oclif/plugin-command-snapshot": "^5.3.8",
@@ -92,6 +92,9 @@
9292
"subtopics": {
9393
"apex": {
9494
"description": "Create an apex class or trigger."
95+
},
96+
"digital-experience": {
97+
"description": "Create a Digital Experience site."
9598
}
9699
}
97100
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import path from 'node:path';
9+
import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core';
10+
import { CreateOutput, DigitalExperienceSiteOptions, TemplateType } from '@salesforce/templates';
11+
import { Messages, SfProject } from '@salesforce/core';
12+
import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js';
13+
14+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
15+
const messages = Messages.loadMessages('@salesforce/plugin-templates', 'digitalExperienceSite');
16+
17+
export default class GenerateSite extends SfCommand<CreateOutput> {
18+
public static readonly state = 'preview';
19+
public static readonly summary = messages.getMessage('summary');
20+
public static readonly description = messages.getMessage('description');
21+
public static readonly examples = messages.getMessages('examples');
22+
public static readonly flags = {
23+
'target-org': Flags.optionalOrg(),
24+
name: Flags.string({
25+
char: 'n',
26+
summary: messages.getMessage('flags.name.summary'),
27+
required: true,
28+
}),
29+
template: Flags.string({
30+
char: 't',
31+
summary: messages.getMessage('flags.template.summary'),
32+
options: ['BuildYourOwnLWR'] as const,
33+
required: true,
34+
}),
35+
'url-path-prefix': Flags.string({
36+
char: 'p',
37+
summary: messages.getMessage('flags.url-path-prefix.summary'),
38+
// each site must have a unique url path prefix, if not provided assume it's empty
39+
// to mimic UI's behavior
40+
default: '',
41+
}),
42+
'admin-email': Flags.string({
43+
char: 'e',
44+
summary: messages.getMessage('flags.admin-email.summary'),
45+
}),
46+
'output-dir': Flags.directory({
47+
char: 'd',
48+
summary: messages.getMessage('flags.output-dir.summary'),
49+
description: messages.getMessage('flags.output-dir.description'),
50+
}),
51+
};
52+
53+
/**
54+
* Resolves the default output directory by reading the project's sfdx-project.json.
55+
* Returns the path to the default package directory,
56+
* or falls back to the current directory if not in a project context.
57+
*/
58+
private static async getDefaultOutputDir(): Promise<string> {
59+
try {
60+
const project = await SfProject.resolve();
61+
const defaultPackage = project.getDefaultPackage();
62+
return path.join(defaultPackage.path, 'main', 'default');
63+
} catch {
64+
return '.';
65+
}
66+
}
67+
68+
public async run(): Promise<CreateOutput> {
69+
const { flags } = await this.parse(GenerateSite);
70+
71+
let adminEmail = flags['admin-email'];
72+
if (!adminEmail) {
73+
const org = flags['target-org'];
74+
// If this ever fails to return a username, the default value will be appeneded ".invalid"
75+
// in admin workspace with a note asking the admin to set a valid email and verify it.
76+
adminEmail = org?.getConnection()?.getUsername() ?? 'senderEmail@example.com';
77+
}
78+
79+
const outputDir = flags['output-dir'] ?? (await GenerateSite.getDefaultOutputDir());
80+
81+
const flagsAsOptions: DigitalExperienceSiteOptions = {
82+
sitename: flags.name,
83+
urlpathprefix: flags['url-path-prefix'],
84+
adminemail: adminEmail,
85+
template: flags.template,
86+
outputdir: outputDir,
87+
};
88+
89+
return runGenerator({
90+
templateType: TemplateType.DigitalExperienceSite,
91+
opts: flagsAsOptions,
92+
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
93+
templates: getCustomTemplates(this.configAggregator),
94+
});
95+
}
96+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* Copyright (c) 2026, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import path from 'node:path';
8+
import fs from 'node:fs';
9+
import { expect } from 'chai';
10+
import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit';
11+
import assert from 'yeoman-assert';
12+
13+
const COMMAND = 'template generate digital-experience site';
14+
15+
describe(COMMAND, () => {
16+
let session: TestSession;
17+
before(async () => {
18+
session = await TestSession.create({
19+
project: {},
20+
devhubAuthStrategy: 'NONE',
21+
});
22+
});
23+
after(async () => {
24+
await session?.clean();
25+
});
26+
27+
describe('--template BuildYourOwnLWR', () => {
28+
it('should create with all required files', () => {
29+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
30+
execCmd(
31+
`${COMMAND} --template BuildYourOwnLWR --name "123 @ Test Site" --url-path-prefix 123testsite --output-dir "${outputDir}"`,
32+
{
33+
ensureExitCode: 0,
34+
}
35+
);
36+
37+
const bundlePath = path.join(outputDir, 'digitalExperiences', 'site', 'X123_Test_Site1');
38+
39+
// Check top-level metadata files
40+
assert.file([
41+
path.join(outputDir, 'networks', '123 %40 Test Site.network-meta.xml'),
42+
path.join(outputDir, 'sites', 'X123_Test_Site.site-meta.xml'),
43+
path.join(outputDir, 'digitalExperienceConfigs', 'X123_Test_Site1.digitalExperienceConfig-meta.xml'),
44+
path.join(bundlePath, 'X123_Test_Site1.digitalExperience-meta.xml'),
45+
]);
46+
47+
// Check DEB components
48+
assert.file([
49+
path.join(bundlePath, 'sfdc_cms__appPage', 'mainAppPage', 'content.json'),
50+
path.join(bundlePath, 'sfdc_cms__appPage', 'mainAppPage', '_meta.json'),
51+
path.join(bundlePath, 'sfdc_cms__brandingSet', 'Build_Your_Own_LWR', 'content.json'),
52+
path.join(bundlePath, 'sfdc_cms__brandingSet', 'Build_Your_Own_LWR', '_meta.json'),
53+
path.join(bundlePath, 'sfdc_cms__languageSettings', 'languages', 'content.json'),
54+
path.join(bundlePath, 'sfdc_cms__languageSettings', 'languages', '_meta.json'),
55+
path.join(bundlePath, 'sfdc_cms__mobilePublisherConfig', 'mobilePublisherConfig', 'content.json'),
56+
path.join(bundlePath, 'sfdc_cms__mobilePublisherConfig', 'mobilePublisherConfig', '_meta.json'),
57+
path.join(bundlePath, 'sfdc_cms__theme', 'Build_Your_Own_LWR', 'content.json'),
58+
path.join(bundlePath, 'sfdc_cms__theme', 'Build_Your_Own_LWR', '_meta.json'),
59+
path.join(bundlePath, 'sfdc_cms__site', 'X123_Test_Site1', 'content.json'),
60+
path.join(bundlePath, 'sfdc_cms__site', 'X123_Test_Site1', '_meta.json'),
61+
]);
62+
63+
// Check routes
64+
const routes = [
65+
'Check_Password',
66+
'Error',
67+
'Forgot_Password',
68+
'Home',
69+
'Login',
70+
'News_Detail__c',
71+
'Register',
72+
'Service_Not_Available',
73+
'Too_Many_Requests',
74+
];
75+
for (const route of routes) {
76+
assert.file([
77+
path.join(bundlePath, 'sfdc_cms__route', route, 'content.json'),
78+
path.join(bundlePath, 'sfdc_cms__route', route, '_meta.json'),
79+
]);
80+
}
81+
82+
// Check theme layouts
83+
const layouts = ['scopedHeaderAndFooter', 'snaThemeLayout'];
84+
for (const layout of layouts) {
85+
assert.file([
86+
path.join(bundlePath, 'sfdc_cms__themeLayout', layout, 'content.json'),
87+
path.join(bundlePath, 'sfdc_cms__themeLayout', layout, '_meta.json'),
88+
]);
89+
}
90+
91+
// Check views
92+
const views = [
93+
'checkPasswordResetEmail',
94+
'error',
95+
'forgotPassword',
96+
'home',
97+
'login',
98+
'newsDetail',
99+
'register',
100+
'serviceNotAvailable',
101+
'tooManyRequests',
102+
];
103+
for (const view of views) {
104+
assert.file([
105+
path.join(bundlePath, 'sfdc_cms__view', view, 'content.json'),
106+
path.join(bundlePath, 'sfdc_cms__view', view, '_meta.json'),
107+
]);
108+
}
109+
});
110+
});
111+
112+
describe('parameter validation', () => {
113+
it('should throw error if missing site name', () => {
114+
const stderr = execCmd(`${COMMAND} --template BuildYourOwnLWR --url-path-prefix test`).shellOutput.stderr;
115+
expect(stderr).to.contain('Missing required flag');
116+
});
117+
118+
it('should throw error if missing template', () => {
119+
const stderr = execCmd(`${COMMAND} --name test --url-path-prefix test`).shellOutput.stderr;
120+
expect(stderr).to.contain('Missing required flag');
121+
});
122+
123+
it('should default to empty string if url-path-prefix is not provided', () => {
124+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
125+
execCmd(`${COMMAND} --template BuildYourOwnLWR --name "DefaultPrefixSite" --output-dir "${outputDir}"`, {
126+
ensureExitCode: 0,
127+
});
128+
129+
const networkPath = path.join(outputDir, 'networks', 'DefaultPrefixSite.network-meta.xml');
130+
const networkContent = fs.readFileSync(networkPath, 'utf8');
131+
expect(networkContent).to.include('<urlPathPrefix>vforcesite</urlPathPrefix>');
132+
133+
const configPath = path.join(
134+
outputDir,
135+
'digitalExperienceConfigs',
136+
'DefaultPrefixSite1.digitalExperienceConfig-meta.xml'
137+
);
138+
const configContent = fs.readFileSync(configPath, 'utf8');
139+
expect(configContent).to.include('<urlPathPrefix></urlPathPrefix>');
140+
});
141+
142+
it('should default to force/main/default if output-dir is not provided', () => {
143+
execCmd(`${COMMAND} --template BuildYourOwnLWR --name "DefaultDirSite" --url-path-prefix defaultdir`, {
144+
ensureExitCode: 0,
145+
});
146+
const defaultOutputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
147+
assert.file(path.join(defaultOutputDir, 'networks', 'DefaultDirSite.network-meta.xml'));
148+
assert.file(path.join(defaultOutputDir, 'sites', 'DefaultDirSite.site-meta.xml'));
149+
});
150+
});
151+
});

yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1660,10 +1660,10 @@
16601660
cli-progress "^3.12.0"
16611661
terminal-link "^3.0.0"
16621662

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

0 commit comments

Comments
 (0)