Skip to content
Merged
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
30 changes: 29 additions & 1 deletion src/client/deployMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { basename, dirname, extname, join, posix, sep } from 'node:path/posix';
import { basename, dirname, extname, join, posix, sep } from 'node:path';
import { SfError } from '@salesforce/core/sfError';
import { ensureArray } from '@salesforce/kit';
import { ComponentLike, SourceComponent } from '../resolve';
Expand Down Expand Up @@ -87,6 +87,34 @@ export const createResponses = (component: SourceComponent, responseMessages: De
if (state === ComponentStatus.Failed) {
return [{ ...base, state, ...parseDeployDiagnostic(component, message) } satisfies FileResponseFailure];
} else {
const isWebAppBundle =
component.type.name === 'DigitalExperienceBundle' &&
component.fullName.startsWith('web_app/') &&
component.content;

if (isWebAppBundle) {
const walkedPaths = component.walkContent();
const bundleResponse: FileResponseSuccess = {
fullName: component.fullName,
type: component.type.name,
state,
filePath: component.content!,
};
const fileResponses: FileResponseSuccess[] = walkedPaths.map((filePath) => {
// Normalize paths to ensure relative() works correctly on Windows
const normalizedContent = component.content!.split(sep).join(posix.sep);
const normalizedFilePath = filePath.split(sep).join(posix.sep);
const relPath = posix.relative(normalizedContent, normalizedFilePath);
return {
fullName: posix.join(component.fullName, relPath),
type: 'DigitalExperience',
state,
filePath,
};
});
return [bundleResponse, ...fileResponses];
}

return [
...(shouldWalkContent(component)
? component.walkContent().map((filePath): FileResponseSuccess => ({ ...base, state, filePath }))
Expand Down
15 changes: 13 additions & 2 deletions src/client/metadataApiRetrieve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,25 @@ export class RetrieveResult implements MetadataTransferResult {

// construct successes
for (const retrievedComponent of this.components.getSourceComponents()) {
const { fullName, type, xml } = retrievedComponent;
const { fullName, type, xml, content } = retrievedComponent;
const baseResponse = {
fullName,
type: type.name,
state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created,
} as const;

if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
// Special handling for web_app bundles - they need to walk content and report individual files
const isWebAppBundle = type.name === 'DigitalExperienceBundle' && fullName.startsWith('web_app/') && content;

if (isWebAppBundle) {
const walkedPaths = retrievedComponent.walkContent();
// Add the bundle directory itself
this.fileResponses.push({ ...baseResponse, filePath: content } satisfies FileResponseSuccess);
// Add each file with its specific path
for (const filePath of walkedPaths) {
this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess);
}
} else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
for (const filePath of retrievedComponent.walkContent()) {
this.fileResponses.push({ ...baseResponse, filePath } satisfies FileResponseSuccess);
}
Expand Down
4 changes: 3 additions & 1 deletion src/convert/transformers/defaultMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ const getContentSourceDestination = (
if (mergeWith?.content) {
if (component.content && component.tree.isDirectory(component.content)) {
// DEs are always inside a dir.
if (component.type.strategies?.adapter === 'digitalExperience') {
// For web_app base type, use standard relative path logic (no ContentType folders)
const isWebApp = source.includes(`${sep}web_app${sep}`);
if (component.type.strategies?.adapter === 'digitalExperience' && !isWebApp) {
const parts = source.split(sep);
const file = parts.pop() ?? '';
const dir = join(mergeWith.content, parts.pop() ?? '');
Expand Down
132 changes: 130 additions & 2 deletions src/resolve/adapters/digitalExperienceSourceAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import { BundleSourceAdapter } from './bundleSourceAdapter';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');

// Constants for DigitalExperience base types
const WEB_APP_BASE_TYPE = 'web_app';

/**
* Source Adapter for DigitalExperience metadata types. This metadata type is a bundled type of the format
*
Expand Down Expand Up @@ -58,18 +62,57 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd
* content/
* ├── bars/
* | ├── bars.digitalExperience-meta.xml
* web_app/
* ├── zenith/
* | ├── css/
* | | ├── header/
* | | | ├── header.css
* | | ├── home.css
* | ├── js/
* | | ├── home.js
* | ├── html/
* | | ├── home.html
* | ├── images/
* | | ├── logos/
* | | | ├── logo.png
* ```
*
* In the above structure the metadata xml file ending with "digitalExperience-meta.xml" belongs to DigitalExperienceBundle MD type.
* The "_meta.json" files are child metadata files of DigitalExperienceBundle belonging to DigitalExperience MD type. The rest of the files in the
* corresponding folder are the contents to the DigitalExperience metadata. So, incase of DigitalExperience the metadata file is a JSON file
* and not an XML file
* and not an XML file.
*
* For web_app base type, the bundle is identified by directory structure alone without metadata XML files.
*/
export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
public getComponent(path: SourcePath, isResolvingSource = true): SourceComponent | undefined {
if (this.isBundleType() && isWebAppBaseType(path) && this.tree.isDirectory(path)) {
const pathParts = path.split(sep);
const bundleNameIndex = getDigitalExperiencesIndex(path) + 2;
if (bundleNameIndex === pathParts.length - 1) {
return this.populate(path, undefined);
}
}
return super.getComponent(path, isResolvingSource);
}

protected parseAsRootMetadataXml(path: string): MetadataXml | undefined {
if (isWebAppBaseType(path)) {
return undefined;
}
if (!this.isBundleType() && !path.endsWith(this.type.metaFileSuffix ?? '_meta.json')) {
return undefined;
}
return super.parseAsRootMetadataXml(path);
}

protected getRootMetadataXmlPath(trigger: string): string {
if (this.isBundleType()) {
return this.getBundleMetadataXmlPath(trigger);
}
if (isWebAppBaseType(trigger)) {
return '';
}
// metafile name = metaFileSuffix for DigitalExperience.
if (!this.type.metaFileSuffix) {
throw messages.createError('missingMetaFileSuffix', [this.type.name]);
Expand All @@ -81,6 +124,10 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
if (this.isBundleType()) {
return path;
}
if (isWebAppBaseType(path)) {
// For web_app, trim to the bundle directory: digitalExperiences/web_app/WebApp
return getWebAppBundleDir(path);
}
const pathToContent = dirname(path);
const parts = pathToContent.split(sep);
/* Handle mobile or tablet variants.Eg- digitalExperiences/site/lwr11/sfdc_cms__view/home/mobile/mobile.json
Expand All @@ -104,6 +151,9 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
// for top level types we don't need to resolve parent
return component;
}
if (isWebAppBaseType(trigger)) {
return this.populateWebAppBundle(trigger, component);
}
const source = super.populate(trigger, component);
const parentType = this.registry.getParentType(this.type.id);
// we expect source, parentType and content to be defined.
Expand Down Expand Up @@ -144,16 +194,59 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
}
}

private populateWebAppBundle(trigger: string, component?: SourceComponent): SourceComponent {
if (component) {
return component;
}

const pathParts = trigger.split(sep);
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');

// Extract bundle name: web_app/WebApp3 (always use posix separator for metadata names)
const baseType = pathParts[digitalExperiencesIndex + 1];
const spaceApiName = pathParts[digitalExperiencesIndex + 2];
const bundleName = [baseType, spaceApiName].join('/');

// Extract bundle directory: /path/to/digitalExperiences/web_app/WebApp3
const bundleDir = getWebAppBundleDir(trigger);

// Get the DigitalExperienceBundle type
const parentType = this.isBundleType() ? this.type : this.registry.getParentType(this.type.id);
if (!parentType) {
throw messages.createError('error_failed_convert', [bundleName]);
}

return new SourceComponent(
{
name: bundleName,
type: parentType,
content: bundleDir,
},
this.tree,
this.forceIgnore
);
}

private getBundleName(contentPath: string): string {
if (isWebAppBaseType(contentPath)) {
const pathParts = contentPath.split(sep);
const digitalExperiencesIndex = getDigitalExperiencesIndex(contentPath);
const baseType = pathParts[digitalExperiencesIndex + 1];
const spaceApiName = pathParts[digitalExperiencesIndex + 2];
return [baseType, spaceApiName].join('/');
}
const bundlePath = this.getBundleMetadataXmlPath(contentPath);
return `${parentName(dirname(bundlePath))}/${parentName(bundlePath)}`;
return [parentName(dirname(bundlePath)), parentName(bundlePath)].join('/');
}

private getBundleMetadataXmlPath(path: string): string {
if (this.isBundleType() && path.endsWith(META_XML_SUFFIX)) {
// if this is the bundle type and it ends with -meta.xml, then this is the bundle metadata xml path
return path;
}
if (isWebAppBaseType(path)) {
return '';
}
const pathParts = path.split(sep);
const typeFolderIndex = pathParts.lastIndexOf(this.type.directoryName);
// 3 because we want 'digitalExperiences' directory, 'baseType' directory and 'bundleName' directory
Expand All @@ -177,3 +270,38 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
const calculateNameFromPath = (contentPath: string): string => `${parentName(contentPath)}/${baseName(contentPath)}`;
const digitalExperienceStructure = join('BaseType', 'SpaceApiName', 'ContentType', 'ContentApiName');
const contentParts = digitalExperienceStructure.split(sep);

/**
* Checks if the given path belongs to the web_app base type.
* web_app base type has a simpler structure without ContentType folders.
* Structure: digitalExperiences/web_app/spaceApiName/...files...
*/
export const isWebAppBaseType = (path: string): boolean => {
const pathParts = path.split(sep);
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
return pathParts[digitalExperiencesIndex + 1] === WEB_APP_BASE_TYPE;
};

/**
* Gets the digitalExperiences index from a path.
* Returns -1 if not found.
*/
const getDigitalExperiencesIndex = (path: string): number => {
const pathParts = path.split(sep);
return pathParts.indexOf('digitalExperiences');
};

/**
* Gets the web_app bundle directory path.
* For a path like: /path/to/digitalExperiences/web_app/WebApp/src/App.js
* Returns: /path/to/digitalExperiences/web_app/WebApp
*/
const getWebAppBundleDir = (path: string): string => {
const pathParts = path.split(sep);
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
if (digitalExperiencesIndex > -1 && pathParts.length > digitalExperiencesIndex + 3) {
// Return up to digitalExperiences/web_app/spaceApiName
return pathParts.slice(0, digitalExperiencesIndex + 3).join(sep);
}
return path;
};
14 changes: 14 additions & 0 deletions src/resolve/metadataResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { SourceAdapterFactory } from './adapters/sourceAdapterFactory';
import { ForceIgnore } from './forceIgnore';
import { SourceComponent } from './sourceComponent';
import { NodeFSTreeContainer, TreeContainer } from './treeContainers';
import { isWebAppBaseType } from './adapters/digitalExperienceSourceAdapter';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
Expand Down Expand Up @@ -224,6 +225,15 @@ const resolveDirectoryAsComponent =
(registry: RegistryAccess) =>
(tree: TreeContainer) =>
(dirPath: string): boolean => {
// For web_app bundles, only the bundle directory itself should be resolved as a component
// (e.g., digitalExperiences/web_app/WebApp), not subdirectories like src/, public/, etc.
if (isWebAppBaseType(dirPath)) {
const pathParts = dirPath.split(sep);
const digitalExperiencesIndex = pathParts.indexOf('digitalExperiences');
// The bundle directory is exactly 3 levels deep: digitalExperiences/web_app/bundleName
return digitalExperiencesIndex !== -1 && pathParts.length === digitalExperiencesIndex + 3;
}

const type = resolveType(registry)(tree)(dirPath);
if (type) {
const { directoryName, inFolder } = type;
Expand Down Expand Up @@ -335,6 +345,10 @@ const resolveType =
(registry: RegistryAccess) =>
(tree: TreeContainer) =>
(fsPath: string): MetadataType | undefined => {
if (isWebAppBaseType(fsPath)) {
return registry.getTypeByName('DigitalExperienceBundle');
}

// attempt 1 - check if the file is part of a component that requires a strict type folder
let resolvedType = resolveTypeFromStrictFolder(registry)(fsPath);

Expand Down
51 changes: 51 additions & 0 deletions test/client/metadataApiDeploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,57 @@ describe('MetadataApiDeploy', () => {
expect(responses).to.deep.equal(expected);
});

it('should return FileResponses for web_app DigitalExperienceBundle with child files', () => {
const bundlePath = join('path', 'to', 'digitalExperiences', 'web_app', 'zenith');
const props = {
name: 'web_app/zenith',
type: registry.types.digitalexperiencebundle,
content: bundlePath,
};
const component = SourceComponent.createVirtualComponent(props, [
{
dirPath: bundlePath,
children: ['index.html', 'app.js', 'style.css'],
},
]);
const deployedSet = new ComponentSet([component]);
const { fullName, type } = component;
const apiStatus: Partial<MetadataApiDeployStatus> = {
details: {
componentSuccesses: {
changed: 'false',
created: 'true',
deleted: 'false',
success: 'true',
fullName,
componentType: type.name,
} as DeployMessage,
},
};
const result = new DeployResult(apiStatus as MetadataApiDeployStatus, deployedSet);

const responses = result.getFileResponses();

// Should have 1 bundle response + 3 file responses
expect(responses).to.have.lengthOf(4);

// First response should be the bundle
expect(responses[0]).to.deep.include({
fullName: 'web_app/zenith',
type: 'DigitalExperienceBundle',
state: ComponentStatus.Created,
filePath: bundlePath,
});

// Remaining responses should be DigitalExperience child files
const childResponses = responses.slice(1);
childResponses.forEach((response) => {
expect(response.type).to.equal('DigitalExperience');
expect(response.fullName).to.match(/^web_app\/zenith\//);
expect(response.state).to.equal(ComponentStatus.Created);
});
});

it('should cache fileResponses', () => {
const component = COMPONENT;
const deployedSet = new ComponentSet([component]);
Expand Down
22 changes: 22 additions & 0 deletions test/resolve/adapters/digitalExperienceSourceAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,26 @@ describe('DigitalExperienceSourceAdapter', () => {
});
});
});

describe('DigitalExperienceSourceAdapter for web_app base type', () => {
const WEBAPP_BUNDLE_PATH = join(BASE_PATH, 'web_app', 'zenith');
const WEBAPP_CSS_FILE = join(WEBAPP_BUNDLE_PATH, 'css', 'home.css');

const webappTree = VirtualTreeContainer.fromFilePaths([WEBAPP_CSS_FILE]);

const webappBundleAdapter = new DigitalExperienceSourceAdapter(
registry.types.digitalexperiencebundle,
registryAccess,
forceIgnore,
webappTree
);

it('should return a SourceComponent for web_app bundle directory (no meta.xml required)', () => {
const component = webappBundleAdapter.getComponent(WEBAPP_BUNDLE_PATH);
expect(component).to.not.be.undefined;
expect(component?.type.name).to.equal('DigitalExperienceBundle');
expect(component?.fullName).to.equal('web_app/zenith');
expect(component?.content).to.equal(WEBAPP_BUNDLE_PATH);
});
});
});
Loading