Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/content-blocks-fragment-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@salesforce/b2c-tooling-sdk': minor
'@salesforce/b2c-cli': patch
'b2c-vs-extension': minor
'@salesforce/b2c-agent-plugins': patch
---

Add support for Page Designer "content blocks" (reusable `fragment.*`-typed content).

Content blocks are now a first-class node type: the SDK classifies them as `FRAGMENT` (instead of mislabeling them as components), parses their display name, and exposes `Library.getContentBlocks()` to list a library's blocks (including unlinked ones). The CLI renders them distinctly in `content export`/`content list` (as `CONTENT BLOCK`), counts them in export summaries, and supports `content list --type fragment`. In the VS Code extension, each library gains a **Content Blocks** group that is the single source of truth for a block; because blocks are shared singletons, every page/component that links a block shows a reference that reveals the canonical block in the group rather than an editable copy. A right-click **Convert to Content Block** action turns an inline component into a reusable shared block.
18 changes: 12 additions & 6 deletions docs/cli/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ b2c content export [PAGES...] --library <library-id>

| Argument | Description | Required |
|----------|-------------|----------|
| `PAGES` | One or more content IDs to export (pages, content assets, or components) | Yes |
| `PAGES` | One or more content IDs to export (pages, content assets, components, or content blocks) | Yes |

### Flags

Expand Down Expand Up @@ -140,17 +140,18 @@ b2c content export homepage --library SiteGenesis --no-site-library

The command displays:

1. A tree visualization of the exported content (pages, components, and assets)
1. A tree visualization of the exported content (pages, components, content blocks, and assets)
2. Asset download progress with success/failure indicators
3. A summary line listing counts by type, e.g.: `Exported: 2 pages, 1 content asset, 5 components, 3 static assets to ./export`
3. A summary line listing counts by type, e.g.: `Exported: 2 pages, 1 content asset, 5 components, 1 content block, 3 static assets to ./export`

With `--json`, returns a structured result including the library tree, output path, downloaded/failed asset lists, and counts.

### Notes

- The `--library` flag can be set in `dw.json` as `content-library` or in `package.json` under `b2c.contentLibrary` to avoid passing it every time. You can also list libraries under `b2c.libraries` (mixed strings or `{id, siteLibrary?}` objects); when the resolved library matches an entry marked `siteLibrary: true`, `--site-library` defaults to true automatically. The CLI flag still wins when passed explicitly
- Use `b2c content list` to discover available page IDs before exporting
- You can export pages, content assets, or individual components by their content ID. When a component ID is specified, it is promoted to the root of the export with its full child tree
- You can export pages, content assets, individual components, or content blocks by their content ID. When a component or content-block ID is specified, it is promoted to the root of the export with its full child tree
- **Content blocks** are Page Designer "content blocks" — reusable `fragment.*`-typed content shared across pages. They are listed distinctly in the tree as `(CONTENT BLOCK)` and counted separately in the summary. A content block can be exported by its ID just like a component; a Layout content block retains its region children
- The `--asset-query` flag specifies JSON dot-notation paths within component data to extract static asset references. The default `image.path` covers the common Page Designer image component pattern
- Use `*` in asset query paths to traverse arrays (e.g., `slides.*.image.path`)

