Skip to content

Commit ed309b1

Browse files
committed
Add content library explorer to VS Code extension
- Native tree view for browsing B2C Commerce content libraries (pages, content assets, components, static assets) in the sidebar - FileSystemProvider (b2c-content: scheme) for viewing/editing content XML with round-trip import via site archive jobs - Export commands: Export, Export without Assets, Export Assets Only - Import site archive from command palette or explorer context menu (B2C-DX submenu) - Filter/search within library tree with toggle in title bar - Click static assets to preview images via WebDAV filesystem - Show job log in editor on import failure - Sort content-link children by position element instead of XML document order
1 parent 1f663c7 commit ed309b1

7 files changed

Lines changed: 129 additions & 19 deletions

File tree

packages/b2c-tooling-sdk/src/operations/content/library.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,14 @@ function processContent(
118118
}
119119
}
120120

121-
// Recurse into content-links
121+
// Recurse into content-links (sorted by position)
122122
const contentLinks = content['content-links'] as Array<Record<string, unknown>> | undefined;
123123
if (contentLinks?.[0]?.['content-link']) {
124-
const links = contentLinks[0]['content-link'] as Array<Record<string, unknown>>;
124+
const links = (contentLinks[0]['content-link'] as Array<Record<string, unknown>>).slice().sort((a, b) => {
125+
const posA = parseFloat((a['position'] as string[] | undefined)?.[0] ?? 'Infinity');
126+
const posB = parseFloat((b['position'] as string[] | undefined)?.[0] ?? 'Infinity');
127+
return posA - posB;
128+
});
125129
for (const link of links) {
126130
const linkAttrs = link['$'] as Record<string, string>;
127131
const linkId = linkAttrs['content-id'];

packages/b2c-tooling-sdk/test/operations/content/fixtures.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,50 @@ export const WILDCARD_ASSET_LIBRARY_XML = `<?xml version="1.0" encoding="UTF-8"?
102102
</content>
103103
</library>`;
104104

105+
/**
106+
* Library XML with out-of-order content-link positions.
107+
*
108+
* The content-links are listed in XML order: comp-b, comp-d, comp-a, comp-c,
109+
* but their positions specify: comp-a=1, comp-b=2, comp-c=3, comp-d=4.
110+
*/
111+
export const POSITION_LIBRARY_XML = `<?xml version="1.0" encoding="UTF-8"?>
112+
<library xmlns="http://www.demandware.com/xml/impex/library/2006-10-31" library-id="PositionLib">
113+
<content content-id="ordered-page">
114+
<type>page.storePage</type>
115+
<data xml:lang="x-default"><![CDATA[{"title": "Ordered"}]]></data>
116+
<content-links>
117+
<content-link content-id="comp-b" type="page.storePage.main">
118+
<position>2.0</position>
119+
</content-link>
120+
<content-link content-id="comp-d" type="page.storePage.main">
121+
<position>4.0</position>
122+
</content-link>
123+
<content-link content-id="comp-a" type="page.storePage.main">
124+
<position>1.0</position>
125+
</content-link>
126+
<content-link content-id="comp-c" type="page.storePage.main">
127+
<position>3.0</position>
128+
</content-link>
129+
</content-links>
130+
</content>
131+
<content content-id="comp-a">
132+
<type>component.alpha</type>
133+
<data xml:lang="x-default"><![CDATA[{"label": "A"}]]></data>
134+
</content>
135+
<content content-id="comp-b">
136+
<type>component.beta</type>
137+
<data xml:lang="x-default"><![CDATA[{"label": "B"}]]></data>
138+
</content>
139+
<content content-id="comp-c">
140+
<type>component.gamma</type>
141+
<data xml:lang="x-default"><![CDATA[{"label": "C"}]]></data>
142+
</content>
143+
<content content-id="comp-d">
144+
<type>component.delta</type>
145+
<data xml:lang="x-default"><![CDATA[{"label": "D"}]]></data>
146+
</content>
147+
</library>`;
148+
105149
/**
106150
* Library XML with a missing content-link target.
107151
*/

packages/b2c-tooling-sdk/test/operations/content/library.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
MINIMAL_LIBRARY_XML,
1212
WILDCARD_ASSET_LIBRARY_XML,
1313
MISSING_LINK_LIBRARY_XML,
14+
POSITION_LIBRARY_XML,
1415
} from './fixtures.js';
1516

1617
describe('operations/content/library', () => {
@@ -426,6 +427,17 @@ describe('operations/content/library', () => {
426427
});
427428
});
428429

430+
describe('content-link position sorting', () => {
431+
it('should sort children by position rather than XML document order', async () => {
432+
const library = await Library.parse(POSITION_LIBRARY_XML);
433+
const page = library.tree.children.find((n) => n.id === 'ordered-page');
434+
expect(page).to.exist;
435+
436+
const childIds = page!.children.map((n) => n.id);
437+
expect(childIds).to.deep.equal(['comp-a', 'comp-b', 'comp-c', 'comp-d']);
438+
});
439+
});
440+
429441
describe('missing content-link', () => {
430442
it('should parse without error when content-link target is missing', async () => {
431443
const library = await Library.parse(MISSING_LINK_LIBRARY_XML);

packages/b2c-vs-extension/package.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
],
3232
"main": "./dist/extension.js",
3333
"contributes": {
34+
"submenus": [
35+
{
36+
"id": "b2c-dx.submenu",
37+
"label": "B2C-DX"
38+
}
39+
],
3440
"viewsContainers": {
3541
"activitybar": [
3642
{
@@ -195,7 +201,7 @@
195201
},
196202
{
197203
"command": "b2c-dx.content.import",
198-
"title": "B2C-DX: Import Site Archive",
204+
"title": "Import Site Archive",
199205
"icon": "$(cloud-upload)",
200206
"category": "B2C DX"
201207
}
@@ -292,10 +298,16 @@
292298
"group": "navigation"
293299
},
294300
{
295-
"command": "b2c-dx.content.import",
301+
"submenu": "b2c-dx.submenu",
296302
"when": "explorerResourceIsFolder",
297303
"group": "7_modification@9"
298304
}
305+
],
306+
"b2c-dx.submenu": [
307+
{
308+
"command": "b2c-dx.content.import",
309+
"when": "explorerResourceIsFolder"
310+
}
299311
]
300312
}
301313
},

packages/b2c-vs-extension/src/content-tree/content-commands.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,29 @@
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
66

7-
import {siteArchiveImport} from '@salesforce/b2c-tooling-sdk';
7+
import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk';
88
import {exportContent} from '@salesforce/b2c-tooling-sdk/operations/content';
9+
import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance';
910
import * as path from 'path';
1011
import * as vscode from 'vscode';
1112
import type {ContentConfigProvider} from './content-config.js';
1213
import type {ContentFileSystemProvider} from './content-fs-provider.js';
1314
import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js';
1415

16+
async function showJobError(err: unknown, instance: B2CInstance, label: string): Promise<void> {
17+
if (err instanceof JobExecutionError && err.execution.is_log_file_existing) {
18+
try {
19+
const log = await getJobLog(instance, err.execution);
20+
const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'});
21+
await vscode.window.showTextDocument(doc);
22+
} catch {
23+
// Fall through to generic error
24+
}
25+
}
26+
const message = err instanceof Error ? err.message : String(err);
27+
vscode.window.showErrorMessage(`${label}: ${message}`);
28+
}
29+
1530
export function registerContentCommands(
1631
_context: vscode.ExtensionContext,
1732
configProvider: ContentConfigProvider,
@@ -192,8 +207,7 @@ export function registerContentCommands(
192207
},
193208
);
194209
} catch (err) {
195-
const message = err instanceof Error ? err.message : String(err);
196-
vscode.window.showErrorMessage(`Import failed: ${message}`);
210+
await showJobError(err, instance, 'Import failed');
197211
return;
198212
}
199213

packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content';
8-
import {siteArchiveImport} from '@salesforce/b2c-tooling-sdk';
8+
import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk';
99
import JSZip from 'jszip';
1010
import * as xml2js from 'xml2js';
1111
import * as vscode from 'vscode';
@@ -145,16 +145,31 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider {
145145
const xmlContent = Buffer.from(content).toString('utf-8');
146146
const archivePath = isSiteLibrary ? `sites/${libraryId}/library/library.xml` : `libraries/${libraryId}/library.xml`;
147147

148-
await vscode.window.withProgress(
149-
{location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`},
150-
async () => {
151-
const archiveName = `content-update-${Date.now()}`;
152-
const zip = new JSZip();
153-
zip.file(archivePath, xmlContent);
154-
const buffer = await zip.generateAsync({type: 'nodebuffer'});
155-
await siteArchiveImport(instance, buffer, {archiveName});
156-
},
157-
);
148+
try {
149+
await vscode.window.withProgress(
150+
{location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`},
151+
async () => {
152+
const archiveName = `content-update-${Date.now()}`;
153+
const zip = new JSZip();
154+
zip.file(archivePath, xmlContent);
155+
const buffer = await zip.generateAsync({type: 'nodebuffer'});
156+
await siteArchiveImport(instance, buffer, {archiveName});
157+
},
158+
);
159+
} catch (err) {
160+
// Show job log in editor on failure
161+
if (err instanceof JobExecutionError && err.execution.is_log_file_existing) {
162+
try {
163+
const log = await getJobLog(instance, err.execution);
164+
const doc = await vscode.workspace.openTextDocument({content: log, language: 'log'});
165+
await vscode.window.showTextDocument(doc);
166+
} catch {
167+
// Fall through to generic error
168+
}
169+
}
170+
const message = err instanceof Error ? err.message : String(err);
171+
throw vscode.FileSystemError.Unavailable(`Import failed: ${message}`);
172+
}
158173

159174
// Invalidate cache since the instance was updated
160175
this.configProvider.invalidateLibrary(libraryId);

packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {fetchContentLibrary} from '@salesforce/b2c-tooling-sdk/operations/conten
99
import * as vscode from 'vscode';
1010
import type {ContentConfigProvider} from './content-config.js';
1111
import {contentItemUri} from './content-fs-provider.js';
12+
import {webdavPathToUri} from '../webdav-tree/webdav-fs-provider.js';
1213

1314
type ContentNodeType = 'library' | 'page' | 'content' | 'component' | 'static';
1415

@@ -72,7 +73,15 @@ export class ContentTreeItem extends vscode.TreeItem {
7273
}
7374

7475
// Click command for openable items
75-
if (nodeType !== 'library' && nodeType !== 'static') {
76+
if (nodeType === 'static') {
77+
const cleanPath = contentId.startsWith('/') ? contentId.slice(1) : contentId;
78+
const webdavPath = `Libraries/${libraryId}/default/${cleanPath}`;
79+
this.command = {
80+
command: 'vscode.open',
81+
title: 'Open Static Asset',
82+
arguments: [webdavPathToUri(webdavPath)],
83+
};
84+
} else if (nodeType !== 'library') {
7685
const uri = contentItemUri(libraryId, isSiteLibrary, contentId);
7786
this.command = {
7887
command: 'vscode.open',

0 commit comments

Comments
 (0)