Skip to content

Commit d1ec3da

Browse files
authored
feat: Support web app bundle @W-20091482 (#1646)
* feat: Support web app bundle @W-20091482 * refactor: Remove dead code in parseMetadataXml The second if block checking web_app paths can never execute because: - web_app bundles don't have meta.xml files - super.parseMetadataXml() returns undefined for web_app paths - The condition 'xml && isWebAppBaseType(path)' is always false * refactor: optimize populateWebAppBundle logic - Reduced path splitting from 3 times to 1 time - Removed unused pathParts variable - Improved code clarity with inline comments * refactor: addressed review comments * test: added snapshot tests for webapp - Added new snapshot test for DigitalExperienceBundle with web_app base type - Fixed merge conversion bug where files were placed at wrong directory level - Refactored duplicate code in digitalExperienceSourceAdapter - Added getWebAppBundleDir helper function to eliminate duplication * test: added snapshot tests * test: removed unnecessary test data * fix: normalize paths for web_app file responses on Windows - Use posix.relative and posix.join to ensure consistent path separators - Fixes test failure on Windows CI where mixed path separators caused incorrect fullName generation - Child DigitalExperience components now get correct fullNames like 'web_app/zenith/index.html' * fix: add web_app file responses for retrieve operations - Retrieve now reports individual files for web_app bundles - Fixes 'Nothing retrieved' warning when retrieving web_app bundles - Matches the deploy behavior for consistent file response reporting
1 parent f53efc2 commit d1ec3da

File tree

30 files changed

+791
-6
lines changed

30 files changed

+791
-6
lines changed

src/client/deployMessages.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { basename, dirname, extname, join, posix, sep } from 'node:path/posix';
17+
import { basename, dirname, extname, join, posix, sep } from 'node:path';
1818
import { SfError } from '@salesforce/core/sfError';
1919
import { ensureArray } from '@salesforce/kit';
2020
import { ComponentLike, SourceComponent } from '../resolve';
@@ -87,6 +87,34 @@ export const createResponses = (component: SourceComponent, responseMessages: De
8787
if (state === ComponentStatus.Failed) {
8888
return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure];
8989
} else {
90+
const isWebAppBundle =
91+
component.type.name === 'DigitalExperienceBundle' &&
92+
component.fullName.startsWith('web_app/') &&
93+
component.content;
94+
95+
if (isWebAppBundle) {
96+
const walkedPaths = component.walkContent();
97+
const bundleResponse: FileResponseSuccess = {
98+
fullName: component.fullName,
99+
type: component.type.name,
100+
state,
101+
filePath: component.content!,
102+
};
103+
const fileResponses: FileResponseSuccess[] = walkedPaths.map((filePath) => {
104+
// Normalize paths to ensure relative() works correctly on Windows
105+
const normalizedContent = component.content!.split(sep).join(posix.sep);
106+
const normalizedFilePath = filePath.split(sep).join(posix.sep);
107+
const relPath = posix.relative(normalizedContent, normalizedFilePath);
108+
return {
109+
fullName: posix.join(component.fullName, relPath),
110+
type: 'DigitalExperience',
111+
state,
112+
filePath,
113+
};
114+
});
115+
return [bundleResponse, ...fileResponses];
116+
}
117+
90118
return [
91119
...(shouldWalkContent(component)
92120
? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath }))

src/client/metadataApiRetrieve.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,25 @@ export class RetrieveResult implements MetadataTransferResult {
101101

102102
// construct successes
103103
for (const retrievedComponent of this.components.getSourceComponents()) {
104-
const { fullName, type, xml } = retrievedComponent;
104+
const { fullName, type, xml, content } = retrievedComponent;
105105
const baseResponse = {
106106
fullName,
107107
type: type.name,
108108
state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created,
109109
} as const;
110110

111-
if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
111+
// Special handling for web_app bundles - they need to walk content and report individual files
112+
const isWebAppBundle = type.name === 'DigitalExperienceBundle' && fullName.startsWith('web_app/') && content;
113+
114+
if (isWebAppBundle) {
115+
const walkedPaths = retrievedComponent.walkContent();
116+
// Add the bundle directory itself
117+
this.fileResponses.push({ ...baseResponse, filePath: content } satisfies FileResponseSuccess);
118+
// Add each file with its specific path
119+
for (const filePath of walkedPaths) {
120+
this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess);
121+
}
122+
} else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
112123
for (const filePath of retrievedComponent.walkContent()) {
113124
this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess);
114125
}

src/convert/transformers/defaultMetadataTransformer.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ const getContentSourceDestination = (
8585
if (mergeWith?.content) {
8686
if (component.content && component.tree.isDirectory(component.content)) {
8787
// DEs are always inside a dir.
88-
if (component.type.strategies?.adapter === 'digitalExperience') {
88+
// For web_app base type, use standard relative path logic (no ContentType folders)
89+
const isWebApp = source.includes(`${sep}web_app${sep}`);
90+
if (component.type.strategies?.adapter === 'digitalExperience' && !isWebApp) {
8991
const parts = source.split(sep);
9092
const file = parts.pop() ?? '';
9193
const dir = join(mergeWith.content, parts.pop() ?? '');

src/resolve/adapters/digitalExperienceSourceAdapter.ts

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import { BundleSourceAdapter } from './bundleSourceAdapter';
2525

2626
Messages.importMessagesDirectory(__dirname);
2727
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
28+
29+
// Constants for DigitalExperience base types
30+
const WEB_APP_BASE_TYPE = 'web_app';
31+
2832
/**
2933
* Source Adapter for DigitalExperience metadata types. This metadata type is a bundled type of the format
3034
*
@@ -58,18 +62,57 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd
5862
* content/
5963
* ├── bars/
6064
* | ├── bars.digitalExperience-meta.xml
65+
* web_app/
66+
* ├── zenith/
67+
* | ├── css/
68+
* | | ├── header/
69+
* | | | ├── header.css
70+
* | | ├── home.css
71+
* | ├── js/
72+
* | | ├── home.js
73+
* | ├── html/
74+
* | | ├── home.html
75+
* | ├── images/
76+
* | | ├── logos/
77+
* | | | ├── logo.png
6178
* ```
6279
*
6380
* In the above structure the metadata xml file ending with "digitalExperience-meta.xml" belongs to DigitalExperienceBundle MD type.
6481
* The "_meta.json" files are child metadata files of DigitalExperienceBundle belonging to DigitalExperience MD type. The rest of the files in the
6582
* corresponding folder are the contents to the DigitalExperience metadata. So, incase of DigitalExperience the metadata file is a JSON file
66-
* and not an XML file
83+
* and not an XML file.
84+
*
85+
* For web_app base type, the bundle is identified by directory structure alone without metadata XML files.
6786
*/
6887
export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
88+
public getComponent(path: SourcePath, isResolvingSource = true): SourceComponent | undefined {
89+
if (this.isBundleType() && isWebAppBaseType(path) && this.tree.isDirectory(path)) {
90+
const pathParts = path.split(sep);
91+
const bundleNameIndex = getDigitalExperiencesIndex(path) + 2;
92+
if (bundleNameIndex === pathParts.length - 1) {
93+
return this.populate(path, undefined);
94+
}
95+
}
96+
return super.getComponent(path, isResolvingSource);
97+
}
98+
99+
protected parseAsRootMetadataXml(path: string): MetadataXml | undefined {
100+
if (isWebAppBaseType(path)) {
101+
return undefined;
102+
}
103+
if (!this.isBundleType() && !path.endsWith(this.type.metaFileSuffix ?? '_meta.json')) {
104+
return undefined;
105+
}
106+
return super.parseAsRootMetadataXml(path);
107+
}
108+
69109
protected getRootMetadataXmlPath(trigger: string): string {
70110
if (this.isBundleType()) {
71111
return this.getBundleMetadataXmlPath(trigger);
72112
}
113+
if (isWebAppBaseType(trigger)) {
114+
return '';
115+
}
73116
// metafile name = metaFileSuffix for DigitalExperience.
74117
if (!this.type.metaFileSuffix) {
75118
throw messages.createError('missingMetaFileSuffix', [this.type.name]);
@@ -81,6 +124,10 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
81124
if (this.isBundleType()) {
82125
return path;
83126
}
127+
if (isWebAppBaseType(path)) {
128+
// For web_app, trim to the bundle directory: digitalExperiences/web_app/WebApp
129+
return getWebAppBundleDir(path);
130+
}
84131
const pathToContent = dirname(path);
85132
const parts = pathToContent.split(sep);
86133
/* Handle mobile or tablet variants.Eg- digitalExperiences/site/lwr11/sfdc_cms__view/home/mobile/mobile.json
@@ -104,6 +151,9 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
104151
// for top level types we don't need to resolve parent
105152
return component;
106153
}
154+
if (isWebAppBaseType(trigger)) {
155+
return this.populateWebAppBundle(trigger, component);
156+
}
107157
const source = super.populate(trigger, component);
108158
const parentType = this.registry.getParentType(this.type.id);
109159
// we expect source, parentType and content to be defined.
@@ -144,16 +194,59 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
144194
}
145195
}
146196

197+
private populateWebAppBundle(trigger: string, component?: SourceComponent): SourceComponent {
198+
if (component) {
199+
return component;
200+
}
201+
202+
const pathParts = trigger.split(sep);
203+
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
204+
205+
// Extract bundle name: web_app/WebApp3 (always use posix separator for metadata names)
206+
const baseType = pathParts[digitalExperiencesIndex + 1];
207+
const spaceApiName = pathParts[digitalExperiencesIndex + 2];
208+
const bundleName = [baseType, spaceApiName].join('/');
209+
210+
// Extract bundle directory: /path/to/digitalExperiences/web_app/WebApp3
211+
const bundleDir = getWebAppBundleDir(trigger);
212+
213+
// Get the DigitalExperienceBundle type
214+
const parentType = this.isBundleType() ? this.type : this.registry.getParentType(this.type.id);
215+
if (!parentType) {
216+
throw messages.createError('error_failed_convert', [bundleName]);
217+
}
218+
219+
return new SourceComponent(
220+
{
221+
name: bundleName,
222+
type: parentType,
223+
content: bundleDir,
224+
},
225+
this.tree,
226+
this.forceIgnore
227+
);
228+
}
229+
147230
private getBundleName(contentPath: string): string {
231+
if (isWebAppBaseType(contentPath)) {
232+
const pathParts = contentPath.split(sep);
233+
const digitalExperiencesIndex = getDigitalExperiencesIndex(contentPath);
234+
const baseType = pathParts[digitalExperiencesIndex + 1];
235+
const spaceApiName = pathParts[digitalExperiencesIndex + 2];
236+
return [baseType, spaceApiName].join('/');
237+
}
148238
const bundlePath = this.getBundleMetadataXmlPath(contentPath);
149-
return `${parentName(dirname(bundlePath))}/${parentName(bundlePath)}`;
239+
return [parentName(dirname(bundlePath)), parentName(bundlePath)].join('/');
150240
}
151241

152242
private getBundleMetadataXmlPath(path: string): string {
153243
if (this.isBundleType() && path.endsWith(META_XML_SUFFIX)) {
154244
// if this is the bundle type and it ends with -meta.xml, then this is the bundle metadata xml path
155245
return path;
156246
}
247+
if (isWebAppBaseType(path)) {
248+
return '';
249+
}
157250
const pathParts = path.split(sep);
158251
const typeFolderIndex = pathParts.lastIndexOf(this.type.directoryName);
159252
// 3 because we want 'digitalExperiences' directory, 'baseType' directory and 'bundleName' directory
@@ -177,3 +270,38 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
177270
const calculateNameFromPath = (contentPath: string): string => `${parentName(contentPath)}/${baseName(contentPath)}`;
178271
const digitalExperienceStructure = join('BaseType', 'SpaceApiName', 'ContentType', 'ContentApiName');
179272
const contentParts = digitalExperienceStructure.split(sep);
273+
274+
/**
275+
* Checks if the given path belongs to the web_app base type.
276+
* web_app base type has a simpler structure without ContentType folders.
277+
* Structure: digitalExperiences/web_app/spaceApiName/...files...
278+
*/
279+
export const isWebAppBaseType = (path: string): boolean => {
280+
const pathParts = path.split(sep);
281+
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
282+
return pathParts[digitalExperiencesIndex + 1] === WEB_APP_BASE_TYPE;
283+
};
284+
285+
/**
286+
* Gets the digitalExperiences index from a path.
287+
* Returns -1 if not found.
288+
*/
289+
const getDigitalExperiencesIndex = (path: string): number => {
290+
const pathParts = path.split(sep);
291+
return pathParts.indexOf('digitalExperiences');
292+
};
293+
294+
/**
295+
* Gets the web_app bundle directory path.
296+
* For a path like: /path/to/digitalExperiences/web_app/WebApp/src/App.js
297+
* Returns: /path/to/digitalExperiences/web_app/WebApp
298+
*/
299+
const getWebAppBundleDir = (path: string): string => {
300+
const pathParts = path.split(sep);
301+
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
302+
if (digitalExperiencesIndex > -1 && pathParts.length > digitalExperiencesIndex + 3) {
303+
// Return up to digitalExperiences/web_app/spaceApiName
304+
return pathParts.slice(0, digitalExperiencesIndex + 3).join(sep);
305+
}
306+
return path;
307+
};

src/resolve/metadataResolver.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { SourceAdapterFactory } from './adapters/sourceAdapterFactory';
2727
import { ForceIgnore } from './forceIgnore';
2828
import { SourceComponent } from './sourceComponent';
2929
import { NodeFSTreeContainer, TreeContainer } from './treeContainers';
30+
import { isWebAppBaseType } from './adapters/digitalExperienceSourceAdapter';
3031

3132
Messages.importMessagesDirectory(__dirname);
3233
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
@@ -224,6 +225,15 @@ const resolveDirectoryAsComponent =
224225
(registry: RegistryAccess) =>
225226
(tree: TreeContainer) =>
226227
(dirPath: string): boolean => {
228+
// For web_app bundles, only the bundle directory itself should be resolved as a component
229+
// (e.g., digitalExperiences/web_app/WebApp), not subdirectories like src/, public/, etc.
230+
if (isWebAppBaseType(dirPath)) {
231+
const pathParts = dirPath.split(sep);
232+
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
233+
// The bundle directory is exactly 3 levels deep: digitalExperiences/web_app/bundleName
234+
return digitalExperiencesIndex !== -1 && pathParts.length === digitalExperiencesIndex + 3;
235+
}
236+
227237
const type = resolveType(registry)(tree)(dirPath);
228238
if (type) {
229239
const { directoryName, inFolder } = type;
@@ -335,6 +345,10 @@ const resolveType =
335345
(registry: RegistryAccess) =>
336346
(tree: TreeContainer) =>
337347
(fsPath: string): MetadataType | undefined => {
348+
if (isWebAppBaseType(fsPath)) {
349+
return registry.getTypeByName('DigitalExperienceBundle');
350+
}
351+
338352
// attempt 1 - check if the file is part of a component that requires a strict type folder
339353
let resolvedType = resolveTypeFromStrictFolder(registry)(fsPath);
340354

test/client/metadataApiDeploy.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,57 @@ describe('MetadataApiDeploy', () => {
12181218
expect(responses).to.deep.equal(expected);
12191219
});
12201220

1221+
it('should return FileResponses for web_app DigitalExperienceBundle with child files', () => {
1222+
const bundlePath = join('path', 'to', 'digitalExperiences', 'web_app', 'zenith');
1223+
const props = {
1224+
name: 'web_app/zenith',
1225+
type: registry.types.digitalexperiencebundle,
1226+
content: bundlePath,
1227+
};
1228+
const component = SourceComponent.createVirtualComponent(props, [
1229+
{
1230+
dirPath: bundlePath,
1231+
children: ['index.html', 'app.js', 'style.css'],
1232+
},
1233+
]);
1234+
const deployedSet = new ComponentSet([component]);
1235+
const { fullName, type } = component;
1236+
const apiStatus: Partial<MetadataApiDeployStatus> = {
1237+
details: {
1238+
componentSuccesses: {
1239+
changed: 'false',
1240+
created: 'true',
1241+
deleted: 'false',
1242+
success: 'true',
1243+
fullName,
1244+
componentType: type.name,
1245+
} as DeployMessage,
1246+
},
1247+
};
1248+
const result = new DeployResult(apiStatus as MetadataApiDeployStatus, deployedSet);
1249+
1250+
const responses = result.getFileResponses();
1251+
1252+
// Should have 1 bundle response + 3 file responses
1253+
expect(responses).to.have.lengthOf(4);
1254+
1255+
// First response should be the bundle
1256+
expect(responses[0]).to.deep.include({
1257+
fullName: 'web_app/zenith',
1258+
type: 'DigitalExperienceBundle',
1259+
state: ComponentStatus.Created,
1260+
filePath: bundlePath,
1261+
});
1262+
1263+
// Remaining responses should be DigitalExperience child files
1264+
const childResponses = responses.slice(1);
1265+
childResponses.forEach((response) => {
1266+
expect(response.type).to.equal('DigitalExperience');
1267+
expect(response.fullName).to.match(/^web_app\/zenith\//);
1268+
expect(response.state).to.equal(ComponentStatus.Created);
1269+
});
1270+
});
1271+
12211272
it('should cache fileResponses', () => {
12221273
const component = COMPONENT;
12231274
const deployedSet = new ComponentSet([component]);

test/resolve/adapters/digitalExperienceSourceAdapter.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,26 @@ describe('DigitalExperienceSourceAdapter', () => {
192192
});
193193
});
194194
});
195+
196+
describe('DigitalExperienceSourceAdapter for web_app base type', () => {
197+
const WEBAPP_BUNDLE_PATH = join(BASE_PATH, 'web_app', 'zenith');
198+
const WEBAPP_CSS_FILE = join(WEBAPP_BUNDLE_PATH, 'css', 'home.css');
199+
200+
const webappTree = VirtualTreeContainer.fromFilePaths([WEBAPP_CSS_FILE]);
201+
202+
const webappBundleAdapter = new DigitalExperienceSourceAdapter(
203+
registry.types.digitalexperiencebundle,
204+
registryAccess,
205+
forceIgnore,
206+
webappTree
207+
);
208+
209+
it('should return a SourceComponent for web_app bundle directory (no meta.xml required)', () => {
210+
const component = webappBundleAdapter.getComponent(WEBAPP_BUNDLE_PATH);
211+
expect(component).to.not.be.undefined;
212+
expect(component?.type.name).to.equal('DigitalExperienceBundle');
213+
expect(component?.fullName).to.equal('web_app/zenith');
214+
expect(component?.content).to.equal(WEBAPP_BUNDLE_PATH);
215+
});
216+
});
195217
});

0 commit comments

Comments
 (0)