Expand All @@ -175,7 +176,7 @@ In addition to [global flags](./index#global-flags):
| `--library` | Library ID or site ID. Also configurable via `content-library` in dw.json. | |
| `--site-library` / `--no-site-library` | Treat the library as a site-private library. Defaults from a matching `libraries` config entry, otherwise `false` | from config |
| `--library-file` | Use a local library XML file instead of fetching from instance | |
| `--type` | Filter by node type: `page`, `content`, or `component` | |
| `--type` | Filter by node type: `page`, `content`, `component`, or `fragment` (content blocks) | |
| `--components` | Include components in table output | `false` |
| `--tree` | Show tree structure instead of table | `false` |
| `--timeout` | Job timeout in seconds | |
Expand All @@ -191,6 +192,9 @@ b2c content list --library SharedLibrary --server my-sandbox.demandware.net
# List only pages
b2c content list --library SharedLibrary --type page

# List only content blocks (the deduplicated catalog, including unlinked blocks)
b2c content list --library SharedLibrary --type fragment

# List including components
b2c content list --library SharedLibrary --components

Expand Down Expand Up @@ -238,7 +242,9 @@ about-us (typeId: page.storePage)
footer-content (CONTENT ASSET)
```

Pages show `id (typeId: type)`, components show `typeId (id)`, content assets show `id (CONTENT ASSET)`, and static assets show `path (STATIC ASSET)`. The tree uses color when output to a terminal: page names are bold, component type IDs are cyan, asset paths are green, and tree connectors are dim.
Pages show `id (typeId: type)`, components show `typeId (id)`, content blocks show `displayName (CONTENT BLOCK: typeId)`, content assets show `id (CONTENT ASSET)`, and static assets show `path (STATIC ASSET)`. The tree uses color when output to a terminal: page names are bold, component type IDs are cyan, content-block names are magenta, asset paths are green, and tree connectors are dim.

> **Content blocks** (`fragment.*`-typed content) are reusable, shared singletons. `--type fragment` lists the deduplicated catalog of all content blocks in the library (including blocks not currently linked to any page), since they are not root-level content items.

With `--json`, returns `{ data: [...] }` with each item containing `id`, `type`, `typeId`, and `children` count.

Expand Down
2 changes: 2 additions & 0 deletions docs/vscode-extension/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Spin up, start, stop, clone, and clean up your on-demand sandboxes from a tree v

Find Page Designer pages and components fast, with one-click export (with assets, without assets, or assets only), live editing of component XML, and round-trip imports of site archives. The library tree is filterable when you have hundreds of pages.

**Content blocks** (reusable, shared `fragment.*` content) get a dedicated **Content Blocks** group under each library — the single source of truth where a block and its full child tree live. Wherever a page or component links a block, it appears as a reference (↗) that reveals the canonical block in the group when clicked, so a shared block is only ever edited in one place. Right-click a component assigned to a page to **Convert to Content Block** and turn it into a reusable, shared block.

[![Library Explorer](./images/library-explorer.png)](./images/library-explorer.png)

### Cartridge Management and Code Watch/Upload
Expand Down
19 changes: 15 additions & 4 deletions packages/b2c-cli/src/commands/content/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
fetchContentLibrary,
};

async run(): Promise<ContentExportResult> {

Check warning on line 96 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Async method 'run' has a complexity of 22. Maximum allowed is 20

Check warning on line 96 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (26.x)

Async method 'run' has a complexity of 22. Maximum allowed is 20

Check warning on line 96 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Async method 'run' has a complexity of 22. Maximum allowed is 20

Check warning on line 96 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test-windows (24.15.0)

Async method 'run' has a complexity of 22. Maximum allowed is 20

Check warning on line 96 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test-windows (22.x)

Async method 'run' has a complexity of 22. Maximum allowed is 20
const {argv, flags} = await this.parse(ContentExport);
const pageIds = argv as string[];
const outputPath =
Expand Down Expand Up @@ -172,18 +172,19 @@
return true;
});

// Promote matching components to root level
// Promote matching components and content blocks to root level
const allNodes = [...library.nodes({traverseHidden: true, callbackHidden: true})];
for (const node of allNodes) {
if (node.type === 'COMPONENT' && matchesId(node.id)) {
if ((node.type === 'COMPONENT' || node.type === 'FRAGMENT') && matchesId(node.id)) {
library.promoteToRoot(node as LibraryNode);
}
}

// Count pages, content, and components
// Count pages, content, components, and content blocks
let pageCount = 0;
let contentCount = 0;
let componentCount = 0;
let fragmentCount = 0;
const assetPaths: string[] = [];

library.traverse(
Expand All @@ -197,6 +198,10 @@
contentCount++;
break;
}
case 'FRAGMENT': {
fragmentCount++;
break;
}
case 'PAGE': {
pageCount++;
break;
Expand All @@ -214,7 +219,9 @@
ux.stdout(library.getTreeString({colorize: ux.colorize}));
}

this.log(formatSummary('Dry run', pageCount, contentCount, componentCount, assetPaths.length, outputPath));
this.log(
formatSummary('Dry run', pageCount, contentCount, componentCount, fragmentCount, assetPaths.length, outputPath),
);

return {
library,
Expand All @@ -224,6 +231,7 @@
pageCount,
contentCount,
componentCount,
fragmentCount,
};
}

Expand Down Expand Up @@ -251,6 +259,7 @@
result.pageCount,
result.contentCount,
result.componentCount,
result.fragmentCount,
result.downloadedAssets.length,
result.outputPath,
),
Expand All @@ -262,18 +271,20 @@

const pluralS = (n: number) => (n === 1 ? '' : 's');

function formatSummary(

Check warning on line 274 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

Function 'formatSummary' has too many parameters (7). Maximum allowed is 4

Check warning on line 274 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (26.x)

Function 'formatSummary' has too many parameters (7). Maximum allowed is 4

Check warning on line 274 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

Function 'formatSummary' has too many parameters (7). Maximum allowed is 4

Check warning on line 274 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test-windows (24.15.0)

Function 'formatSummary' has too many parameters (7). Maximum allowed is 4

Check warning on line 274 in packages/b2c-cli/src/commands/content/export.ts

View workflow job for this annotation

GitHub Actions / test-windows (22.x)

Function 'formatSummary' has too many parameters (7). Maximum allowed is 4
prefix: string,
pages: number,
content: number,
components: number,
fragments: number,
assets: number,
outputPath: string,
): string {
const parts: string[] = [];
if (pages > 0) parts.push(`${pages} page${pluralS(pages)}`);
if (content > 0) parts.push(`${content} content asset${pluralS(content)}`);
if (components > 0) parts.push(`${components} component${pluralS(components)}`);
if (fragments > 0) parts.push(`${fragments} content block${pluralS(fragments)}`);
if (assets > 0) parts.push(`${assets} static asset${pluralS(assets)}`);
const suffix = prefix === 'Dry run' ? `would be exported to ${outputPath}` : `to ${outputPath}`;
return parts.length > 0 ? `${prefix}: ${parts.join(', ')} ${suffix}` : `${prefix}: nothing to export`;
Expand Down
73 changes: 44 additions & 29 deletions packages/b2c-cli/src/commands/content/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* SPDX-License-Identifier: Apache-2
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
*/
import {Flags} from '@oclif/core';

Check warning on line 6 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 6 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (26.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 6 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 6 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test-windows (24.15.0)

'D:\a\b2c-developer-tooling\b2c-developer-tooling\node_modules\@oclif\core\lib\index.d.ts' imported multiple times

Check warning on line 6 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test-windows (22.x)

'D:\a\b2c-developer-tooling\b2c-developer-tooling\node_modules\@oclif\core\lib\index.d.ts' imported multiple times
import {ux} from '@oclif/core';

Check warning on line 7 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (22.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 7 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (26.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 7 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test (24.x)

'/home/runner/work/b2c-developer-tooling/b2c-developer-tooling/node_modules/@oclif/core/lib/index.d.ts' imported multiple times

Check warning on line 7 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test-windows (24.15.0)

'D:\a\b2c-developer-tooling\b2c-developer-tooling\node_modules\@oclif\core\lib\index.d.ts' imported multiple times

Check warning on line 7 in packages/b2c-cli/src/commands/content/list.ts

View workflow job for this annotation

GitHub Actions / test-windows (22.x)

'D:\a\b2c-developer-tooling\b2c-developer-tooling\node_modules\@oclif\core\lib\index.d.ts' imported multiple times
import {
JobCommand,
TableRenderer,
Expand Down Expand Up @@ -49,6 +49,7 @@
page: 'PAGE',
content: 'CONTENT',
component: 'COMPONENT',
fragment: 'FRAGMENT',
};

export default class ContentList extends JobCommand<typeof ContentList> {
Expand All @@ -60,6 +61,7 @@
'<%= config.bin %> <%= command.id %> --library SharedLibrary',
'<%= config.bin %> <%= command.id %> --library SharedLibrary --tree',
'<%= config.bin %> <%= command.id %> --library RefArch --site-library --type page',
'<%= config.bin %> <%= command.id %> --library RefArch --site-library --type fragment',
];

static flags = {
Expand All @@ -76,7 +78,7 @@
}),
type: Flags.string({
description: 'Filter by node type',
options: ['page', 'content', 'component'],
options: ['page', 'content', 'component', 'fragment'],
}),
components: Flags.boolean({
description: 'Include components in output',
Expand Down Expand Up @@ -128,38 +130,51 @@

const items: ContentListItem[] = [];

function collectItems(nodes: typeof library.tree.children, includeComponents: boolean): void {
for (const child of nodes) {
// Skip static asset nodes in table view
if (child.type === 'STATIC') {
continue;
}

// Skip components unless --components is set
if (child.type === 'COMPONENT' && !includeComponents) {
continue;
}

// Apply type filter
const matchesType = !typeFilter || child.type === typeFilter;
if (matchesType) {
items.push({
id: child.id,
type: child.type,
typeId: child.typeId ?? '',
children: child.children.length,
});
if (typeFilter === 'FRAGMENT') {
// Content blocks are not root-level tree children; they surface wherever
// they are linked. List the deduplicated catalog (incl. unlinked blocks).
for (const block of library.getContentBlocks()) {
items.push({
id: block.id,
type: block.type,
typeId: block.typeId ?? '',
children: block.children.length,
});
}
} else {
const collectItems = (nodes: typeof library.tree.children, includeComponents: boolean): void => {
for (const child of nodes) {
// Skip static asset nodes in table view
if (child.type === 'STATIC') {
continue;
}

// Skip components unless --components is set
if (child.type === 'COMPONENT' && !includeComponents) {
continue;
}

// Apply type filter
const matchesType = !typeFilter || child.type === typeFilter;
if (matchesType) {
items.push({
id: child.id,
type: child.type,
typeId: child.typeId ?? '',
children: child.children.length,
});
}

// Recurse into children when --components is set
if (includeComponents && child.children.length > 0) {
collectItems(child.children, true);
}
}
};

// Recurse into children when --components is set
if (includeComponents && child.children.length > 0) {
collectItems(child.children, true);
}
}
collectItems(library.tree.children, flags.components);
}

collectItems(library.tree.children, flags.components);

if (flags.tree) {
ux.stdout(library.getTreeString({colorize: ux.colorize}));
return {data: items};
Expand Down
33 changes: 33 additions & 0 deletions packages/b2c-cli/test/commands/content/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@ function createMockLibrary() {
{id: 'footer', type: 'CONTENT', typeId: null, hidden: false, children: []},
],
},
getContentBlocks: sinon.stub().returns([
{
id: 'discover-block',
type: 'FRAGMENT',
typeId: 'fragment.Content.contentCard',
displayName: 'Discover',
children: [],
},
{
id: 'grid-block',
type: 'FRAGMENT',
typeId: 'fragment.Layout.grid',
displayName: 'Grid',
children: [{id: 'card', type: 'COMPONENT'}],
},
]),
getTreeString: sinon.stub().returns('homepage (typeId: page.storePage)\nabout-us (typeId: page.storePage)'),
};
}
Expand Down Expand Up @@ -79,6 +95,23 @@ describe('content list', () => {
expect(result.data.every((item: any) => item.type === 'PAGE')).to.equal(true);
});

it('lists content blocks via type=fragment using getContentBlocks', async () => {
const command: any = await createCommand({library: 'TestLib', type: 'fragment'});
stubCommon(command);
sinon.stub(command, 'jsonEnabled').returns(true);

const mockLibrary = createMockLibrary();
sinon.stub(command.operations, 'fetchContentLibrary').resolves({library: mockLibrary});

const result = await command.run();

expect(mockLibrary.getContentBlocks.calledOnce).to.equal(true);
expect(result.data).to.have.lengthOf(2);
expect(result.data.every((item: any) => item.type === 'FRAGMENT')).to.equal(true);
const grid = result.data.find((item: any) => item.id === 'grid-block');
expect(grid.children).to.equal(1);
});

it('shows tree structure when --tree is set', async () => {
const command: any = await createCommand({library: 'TestLib', tree: true});
stubCommon(command);
Expand Down
9 changes: 6 additions & 3 deletions packages/b2c-tooling-sdk/src/operations/content/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,23 +194,25 @@ export async function exportContent(
return true;
});

// Step 3b: Promote matching components to root level
// Step 3b: Promote matching components and content blocks to root level
const allNodes = [...library.nodes({traverseHidden: true, callbackHidden: true})];
for (const node of allNodes) {
if (node.type === 'COMPONENT' && matchesId(node.id)) {
if ((node.type === 'COMPONENT' || node.type === 'FRAGMENT') && matchesId(node.id)) {
library.promoteToRoot(node as LibraryNode);
}
}

// Step 4: Count pages, content, and components
// Step 4: Count pages, content, components, and content blocks
let pageCount = 0;
let contentCount = 0;
let componentCount = 0;
let fragmentCount = 0;
library.traverse(
(node) => {
if (node.type === 'PAGE') pageCount++;
else if (node.type === 'CONTENT') contentCount++;
else if (node.type === 'COMPONENT') componentCount++;
else if (node.type === 'FRAGMENT') fragmentCount++;
},
{traverseHidden: false},
);
Expand Down Expand Up @@ -297,5 +299,6 @@ export async function exportContent(
pageCount,
contentCount,
componentCount,
fragmentCount,
};
}
Loading
Loading