# et_builder_layouts Paste Support — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Allow users to paste Divi's `et_builder_layouts` JSON (from demo site "Live Copy" buttons or downloaded layout files) directly into the Visual Builder, supporting sections, rows, columns, modules, and full page layouts.

**Architecture:** Add a block markup parser (`layout-parser.ts`) that tokenizes WordPress block comments into a tree, then converts that tree into `DiviClipboardItem[]`. The existing paste handler in `clipboard-bridge.ts` gains a second detection path: if the clipboard text is valid `et_builder_layouts` JSON, parse it and inject items into Divi's clipboard store. For full pages, each top-level section becomes a separate clipboard item pasted sequentially.

**Tech Stack:** TypeScript, Divi data store (`@divi/data`, `@divi/clipboard`)

---

## File Structure

| Action | File | Responsibility |
|--------|------|----------------|
| Create | `divi-5/visual-builder/src/extensions/cross-site-clipboard/layout-parser.ts` | Parse `et_builder_layouts` JSON into `DiviClipboardItem[]` |
| Modify | `divi-5/visual-builder/src/extensions/cross-site-clipboard/types.ts` | Add `LayoutExportData` type |
| Modify | `divi-5/visual-builder/src/extensions/cross-site-clipboard/clipboard-bridge.ts` | Add layout detection in paste, fix copy to support all element types |

---

### Task 1: Add Layout Export Types

**Files:**
- Modify: `divi-5/visual-builder/src/extensions/cross-site-clipboard/types.ts`

- [ ] **Step 1: Add the LayoutExportData types**

Add these types to the end of `types.ts`:

```typescript
/**
 * A single layout post from the et_builder_layouts export.
 */
export interface LayoutExportPost {
  ID: number;
  post_content: string;
  post_title: string;
  post_meta: {
    _et_pb_template_type?: string[];
    [key: string]: any;
  };
  terms?: Record<string, { slug: string; [key: string]: any }>;
  [key: string]: any;
}

/**
 * The top-level et_builder_layouts JSON structure.
 */
export interface LayoutExportData {
  context: 'et_builder_layouts';
  data: Record<string, LayoutExportPost>;
  images: string[];
  [key: string]: any;
}
```

- [ ] **Step 2: Commit**

```
feat(cross-clipboard): add et_builder_layouts export types
```

---

### Task 2: Create Layout Parser

**Files:**
- Create: `divi-5/visual-builder/src/extensions/cross-site-clipboard/layout-parser.ts`

- [ ] **Step 1: Create the layout parser file**

Create `layout-parser.ts` with the full implementation:

