| 1 |
import { detectBlockType } from './block-type'; |
| 2 |
import { readComputedStyles } from './computed-styles'; |
| 3 |
|
| 4 |
const BLOCK_ID_ATTR = 'data-extendify-agent-block-id'; |
| 5 |
|
| 6 |
// Signal-less but edit targets ("swap the columns", "round the image") — dropped, they're unaddressable. |
| 7 |
const ALWAYS_KEPT_TYPES = new Set([ |
| 8 |
'core/column', |
| 9 |
'core/columns', |
| 10 |
'core/image', |
| 11 |
'core/cover', |
| 12 |
'core/site-logo', |
| 13 |
]); |
| 14 |
|
| 15 |
const isTransparent = (value) => |
| 16 |
!value || |
| 17 |
value === 'transparent' || |
| 18 |
value.replace(/\s/g, '') === 'rgba(0,0,0,0)'; |
| 19 |
|
| 20 |
// core/button renders text + background in an inner link, so read styles off the |
| 21 |
// link — only for buttons, or a container would report a nested button's styles. |
| 22 |
const styleSource = (el) => |
| 23 |
el.classList?.contains('wp-block-button') |
| 24 |
? (el.querySelector('.wp-block-button__link') ?? el) |
| 25 |
: el; |
| 26 |
|
| 27 |
// Narrow by appearance — a token like `tertiary` says nothing about being yellow. |
| 28 |
const renderedStyles = (el) => { |
| 29 |
const styles = readComputedStyles(styleSource(el)); |
| 30 |
if (styles && isTransparent(styles.backgroundColor)) |
| 31 |
delete styles.backgroundColor; |
| 32 |
return styles && Object.keys(styles).length ? styles : null; |
| 33 |
}; |
| 34 |
|
| 35 |
// style.css lives in a scoped `wp-custom-css-*` rule, invisible in the block code. |
| 36 |
const customCss = (el) => { |
| 37 |
const cls = [...(el.classList ?? [])].find((c) => |
| 38 |
c.startsWith('wp-custom-css-'), |
| 39 |
); |
| 40 |
if (!cls) return null; |
| 41 |
for (const sheet of el.ownerDocument?.styleSheets ?? []) { |
| 42 |
let rules; |
| 43 |
try { |
| 44 |
rules = sheet.cssRules; |
| 45 |
} catch { |
| 46 |
continue; // cross-origin sheet — can't read |
| 47 |
} |
| 48 |
for (const rule of rules) { |
| 49 |
if (rule.selectorText?.includes(cls)) return rule.style?.cssText || null; |
| 50 |
} |
| 51 |
} |
| 52 |
return null; |
| 53 |
}; |
| 54 |
|
| 55 |
// Stops at nested tagged blocks so a container's text never bleeds from its children. |
| 56 |
const ownSubtree = (el) => { |
| 57 |
const clone = el.cloneNode(true); |
| 58 |
for (const nested of clone.querySelectorAll(`[${BLOCK_ID_ATTR}]`)) |
| 59 |
nested.remove(); |
| 60 |
return clone; |
| 61 |
}; |
| 62 |
|
| 63 |
const ownText = (clone) => { |
| 64 |
const text = (clone.textContent || '').replace(/\s+/g, ' ').trim(); |
| 65 |
return text.length > 80 ? `${text.slice(0, 80)}…` : text; |
| 66 |
}; |
| 67 |
|
| 68 |
const colorSlugs = (clone) => { |
| 69 |
const out = {}; |
| 70 |
const nodes = [clone, ...clone.querySelectorAll('*')]; |
| 71 |
for (const node of nodes) { |
| 72 |
for (const cls of node.classList ?? []) { |
| 73 |
const bg = cls.match(/^has-(.+)-background-color$/); |
| 74 |
if (bg) out.backgroundColor = bg[1]; |
| 75 |
else if (cls !== 'has-text-color') { |
| 76 |
const text = cls.match(/^has-(.+)-color$/); |
| 77 |
if (text) out.textColor = text[1]; |
| 78 |
} |
| 79 |
} |
| 80 |
} |
| 81 |
return out; |
| 82 |
}; |
| 83 |
|
| 84 |
// Per-block summary of the selection — enough context for the agent to pick |
| 85 |
// targets ("the red button"); schemas + full code are fetched after the pick. |
| 86 |
export const buildSubtreeManifest = (root) => { |
| 87 |
if (!root) return []; |
| 88 |
const manifest = []; |
| 89 |
// A directly-selected block is its own (and only) target, so the root is included. |
| 90 |
const els = [root, ...root.querySelectorAll(`[${BLOCK_ID_ATTR}]`)]; |
| 91 |
for (const [index, el] of els.entries()) { |
| 92 |
if (!el.getAttribute?.(BLOCK_ID_ATTR)) continue; |
| 93 |
const type = detectBlockType(el); |
| 94 |
if (!type) continue; |
| 95 |
const clone = ownSubtree(el); |
| 96 |
const text = ownText(clone); |
| 97 |
const colors = colorSlugs(clone); |
| 98 |
// Drop signal-less descendants, never the root (baseline for relative edits). |
| 99 |
if ( |
| 100 |
index > 0 && |
| 101 |
!ALWAYS_KEPT_TYPES.has(type) && |
| 102 |
!text && |
| 103 |
!colors.backgroundColor && |
| 104 |
!colors.textColor |
| 105 |
) |
| 106 |
continue; |
| 107 |
const styles = renderedStyles(el); |
| 108 |
const css = customCss(el); |
| 109 |
manifest.push({ |
| 110 |
blockId: el.getAttribute(BLOCK_ID_ATTR), |
| 111 |
type, |
| 112 |
...(text && { text }), |
| 113 |
...colors, |
| 114 |
...(styles && { styles }), |
| 115 |
...(css && { css }), |
| 116 |
}); |
| 117 |
} |
| 118 |
return manifest; |
| 119 |
}; |
| 120 |
|
| 121 |
// Tagged blocks directly beneath el, past any untagged wrappers. |
| 122 |
const childBlockEls = (el) => { |
| 123 |
const out = []; |
| 124 |
for (const child of el.children ?? []) { |
| 125 |
if (child.getAttribute?.(BLOCK_ID_ATTR)) out.push(child); |
| 126 |
else out.push(...childBlockEls(child)); |
| 127 |
} |
| 128 |
return out; |
| 129 |
}; |
| 130 |
|
| 131 |
// Keeps every tagged block; the flat manifest drops signal-less ones. |
| 132 |
const treeNodesFor = (el) => |
| 133 |
childBlockEls(el).flatMap((child) => { |
| 134 |
const type = detectBlockType(child); |
| 135 |
const children = treeNodesFor(child); |
| 136 |
if (!type || !child.getAttribute?.(BLOCK_ID_ATTR)) return children; |
| 137 |
return [ |
| 138 |
{ |
| 139 |
blockId: child.getAttribute(BLOCK_ID_ATTR), |
| 140 |
type, |
| 141 |
...(children.length && { children }), |
| 142 |
}, |
| 143 |
]; |
| 144 |
}); |
| 145 |
|
| 146 |
// Nested block structure for the selection. |
| 147 |
export const buildSubtreeTree = (root) => |
| 148 |
root ? treeNodesFor({ children: [root] }) : []; |
| 149 |
|