-
Notifications
You must be signed in to change notification settings - Fork 9
@W-20906837 feat: add command to use BYO LWR template #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
iowillhoit
merged 21 commits into
salesforcecli:main
from
scottmo:t/experience-sites-runtime/w-20906837/byotemplate
Feb 9, 2026
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
6ea9a16
feat: dxp site generator
scottmo 6abb9aa
feat: add adminEmail param and update docs/tests
scottmo cd6e8e7
fix: casing
scottmo 9fb0036
feat: use username as admin email if available
scottmo 2518184
fix: address comments
scottmo 9eef6a9
refactor: change command structure and namespace
scottmo 73e195c
refactor: mv command and update tests
scottmo 5f438ea
fix: undo license change
scottmo 2cc0a56
Merge branch 'main' of https://github.com/salesforcecli/plugin-templa…
scottmo 367bb61
refactor: rename command and template
scottmo 0505698
chore: bump template version
scottmo 8a987cd
Update messages/digitalExperienceSite.md
scottmo 4532b3e
Update messages/digitalExperienceSite.md
scottmo a3eee90
Update messages/digitalExperienceSite.md
scottmo cbec368
Apply suggestion from @jshackell-sfdc
scottmo 5b3899a
Apply suggestion from @jshackell-sfdc
scottmo 4e53b12
Apply suggestion from @jshackell-sfdc
scottmo f04bcd4
Apply suggestion from @jshackell-sfdc
scottmo 6c9b3ea
Apply suggestion from @jshackell-sfdc
scottmo 0fe41bf
Merge branch 'main' of https://github.com/salesforcecli/plugin-templa…
scottmo 9f4bd7d
chore: bump template version
scottmo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: '', | ||
scottmo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }), | ||
| '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
151
test/commands/template/generate/digital-experience/site/create.nut.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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')); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.