```typescript
/**
 * Layout Parser
 *
 * Parses Divi's et_builder_layouts JSON export (which contains WordPress
 * block markup in post_content) into DiviClipboardItem[] for injection
 * into Divi's clipboard store.
 *
 * Supports: section, row, column, module, and full page (multi-section) layouts.
 */

import type { DiviClipboardItem, LayoutExportData } from './types';

// ─── Internal Types ─────────────────────────────────────────────────

interface BlockToken {
  type: 'open' | 'close' | 'self-closing';
  name: string;
  attrs: Record<string, any>;
}

interface ParsedBlock {
  name: string;
  attrs: Record<string, any>;
  children: ParsedBlock[];
}

interface ModuleObject {
  id: string;
  children: string[];
  name: string;
  parent: string;
  props: { attrs: Record<string, any> };
}

// ─── UUID Generator ─────────────────────────────────────────────────

function generateUuid(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  });
}

// ─── Block Tokenizer ────────────────────────────────────────────────

/**
 * Tokenize WordPress block markup into a flat list of tokens.
 *
 * Handles three forms:
 *   <!-- wp:namespace/name {"json":"here"} -->     (open)
 *   <!-- wp:namespace/name {"json":"here"} /-->    (self-closing)
 *   <!-- /wp:namespace/name -->                     (close)
 */
function tokenize(content: string): BlockToken[] {
  const regex = /<!--\s*(?:(\/)\s*)?wp:(\S+?)(?:\s+([\s\S]*?))?\s*(\/)?-->/g;
  const tokens: BlockToken[] = [];
  let match: RegExpExecArray | null;

  while ((match = regex.exec(content)) !== null) {
    const isClosing = !!match[1];
    const name = match[2];
    const jsonStr = match[3]?.trim();
    const isSelfClosing = !!match[4];

    let attrs: Record<string, any> = {};
    if (jsonStr) {
      try {
        attrs = JSON.parse(jsonStr);
      } catch {
        // Malformed JSON — skip attrs, keep the block
      }
    }

    if (isClosing) {
      tokens.push({ type: 'close', name, attrs: {} });
    } else if (isSelfClosing) {
      tokens.push({ type: 'self-closing', name, attrs });
    } else {
      tokens.push({ type: 'open', name, attrs });
    }
  }

  return tokens;
}

// ─── Tree Builder ───────────────────────────────────────────────────

/**
 * Build a nested block tree from flat tokens.
 */
function buildTree(tokens: BlockToken[]): ParsedBlock[] {
  const root: ParsedBlock[] = [];
  const stack: ParsedBlock[] = [];

  for (const token of tokens) {
    if (token.type === 'self-closing') {
      const block: ParsedBlock = {
        name: token.name,
        attrs: token.attrs,
        children: [],
      };
      if (stack.length > 0) {
        stack[stack.length - 1].children.push(block);
      } else {
        root.push(block);
      }
    } else if (token.type === 'open') {
      const block: ParsedBlock = {
        name: token.name,
        attrs: token.attrs,
        children: [],
      };
      if (stack.length > 0) {
        stack[stack.length - 1].children.push(block);
      } else {
        root.push(block);
      }
      stack.push(block);
    } else if (token.type === 'close') {
      if (stack.length > 0 && stack[stack.length - 1].name === token.name) {
        stack.pop();
      }
    }
  }

  return root;
}

/**
 * Skip wrapper blocks (divi/placeholder) to get actual content blocks.
 */
function unwrapPlaceholders(blocks: ParsedBlock[]): ParsedBlock[] {
  const result: ParsedBlock[] = [];
  for (const block of blocks) {
    if (block.name === 'divi/placeholder') {
      result.push(...unwrapPlaceholders(block.children));
    } else {
      result.push(block);
    }
  }
  return result;
}

// ─── Clipboard Conversion ───────────────────────────────────────────

/**
 * Map block name to clipboard type.
 */
function getClipboardType(name: string): string {
  if (name === 'divi/section') return 'section';
  if (name === 'divi/row') return 'row';
  if (name === 'divi/column') return 'column';
  return 'module';
}

/**
 * Recursively flatten a block tree into a flat moduleObjects map.
 * Returns the root block's generated UUID.
 */
function flattenBlock(
  block: ParsedBlock,
  parentId: string,
  moduleObjects: Record<string, ModuleObject>
): string {
  const id = generateUuid();
  const childIds: string[] = [];

  for (const child of block.children) {
    childIds.push(flattenBlock(child, id, moduleObjects));
  }

  moduleObjects[id] = {
    id,
    children: childIds,
    name: block.name,
    parent: parentId,
    props: { attrs: block.attrs },
  };

  return id;
}

/**
 * Convert a single parsed block (with its full subtree) into a DiviClipboardItem.
 */
function blockToClipboardItem(block: ParsedBlock): DiviClipboardItem {
  const moduleObjects: Record<string, any> = {};
  const rootId = flattenBlock(block, '', moduleObjects);

  return {
    clipboardType: getClipboardType(block.name),
    origin: rootId,
    payload: {
      moduleIds: [rootId],
      moduleType: getClipboardType(block.name),
      moduleObjects,
    },
  };
}

// ─── Public API ─────────────────────────────────────────────────────

/**
 * Check if a string looks like an et_builder_layouts export.
 * Does a fast structural check without full parsing.
 */
export function isLayoutExport(text: string): boolean {
  if (!text || text.length < 30) return false;
  const trimmed = text.trimStart();
  if (!trimmed.startsWith('{')) return false;

  try {
    const data = JSON.parse(trimmed);
    return data?.context === 'et_builder_layouts' && typeof data?.data === 'object';
  } catch {
    return false;
  }
}

/**
 * Parse an et_builder_layouts JSON string into DiviClipboardItem[].
 *
 * For section/row/column/module layouts: returns one item.
 * For full page layouts: returns one item per top-level section.
 */
export function parseLayoutExport(jsonStr: string): DiviClipboardItem[] {
  const data: LayoutExportData = JSON.parse(jsonStr.trim());
  const items: DiviClipboardItem[] = [];

  for (const layout of Object.values(data.data)) {
    const content = layout?.post_content;
    if (!content) continue;

    const tokens = tokenize(content);
    const tree = buildTree(tokens);
    const blocks = unwrapPlaceholders(tree);

    for (const block of blocks) {
      items.push(blockToClipboardItem(block));
    }
  }

  return items;
}

/**
 * Extract all media URLs from layout export data.
 * Checks both the top-level `images` array and URLs within block attributes.
 */
export function extractLayoutMediaUrls(jsonStr: string): string[] {
  const urls = new Set<string>();

  const data: LayoutExportData = JSON.parse(jsonStr.trim());

  // Collect from the top-level images array
  if (Array.isArray(data.images)) {
    for (const url of data.images) {
      if (typeof url === 'string' && url.startsWith('http')) {
        urls.add(url);
      }
    }
  }

  // Collect from block attributes by scanning post_content for upload URLs
  const mediaRegex = /https?:\/\/[^\s"'<>]+?\/wp-content\/uploads\/[^\s"'<>]+/g;

  for (const layout of Object.values(data.data)) {
    const content = layout?.post_content;
    if (!content) continue;

    let match: RegExpExecArray | null;
    while ((match = mediaRegex.exec(content)) !== null) {
      urls.add(match[0]);
    }
  }

  return Array.from(urls);
}
```

