diff --git a/command-snapshot.json b/command-snapshot.json index 7fd523cf..f2237961 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -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", diff --git a/messages/digitalExperienceSite.md b/messages/digitalExperienceSite.md new file mode 100644 index 00000000..7163a9b1 --- /dev/null +++ b/messages/digitalExperienceSite.md @@ -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. diff --git a/package.json b/package.json index d859c7b1..9d24bda3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -92,6 +92,9 @@ "subtopics": { "apex": { "description": "Create an apex class or trigger." + }, + "digital-experience": { + "description": "Create a Digital Experience site." } } } diff --git a/src/commands/template/generate/digital-experience/site.ts b/src/commands/template/generate/digital-experience/site.ts new file mode 100644 index 00000000..a617ee28 --- /dev/null +++ b/src/commands/template/generate/digital-experience/site.ts @@ -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 { + 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 { + try { + const project = await SfProject.resolve(); + const defaultPackage = project.getDefaultPackage(); + return path.join(defaultPackage.path, 'main', 'default'); + } catch { + return '.'; + } + } + + public async run(): Promise { + 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), + }); + } +} diff --git a/test/commands/template/generate/digital-experience/site/create.nut.ts b/test/commands/template/generate/digital-experience/site/create.nut.ts new file mode 100644 index 00000000..490f11a2 --- /dev/null +++ b/test/commands/template/generate/digital-experience/site/create.nut.ts @@ -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('vforcesite'); + + const configPath = path.join( + outputDir, + 'digitalExperienceConfigs', + 'DefaultPrefixSite1.digitalExperienceConfig-meta.xml' + ); + const configContent = fs.readFileSync(configPath, 'utf8'); + expect(configContent).to.include(''); + }); + + 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')); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index f5d3b2cd..222fc4cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"