Skip to content

Commit 63669d4

Browse files
committed
feat: update hash computation for web_app files @W-20287706
- Add computeWebAppHashedName function with SHA256 hash generation - Implement correct content type detection (image/asset/manifest) - Use posix paths for cross-platform compatibility - Update deploy and retrieve responses with hashed fullNames - Fix webapp.json classification as webApplicationManifest - Match server-side naming convention exactly
1 parent cb463c1 commit 63669d4

File tree

4 files changed

+198
-24
lines changed

4 files changed

+198
-24
lines changed

src/client/deployMessages.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
import { basename, dirname, extname, join, posix, sep } from 'node:path';
1818
import { SfError } from '@salesforce/core/sfError';
1919
import { ensureArray } from '@salesforce/kit';
20-
import { SourceComponentWithContent, SourceComponent } from '../resolve/sourceComponent';
20+
import { computeWebAppHashedName } from '../resolve/adapters/digitalExperienceSourceAdapter';
21+
import { SourceComponent } from '../resolve/sourceComponent';
2122
import { ComponentLike } from '../resolve';
2223
import { registry } from '../registry/registry';
24+
import { isWebAppBundle } from './utils';
2325
import {
2426
BooleanString,
2527
ComponentStatus,
@@ -30,7 +32,6 @@ import {
3032
MetadataApiDeployStatus,
3133
} from './types';
3234
import { parseDeployDiagnostic } from './diagnosticUtil';
33-
import { isWebAppBundle } from './utils';
3435

3536
type DeployMessageWithComponentType = DeployMessage & { componentType: string };
3637
/**
@@ -101,7 +102,7 @@ export const createResponses =
101102
filePath: component.content,
102103
},
103104
...component.walkContent().map((filePath) => ({
104-
fullName: getWebAppBundleContentFullName(component)(filePath),
105+
fullName: computeWebAppHashedName(filePath, component.content),
105106
type: 'DigitalExperience',
106107
state,
107108
filePath,
@@ -124,16 +125,6 @@ export const createResponses =
124125
})) satisfies FileResponseSuccess[];
125126
});
126127

127-
const getWebAppBundleContentFullName =
128-
(component: SourceComponentWithContent) =>
129-
(filePath: string): string => {
130-
// Normalize paths to ensure relative() works correctly on Windows
131-
const normalizedContent = component.content.split(sep).join(posix.sep);
132-
const normalizedFilePath = filePath.split(sep).join(posix.sep);
133-
const relPath = posix.relative(normalizedContent, normalizedFilePath);
134-
return posix.join(component.fullName, relPath);
135-
};
136-
137128
/**
138129
* Groups messages from the deploy result by component fullName and type
139130
*/

src/client/metadataApiRetrieve.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { Lifecycle } from '@salesforce/core/lifecycle';
2323
import { ensureArray } from '@salesforce/kit';
2424
import { RegistryAccess } from '../registry/registryAccess';
2525
import { ComponentSet } from '../collections/componentSet';
26+
import { computeWebAppHashedName } from '../resolve/adapters/digitalExperienceSourceAdapter';
2627
import { MetadataTransfer } from './metadataTransfer';
2728
import {
2829
AsyncResult,
@@ -102,21 +103,24 @@ export class RetrieveResult implements MetadataTransferResult {
102103

103104
// construct successes
104105
for (const retrievedComponent of this.components.getSourceComponents()) {
105-
const { fullName, type, xml } = retrievedComponent;
106+
const { fullName, type, xml, content } = retrievedComponent;
106107
const baseResponse = {
107108
fullName,
108109
type: type.name,
109110
state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created,
110111
} as const;
111112

112-
// Special handling for web_app bundles - they need to walk content and report individual files
113113
if (isWebAppBundle(retrievedComponent)) {
114-
// Add the bundle directory itself
115-
this.fileResponses.push(
116-
...[retrievedComponent.content, ...retrievedComponent.walkContent()].map(
117-
(filePath) => ({ ...baseResponse, filePath } satisfies FileResponseSuccess)
118-
)
119-
);
114+
this.fileResponses.push({ ...baseResponse, filePath: content! } satisfies FileResponseSuccess);
115+
for (const filePath of retrievedComponent.walkContent()) {
116+
const hashedFullName = computeWebAppHashedName(filePath, content!);
117+
this.fileResponses.push({
118+
fullName: hashedFullName,
119+
type: 'DigitalExperience',
120+
state: this.localComponents.has(retrievedComponent) ? ComponentStatus.Changed : ComponentStatus.Created,
121+
filePath,
122+
} satisfies FileResponseSuccess);
123+
}
120124
} else if (!type.children || Object.values(type.children.types).some((t) => t.unaddressableWithoutParent)) {
121125
this.fileResponses.push(
122126
...retrievedComponent

src/resolve/adapters/digitalExperienceSourceAdapter.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import { dirname, join, sep } from 'node:path';
16+
import { createHash } from 'node:crypto';
17+
import { basename, dirname, extname, join, posix, sep } from 'node:path';
1718
import { Messages } from '@salesforce/core/messages';
1819
import { ensureString } from '@salesforce/ts-types';
1920
import { META_XML_SUFFIX } from '../../common/constants';
@@ -86,13 +87,15 @@ const WEB_APP_BASE_TYPE = 'web_app';
8687
*/
8788
export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
8889
public getComponent(path: SourcePath, isResolvingSource = true): SourceComponent | undefined {
90+
// Handle web_app bundle directory
8991
if (this.isBundleType() && isWebAppBaseType(path) && this.tree.isDirectory(path)) {
9092
const pathParts = path.split(sep);
9193
const bundleNameIndex = getDigitalExperiencesIndex(path) + 2;
9294
if (bundleNameIndex === pathParts.length - 1) {
9395
return this.populate(path, undefined);
9496
}
9597
}
98+
9699
return super.getComponent(path, isResolvingSource);
97100
}
98101

@@ -146,7 +149,7 @@ export class DigitalExperienceSourceAdapter extends BundleSourceAdapter {
146149
return pathToContent;
147150
}
148151

149-
protected populate(trigger: string, component?: SourceComponent): SourceComponent {
152+
protected populate(trigger: string, component?: SourceComponent): SourceComponent | undefined {
150153
if (this.isBundleType() && component) {
151154
// for top level types we don't need to resolve parent
152155
return component;
@@ -305,3 +308,50 @@ const getWebAppBundleDir = (path: string): string => {
305308
}
306309
return path;
307310
};
311+
312+
/**
313+
* Determines the content type for web_app bundle files based on file extension and name.
314+
* Matches server-side FileType classification logic.
315+
*/
316+
const getContentTypeFromExtension = (filePath: string): string => {
317+
const ext = extname(filePath).toLowerCase();
318+
const fileName = basename(filePath).toLowerCase();
319+
320+
// Image types: BMP, GIF, PNG, JPG, JPEG
321+
const imageExtensions = ['.bmp', '.gif', '.png', '.jpg', '.jpeg'];
322+
if (imageExtensions.includes(ext)) {
323+
return 'sfdc_cms__image';
324+
}
325+
326+
// Special case: webapp.json is a manifest file
327+
if (ext === '.json' && fileName === 'webapp.json') {
328+
return 'sfdc_cms__webApplicationManifest';
329+
}
330+
331+
return 'sfdc_cms__webApplicationAsset';
332+
};
333+
334+
/**
335+
* Computes the hashed fullName for a web_app bundle file.
336+
* Format: baseType/spaceApiName.contentType/mHash
337+
*
338+
* @param filePath - Full file system path to the file
339+
* @param bundleDir - Bundle directory path
340+
* @returns Hashed fullName matching server-side naming convention
341+
*/
342+
export const computeWebAppHashedName = (filePath: string, bundleDir: string): string => {
343+
const pathParts = filePath.split(sep);
344+
const bundleParts = bundleDir.split(sep);
345+
const digitalExperiencesIndex = bundleParts.indexOf('digitalExperiences');
346+
347+
const baseType = bundleParts[digitalExperiencesIndex + 1];
348+
const spaceApiName = bundleParts[digitalExperiencesIndex + 2];
349+
const baseTypeIndex = digitalExperiencesIndex + 1;
350+
351+
// Build full path with forward slashes for cross-platform consistency
352+
const fullPath = pathParts.slice(baseTypeIndex).join(posix.sep);
353+
const hash = createHash('sha256').update(fullPath, 'utf8').digest('hex').substring(0, 39);
354+
const contentType = getContentTypeFromExtension(filePath);
355+
356+
return `${baseType}${posix.sep}${spaceApiName}.${contentType}${posix.sep}m${hash}`;
357+
};

test/resolve/adapters/digitalExperienceSourceAdapter.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
import { createHash } from 'node:crypto';
1617
import { join } from 'node:path';
1718
import { assert, expect } from 'chai';
1819
import { RegistryAccess, registry, VirtualTreeContainer, ForceIgnore, SourceComponent } from '../../../src';
19-
import { DigitalExperienceSourceAdapter } from '../../../src/resolve/adapters/digitalExperienceSourceAdapter';
20+
import {
21+
DigitalExperienceSourceAdapter,
22+
computeWebAppHashedName,
23+
} from '../../../src/resolve/adapters/digitalExperienceSourceAdapter';
2024
import { META_XML_SUFFIX } from '../../../src/common';
2125
import { DE_METAFILE } from '../../mock/type-constants/digitalExperienceBundleConstants';
2226

@@ -214,4 +218,129 @@ describe('DigitalExperienceSourceAdapter', () => {
214218
expect(component?.content).to.equal(WEBAPP_BUNDLE_PATH);
215219
});
216220
});
221+
222+
describe('computeWebAppHashedName', () => {
223+
it('should compute hash for nested file', () => {
224+
const filePath = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'assets', 'icon.png');
225+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
226+
const hashedName = computeWebAppHashedName(filePath, bundleDir);
227+
228+
// Verify format: web_app/dist2.sfdc_cms__image/m<hash>
229+
expect(hashedName).to.match(/^web_app\/dist2\.sfdc_cms__image\/m[0-9a-f]{39}$/);
230+
231+
// Verify the hash is computed from the FULL path: 'web_app/dist2/assets/icon.png'
232+
const expectedHash = createHash('sha256').update('web_app/dist2/assets/icon.png', 'utf8').digest('hex').substring(0, 39);
233+
expect(hashedName).to.equal(`web_app/dist2.sfdc_cms__image/m${expectedHash}`);
234+
});
235+
236+
it('should compute hash for root-level file', () => {
237+
const filePath = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', '404.html');
238+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
239+
const hashedName = computeWebAppHashedName(filePath, bundleDir);
240+
241+
// Verify format: web_app/dist2.sfdc_cms__webApplicationAsset/m<hash>
242+
expect(hashedName).to.match(/^web_app\/dist2\.sfdc_cms__webApplicationAsset\/m[0-9a-f]{39}$/);
243+
244+
// Verify the hash is computed from the FULL path: 'web_app/dist2/404.html'
245+
const expectedHash = createHash('sha256').update('web_app/dist2/404.html', 'utf8').digest('hex').substring(0, 39);
246+
expect(hashedName).to.equal(`web_app/dist2.sfdc_cms__webApplicationAsset/m${expectedHash}`);
247+
});
248+
249+
it('should use correct content type for images (BMP, GIF, PNG, JPG, JPEG only)', () => {
250+
const pngFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'icon.png');
251+
const jpgFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'photo.jpg');
252+
const gifFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'animation.gif');
253+
const bmpFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'image.bmp');
254+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
255+
256+
expect(computeWebAppHashedName(pngFile, bundleDir)).to.include('sfdc_cms__image');
257+
expect(computeWebAppHashedName(jpgFile, bundleDir)).to.include('sfdc_cms__image');
258+
expect(computeWebAppHashedName(gifFile, bundleDir)).to.include('sfdc_cms__image');
259+
expect(computeWebAppHashedName(bmpFile, bundleDir)).to.include('sfdc_cms__image');
260+
});
261+
262+
it('should use correct content type for web assets (including SVG, WebP, ICO)', () => {
263+
const jsFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'main.js');
264+
const cssFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'styles.css');
265+
const htmlFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'index.html');
266+
const jsonFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'data.json');
267+
const svgFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'icon.svg');
268+
const webpFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'photo.webp');
269+
const icoFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'favicon.ico');
270+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
271+
272+
expect(computeWebAppHashedName(jsFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
273+
expect(computeWebAppHashedName(cssFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
274+
expect(computeWebAppHashedName(htmlFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
275+
expect(computeWebAppHashedName(jsonFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
276+
expect(computeWebAppHashedName(svgFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
277+
expect(computeWebAppHashedName(webpFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
278+
expect(computeWebAppHashedName(icoFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
279+
});
280+
281+
it('should use correct content type for webapp.json only', () => {
282+
const webappFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'webapp.json');
283+
const manifestFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'manifest.json');
284+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
285+
286+
// webapp.json is special
287+
expect(computeWebAppHashedName(webappFile, bundleDir)).to.include('sfdc_cms__webApplicationManifest');
288+
289+
// manifest.json is treated as regular web asset
290+
expect(computeWebAppHashedName(manifestFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
291+
});
292+
293+
it('should use correct content type for manifest.json', () => {
294+
const manifestFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'manifest.json');
295+
const otherJsonFile = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2', 'config.json');
296+
const bundleDir = join('path', 'to', 'digitalExperiences', 'web_app', 'dist2');
297+
298+
expect(computeWebAppHashedName(manifestFile, bundleDir)).to.include('sfdc_cms__webApplicationManifest');
299+
expect(computeWebAppHashedName(otherJsonFile, bundleDir)).to.include('sfdc_cms__webApplicationAsset');
300+
});
301+
});
302+
303+
describe('DigitalExperienceSourceAdapter for web_app child files', () => {
304+
const WEBAPP_BUNDLE_PATH = join(BASE_PATH, 'web_app', 'dist2');
305+
const WEBAPP_ICON_FILE = join(WEBAPP_BUNDLE_PATH, 'assets', 'icon.png');
306+
const WEBAPP_404_FILE = join(WEBAPP_BUNDLE_PATH, '404.html');
307+
308+
const webappTree = VirtualTreeContainer.fromFilePaths([WEBAPP_ICON_FILE, WEBAPP_404_FILE]);
309+
310+
assert(registry.types.digitalexperiencebundle.children?.types.digitalexperience);
311+
const webappChildAdapter = new DigitalExperienceSourceAdapter(
312+
registry.types.digitalexperiencebundle.children.types.digitalexperience,
313+
registryAccess,
314+
forceIgnore,
315+
webappTree
316+
);
317+
318+
it('should create child component with hashed name for nested file', () => {
319+
const component = webappChildAdapter.getComponent(WEBAPP_ICON_FILE);
320+
expect(component).to.not.be.undefined;
321+
expect(component?.type.name).to.equal('DigitalExperience');
322+
323+
// Verify hashed name format - hash is computed from FULL path 'web_app/dist2/assets/icon.png'
324+
const expectedHash = createHash('sha256').update('web_app/dist2/assets/icon.png', 'utf8').digest('hex').substring(0, 39);
325+
expect(component?.fullName).to.equal(`web_app/dist2.sfdc_cms__image/m${expectedHash}`);
326+
327+
// Verify parent
328+
expect(component?.parent?.type.name).to.equal('DigitalExperienceBundle');
329+
expect(component?.parent?.fullName).to.equal('web_app/dist2');
330+
});
331+
332+
it('should create child component with hashed name for root file', () => {
333+
const component = webappChildAdapter.getComponent(WEBAPP_404_FILE);
334+
expect(component).to.not.be.undefined;
335+
expect(component?.type.name).to.equal('DigitalExperience');
336+
337+
// Verify hashed name format - hash is computed from FULL path 'web_app/dist2/404.html'
338+
const expectedHash = createHash('sha256').update('web_app/dist2/404.html', 'utf8').digest('hex').substring(0, 39);
339+
expect(component?.fullName).to.equal(`web_app/dist2.sfdc_cms__webApplicationAsset/m${expectedHash}`);
340+
341+
// Verify parent
342+
expect(component?.parent?.type.name).to.equal('DigitalExperienceBundle');
343+
expect(component?.parent?.fullName).to.equal('web_app/dist2');
344+
});
345+
});
217346
});

0 commit comments

Comments
 (0)