- [ ] **Step 2: Verify build compiles**

Run `npm run build:divi5` and check for errors.

- [ ] **Step 3: Commit**

```
feat(cross-clipboard): add layout parser for et_builder_layouts format
```

---

### Task 3: Update Clipboard Bridge — Fix Copy + Add Layout Paste

**Files:**
- Modify: `divi-5/visual-builder/src/extensions/cross-site-clipboard/clipboard-bridge.ts`

- [ ] **Step 1: Add import for layout parser**

At the top of `clipboard-bridge.ts`, after the existing type imports, add:

```typescript
import { isLayoutExport, parseLayoutExport, extractLayoutMediaUrls } from './layout-parser';
```

- [ ] **Step 2: Fix copy handler to support all element types**

In `handleCopyAfterDelay()`, replace:

```typescript
const lastItem = clipboardStore?.getLastItem?.(['module', 'modules']);
```

with:

```typescript
const lastItem = clipboardStore?.getLastItem?.('all');
```

- [ ] **Step 3: Extract shared paste execution into a helper**

Replace the entire `handlePaste` function (lines 207–280) with two functions — `handlePaste` and `executePaste`:

```typescript
/**
 * Before Divi processes Cmd+V, check system clipboard for cross-site data.
 * Supports two formats:
 *   1. DNXTE_CROSSCLIP_V1 — our own serialized clipboard items
 *   2. et_builder_layouts JSON — Divi's layout library export format
 */
async function handlePaste(e: KeyboardEvent): Promise<void> {
  if (isPasting) return;

  const clipboard = getClipboard();
  if (!clipboard) return;

  let text: string;
  try {
    text = await clipboard.readText();
  } catch {
    return;
  }

  // ── Path 1: Our own cross-site format ──
  const pkg = deserialize(text);
  if (pkg && pkg.sourceUrl !== window.location.origin) {
    await executePaste(pkg.items, pkg.mediaUrls, pkg.sourceUrl);
    return;
  }

  // ── Path 2: et_builder_layouts JSON ──
  if (isLayoutExport(text)) {
    let items: DiviClipboardItem[];
    let mediaUrls: string[];

    try {
      items = parseLayoutExport(text);
      mediaUrls = extractLayoutMediaUrls(text);
    } catch (err) {
      console.error('[CrossSiteClipboard] Failed to parse layout export:', err);
      return;
    }

    if (items.length === 0) return;

    await executePaste(items, mediaUrls, 'layout export');
    return;
  }

  // ── Neither format — let Divi handle paste normally ──
}

/**
 * Shared paste execution: confirm → import media → inject → trigger paste.
 */
async function executePaste(
  items: DiviClipboardItem[],
  mediaUrls: string[],
  source: string
): Promise<void> {
  // Build a human-readable summary of what's being pasted
  const typeCount: Record<string, number> = {};
  for (const item of items) {
    typeCount[item.clipboardType] = (typeCount[item.clipboardType] || 0) + 1;
  }
  const summary = Object.entries(typeCount)
    .map(([type, count]) => `${count} ${type}(s)`)
    .join(', ');

  const confirmed = confirm(
    `Paste content from ${source}?\n\n` +
      summary +
      (mediaUrls.length > 0 ? `\n${mediaUrls.length} media file(s) will be imported` : '')
  );

  if (!confirmed) return;

  isPasting = true;

  try {
    let processedItems = items;

    // Import remote media
    if (mediaUrls.length > 0) {
      try {
        const mediaResult = await importMedia(mediaUrls);
        if (Object.keys(mediaResult.mapping).length > 0) {
          processedItems = replaceMediaUrls(items, mediaResult.mapping);
        }
      } catch (err) {
        console.warn('[CrossSiteClipboard] Media import failed, pasting with original URLs:', err);
      }
    }

    const clipboardDispatch = dispatch('divi/clipboard');
    const eventsStore = select('divi/events');
    const hoveredModule = eventsStore?.getHoveredModule?.();

    if (!hoveredModule?.id) {
      alert('Please hover over a section, row, or module where you want to paste.');
      return;
    }

    const diviClipboard = (window as any)?.divi?.clipboard;
    if (typeof diviClipboard?.pasteModuleFromClipboard !== 'function') {
      alert('Paste failed: Divi clipboard API not available.');
      return;
    }

    // Inject each item and trigger paste sequentially
    for (const item of processedItems) {
      if (typeof clipboardDispatch?.addItem === 'function') {
        clipboardDispatch.addItem(item);
      }
      diviClipboard.pasteModuleFromClipboard(hoveredModule.id);
    }
  } catch (err) {
    console.error('[CrossSiteClipboard] Paste failed:', err);
    alert('Paste failed: ' + (err as Error).message);
  } finally {
    isPasting = false;
  }
}
```

