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 c99b0d5b9..2b88bea5d 100644 --- a/docs/vscode-extension/index.md +++ b/docs/vscode-extension/index.md @@ -36,6 +36,8 @@ Spin up, start, stop, clone, and clean up your on-demand sandboxes from a tree v Find Page Designer pages and components fast, with one-click export (with assets, without assets, or assets only), live editing of component XML, and round-trip imports of site archives. The library tree is filterable when you have hundreds of pages. +**Content blocks** (reusable, shared `fragment.*` content) get a dedicated **Content Blocks** group under each library — the single source of truth where a block and its full child tree live. Wherever a page or component links a block, it appears as a reference (↗) that reveals the canonical block in the group when clicked, so a shared block is only ever edited in one place. Right-click a component assigned to a page to **Convert to Content Block** and turn it into a reusable, shared block. + [![Library Explorer](./images/library-explorer.png)](./images/library-explorer.png) ### Cartridge Management and Code Watch/Upload 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..f6e7fec3e 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,11 +62,157 @@ export class LibraryNode { id: this.id, type: this.type, typeId: this.typeId, + displayName: this.displayName, data: this.data, children: this.children, 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. 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) + * @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; + } +} + +/** + * 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; +} + +/** + * 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; } /** @@ -80,11 +230,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 +326,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 +367,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 +413,126 @@ 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; + } + + /** + * 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. * @@ -427,6 +702,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..133ac501d 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,352 @@ 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'); + }); + }); + + 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/package.json b/packages/b2c-vs-extension/package.json index 8ddef7d47..5eaae8fb0 100644 --- a/packages/b2c-vs-extension/package.json +++ b/packages/b2c-vs-extension/package.json @@ -1056,6 +1056,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...", @@ -1664,19 +1676,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", @@ -2196,6 +2213,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..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,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 {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,84 @@ 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. 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; + } + 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; + } + + if (node.libraryNode.type !== '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 + + try { + // 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 () => { + await importLibraryXML(instance, node.libraryId, node.isSiteLibrary, contentXML); + }, + ); + } catch (err) { + await showJobError(err, instance, 'Convert to content block failed'); + return; + } finally { + // Re-fetch fresh state from the instance on the next expand. + 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 +351,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 bd16cea3f..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 @@ -5,6 +5,7 @@ */ 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'; @@ -41,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) { @@ -156,17 +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'}); - - 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 9b1262344..b1c1175e1 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 @@ -12,7 +12,28 @@ import {contentItemUri} from './content-fs-provider.js'; import {webdavPathToUri} from '../webdav-tree/webdav-fs-provider.js'; import {showThrottledError} from '../notify.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 @@ -37,32 +58,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}`; @@ -70,14 +82,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 @@ -97,6 +111,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 @@ -108,7 +131,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', @@ -117,6 +155,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 { @@ -144,6 +222,18 @@ export class ContentTreeDataProvider implements vscode.TreeDataProvider { if (!element) { return this.getRootChildren(); @@ -153,7 +243,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), @@ -163,6 +271,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) { @@ -226,11 +347,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: