From 41f965be11d3e8cf2fa87a00c942abf02a722556 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Sun, 14 Jun 2026 17:12:52 -0400 Subject: [PATCH 1/4] @W-22849705 feat(content): support Page Designer content blocks (fragments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content blocks are reusable, shared `fragment.*`-typed Page Designer content. Previously the SDK bucketed them as components, so they were invisible as a distinct, shared concept across the SDK, CLI, and VS Code extension. SDK (@salesforce/b2c-tooling-sdk): - Add `FRAGMENT` to `LibraryNodeType`; classify `fragment.*` content as FRAGMENT - Parse and expose `LibraryNode.displayName` (content blocks always have one) - Add `Library.getContentBlocks()`: deduplicated catalog of a library's blocks, including unlinked ones (scans full content set, not just the linked tree) - Render blocks distinctly in `getTreeString` ("(CONTENT BLOCK: )", magenta) - Promote and count fragments in `exportContent` (new `fragmentCount`) CLI (@salesforce/b2c-cli): - `content export`/`list` render blocks distinctly and count them separately - `content list --type fragment` lists the dedup catalog via getContentBlocks VS Code extension (b2c-vs-extension): - Per-library "Content Blocks" group is the single source of truth for a block (full child subtree). Because blocks are shared singletons, every page/component that links a block shows a non-expanding reference (↗) that reveals the canonical block in the group — so a block is only ever edited in one place - Right-click "Convert to Content Block" rewrites a component's to fragment.* + adds a display-name and re-imports the element - Extract reusable importLibraryXML helper; remove leftover debug temp-file write Verified end-to-end against the zzpq-023 MarketStreet site library (leaf, Layout, shared, nested, and unlinked content blocks). The bundled library.xsd is byte-identical to the instance's 26.6 schema (fragment.* already validates), so no schema change was needed. --- .changeset/content-blocks-fragment-support.md | 10 + docs/cli/content.md | 18 +- docs/vscode-extension/index.md | 2 + .../b2c-cli/src/commands/content/export.ts | 19 +- packages/b2c-cli/src/commands/content/list.ts | 73 +++--- .../test/commands/content/list.test.ts | 33 +++ .../src/operations/content/export.ts | 9 +- .../src/operations/content/library.ts | 90 +++++++- .../src/operations/content/types.ts | 11 +- .../test/operations/content/fixtures.ts | 61 +++++ .../test/operations/content/library.test.ts | 117 ++++++++++ packages/b2c-vs-extension/package.json | 31 ++- .../src/content-tree/content-commands.ts | 84 +++++++ .../src/content-tree/content-fs-provider.ts | 41 ++-- .../src/content-tree/content-tree-provider.ts | 208 +++++++++++++++--- .../src/content-tree/index.ts | 2 +- skills/b2c-cli/skills/b2c-content/SKILL.md | 12 +- 17 files changed, 725 insertions(+), 96 deletions(-) create mode 100644 .changeset/content-blocks-fragment-support.md diff --git a/.changeset/content-blocks-fragment-support.md b/.changeset/content-blocks-fragment-support.md new file mode 100644 index 000000000..92b2df8ae --- /dev/null +++ b/.changeset/content-blocks-fragment-support.md @@ -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. diff --git a/docs/cli/content.md b/docs/cli/content.md index e1e3dadc0..c1f0c0f70 100644 --- a/docs/cli/content.md +++ b/docs/cli/content.md @@ -43,7 +43,7 @@ b2c content export [PAGES...] --library | 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 @@ -140,9 +140,9 @@ 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. @@ -150,7 +150,8 @@ With `--json`, returns a structured result including the library tree, output pa - 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`) @@ -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 | | @@ -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 @@ -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. diff --git a/docs/vscode-extension/index.md b/docs/vscode-extension/index.md index c8509a6a4..a41f47c95 100644 --- a/docs/vscode-extension/index.md +++ b/docs/vscode-extension/index.md @@ -26,6 +26,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) ### B2C Script Debugger diff --git a/packages/b2c-cli/src/commands/content/export.ts b/packages/b2c-cli/src/commands/content/export.ts index 14b668696..ff2284901 100644 --- a/packages/b2c-cli/src/commands/content/export.ts +++ b/packages/b2c-cli/src/commands/content/export.ts @@ -172,18 +172,19 @@ export default class ContentExport extends JobCommand { 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( @@ -197,6 +198,10 @@ export default class ContentExport extends JobCommand { contentCount++; break; } + case 'FRAGMENT': { + fragmentCount++; + break; + } case 'PAGE': { pageCount++; break; @@ -214,7 +219,9 @@ export default class ContentExport extends JobCommand { 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, @@ -224,6 +231,7 @@ export default class ContentExport extends JobCommand { pageCount, contentCount, componentCount, + fragmentCount, }; } @@ -251,6 +259,7 @@ export default class ContentExport extends JobCommand { result.pageCount, result.contentCount, result.componentCount, + result.fragmentCount, result.downloadedAssets.length, result.outputPath, ), @@ -267,6 +276,7 @@ function formatSummary( pages: number, content: number, components: number, + fragments: number, assets: number, outputPath: string, ): string { @@ -274,6 +284,7 @@ function formatSummary( 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`; diff --git a/packages/b2c-cli/src/commands/content/list.ts b/packages/b2c-cli/src/commands/content/list.ts index 6647b4b2c..d6415de87 100644 --- a/packages/b2c-cli/src/commands/content/list.ts +++ b/packages/b2c-cli/src/commands/content/list.ts @@ -49,6 +49,7 @@ const TYPE_MAP: Record = { page: 'PAGE', content: 'CONTENT', component: 'COMPONENT', + fragment: 'FRAGMENT', }; export default class ContentList extends JobCommand { @@ -60,6 +61,7 @@ export default class ContentList extends JobCommand { '<%= 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 = { @@ -76,7 +78,7 @@ export default class ContentList extends JobCommand { }), 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', @@ -128,38 +130,51 @@ export default class ContentList extends JobCommand { 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}; diff --git a/packages/b2c-cli/test/commands/content/list.test.ts b/packages/b2c-cli/test/commands/content/list.test.ts index 14c9a797f..7f17ab251 100644 --- a/packages/b2c-cli/test/commands/content/list.test.ts +++ b/packages/b2c-cli/test/commands/content/list.test.ts @@ -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)'), }; } @@ -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); diff --git a/packages/b2c-tooling-sdk/src/operations/content/export.ts b/packages/b2c-tooling-sdk/src/operations/content/export.ts index 092e67179..d288814e9 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/export.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/export.ts @@ -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}, ); @@ -297,5 +299,6 @@ export async function exportContent( pageCount, contentCount, componentCount, + fragmentCount, }; } diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 9d1170b35..329ae6856 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/library.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/library.ts @@ -26,6 +26,8 @@ export class LibraryNode { id: string; type: LibraryNodeType; typeId: string | null; + /** Localized display name (x-default), when present. Content blocks (fragments) always have one. */ + displayName: string | null; data: Record | null; parent: LibraryNode | null; children: LibraryNode[]; @@ -37,6 +39,7 @@ export class LibraryNode { id: string; type: LibraryNodeType; typeId: string | null; + displayName?: string | null; data: Record | null; parent: LibraryNode | null; children: LibraryNode[]; @@ -46,6 +49,7 @@ export class LibraryNode { this.id = values.id; this.type = values.type; this.typeId = values.typeId; + this.displayName = values.displayName ?? null; this.data = values.data; this.parent = values.parent; this.children = values.children; @@ -58,6 +62,7 @@ export class LibraryNode { id: this.id, type: this.type, typeId: this.typeId, + displayName: this.displayName, data: this.data, children: this.children, hidden: this.hidden, @@ -65,6 +70,35 @@ export class LibraryNode { } } +/** + * Classify a content element's `` value into a {@link LibraryNodeType}. + * + * - `page.*` → `PAGE` + * - `fragment.*` → `FRAGMENT` (a Page Designer "content block": a shared/reusable singleton) + * - any other typed value → `COMPONENT` + * - no type → `CONTENT` (a content asset) + */ +function classifyContentType(contentType: string | null): LibraryNodeType { + if (!contentType) { + return 'CONTENT'; + } + if (contentType.startsWith('page.')) { + return 'PAGE'; + } + if (contentType.startsWith('fragment.')) { + return 'FRAGMENT'; + } + return 'COMPONENT'; +} + +/** + * Extract the x-default `` text from a content XML element, if present. + */ +function extractDisplayName(content: Record): string | null { + const displayNames = content['display-name'] as Array> | undefined; + return displayNames?.[0]?.['_'] ?? null; +} + /** * Recursively processes a content XML element into a LibraryNode tree. * @@ -80,11 +114,13 @@ function processContent( const contentId = attrs['content-id']; const contentType = (content['type'] as string[] | undefined)?.[0] ?? null; const dataElements = content['data'] as Array> | undefined; + const displayName = extractDisplayName(content); const node = new LibraryNode({ id: contentId, - type: contentType ? (contentType.startsWith('page.') ? 'PAGE' : 'COMPONENT') : 'CONTENT', + type: classifyContentType(contentType), typeId: contentType, + displayName, data: null, children: [], xml: content, @@ -174,6 +210,8 @@ export class Library { tree!: LibraryNode; /** @internal Raw xml2js parsed object */ xml!: Record; + /** @internal Index of every content element by content-id (built during parse, reused by getContentBlocks) */ + private contentById: Record> = {}; /** @internal */ constructor(guard: symbol) { @@ -213,12 +251,13 @@ export class Library { xml: null, }); - // Index all content by ID + // Index all content by ID (retained on the instance for getContentBlocks) const contentById: Record> = {}; for (const c of contentArray) { const cAttrs = c['$'] as Record; contentById[cAttrs['content-id']] = c; } + library.contentById = contentById; // Process pages and root-level content (no type = content asset) for (const c of contentArray) { @@ -258,6 +297,48 @@ export class Library { return library; } + /** + * Returns the library's content blocks (fragments) as a deduplicated catalog. + * + * A content block is a `` element typed `fragment.*`. Unlike pages or + * content assets, fragments are not root-level tree children — they surface + * wherever a page/component/other-fragment links them. This method scans the + * full content set (not just the linked tree) so that **unlinked** blocks are + * included too, and returns one source-of-truth {@link LibraryNode} per block, + * with its child subtree attached (Layout fragments keep their region children). + * + * @returns One LibraryNode per content block, deduplicated by content-id. + * + * @example + * ```typescript + * const library = await Library.parse(xml); + * for (const block of library.getContentBlocks()) { + * console.log(block.displayName ?? block.id, block.typeId); + * } + * ``` + */ + getContentBlocks(): LibraryNode[] { + const libraryElement = this.xml['library'] as Record | undefined; + const contentArray = (libraryElement?.['content'] as Array> | undefined) ?? []; + + const blocks: LibraryNode[] = []; + const seen = new Set(); + for (const c of contentArray) { + const cType = (c['type'] as string[] | undefined)?.[0]; + if (!cType || !cType.startsWith('fragment.')) { + continue; + } + const cId = (c['$'] as Record)['content-id']; + if (seen.has(cId)) { + continue; + } + seen.add(cId); + const node = processContent(c, this.contentById, this.assetQuery); + blocks.push(node); + } + return blocks; + } + /** * Depth-first traversal of the library tree. * @@ -427,6 +508,11 @@ export class Library { case 'COMPONENT': { return node.typeId ? `${c('cyan', node.typeId)} ${c('dim', `(${node.id})`)}` : node.id; } + case 'FRAGMENT': { + const name = c('magenta', node.displayName ?? node.id); + const annotation = node.typeId ? `(CONTENT BLOCK: ${node.typeId})` : '(CONTENT BLOCK)'; + return `${name} ${c('dim', annotation)}`; + } case 'CONTENT': { return `${c('bold', node.id)} ${c('dim', '(CONTENT ASSET)')}`; } diff --git a/packages/b2c-tooling-sdk/src/operations/content/types.ts b/packages/b2c-tooling-sdk/src/operations/content/types.ts index d1a2604cb..558da303d 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/types.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/types.ts @@ -8,8 +8,13 @@ import type {WaitForJobOptions} from '../jobs/run.js'; /** * Node types in a content library tree. + * + * `FRAGMENT` represents a Page Designer "content block" — a `` element + * whose type is `fragment.*`. Unlike a `COMPONENT` (at most one incoming link), + * a fragment is a shared/reusable singleton that can be linked from multiple + * pages or other content; every placement references the same underlying object. */ -export type LibraryNodeType = 'LIBRARY' | 'PAGE' | 'CONTENT' | 'COMPONENT' | 'STATIC'; +export type LibraryNodeType = 'LIBRARY' | 'PAGE' | 'CONTENT' | 'COMPONENT' | 'FRAGMENT' | 'STATIC'; /** * Options for parsing a library XML string into a Library tree. @@ -81,6 +86,8 @@ export interface LibraryNodeData { id: string; type: LibraryNodeType; typeId: string | null; + /** Localized display name (x-default), when present. Content blocks (fragments) always have one. */ + displayName: string | null; data: Record | null; parent: LibraryNodeData | null; children: LibraryNodeData[]; @@ -157,4 +164,6 @@ export interface ContentExportResult { contentCount: number; /** Number of components in the filtered export. */ componentCount: number; + /** Number of content blocks (fragments) in the filtered export. */ + fragmentCount: number; } diff --git a/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts b/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts index f8c6c9d72..35b2b44fb 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/fixtures.ts @@ -159,3 +159,64 @@ export const MISSING_LINK_LIBRARY_XML = ` `; + +/** + * Library XML exercising Page Designer "content blocks" (fragment.* typed content). + * + * Mirrors the real shapes observed on a live instance (MarketStreet site library): + * - A leaf content block: discover-block (fragment.Content.contentCard) with a display-name. + * - A Layout content block: grid-block (fragment.Layout.grid) that keeps its regions; + * its region children stay plain components (converting a block does NOT convert children). + * - The grid-block is SHARED: linked from BOTH home-page and promo-page (one definition, + * two incoming links) — and the leaf discover-block is nested INSIDE grid-block's region. + * - An UNLINKED block: lonely-block (fragment.Content.contentCard) referenced by no page, + * to prove getContentBlocks() scans the full content set, not just the linked tree. + * - grid-block's links omit (self-closing); home-page's links carry + * to cover both serialization shapes. + */ +export const FRAGMENT_LIBRARY_XML = ` + + + Home Page + page.homePage + + + + 0.0 + + + + + Promo Page + page.homePage + + + + 0.0 + + + + + GridBlock + fragment.Layout.grid + + + + + + + + component.Content.contentCard + + + + DiscoverBlock + fragment.Content.contentCard + + + + LonelyBlock + fragment.Content.contentCard + + +`; diff --git a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts index 634fe2c21..a8ace14a3 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts @@ -12,6 +12,7 @@ import { WILDCARD_ASSET_LIBRARY_XML, MISSING_LINK_LIBRARY_XML, POSITION_LIBRARY_XML, + FRAGMENT_LIBRARY_XML, } from './fixtures.js'; describe('operations/content/library', () => { @@ -454,4 +455,120 @@ describe('operations/content/library', () => { expect(componentChildren).to.have.lengthOf(0); }); }); + + describe('content blocks (fragments)', () => { + let library: Library; + + beforeEach(async () => { + library = await Library.parse(FRAGMENT_LIBRARY_XML); + }); + + it('should classify fragment.* content as FRAGMENT (not COMPONENT)', () => { + const homePage = library.tree.children.find((n) => n.id === 'home-page'); + expect(homePage).to.exist; + const gridBlock = homePage!.children.find((n) => n.id === 'grid-block'); + expect(gridBlock, 'grid-block should be linked under home-page').to.exist; + expect(gridBlock!.type).to.equal('FRAGMENT'); + expect(gridBlock!.typeId).to.equal('fragment.Layout.grid'); + }); + + it('should parse the x-default display-name for content blocks', () => { + const homePage = library.tree.children.find((n) => n.id === 'home-page'); + const gridBlock = homePage!.children.find((n) => n.id === 'grid-block'); + expect(gridBlock!.displayName).to.equal('GridBlock'); + + // A nested leaf block also carries its display-name + const discover = gridBlock!.children.find((n) => n.id === 'discover-block'); + expect(discover).to.exist; + expect(discover!.type).to.equal('FRAGMENT'); + expect(discover!.displayName).to.equal('DiscoverBlock'); + }); + + it('should leave a content block region child as COMPONENT (children are not converted)', () => { + const homePage = library.tree.children.find((n) => n.id === 'home-page'); + const gridBlock = homePage!.children.find((n) => n.id === 'grid-block'); + const plainCard = gridBlock!.children.find((n) => n.id === 'plain-card'); + expect(plainCard).to.exist; + expect(plainCard!.type).to.equal('COMPONENT'); + expect(plainCard!.displayName).to.be.null; + }); + + it('should render a shared content block identically wherever it is linked', () => { + // grid-block is linked from BOTH home-page and promo-page; both must show + // the same children (it is a single shared object). + const homeGrid = library.tree.children + .find((n) => n.id === 'home-page')! + .children.find((n) => n.id === 'grid-block'); + const promoGrid = library.tree.children + .find((n) => n.id === 'promo-page')! + .children.find((n) => n.id === 'grid-block'); + expect(homeGrid, 'home-page links grid-block').to.exist; + expect(promoGrid, 'promo-page links grid-block').to.exist; + const homeChildIds = homeGrid!.children.map((n) => n.id).sort(); + const promoChildIds = promoGrid!.children.map((n) => n.id).sort(); + expect(homeChildIds).to.deep.equal(['discover-block', 'plain-card']); + expect(promoChildIds).to.deep.equal(homeChildIds); + }); + + describe('getContentBlocks()', () => { + it('should return one source node per content block, deduplicated', () => { + const blocks = library.getContentBlocks(); + const ids = blocks.map((b) => b.id).sort(); + // grid-block + discover-block + lonely-block — each exactly once, + // even though grid-block is linked twice and discover-block is nested. + expect(ids).to.deep.equal(['discover-block', 'grid-block', 'lonely-block']); + }); + + it('should include UNLINKED content blocks (full content scan, not just the tree)', () => { + const blocks = library.getContentBlocks(); + const lonely = blocks.find((b) => b.id === 'lonely-block'); + expect(lonely, 'unlinked block should be surfaced').to.exist; + expect(lonely!.type).to.equal('FRAGMENT'); + expect(lonely!.displayName).to.equal('LonelyBlock'); + }); + + it('should attach the child subtree to a Layout content block source node', () => { + const grid = library.getContentBlocks().find((b) => b.id === 'grid-block'); + expect(grid).to.exist; + const childIds = grid!.children.map((n) => n.id).sort(); + expect(childIds).to.deep.equal(['discover-block', 'plain-card']); + }); + + it('should classify every returned node as FRAGMENT with a displayName', () => { + for (const block of library.getContentBlocks()) { + expect(block.type).to.equal('FRAGMENT'); + expect(block.displayName, `block ${block.id} should have a display-name`).to.be.a('string'); + } + }); + }); + + it('should parse position-less and position-bearing content-links equivalently', () => { + // grid-block's links omit ; home-page's link carries one. Both + // must parse without error and produce a stable child ordering. + const grid = library.getContentBlocks().find((b) => b.id === 'grid-block')!; + // column_1 (plain-card) before column_2 (discover-block): document order preserved + // when positions are absent (both default to Infinity → stable sort). + expect(grid.children.map((n) => n.id)).to.deep.equal(['plain-card', 'discover-block']); + }); + + it('should render content blocks distinctly in getTreeString', () => { + const tree = library.getTreeString({traverseHidden: false}); + // The display-name and a CONTENT BLOCK annotation (with typeId) are shown. + expect(tree).to.include('GridBlock (CONTENT BLOCK: fragment.Layout.grid)'); + expect(tree).to.include('DiscoverBlock (CONTENT BLOCK: fragment.Content.contentCard)'); + }); + + it('should apply the magenta color to content blocks when colorized', () => { + const colorize = (color: string, text: string) => `[${color}]${text}[/${color}]`; + const tree = library.getTreeString({colorize}); + expect(tree).to.include('[magenta]GridBlock[/magenta]'); + expect(tree).to.include('[dim](CONTENT BLOCK: fragment.Layout.grid)[/dim]'); + }); + + it('should include displayName in toJSON output', () => { + const grid = library.getContentBlocks().find((b) => b.id === 'grid-block')!; + const json = grid.toJSON(); + expect(json).to.have.property('displayName', 'GridBlock'); + }); + }); }); diff --git a/packages/b2c-vs-extension/package.json b/packages/b2c-vs-extension/package.json index 7cd6ce3eb..a0d31659e 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -504,6 +504,18 @@ "icon": "$(cloud-upload)", "category": "B2C DX" }, + { + "command": "b2c-dx.content.convertToBlock", + "title": "Convert to Content Block", + "icon": "$(symbol-field)", + "category": "B2C DX" + }, + { + "command": "b2c-dx.content.revealBlock", + "title": "Reveal Content Block", + "icon": "$(references)", + "category": "B2C DX" + }, { "command": "b2c-dx.scaffold.generate", "title": "New from Scaffold...", @@ -753,19 +765,24 @@ }, { "command": "b2c-dx.content.export", - "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component|fragment)$/", "group": "1_export@1" }, { "command": "b2c-dx.content.exportNoAssets", - "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component|fragment)$/", "group": "1_export@2" }, { "command": "b2c-dx.content.exportAssets", - "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component)$/", + "when": "view == b2cContentExplorer && viewItem =~ /^(page|content|component|fragment)$/", "group": "1_export@3" }, + { + "command": "b2c-dx.content.convertToBlock", + "when": "view == b2cContentExplorer && viewItem == component", + "group": "2_manage@1" + }, { "command": "b2c-dx.content.removeLibrary", "when": "view == b2cContentExplorer && viewItem == library", @@ -1005,6 +1022,14 @@ "command": "b2c-dx.content.removeLibrary", "when": "false" }, + { + "command": "b2c-dx.content.convertToBlock", + "when": "false" + }, + { + "command": "b2c-dx.content.revealBlock", + "when": "false" + }, { "command": "b2c-dx.content.export", "when": "false" diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index ea0c42b81..a34224709 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; +import {generateContentXML, importLibraryXML} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; import {openJobLog} from '../job-log-viewer.js'; import {registerSafeCommand} from '../safety.js'; @@ -33,6 +34,7 @@ export function registerContentCommands( configProvider: ContentConfigProvider, treeProvider: ContentTreeDataProvider, _fsProvider: ContentFileSystemProvider, + treeView: vscode.TreeView, ): vscode.Disposable[] { const refresh = registerSafeCommand('b2c-dx.content.refresh', () => { configProvider.clearCache(); @@ -248,6 +250,86 @@ export function registerContentCommands( vscode.window.showInformationMessage('Site archive imported successfully.'); }); + // Reveal the canonical source of a shared content block under the "Content + // Blocks" group. Triggered when a user clicks a reference node, enforcing that + // a block is only ever viewed/edited in one place. + const revealBlock = registerSafeCommand('b2c-dx.content.revealBlock', async (node: ContentTreeItem) => { + if (!node || node.nodeType !== 'fragmentRef') return; + const source = treeProvider.getContentBlockSource(node.libraryId, node.isSiteLibrary, node.contentId); + if (!source) { + vscode.window.showWarningMessage(`Content block "${node.contentId}" was not found in the library.`); + return; + } + try { + await treeView.reveal(source, {select: true, focus: true, expand: true}); + } catch { + // reveal can throw if the tree isn't ready; non-fatal. + } + // Open the block's XML so the reference click still lands somewhere useful. + await vscode.commands.executeCommand('vscode.open', ...(source.command?.arguments ?? [])); + }); + + // Convert a component (assigned inline to a page/region) into a shared content + // block. On the platform this rewrites the element's from component.* to + // fragment.* and adds a on the SAME content object — no new id, + // no link rewiring (the parent link-type follows the parent's type). We mirror + // that exact mutation and re-import the single element. + const convertToBlock = registerSafeCommand('b2c-dx.content.convertToBlock', async (node: ContentTreeItem) => { + if (!node || node.nodeType !== 'component' || !node.libraryNode) { + return; + } + const instance = configProvider.getInstance(); + if (!instance) { + vscode.window.showErrorMessage('No B2C Commerce instance configured.'); + return; + } + const library = configProvider.getCachedLibrary(node.libraryId); + if (!library) { + vscode.window.showErrorMessage(`Library "${node.libraryId}" is not loaded. Expand it first.`); + return; + } + + const xml = node.libraryNode.xml; + const currentType = (xml?.['type'] as string[] | undefined)?.[0]; + if (!xml || !currentType || !currentType.startsWith('component.')) { + vscode.window.showErrorMessage('Only a component can be converted to a content block.'); + return; + } + + const displayName = await vscode.window.showInputBox({ + title: 'Convert to Content Block', + prompt: 'Enter a display name for the new content block', + value: node.libraryNode.displayName ?? node.contentId, + validateInput: (value: string) => (value.trim() ? null : 'Enter a display name'), + }); + if (displayName === undefined) return; // cancelled + + // Mutate the in-memory xml: component.* -> fragment.* and inject display-name. + const fragmentType = `fragment.${currentType.slice('component.'.length)}`; + (xml['type'] as string[])[0] = fragmentType; + xml['display-name'] = [{$: {'xml:lang': 'x-default'}, _: displayName.trim()}]; + + try { + const contentXML = generateContentXML(library, node.contentId); + await vscode.window.withProgress( + {location: vscode.ProgressLocation.Notification, title: `Converting ${node.contentId} to a content block...`}, + async () => { + await importLibraryXML(instance, node.libraryId, node.isSiteLibrary, contentXML); + }, + ); + } catch (err) { + await showJobError(err, instance, 'Convert to content block failed'); + return; + } finally { + // Discard the transient in-place mutation; the tree re-fetches fresh state + // (whether the import succeeded or failed). + configProvider.invalidateLibrary(node.libraryId); + } + + treeProvider.refresh(); + vscode.window.showInformationMessage(`Converted "${node.contentId}" to content block "${displayName.trim()}".`); + }); + const browseWebdav = registerSafeCommand('b2c-dx.content.browseWebdav', async (node: ContentTreeItem) => { if (!node) return; @@ -271,6 +353,8 @@ export function registerContentCommands( filter, clearFilter, importCmd, + revealBlock, + convertToBlock, browseWebdav, ]; } diff --git a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts index 41c5ce400..f8932e50f 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts @@ -4,10 +4,8 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; import type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content'; +import type {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk'; import JSZip from 'jszip'; import * as xml2js from 'xml2js'; @@ -44,11 +42,34 @@ export function contentItemUri(libraryId: string, isSiteLibrary: boolean, conten return vscode.Uri.from({scheme: CONTENT_SCHEME, path: uriPath}); } +/** + * Import a single library XML payload to an instance. + * + * Wraps the XML at the correct archive path (site-private vs shared library), + * zips it, and runs a site-archive import job. Shared by the content file-system + * writeFile flow and the "Convert to Content Block" command. + * + * @throws Re-throws the underlying error (e.g. JobExecutionError) so callers can + * surface the job log; performs no cache invalidation or UI itself. + */ +export async function importLibraryXML( + instance: B2CInstance, + libraryId: string, + isSiteLibrary: boolean, + xmlString: string, +): Promise { + const archivePath = isSiteLibrary ? `sites/${libraryId}/library/library.xml` : `libraries/${libraryId}/library.xml`; + const zip = new JSZip(); + zip.file(archivePath, xmlString); + const buffer = await zip.generateAsync({type: 'nodebuffer'}); + await siteArchiveImport(instance, buffer); +} + /** * Generate library XML for a single content item and its descendants, * without mutating the cached Library instance. */ -function generateContentXML(library: Library, contentId: string): string { +export function generateContentXML(library: Library, contentId: string): string { let target: LibraryNode | undefined; for (const node of library.nodes({traverseHidden: true, callbackHidden: true})) { if (node.id === contentId) { @@ -159,22 +180,12 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider { } const xmlContent = Buffer.from(content).toString('utf-8'); - const archivePath = isSiteLibrary ? `sites/${libraryId}/library/library.xml` : `libraries/${libraryId}/library.xml`; try { await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`}, async () => { - const zip = new JSZip(); - zip.file(archivePath, xmlContent); - const buffer = await zip.generateAsync({type: 'nodebuffer'}); - - // DEBUG: write archive to temp dir for inspection - const debugPath = path.join(os.tmpdir(), `content-update-${Date.now()}.zip`); - await fs.promises.writeFile(debugPath, buffer); - console.log(`[content-fs] Debug archive written to: ${debugPath}`); - - await siteArchiveImport(instance, buffer); + await importLibraryXML(instance, libraryId, isSiteLibrary, xmlContent); }, ); } catch (err) { diff --git a/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts index f99f60f3e..8e213467c 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-tree-provider.ts @@ -11,7 +11,28 @@ import type {ContentConfigProvider} from './content-config.js'; import {contentItemUri} from './content-fs-provider.js'; import {webdavPathToUri} from '../webdav-tree/webdav-fs-provider.js'; -type ContentNodeType = 'library' | 'page' | 'content' | 'component' | 'static'; +/** + * Tree node kinds. + * + * Content blocks (SDK `FRAGMENT` nodes) are shared/reusable singletons, so they + * are rendered two ways: + * - `contentBlockGroup`: a synthetic per-library group node ("Content Blocks") + * that is the single source of truth — it lists every block with its full + * child subtree, and is the only place a block can be opened/edited. + * - `fragment`: a block as it appears *inside the group* (expandable source). + * - `fragmentRef`: a block as it appears *anywhere it is linked* (under a page, + * component, or another block) — a non-expanding pointer that reveals the + * source in the group when clicked. Editing only ever happens via the source. + */ +type ContentNodeType = + | 'library' + | 'page' + | 'content' + | 'component' + | 'static' + | 'contentBlockGroup' + | 'fragment' + | 'fragmentRef'; /** * Build a stable path-from-root string for a LibraryNode. Used to produce a @@ -36,32 +57,23 @@ export class ContentTreeItem extends vscode.TreeItem { readonly contentId: string, readonly libraryNode?: LibraryNode, ) { - const label = - nodeType === 'library' - ? isSiteLibrary - ? `${libraryId} [site]` - : libraryId - : nodeType === 'component' && libraryNode?.typeId - ? libraryNode.typeId - : contentId; - - const collapsible = - nodeType === 'static' - ? vscode.TreeItemCollapsibleState.None - : nodeType === 'library' || nodeType === 'page' - ? vscode.TreeItemCollapsibleState.Collapsed - : (libraryNode?.children.length ?? 0) > 0 - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None; + const label = ContentTreeItem.buildLabel(nodeType, libraryId, isSiteLibrary, contentId, libraryNode); + const collapsible = ContentTreeItem.buildCollapsibleState(nodeType, libraryNode); super(label, collapsible); - // Stable id: libraries are unique by id+scope; non-library nodes need a - // path-from-root because the same content id can appear under multiple - // parents (a component can be referenced by several pages). + // Stable id: libraries are unique by id+scope; the synthetic group is unique + // per library; a source fragment is unique by its content id (the block is a + // single shared object, regardless of where it is linked); other content + // nodes (incl. fragment references) need a path-from-root because the same + // content id can appear under multiple parents. const libScope = `${libraryId}:${isSiteLibrary ? 'site' : 'shared'}`; if (nodeType === 'library') { this.id = `lib:${libScope}`; + } else if (nodeType === 'contentBlockGroup') { + this.id = `content:contentBlockGroup:${libScope}`; + } else if (nodeType === 'fragment') { + this.id = `content:fragment:${libScope}:${contentId}`; } else { const ancestorPath = libraryNode ? buildLibraryNodePath(libraryNode) : contentId; this.id = `content:${nodeType}:${libScope}:${ancestorPath}`; @@ -69,14 +81,16 @@ export class ContentTreeItem extends vscode.TreeItem { this.contextValue = nodeType; - // Show content ID as description for components (label is typeId) + // Descriptions if (nodeType === 'component' && libraryNode?.typeId) { + // Show content ID as description for components (label is typeId) this.description = contentId; - } - - // Type suffix for content assets - if (nodeType === 'content') { + } else if (nodeType === 'content') { this.description = 'CONTENT ASSET'; + } else if (nodeType === 'fragment' || nodeType === 'fragmentRef') { + // Reference nodes get an arrow affordance signalling "shared block". + this.description = + nodeType === 'fragmentRef' ? `↗ ${libraryNode?.typeId ?? 'content block'}` : (libraryNode?.typeId ?? undefined); } // Icons @@ -96,6 +110,15 @@ export class ContentTreeItem extends vscode.TreeItem { case 'static': this.iconPath = new vscode.ThemeIcon('file-media'); break; + case 'contentBlockGroup': + this.iconPath = new vscode.ThemeIcon('symbol-structure'); + break; + case 'fragment': + this.iconPath = new vscode.ThemeIcon('symbol-field'); + break; + case 'fragmentRef': + this.iconPath = new vscode.ThemeIcon('references'); + break; } // Click command for openable items @@ -107,7 +130,22 @@ export class ContentTreeItem extends vscode.TreeItem { title: 'Open Static Asset', arguments: [webdavPathToUri(webdavPath)], }; - } else if (nodeType !== 'library') { + } else if (nodeType === 'fragmentRef') { + // A reference is navigational, not editable: reveal the canonical source + // under the Content Blocks group so all edits funnel through one place. + this.command = { + command: 'b2c-dx.content.revealBlock', + title: 'Reveal Content Block', + arguments: [this], + }; + } else if (nodeType === 'fragment') { + // The source block opens its XML for viewing/editing. + this.command = { + command: 'vscode.open', + title: 'Open Content Block', + arguments: [contentItemUri(libraryId, isSiteLibrary, contentId)], + }; + } else if (nodeType === 'page' || nodeType === 'content' || nodeType === 'component') { const uri = contentItemUri(libraryId, isSiteLibrary, contentId); this.command = { command: 'vscode.open', @@ -116,6 +154,46 @@ export class ContentTreeItem extends vscode.TreeItem { }; } } + + private static buildLabel( + nodeType: ContentNodeType, + libraryId: string, + isSiteLibrary: boolean, + contentId: string, + libraryNode?: LibraryNode, + ): string { + switch (nodeType) { + case 'library': + return isSiteLibrary ? `${libraryId} [site]` : libraryId; + case 'contentBlockGroup': + return 'Content Blocks'; + case 'fragment': + case 'fragmentRef': + // Content blocks are identified by their display name. + return libraryNode?.displayName ?? contentId; + case 'component': + return libraryNode?.typeId ?? contentId; + default: + return contentId; + } + } + + private static buildCollapsibleState( + nodeType: ContentNodeType, + libraryNode?: LibraryNode, + ): vscode.TreeItemCollapsibleState { + // References and static assets are always leaves; the group is always + // expandable. Everything else expands only when it has children. + if (nodeType === 'static' || nodeType === 'fragmentRef') { + return vscode.TreeItemCollapsibleState.None; + } + if (nodeType === 'library' || nodeType === 'page' || nodeType === 'contentBlockGroup') { + return vscode.TreeItemCollapsibleState.Collapsed; + } + return (libraryNode?.children.length ?? 0) > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + } } export class ContentTreeDataProvider implements vscode.TreeDataProvider { @@ -143,6 +221,18 @@ export class ContentTreeDataProvider implements vscode.TreeDataProvider { if (!element) { return this.getRootChildren(); @@ -152,7 +242,25 @@ export class ContentTreeDataProvider implements vscode.TreeDataProvider this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary, 'source')); + } + + // References are leaves — never expand them (avoids duplicating a shared + // block's subtree and any cycle risk). + if (element.nodeType === 'fragmentRef') { + return []; + } + + // PAGE, CONTENT, COMPONENT, and source FRAGMENT: render children from the + // libraryNode reference. A fragment child is rendered as a reference. if (element.libraryNode) { return element.libraryNode.children.map((node) => this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary), @@ -162,6 +270,19 @@ export class ContentTreeDataProvider implements vscode.TreeDataProvider b.id === contentId); + return block ? this.nodeToTreeItem(block, libraryId, isSiteLibrary, 'source') : undefined; + } + private getRootChildren(): ContentTreeItem[] { const instance = this.configProvider.getInstance(); if (!instance) { @@ -223,11 +344,36 @@ export class ContentTreeDataProvider implements vscode.TreeDataProvider node.id.toLowerCase().includes(lower)); } - return children.map((node) => this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary)); + const items = children.map((node) => this.nodeToTreeItem(node, element.libraryId, element.isSiteLibrary)); + + // Prepend the synthetic "Content Blocks" group when the library has any + // fragments. This group is the source of truth for shared blocks. + if (library.getContentBlocks().length > 0) { + items.unshift( + new ContentTreeItem('contentBlockGroup', element.libraryId, element.isSiteLibrary, element.libraryId), + ); + } + + return items; } - private nodeToTreeItem(node: LibraryNode, libraryId: string, isSiteLibrary: boolean): ContentTreeItem { - const nodeType = node.type.toLowerCase() as ContentNodeType; + /** + * Convert a LibraryNode into a tree item. A FRAGMENT renders as an expandable + * source only inside the Content Blocks group (`context === 'source'`); in any + * other position it renders as a non-expanding reference. + */ + private nodeToTreeItem( + node: LibraryNode, + libraryId: string, + isSiteLibrary: boolean, + context: 'source' | 'inline' = 'inline', + ): ContentTreeItem { + let nodeType: ContentNodeType; + if (node.type === 'FRAGMENT') { + nodeType = context === 'source' ? 'fragment' : 'fragmentRef'; + } else { + nodeType = node.type.toLowerCase() as ContentNodeType; + } return new ContentTreeItem(nodeType, libraryId, isSiteLibrary, node.id, node); } } diff --git a/packages/b2c-vs-extension/src/content-tree/index.ts b/packages/b2c-vs-extension/src/content-tree/index.ts index d451a86b3..afb7c000d 100644 --- a/packages/b2c-vs-extension/src/content-tree/index.ts +++ b/packages/b2c-vs-extension/src/content-tree/index.ts @@ -34,7 +34,7 @@ export function registerContentTree(context: vscode.ExtensionContext, configProv treeView.description = filter ? `filter: ${filter}` : undefined; }); - const commandDisposables = registerContentCommands(context, contentConfig, treeProvider, fsProvider); + const commandDisposables = registerContentCommands(context, contentConfig, treeProvider, fsProvider, treeView); configProvider.onDidReset(() => { contentConfig.clearCache(); diff --git a/skills/b2c-cli/skills/b2c-content/SKILL.md b/skills/b2c-cli/skills/b2c-content/SKILL.md index 3b0418535..5534ace8b 100644 --- a/skills/b2c-cli/skills/b2c-content/SKILL.md +++ b/skills/b2c-cli/skills/b2c-content/SKILL.md @@ -1,6 +1,6 @@ --- name: b2c-content -description: Export, list, and validate Page Designer content from B2C Commerce libraries. Use this skill whenever the user needs to export Page Designer pages or components, list pages in a content library, validate page JSON or metadefinitions, discover page IDs, migrate content between instances, or work with library XML offline. Also use when extracting content for review or building content deployment pipelines -- even if they just say 'export the homepage' or 'what pages are in the shared library'. +description: Export, list, and validate Page Designer content from B2C Commerce libraries. Use this skill whenever the user needs to export Page Designer pages, components, or content blocks, list pages in a content library, validate page JSON or metadefinitions, discover page IDs, migrate content between instances, or work with library XML offline. Also use when extracting content for review or building content deployment pipelines -- even if they just say 'export the homepage' or 'what pages are in the shared library'. --- # B2C Content Skill @@ -35,6 +35,9 @@ b2c content export homepage --library SharedLibrary -o ./my-export # export a specific component by ID b2c content export hero-banner --library SharedLibrary +# export a content block (reusable fragment) by ID +b2c content export DiscoverContentBlock --library RefArch --site-library + # export from a site-private library b2c content export homepage --library RefArch --site-library @@ -66,6 +69,9 @@ b2c content list --library SharedLibrary # list only pages b2c content list --library SharedLibrary --type page +# list content blocks (reusable fragments; includes unlinked blocks) +b2c content list --library SharedLibrary --type fragment + # list including components b2c content list --library SharedLibrary --components @@ -82,6 +88,10 @@ b2c content list --library SharedLibrary --library-file ./library.xml b2c content list --library SharedLibrary --json ``` +### Content Blocks + +Content blocks are Page Designer "content blocks" — reusable `fragment.*`-typed content that is **shared** across pages (one definition, linked from many places). They are listed distinctly in the tree as `(CONTENT BLOCK)`, counted separately in export summaries, and can be exported by ID like a component (a Layout content block keeps its region children). `b2c content list --type fragment` shows the deduplicated catalog of every content block in a library, including blocks not currently linked to any page. + ### Configuration The `--library` flag can be configured in `dw.json` or `package.json` so you don't need to pass it every time: From 0d4049ac0af91741492772dcec943516e0780377 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Mon, 15 Jun 2026 11:33:43 -0400 Subject: [PATCH 2/4] @W-22849705 fix(content): make Convert to Content Block work via delete+recreate+relink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial convert prototype rewrote an element's and re-imported it, but live testing against instances proved a site-archive MERGE import silently ignores changes on an existing content element (verified by importing a real fragment from one instance into another — the element stayed a component). Empirically characterized the platform's actual contract (zzpq-019/013/023): - IMPEX can CREATE a net-new fragment element fine. - IMPEX merge will NOT change an existing element's type. - `mode="delete"` on a content element DEEP-deletes its whole subtree AND strips every incoming content-link. - So a faithful in-place conversion (matching Page Designer's "Convert to Content Block") requires, in ONE archive: delete the target, recreate it as fragment.*, recreate all descendants (cascade-deleted), and re-import every referrer so incoming content-links survive. Implements that as `Library.buildContentBlockConversionXML(contentId, displayName)`: - delete marker + recreated fragment (display-name first for XSD order; own region content-link types flipped component.. -> fragment..) - all descendants recreated; all referrers re-imported The extension convert command now builds this archive via the SDK and imports it. Verified end-to-end: the SDK-built archive imported into zzpq-013 produces an element BYTE-IDENTICAL to a manual BM/PD conversion, with the parent's content-link preserved. Added SDK tests (archive structure: delete/recreate/ descendants/referrers, region-link rename) including xmllint validation against the bundled library.xsd. --- .../src/operations/content/library.ts | 192 +++++++++++++++ .../test/operations/content/library.test.ts | 232 ++++++++++++++++++ .../src/content-tree/content-commands.ts | 27 +- 3 files changed, 435 insertions(+), 16 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 329ae6856..3f7027725 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/library.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/library.ts @@ -68,6 +68,38 @@ export class LibraryNode { hidden: this.hidden, }; } + + /** + * Convert this component node into a content block (fragment) in place. + * + * Mirrors the element-level shape of Page Designer's "Convert to Content + * Block": the underlying `` keeps its content-id, its `` is + * rewritten from `component.*` to the matching `fragment.*`, a `` + * is set (in schema-correct first position), and the element's own region + * `content-link` types are flipped to `fragment.*` to match. + * + * NOTE: this only mutates the in-memory node. It is NOT sufficient to persist a + * conversion via site-archive import — a merge import ignores `` changes + * on an existing element. To persist, use {@link Library.buildContentBlockConversionXML}. + * + * @param displayName - The x-default display name for the new content block. + * @returns this (for chaining) + * @throws If this node is not a component or has no backing XML. + */ + convertToFragment(displayName: string): this { + if (this.type !== 'COMPONENT' || !this.typeId || !this.typeId.startsWith('component.')) { + throw new Error('Only a component node can be converted to a content block'); + } + if (!this.xml) { + throw new Error(`Content "${this.id}" has no backing XML to convert`); + } + + this.xml = transformContentXmlToFragment(this.xml, displayName); + this.typeId = (this.xml['type'] as string[])[0]; + this.type = 'FRAGMENT'; + this.displayName = displayName; + return this; + } } /** @@ -99,6 +131,89 @@ function extractDisplayName(content: Record): string | null { return displayNames?.[0]?.['_'] ?? null; } +/** + * Produce a new xml2js content object representing the fragment form of a + * component element: `` rewritten `component.*` → `fragment.*`, a + * `` set as the first child (schema order), and the element's own + * region `content-link` `type` attributes flipped from `component..*` to + * `fragment..*` (Layout blocks keep their regions, renamed to match the + * new type). Does not mutate the input. + */ +function transformContentXmlToFragment(xml: Record, displayName: string): Record { + const currentType = (xml['type'] as string[] | undefined)?.[0]; + if (!currentType || !currentType.startsWith('component.')) { + throw new Error('Only a component element can be converted to a content block'); + } + const baseType = currentType.slice('component.'.length); + const fragmentType = `fragment.${baseType}`; + + // Rebuild with display-name first (xml2js serializes in key-insertion order). + const rebuilt: Record = {}; + if (xml['$'] !== undefined) { + rebuilt['$'] = xml['$']; + } + rebuilt['display-name'] = [{$: {'xml:lang': 'x-default'}, _: displayName}]; + for (const [key, value] of Object.entries(xml)) { + if (key === '$' || key === 'display-name') { + continue; + } + if (key === 'type') { + rebuilt['type'] = [fragmentType]; + continue; + } + if (key === 'content-links') { + // Flip this element's own region link types: component.. -> + // fragment... Child links to OTHER content keep their own + // ids; only the region-type attribute (owned by this element's type) changes. + rebuilt['content-links'] = renameRegionLinkTypes(value, currentType, fragmentType); + continue; + } + rebuilt[key] = value; + } + if (rebuilt['type'] === undefined) { + rebuilt['type'] = [fragmentType]; + } + return rebuilt; +} + +/** + * Return the content-ids this content element links to via its `content-links`. + */ +function childLinkIds(content: Record | undefined): string[] { + if (!content) { + return []; + } + const contentLinks = content['content-links'] as Array> | undefined; + const links = contentLinks?.[0]?.['content-link'] as Array> | undefined; + if (!links) { + return []; + } + return links + .map((link) => (link['$'] as Record | undefined)?.['content-id']) + .filter((id): id is string => typeof id === 'string'); +} + +/** + * Deep-clone a `content-links` xml2js value, rewriting any `content-link` whose + * `type` attribute starts with `${oldType}.` so it starts with `${newType}.`. + */ +function renameRegionLinkTypes(contentLinks: unknown, oldType: string, newType: string): unknown { + const cloned = JSON.parse(JSON.stringify(contentLinks)) as Array>; + for (const wrapper of cloned) { + const links = wrapper['content-link'] as Array> | undefined; + if (!links) { + continue; + } + for (const link of links) { + const attrs = link['$'] as Record | undefined; + if (attrs && typeof attrs['type'] === 'string' && attrs['type'].startsWith(`${oldType}.`)) { + attrs['type'] = `${newType}.${attrs['type'].slice(oldType.length + 1)}`; + } + } + } + return cloned; +} + /** * Recursively processes a content XML element into a LibraryNode tree. * @@ -339,6 +454,83 @@ export class Library { return blocks; } + /** + * Build a self-contained library XML payload that converts a component into a + * content block (fragment) when imported via site-archive import. + * + * A plain merge import ignores `` changes on an existing element, and a + * `mode="delete"` on a content element **deep-deletes its entire subtree** and + * strips every incoming `content-link`. To faithfully reproduce Page Designer's + * in-place conversion (verified byte-for-byte against a manual conversion), this + * archive therefore: + * + * 1. deletes the target (``) — clearing the old type + * and its subtree; + * 2. recreates the target as `fragment.*` (display-name first, own region-link + * types flipped) — see {@link transformContentXmlToFragment}; + * 3. recreates **every descendant** of the target (they were cascade-deleted); + * 4. re-imports **every referrer** (any content that links the target, possibly + * several — content blocks are shared) so their `content-link` survives. + * + * All four happen in one archive/one import job so the storefront is never left + * with a dangling reference. + * + * @param contentId - The component content-id to convert. + * @param displayName - The x-default display name for the new content block. + * @returns Importable library XML string. + * @throws If the content-id is not found or is not a component. + */ + async buildContentBlockConversionXML(contentId: string, displayName: string): Promise { + const target = this.contentById[contentId]; + if (!target) { + throw new Error(`Content "${contentId}" not found in library`); + } + const targetType = (target['type'] as string[] | undefined)?.[0]; + if (!targetType || !targetType.startsWith('component.')) { + throw new Error(`Content "${contentId}" is not a component and cannot be converted to a content block`); + } + + // Collect the target's descendants (its content-link tree). They are + // cascade-deleted by mode="delete" and must be recreated as-is. + const descendantIds: string[] = []; + const seen = new Set([contentId]); + const queue = [contentId]; + while (queue.length > 0) { + const id = queue.shift() as string; + for (const childId of childLinkIds(this.contentById[id])) { + if (!seen.has(childId) && this.contentById[childId]) { + seen.add(childId); + descendantIds.push(childId); + queue.push(childId); + } + } + } + + // Collect every referrer: any content element that links the target. + const referrerIds: string[] = []; + for (const [id, el] of Object.entries(this.contentById)) { + if (id === contentId) { + continue; + } + if (childLinkIds(el).includes(contentId)) { + referrerIds.push(id); + } + } + + // Build the content array: delete marker, recreated fragment, descendants, referrers. + const transformedTarget = transformContentXmlToFragment(target, displayName); + const content: Array> = [ + {$: {'content-id': contentId, mode: 'delete'}}, + transformedTarget, + ...descendantIds.map((id) => this.contentById[id]), + ...referrerIds.map((id) => this.contentById[id]), + ]; + + const libraryAttrs = (this.xml['library'] as Record)['$']; + const doc = {library: {$: libraryAttrs, content}}; + return new xml2js.Builder().buildObject(doc); + } + /** * Depth-first traversal of the library tree. * diff --git a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts index a8ace14a3..133ac501d 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts @@ -571,4 +571,236 @@ describe('operations/content/library', () => { expect(json).to.have.property('displayName', 'GridBlock'); }); }); + + describe('LibraryNode.convertToFragment()', () => { + let library: Library; + let card: LibraryNode; + + beforeEach(async () => { + library = await Library.parse(FRAGMENT_LIBRARY_XML); + // plain-card is a plain component nested in grid-block's region. + card = [...library.nodes({traverseHidden: true, callbackHidden: true})].find( + (n) => n.id === 'plain-card', + ) as LibraryNode; + expect(card, 'plain-card fixture node').to.exist; + }); + + it('should rewrite type component.* -> fragment.* and set displayName in place', () => { + expect(card.type).to.equal('COMPONENT'); + const result = card.convertToFragment('My Card'); + expect(result).to.equal(card); // chainable + expect(card.type).to.equal('FRAGMENT'); + expect(card.typeId).to.equal('fragment.Content.contentCard'); + expect(card.displayName).to.equal('My Card'); + // content-id is unchanged + expect(card.id).to.equal('plain-card'); + }); + + it('should serialize display-name as the FIRST child element (schema order)', async () => { + card.convertToFragment('My Card'); + const xml = await library.toXMLString({traverseHidden: false}); + // The for plain-card must appear before its . + const cardStart = xml.indexOf('content-id="plain-card"'); + const segment = xml.slice(cardStart, cardStart + 400); + const dnIdx = segment.indexOf(''); + expect(dnIdx, 'display-name present').to.be.greaterThan(-1); + expect(typeIdx, 'type present').to.be.greaterThan(-1); + expect(dnIdx, 'display-name before type').to.be.lessThan(typeIdx); + expect(segment).to.include('fragment.Content.contentCard'); + expect(segment).to.include('My Card'); + }); + + it('should replace an existing display-name rather than duplicate it', async () => { + // grid-block already has a display-name; reparse and convert a node that has one. + // Use a fresh component that we first give a name via convert, then re-convert. + card.convertToFragment('First'); + // Simulate a second conversion attempt is blocked (already a fragment): + expect(() => card.convertToFragment('Second')).to.throw(/Only a component/); + const xml = await library.toXMLString({traverseHidden: false}); + const matches = xml.match(/content-id="plain-card"[\s\S]*?<\/content>/); + expect(matches, 'plain-card element').to.exist; + const occurrences = (matches![0].match(/ { + const grid = library.getContentBlocks().find((b) => b.id === 'grid-block')!; + expect(() => grid.convertToFragment('x')).to.throw(/Only a component/); + }); + + it('should produce a library that validates against library.xsd', async function () { + // This is the exact check the platform import job performs. Skip gracefully + // if xmllint is unavailable in the environment. + const {execFileSync} = await import('node:child_process'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const {fileURLToPath} = await import('node:url'); + + try { + execFileSync('xmllint', ['--version'], {stdio: 'ignore'}); + } catch { + this.skip(); + return; + } + + card.convertToFragment('My Card'); + const xml = await library.toXMLString({traverseHidden: false}); + + const here = path.dirname(fileURLToPath(import.meta.url)); + const xsd = path.resolve(here, '../../../data/xsd/library.xsd'); + const tmp = path.join(os.tmpdir(), `convert-validate-${process.pid}.xml`); + fs.writeFileSync(tmp, xml); + try { + // Throws (non-zero exit) if validation fails. + execFileSync('xmllint', ['--noout', '--schema', xsd, tmp], {stdio: 'pipe'}); + } finally { + fs.rmSync(tmp, {force: true}); + } + }); + }); + + describe('Library.buildContentBlockConversionXML()', () => { + let library: Library; + + beforeEach(async () => { + library = await Library.parse(FRAGMENT_LIBRARY_XML); + }); + + type XmlContent = Record & {$: Record}; + async function parseDoc( + xmlString: string, + ): Promise<{ids: string[]; deleted: string[]; byId: Map}> { + const xml2js = await import('xml2js'); + const parsed = (await xml2js.parseStringPromise(xmlString)) as {library: {content?: XmlContent[]}}; + const content = parsed.library.content ?? []; + const ids: string[] = []; + const deleted: string[] = []; + const byId = new Map(); + for (const c of content) { + const id = c.$['content-id']; + ids.push(id); + if (c.$.mode === 'delete') { + deleted.push(id); + } else { + byId.set(id, c); + } + } + return {ids, deleted, byId}; + } + + function typeOf(el: XmlContent): string { + return (el['type'] as string[])[0]; + } + function linkIds(el: XmlContent): string[] { + const cl = el['content-links'] as Array>; + const links = cl[0]['content-link'] as Array<{$: Record}>; + return links.map((l) => l.$['content-id']); + } + + it('should emit a delete marker then a recreated fragment for the target', async () => { + // plain-card: a leaf component, linked only by grid-block. + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const {ids, deleted, byId} = await parseDoc(xml); + + // delete marker comes first, recreate second + expect(deleted).to.deep.equal(['plain-card']); + expect(ids[0]).to.equal('plain-card'); // delete marker + expect(ids[1]).to.equal('plain-card'); // recreate + + const recreated = byId.get('plain-card')!; + expect(typeOf(recreated)).to.equal('fragment.Content.contentCard'); + expect((recreated['display-name'] as Array<{_: string}>)[0]._).to.equal('Promoted Card'); + }); + + it('should re-import every referrer so incoming content-links survive', async () => { + // plain-card is linked from grid-block; grid-block must be re-imported. + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const {byId} = await parseDoc(xml); + + const grid = byId.get('grid-block'); + expect(grid, 'referrer grid-block re-imported').to.exist; + // grid-block still links plain-card + expect(linkIds(grid!)).to.include('plain-card'); + }); + + it('should recreate descendants of the target (cascade-deleted by mode=delete)', async () => { + // Convert grid-block's PARENT chain is n/a; instead convert a component with + // children. Build a small library where a component owns a child. + const withChild = await Library.parse(` + + + page.homePage + + + + component.Layout.grid + + + + component.Content.card + + +`); + + const xml = await withChild.buildContentBlockConversionXML('layout1', 'Grid Block'); + const {deleted, byId} = await parseDoc(xml); + + expect(deleted).to.deep.equal(['layout1']); + // recreated target is a fragment with its region-link type flipped + const layout = byId.get('layout1')!; + expect(typeOf(layout)).to.equal('fragment.Layout.grid'); + const layoutLinks = layout['content-links'] as Array>; + const firstLink = (layoutLinks[0]['content-link'] as Array<{$: Record}>)[0]; + expect(firstLink.$['type']).to.equal('fragment.Layout.grid.column_1'); + // descendant leaf1 is recreated (it would otherwise be cascade-deleted) + const leaf = byId.get('leaf1'); + expect(leaf, 'descendant recreated').to.exist; + expect(typeOf(leaf!)).to.equal('component.Content.card'); + // referrer page1 re-imported + expect(byId.get('page1'), 'referrer recreated').to.exist; + }); + + it('should throw for a non-existent or non-component content id', async () => { + await expectRejection(library.buildContentBlockConversionXML('does-not-exist', 'x'), /not found/); + // grid-block is already a fragment + await expectRejection(library.buildContentBlockConversionXML('grid-block', 'x'), /not a component/); + }); + + it('should produce an archive that validates against library.xsd', async function () { + const {execFileSync} = await import('node:child_process'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const {fileURLToPath} = await import('node:url'); + try { + execFileSync('xmllint', ['--version'], {stdio: 'ignore'}); + } catch { + this.skip(); + return; + } + + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const here = path.dirname(fileURLToPath(import.meta.url)); + const xsd = path.resolve(here, '../../../data/xsd/library.xsd'); + const tmp = path.join(os.tmpdir(), `convert-archive-${process.pid}.xml`); + fs.writeFileSync(tmp, xml); + try { + execFileSync('xmllint', ['--noout', '--schema', xsd, tmp], {stdio: 'pipe'}); + } finally { + fs.rmSync(tmp, {force: true}); + } + }); + }); }); + +async function expectRejection(promise: Promise, matcher: RegExp): Promise { + try { + await promise; + } catch (err) { + expect((err as Error).message).to.match(matcher); + return; + } + throw new Error('Expected promise to reject'); +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index a34224709..19b863752 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; -import {generateContentXML, importLibraryXML} from './content-fs-provider.js'; +import {importLibraryXML} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; import {openJobLog} from '../job-log-viewer.js'; import {registerSafeCommand} from '../safety.js'; @@ -270,10 +270,12 @@ export function registerContentCommands( }); // Convert a component (assigned inline to a page/region) into a shared content - // block. On the platform this rewrites the element's from component.* to - // fragment.* and adds a on the SAME content object — no new id, - // no link rewiring (the parent link-type follows the parent's type). We mirror - // that exact mutation and re-import the single element. + // block. Reproduces Page Designer's in-place conversion: the SDK builds a + // delete+recreate archive that re-types the element to fragment.*, recreates + // its descendants, and re-imports every referrer so existing content-links + // survive (a plain merge import would silently ignore the change, and a + // bare delete would orphan the element from its pages). Verified byte-for-byte + // against a manual conversion. const convertToBlock = registerSafeCommand('b2c-dx.content.convertToBlock', async (node: ContentTreeItem) => { if (!node || node.nodeType !== 'component' || !node.libraryNode) { return; @@ -289,9 +291,7 @@ export function registerContentCommands( return; } - const xml = node.libraryNode.xml; - const currentType = (xml?.['type'] as string[] | undefined)?.[0]; - if (!xml || !currentType || !currentType.startsWith('component.')) { + if (node.libraryNode.type !== 'COMPONENT') { vscode.window.showErrorMessage('Only a component can be converted to a content block.'); return; } @@ -304,13 +304,9 @@ export function registerContentCommands( }); if (displayName === undefined) return; // cancelled - // Mutate the in-memory xml: component.* -> fragment.* and inject display-name. - const fragmentType = `fragment.${currentType.slice('component.'.length)}`; - (xml['type'] as string[])[0] = fragmentType; - xml['display-name'] = [{$: {'xml:lang': 'x-default'}, _: displayName.trim()}]; - try { - const contentXML = generateContentXML(library, node.contentId); + // The SDK builds the full delete+recreate+relink archive for this conversion. + const contentXML = await library.buildContentBlockConversionXML(node.contentId, displayName.trim()); await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Converting ${node.contentId} to a content block...`}, async () => { @@ -321,8 +317,7 @@ export function registerContentCommands( await showJobError(err, instance, 'Convert to content block failed'); return; } finally { - // Discard the transient in-place mutation; the tree re-fetches fresh state - // (whether the import succeeded or failed). + // Re-fetch fresh state from the instance on the next expand. configProvider.invalidateLibrary(node.libraryId); } From d20c0adb6945d9d9cfb22945c612ae16cda30034 Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 12 Aug 2026 16:54:54 -0400 Subject: [PATCH 3/4] @W-22849705 refactor(content): simplify Convert to Content Block to a plain merge import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-tested the platform on the same instances (zzpq-019 fragment / zzpq-013 component, neither the default): a plain site-archive MERGE import of a re-typed element now applies the component.* -> fragment.* change IN PLACE and preserves incoming content-links. This is the behavior that was broken when the feature was built; it has since been fixed on the platform. Verified live: merging just the convertToFragment()-transformed element into zzpq-013 flips the type (byte-identical to the manual conversion on zzpq-019) with the parent carousel's content-link intact. (Also observed the merge is one-way: it promotes component->fragment but won't demote fragment->component.) The delete+recreate+recreate-descendants+re-import-referrers machinery built to work around the old immutability is therefore no longer needed — and was heavier and riskier (deep-deletes subtrees). Removed it: - SDK: drop Library.buildContentBlockConversionXML() and the childLinkIds helper; keep LibraryNode.convertToFragment() (type rewrite + display-name in schema order + own region-link type flip). - Extension: convertToBlock now calls convertToFragment() + generateContentXML() + a normal importLibraryXML() merge (the same single-element import path used elsewhere). - Tests: drop the conversion-archive suite; convertToFragment tests (incl. xmllint schema validation) remain. --- .../src/operations/content/library.ts | 110 +------------- .../test/operations/content/library.test.ts | 143 ------------------ .../src/content-tree/content-commands.ts | 18 +-- 3 files changed, 17 insertions(+), 254 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 3f7027725..1f6395764 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/library.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/library.ts @@ -72,15 +72,15 @@ export class LibraryNode { /** * Convert this component node into a content block (fragment) in place. * - * Mirrors the element-level shape of Page Designer's "Convert to Content - * Block": the underlying `` keeps its content-id, its `` is - * rewritten from `component.*` to the matching `fragment.*`, a `` - * is set (in schema-correct first position), and the element's own region - * `content-link` types are flipped to `fragment.*` to match. + * Mirrors Page Designer's "Convert to Content Block": the underlying + * `` keeps its content-id, its `` is rewritten from + * `component.*` to the matching `fragment.*`, a `` is set (in + * schema-correct first position), and the element's own region `content-link` + * types are flipped to `fragment.*` to match. * - * NOTE: this only mutates the in-memory node. It is NOT sufficient to persist a - * conversion via site-archive import — a merge import ignores `` changes - * on an existing element. To persist, use {@link Library.buildContentBlockConversionXML}. + * Serialize the mutated node (e.g. via a single-element library document) and + * import it with a normal site-archive merge to persist the conversion — the + * platform applies the `` change in place and preserves incoming links. * * @param displayName - The x-default display name for the new content block. * @returns this (for chaining) @@ -176,23 +176,6 @@ function transformContentXmlToFragment(xml: Record, displayName return rebuilt; } -/** - * Return the content-ids this content element links to via its `content-links`. - */ -function childLinkIds(content: Record | undefined): string[] { - if (!content) { - return []; - } - const contentLinks = content['content-links'] as Array> | undefined; - const links = contentLinks?.[0]?.['content-link'] as Array> | undefined; - if (!links) { - return []; - } - return links - .map((link) => (link['$'] as Record | undefined)?.['content-id']) - .filter((id): id is string => typeof id === 'string'); -} - /** * Deep-clone a `content-links` xml2js value, rewriting any `content-link` whose * `type` attribute starts with `${oldType}.` so it starts with `${newType}.`. @@ -454,83 +437,6 @@ export class Library { return blocks; } - /** - * Build a self-contained library XML payload that converts a component into a - * content block (fragment) when imported via site-archive import. - * - * A plain merge import ignores `` changes on an existing element, and a - * `mode="delete"` on a content element **deep-deletes its entire subtree** and - * strips every incoming `content-link`. To faithfully reproduce Page Designer's - * in-place conversion (verified byte-for-byte against a manual conversion), this - * archive therefore: - * - * 1. deletes the target (``) — clearing the old type - * and its subtree; - * 2. recreates the target as `fragment.*` (display-name first, own region-link - * types flipped) — see {@link transformContentXmlToFragment}; - * 3. recreates **every descendant** of the target (they were cascade-deleted); - * 4. re-imports **every referrer** (any content that links the target, possibly - * several — content blocks are shared) so their `content-link` survives. - * - * All four happen in one archive/one import job so the storefront is never left - * with a dangling reference. - * - * @param contentId - The component content-id to convert. - * @param displayName - The x-default display name for the new content block. - * @returns Importable library XML string. - * @throws If the content-id is not found or is not a component. - */ - async buildContentBlockConversionXML(contentId: string, displayName: string): Promise { - const target = this.contentById[contentId]; - if (!target) { - throw new Error(`Content "${contentId}" not found in library`); - } - const targetType = (target['type'] as string[] | undefined)?.[0]; - if (!targetType || !targetType.startsWith('component.')) { - throw new Error(`Content "${contentId}" is not a component and cannot be converted to a content block`); - } - - // Collect the target's descendants (its content-link tree). They are - // cascade-deleted by mode="delete" and must be recreated as-is. - const descendantIds: string[] = []; - const seen = new Set([contentId]); - const queue = [contentId]; - while (queue.length > 0) { - const id = queue.shift() as string; - for (const childId of childLinkIds(this.contentById[id])) { - if (!seen.has(childId) && this.contentById[childId]) { - seen.add(childId); - descendantIds.push(childId); - queue.push(childId); - } - } - } - - // Collect every referrer: any content element that links the target. - const referrerIds: string[] = []; - for (const [id, el] of Object.entries(this.contentById)) { - if (id === contentId) { - continue; - } - if (childLinkIds(el).includes(contentId)) { - referrerIds.push(id); - } - } - - // Build the content array: delete marker, recreated fragment, descendants, referrers. - const transformedTarget = transformContentXmlToFragment(target, displayName); - const content: Array> = [ - {$: {'content-id': contentId, mode: 'delete'}}, - transformedTarget, - ...descendantIds.map((id) => this.contentById[id]), - ...referrerIds.map((id) => this.contentById[id]), - ]; - - const libraryAttrs = (this.xml['library'] as Record)['$']; - const doc = {library: {$: libraryAttrs, content}}; - return new xml2js.Builder().buildObject(doc); - } - /** * Depth-first traversal of the library tree. * diff --git a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts index 133ac501d..dee3d265c 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts @@ -660,147 +660,4 @@ describe('operations/content/library', () => { } }); }); - - describe('Library.buildContentBlockConversionXML()', () => { - let library: Library; - - beforeEach(async () => { - library = await Library.parse(FRAGMENT_LIBRARY_XML); - }); - - type XmlContent = Record & {$: Record}; - async function parseDoc( - xmlString: string, - ): Promise<{ids: string[]; deleted: string[]; byId: Map}> { - const xml2js = await import('xml2js'); - const parsed = (await xml2js.parseStringPromise(xmlString)) as {library: {content?: XmlContent[]}}; - const content = parsed.library.content ?? []; - const ids: string[] = []; - const deleted: string[] = []; - const byId = new Map(); - for (const c of content) { - const id = c.$['content-id']; - ids.push(id); - if (c.$.mode === 'delete') { - deleted.push(id); - } else { - byId.set(id, c); - } - } - return {ids, deleted, byId}; - } - - function typeOf(el: XmlContent): string { - return (el['type'] as string[])[0]; - } - function linkIds(el: XmlContent): string[] { - const cl = el['content-links'] as Array>; - const links = cl[0]['content-link'] as Array<{$: Record}>; - return links.map((l) => l.$['content-id']); - } - - it('should emit a delete marker then a recreated fragment for the target', async () => { - // plain-card: a leaf component, linked only by grid-block. - const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); - const {ids, deleted, byId} = await parseDoc(xml); - - // delete marker comes first, recreate second - expect(deleted).to.deep.equal(['plain-card']); - expect(ids[0]).to.equal('plain-card'); // delete marker - expect(ids[1]).to.equal('plain-card'); // recreate - - const recreated = byId.get('plain-card')!; - expect(typeOf(recreated)).to.equal('fragment.Content.contentCard'); - expect((recreated['display-name'] as Array<{_: string}>)[0]._).to.equal('Promoted Card'); - }); - - it('should re-import every referrer so incoming content-links survive', async () => { - // plain-card is linked from grid-block; grid-block must be re-imported. - const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); - const {byId} = await parseDoc(xml); - - const grid = byId.get('grid-block'); - expect(grid, 'referrer grid-block re-imported').to.exist; - // grid-block still links plain-card - expect(linkIds(grid!)).to.include('plain-card'); - }); - - it('should recreate descendants of the target (cascade-deleted by mode=delete)', async () => { - // Convert grid-block's PARENT chain is n/a; instead convert a component with - // children. Build a small library where a component owns a child. - const withChild = await Library.parse(` - - - page.homePage - - - - component.Layout.grid - - - - component.Content.card - - -`); - - const xml = await withChild.buildContentBlockConversionXML('layout1', 'Grid Block'); - const {deleted, byId} = await parseDoc(xml); - - expect(deleted).to.deep.equal(['layout1']); - // recreated target is a fragment with its region-link type flipped - const layout = byId.get('layout1')!; - expect(typeOf(layout)).to.equal('fragment.Layout.grid'); - const layoutLinks = layout['content-links'] as Array>; - const firstLink = (layoutLinks[0]['content-link'] as Array<{$: Record}>)[0]; - expect(firstLink.$['type']).to.equal('fragment.Layout.grid.column_1'); - // descendant leaf1 is recreated (it would otherwise be cascade-deleted) - const leaf = byId.get('leaf1'); - expect(leaf, 'descendant recreated').to.exist; - expect(typeOf(leaf!)).to.equal('component.Content.card'); - // referrer page1 re-imported - expect(byId.get('page1'), 'referrer recreated').to.exist; - }); - - it('should throw for a non-existent or non-component content id', async () => { - await expectRejection(library.buildContentBlockConversionXML('does-not-exist', 'x'), /not found/); - // grid-block is already a fragment - await expectRejection(library.buildContentBlockConversionXML('grid-block', 'x'), /not a component/); - }); - - it('should produce an archive that validates against library.xsd', async function () { - const {execFileSync} = await import('node:child_process'); - const fs = await import('node:fs'); - const os = await import('node:os'); - const path = await import('node:path'); - const {fileURLToPath} = await import('node:url'); - try { - execFileSync('xmllint', ['--version'], {stdio: 'ignore'}); - } catch { - this.skip(); - return; - } - - const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); - const here = path.dirname(fileURLToPath(import.meta.url)); - const xsd = path.resolve(here, '../../../data/xsd/library.xsd'); - const tmp = path.join(os.tmpdir(), `convert-archive-${process.pid}.xml`); - fs.writeFileSync(tmp, xml); - try { - execFileSync('xmllint', ['--noout', '--schema', xsd, tmp], {stdio: 'pipe'}); - } finally { - fs.rmSync(tmp, {force: true}); - } - }); - }); }); - -async function expectRejection(promise: Promise, matcher: RegExp): Promise { - try { - await promise; - } catch (err) { - expect((err as Error).message).to.match(matcher); - return; - } - throw new Error('Expected promise to reject'); -} diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index 19b863752..98a17d03a 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; -import {importLibraryXML} from './content-fs-provider.js'; +import {generateContentXML, importLibraryXML} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; import {openJobLog} from '../job-log-viewer.js'; import {registerSafeCommand} from '../safety.js'; @@ -270,12 +270,10 @@ export function registerContentCommands( }); // Convert a component (assigned inline to a page/region) into a shared content - // block. Reproduces Page Designer's in-place conversion: the SDK builds a - // delete+recreate archive that re-types the element to fragment.*, recreates - // its descendants, and re-imports every referrer so existing content-links - // survive (a plain merge import would silently ignore the change, and a - // bare delete would orphan the element from its pages). Verified byte-for-byte - // against a manual conversion. + // block. Reproduces Page Designer's in-place conversion: re-type the element + // to fragment.* (display-name first, own region-link types flipped) and import + // just that element with a normal merge. The platform applies the type change + // in place and preserves incoming content-links. const convertToBlock = registerSafeCommand('b2c-dx.content.convertToBlock', async (node: ContentTreeItem) => { if (!node || node.nodeType !== 'component' || !node.libraryNode) { return; @@ -305,8 +303,10 @@ export function registerContentCommands( if (displayName === undefined) return; // cancelled try { - // The SDK builds the full delete+recreate+relink archive for this conversion. - const contentXML = await library.buildContentBlockConversionXML(node.contentId, displayName.trim()); + // Re-type the node in place (component.* -> fragment.*, display-name first) + // and import just that element and its descendants. + node.libraryNode.convertToFragment(displayName.trim()); + const contentXML = generateContentXML(library, node.contentId); await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Converting ${node.contentId} to a content block...`}, async () => { From eee4a3582430c731fadd77072888a1468c2fe25b Mon Sep 17 00:00:00 2001 From: Charles Lavery Date: Wed, 12 Aug 2026 20:52:29 -0400 Subject: [PATCH 4/4] =?UTF-8?q?@W-22849705=20fix(content):=20restore=20del?= =?UTF-8?q?ete+recreate=20convert=20=E2=80=94=20merge=20drops=20region=20l?= =?UTF-8?q?inks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-tested convert on a Layout grid WITH children (zzpq-019 MarketStreet-SharedLibrary). The simple single-element merge — which I switched to in d20c0adb after finding the platform now applies component.*->fragment.* type changes in a merge — turns out to DROP the converted element's own region content-links. A converted grid loses its column_N links and its children are orphaned (they still exist, but the block no longer references them). Reproduced by importing an XSD-valid archive that DID include the flipped fragment.Layout.grid.column_N links; the platform discarded them on the in-place type change. Delete+recreate preserves the region links (verified live: the grid re-exports as fragment.Layout.grid with both column links intact and children attached). So the machinery removed in d20c0adb is required after all — this reverts that simplification and restores Library.buildContentBlockConversionXML() + the extension wiring + tests. Updated the rationale in comments/docstrings: the reason delete+recreate is needed is region-link preservation (merge drops them), not type immutability (merge now handles the type change). This reverts commit d20c0adb6945d9d9cfb22945c612ae16cda30034. --- .../src/operations/content/library.ts | 112 +++++++++++++- .../test/operations/content/library.test.ts | 143 ++++++++++++++++++ .../src/content-tree/content-commands.ts | 21 +-- 3 files changed, 259 insertions(+), 17 deletions(-) diff --git a/packages/b2c-tooling-sdk/src/operations/content/library.ts b/packages/b2c-tooling-sdk/src/operations/content/library.ts index 1f6395764..f6e7fec3e 100644 --- a/packages/b2c-tooling-sdk/src/operations/content/library.ts +++ b/packages/b2c-tooling-sdk/src/operations/content/library.ts @@ -72,15 +72,16 @@ export class LibraryNode { /** * Convert this component node into a content block (fragment) in place. * - * Mirrors Page Designer's "Convert to Content Block": the underlying - * `` keeps its content-id, its `` is rewritten from - * `component.*` to the matching `fragment.*`, a `` is set (in - * schema-correct first position), and the element's own region `content-link` - * types are flipped to `fragment.*` to match. + * Mirrors the element-level shape of Page Designer's "Convert to Content + * Block": the underlying `` keeps its content-id, its `` is + * rewritten from `component.*` to the matching `fragment.*`, a `` + * is set (in schema-correct first position), and the element's own region + * `content-link` types are flipped to `fragment.*` to match. * - * Serialize the mutated node (e.g. via a single-element library document) and - * import it with a normal site-archive merge to persist the conversion — the - * platform applies the `` change in place and preserves incoming links. + * NOTE: this only mutates the in-memory node. Importing just this element with a + * merge applies the type change but DROPS the element's own region + * `content-link`s (orphaning a Layout block's children). To persist a conversion + * that keeps regions/children, use {@link Library.buildContentBlockConversionXML}. * * @param displayName - The x-default display name for the new content block. * @returns this (for chaining) @@ -176,6 +177,23 @@ function transformContentXmlToFragment(xml: Record, displayName return rebuilt; } +/** + * Return the content-ids this content element links to via its `content-links`. + */ +function childLinkIds(content: Record | undefined): string[] { + if (!content) { + return []; + } + const contentLinks = content['content-links'] as Array> | undefined; + const links = contentLinks?.[0]?.['content-link'] as Array> | undefined; + if (!links) { + return []; + } + return links + .map((link) => (link['$'] as Record | undefined)?.['content-id']) + .filter((id): id is string => typeof id === 'string'); +} + /** * Deep-clone a `content-links` xml2js value, rewriting any `content-link` whose * `type` attribute starts with `${oldType}.` so it starts with `${newType}.`. @@ -437,6 +455,84 @@ export class Library { return blocks; } + /** + * Build a self-contained library XML payload that converts a component into a + * content block (fragment) when imported via site-archive import. + * + * A plain merge import applies the `component.* -> fragment.*` type change but + * **drops the element's own region `content-link`s** (orphaning a Layout block's + * children — verified live). Meanwhile a `mode="delete"` on a content element + * **deep-deletes its entire subtree** and strips every incoming `content-link`. + * To faithfully reproduce Page Designer's in-place conversion (verified + * byte-for-byte against a manual conversion), this archive therefore: + * + * 1. deletes the target (``) — clearing the old type + * and its subtree; + * 2. recreates the target as `fragment.*` (display-name first, own region-link + * types flipped) — see {@link transformContentXmlToFragment}; + * 3. recreates **every descendant** of the target (they were cascade-deleted); + * 4. re-imports **every referrer** (any content that links the target, possibly + * several — content blocks are shared) so their `content-link` survives. + * + * All four happen in one archive/one import job so the storefront is never left + * with a dangling reference. + * + * @param contentId - The component content-id to convert. + * @param displayName - The x-default display name for the new content block. + * @returns Importable library XML string. + * @throws If the content-id is not found or is not a component. + */ + async buildContentBlockConversionXML(contentId: string, displayName: string): Promise { + const target = this.contentById[contentId]; + if (!target) { + throw new Error(`Content "${contentId}" not found in library`); + } + const targetType = (target['type'] as string[] | undefined)?.[0]; + if (!targetType || !targetType.startsWith('component.')) { + throw new Error(`Content "${contentId}" is not a component and cannot be converted to a content block`); + } + + // Collect the target's descendants (its content-link tree). They are + // cascade-deleted by mode="delete" and must be recreated as-is. + const descendantIds: string[] = []; + const seen = new Set([contentId]); + const queue = [contentId]; + while (queue.length > 0) { + const id = queue.shift() as string; + for (const childId of childLinkIds(this.contentById[id])) { + if (!seen.has(childId) && this.contentById[childId]) { + seen.add(childId); + descendantIds.push(childId); + queue.push(childId); + } + } + } + + // Collect every referrer: any content element that links the target. + const referrerIds: string[] = []; + for (const [id, el] of Object.entries(this.contentById)) { + if (id === contentId) { + continue; + } + if (childLinkIds(el).includes(contentId)) { + referrerIds.push(id); + } + } + + // Build the content array: delete marker, recreated fragment, descendants, referrers. + const transformedTarget = transformContentXmlToFragment(target, displayName); + const content: Array> = [ + {$: {'content-id': contentId, mode: 'delete'}}, + transformedTarget, + ...descendantIds.map((id) => this.contentById[id]), + ...referrerIds.map((id) => this.contentById[id]), + ]; + + const libraryAttrs = (this.xml['library'] as Record)['$']; + const doc = {library: {$: libraryAttrs, content}}; + return new xml2js.Builder().buildObject(doc); + } + /** * Depth-first traversal of the library tree. * diff --git a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts index dee3d265c..133ac501d 100644 --- a/packages/b2c-tooling-sdk/test/operations/content/library.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/content/library.test.ts @@ -660,4 +660,147 @@ describe('operations/content/library', () => { } }); }); + + describe('Library.buildContentBlockConversionXML()', () => { + let library: Library; + + beforeEach(async () => { + library = await Library.parse(FRAGMENT_LIBRARY_XML); + }); + + type XmlContent = Record & {$: Record}; + async function parseDoc( + xmlString: string, + ): Promise<{ids: string[]; deleted: string[]; byId: Map}> { + const xml2js = await import('xml2js'); + const parsed = (await xml2js.parseStringPromise(xmlString)) as {library: {content?: XmlContent[]}}; + const content = parsed.library.content ?? []; + const ids: string[] = []; + const deleted: string[] = []; + const byId = new Map(); + for (const c of content) { + const id = c.$['content-id']; + ids.push(id); + if (c.$.mode === 'delete') { + deleted.push(id); + } else { + byId.set(id, c); + } + } + return {ids, deleted, byId}; + } + + function typeOf(el: XmlContent): string { + return (el['type'] as string[])[0]; + } + function linkIds(el: XmlContent): string[] { + const cl = el['content-links'] as Array>; + const links = cl[0]['content-link'] as Array<{$: Record}>; + return links.map((l) => l.$['content-id']); + } + + it('should emit a delete marker then a recreated fragment for the target', async () => { + // plain-card: a leaf component, linked only by grid-block. + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const {ids, deleted, byId} = await parseDoc(xml); + + // delete marker comes first, recreate second + expect(deleted).to.deep.equal(['plain-card']); + expect(ids[0]).to.equal('plain-card'); // delete marker + expect(ids[1]).to.equal('plain-card'); // recreate + + const recreated = byId.get('plain-card')!; + expect(typeOf(recreated)).to.equal('fragment.Content.contentCard'); + expect((recreated['display-name'] as Array<{_: string}>)[0]._).to.equal('Promoted Card'); + }); + + it('should re-import every referrer so incoming content-links survive', async () => { + // plain-card is linked from grid-block; grid-block must be re-imported. + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const {byId} = await parseDoc(xml); + + const grid = byId.get('grid-block'); + expect(grid, 'referrer grid-block re-imported').to.exist; + // grid-block still links plain-card + expect(linkIds(grid!)).to.include('plain-card'); + }); + + it('should recreate descendants of the target (cascade-deleted by mode=delete)', async () => { + // Convert grid-block's PARENT chain is n/a; instead convert a component with + // children. Build a small library where a component owns a child. + const withChild = await Library.parse(` + + + page.homePage + + + + component.Layout.grid + + + + component.Content.card + + +`); + + const xml = await withChild.buildContentBlockConversionXML('layout1', 'Grid Block'); + const {deleted, byId} = await parseDoc(xml); + + expect(deleted).to.deep.equal(['layout1']); + // recreated target is a fragment with its region-link type flipped + const layout = byId.get('layout1')!; + expect(typeOf(layout)).to.equal('fragment.Layout.grid'); + const layoutLinks = layout['content-links'] as Array>; + const firstLink = (layoutLinks[0]['content-link'] as Array<{$: Record}>)[0]; + expect(firstLink.$['type']).to.equal('fragment.Layout.grid.column_1'); + // descendant leaf1 is recreated (it would otherwise be cascade-deleted) + const leaf = byId.get('leaf1'); + expect(leaf, 'descendant recreated').to.exist; + expect(typeOf(leaf!)).to.equal('component.Content.card'); + // referrer page1 re-imported + expect(byId.get('page1'), 'referrer recreated').to.exist; + }); + + it('should throw for a non-existent or non-component content id', async () => { + await expectRejection(library.buildContentBlockConversionXML('does-not-exist', 'x'), /not found/); + // grid-block is already a fragment + await expectRejection(library.buildContentBlockConversionXML('grid-block', 'x'), /not a component/); + }); + + it('should produce an archive that validates against library.xsd', async function () { + const {execFileSync} = await import('node:child_process'); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const {fileURLToPath} = await import('node:url'); + try { + execFileSync('xmllint', ['--version'], {stdio: 'ignore'}); + } catch { + this.skip(); + return; + } + + const xml = await library.buildContentBlockConversionXML('plain-card', 'Promoted Card'); + const here = path.dirname(fileURLToPath(import.meta.url)); + const xsd = path.resolve(here, '../../../data/xsd/library.xsd'); + const tmp = path.join(os.tmpdir(), `convert-archive-${process.pid}.xml`); + fs.writeFileSync(tmp, xml); + try { + execFileSync('xmllint', ['--noout', '--schema', xsd, tmp], {stdio: 'pipe'}); + } finally { + fs.rmSync(tmp, {force: true}); + } + }); + }); }); + +async function expectRejection(promise: Promise, matcher: RegExp): Promise { + try { + await promise; + } catch (err) { + expect((err as Error).message).to.match(matcher); + return; + } + throw new Error('Expected promise to reject'); +} diff --git a/packages/b2c-vs-extension/src/content-tree/content-commands.ts b/packages/b2c-vs-extension/src/content-tree/content-commands.ts index 98a17d03a..01aa190d7 100644 --- a/packages/b2c-vs-extension/src/content-tree/content-commands.ts +++ b/packages/b2c-vs-extension/src/content-tree/content-commands.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import type {ContentConfigProvider} from './content-config.js'; import type {ContentFileSystemProvider} from './content-fs-provider.js'; -import {generateContentXML, importLibraryXML} from './content-fs-provider.js'; +import {importLibraryXML} from './content-fs-provider.js'; import type {ContentTreeDataProvider, ContentTreeItem} from './content-tree-provider.js'; import {openJobLog} from '../job-log-viewer.js'; import {registerSafeCommand} from '../safety.js'; @@ -270,10 +270,15 @@ export function registerContentCommands( }); // Convert a component (assigned inline to a page/region) into a shared content - // block. Reproduces Page Designer's in-place conversion: re-type the element - // to fragment.* (display-name first, own region-link types flipped) and import - // just that element with a normal merge. The platform applies the type change - // in place and preserves incoming content-links. + // block. Reproduces Page Designer's in-place conversion: the SDK builds a + // delete+recreate archive that re-types the element to fragment.*, recreates + // its descendants, and re-imports every referrer so existing content-links + // survive. A plain merge import is NOT sufficient: while it now applies the + // component.* -> fragment.* type change, it DROPS the element's own region + // content-links (verified live — a converted Layout grid loses its column + // links and orphans its children). Delete+recreate preserves them; a bare + // delete alone would orphan the element from its pages. Verified byte-for-byte + // against a manual conversion. const convertToBlock = registerSafeCommand('b2c-dx.content.convertToBlock', async (node: ContentTreeItem) => { if (!node || node.nodeType !== 'component' || !node.libraryNode) { return; @@ -303,10 +308,8 @@ export function registerContentCommands( if (displayName === undefined) return; // cancelled try { - // Re-type the node in place (component.* -> fragment.*, display-name first) - // and import just that element and its descendants. - node.libraryNode.convertToFragment(displayName.trim()); - const contentXML = generateContentXML(library, node.contentId); + // The SDK builds the full delete+recreate+relink archive for this conversion. + const contentXML = await library.buildContentBlockConversionXML(node.contentId, displayName.trim()); await vscode.window.withProgress( {location: vscode.ProgressLocation.Notification, title: `Converting ${node.contentId} to a content block...`}, async () => {