- [ ] **Step 4: Build and verify**

Run `npm run build:divi5` and check for errors.

- [ ] **Step 5: Commit**

```
feat(cross-clipboard): support pasting et_builder_layouts JSON format

Adds detection and parsing of Divi layout export JSON in paste handler.
Fixes copy handler to support all element types (section, row, column),
not just modules.
```

---

### Task 4: Manual Integration Test

- [ ] **Step 1: Test section layout paste**

Copy the full `et_builder_layouts` JSON (skill bars demo) to system clipboard. Open Divi 5 VB, hover over page content, press Cmd+V. Confirm dialog should appear showing "1 section(s)". Section should paste in.

- [ ] **Step 2: Test module-only layout paste**

Export a single module layout from the Divi library, copy its JSON, paste into VB.

- [ ] **Step 3: Test row-only layout paste**

Export a row layout, copy its JSON, paste into VB.

- [ ] **Step 4: Test full page layout (multiple sections) paste**

Export a full page layout with 2+ sections, copy its JSON, paste. All sections should appear.

- [ ] **Step 5: Test existing cross-site clipboard still works**

Copy a module from Site A using Cmd+C, paste into Site B using Cmd+V. Verify `DNXTE_CROSSCLIP_V1` format still works.

- [ ] **Step 6: Test cross-site copy of sections/rows/columns**

Copy a section on Site A with Cmd+C, verify system clipboard contains `DNXTE_CROSSCLIP_V1:...` data, paste on Site B.
