Skip to content
Draft
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 @@ -23,6 +23,14 @@
"flags": ["api-version", "event", "flags-dir", "json", "loglevel", "name", "output-dir", "sobject", "template"],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": [],
Copy link
Author

@scottmo scottmo Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this added/generated? I'm actually not sure if this should be inside the force folder in the test directory. WebApp template just sits outside of everything.

"command": "dxpsite:generate:build-your-own-lwr",
"flagAliases": [],
"flagChars": ["d", "n", "p"],
"flags": ["flags-dir", "json", "name", "output-dir", "url-path-prefix"],
"plugin": "@salesforce/plugin-templates"
},
{
"alias": ["force:lightning:app:create"],
"command": "lightning:generate:app",
Expand Down
41 changes: 41 additions & 0 deletions messages/dxpsiteBuildYourOwnLwr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# summary

Generate a Lightning Web Runtime (LWR) Build Your Own Experience Site.

# description

Creates an LWR Build Your Own Experience Site with the specified name and URL path prefix. The site includes all necessary metadata files including DigitalExperienceConfig, DigitalExperienceBundle, Network, and CustomSite.

# examples

- Generate an LWR BYO site named "mysite" with URL path prefix "mysite":

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

- Generate an LWR BYO site with a custom output directory:

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

# flags.name.summary

Name of the site to generate.

# flags.name.description
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest you remove the flag description, because it doesn't add any more info to the flag summary. Flag descriptions are optional.


The name of the site.

# flags.url-path-prefix.summary

URL path prefix for the site.

# flags.url-path-prefix.description

Optional. The URL path prefix for the site. This is used in the site's URL. Must contain only alphanumeric characters.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can also remove this flag description. All flags are optional by default, unless explicitly marked required (in the code, which then automatically shows up in the --help). And the first two sentences don't really add more info to the flag summary. The last part (about only alphanumeric characters) can be tacked on the summary; i.e. "URL path prefix for the site; must contain only alphanumeric characters."


# 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 and uses the default package directory. When running outside a Salesforce DX project, defaults to the current directory.
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@
}
}
},
"dxpsite": {
"description": "Work with Digital Experience Sites.",
"subtopics": {
"generate": {
"description": "Create a Digital Experience Site."
}
}
},
"lightning": {
"description": "Work with Lightning Web and Aura components.",
"external": true,
Expand Down
75 changes: 75 additions & 0 deletions src/commands/dxpsite/generate/build-your-own-lwr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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, DxpSiteOptions, 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', 'dxpsiteBuildYourOwnLwr');

export default class BuildYourOwnLwrGenerate 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 flags = {
name: Flags.string({
char: 'n',
summary: messages.getMessage('flags.name.summary'),
description: messages.getMessage('flags.name.description'),
required: true,
}),
'url-path-prefix': Flags.string({
char: 'p',
summary: messages.getMessage('flags.url-path-prefix.summary'),
description: messages.getMessage('flags.url-path-prefix.description'),
default: '',
}),
'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(BuildYourOwnLwrGenerate);

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

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

return runGenerator({
templateType: TemplateType.DxpSite,
opts: flagsAsOptions,
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
templates: getCustomTemplates(this.configAggregator),
});
}
}
173 changes: 173 additions & 0 deletions test/commands/force/dxpsite/generate/build-your-own-lwr/create.nut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
* 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 } from 'chai';
import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit';
import { nls } from '@salesforce/templates/lib/i18n/index.js';
import assert from 'yeoman-assert';

describe('DXP Site build-your-own-lwr creation tests:', () => {
let session: TestSession;
before(async () => {
session = await TestSession.create({
project: {},
devhubAuthStrategy: 'NONE',
});
});
after(async () => {
await session?.clean();
});

describe('Check dxpsite creation with build-your-own-lwr template', () => {
it('should create dxpsite with all required files', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
execCmd(
`dxpsite generate build-your-own-lwr --name "My Test Site" --url-path-prefix mytestsite --output-dir "${outputDir}"`,
{
ensureExitCode: 0,
}
);

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

// Check top-level metadata files
assert.file([
path.join(outputDir, 'networks', 'My Test Site.network-meta.xml'),
path.join(outputDir, 'sites', 'My_Test_Site.site-meta.xml'),
path.join(outputDir, 'digitalExperienceConfigs', 'My_Test_Site1.digitalExperienceConfig-meta.xml'),
path.join(bundlePath, 'My_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', 'My_Test_Site1', 'content.json'),
path.join(bundlePath, 'sfdc_cms__site', 'My_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'),
]);
}
});

it('should handle site names starting with special characters', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
execCmd(
`dxpsite generate build-your-own-lwr --name "123 @ Test's Site" --url-path-prefix site123 --output-dir "${outputDir}"`,
{
ensureExitCode: 0,
}
);

// Site dev name should be prefixed with X when starting with a number
const bundlePath = path.join(outputDir, 'digitalExperiences', 'site', 'X123_Test_s_Site1');
assert.file([
path.join(outputDir, 'networks', '123 %40 Test%27s Site.network-meta.xml'),
path.join(outputDir, 'sites', 'X123_Test_s_Site.site-meta.xml'),
path.join(outputDir, 'digitalExperienceConfigs', 'X123_Test_s_Site1.digitalExperienceConfig-meta.xml'),
path.join(bundlePath, 'X123_Test_s_Site1.digitalExperience-meta.xml'),
]);
});
});

describe('Check that all invalid input errors are thrown', () => {
it('should throw a missing name error', () => {
const stderr = execCmd('dxpsite generate build-your-own-lwr --url-path-prefix test').shellOutput.stderr;
expect(stderr).to.contain('Missing required flag');
});

it('should create site without url-path-prefix (uses empty default)', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
execCmd(`dxpsite generate build-your-own-lwr --name "No Prefix Site" --output-dir "${outputDir}"`, {
ensureExitCode: 0,
});

const bundlePath = path.join(outputDir, 'digitalExperiences', 'site', 'No_Prefix_Site1');
assert.file([
path.join(outputDir, 'networks', 'No Prefix Site.network-meta.xml'),
path.join(outputDir, 'sites', 'No_Prefix_Site.site-meta.xml'),
path.join(bundlePath, 'No_Prefix_Site1.digitalExperience-meta.xml'),
]);
});

it('should throw invalid url-path-prefix error for non-alphanumeric characters', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
const stderr = execCmd(
`dxpsite generate build-your-own-lwr --name TestSite --url-path-prefix "my-prefix" --output-dir "${outputDir}"`
).shellOutput.stderr;
expect(stderr).to.contain(nls.localize('AlphaNumericValidationError', 'url-path-prefix'));
});

it('should throw invalid url-path-prefix error for underscores', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
const stderr = execCmd(
`dxpsite generate build-your-own-lwr --name TestSite --url-path-prefix "my_prefix" --output-dir "${outputDir}"`
).shellOutput.stderr;
expect(stderr).to.contain(nls.localize('AlphaNumericValidationError', 'url-path-prefix'));
});

it('should throw invalid url-path-prefix error for spaces', () => {
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default');
const stderr = execCmd(
`dxpsite generate build-your-own-lwr --name TestSite --url-path-prefix "my prefix" --output-dir "${outputDir}"`
).shellOutput.stderr;
expect(stderr).to.contain(nls.localize('AlphaNumericValidationError', 'url-path-prefix'));
});
});
});