| 1 |
import { IGNORED_BLOCKS } from '@agent/lib/classify-block-edit'; |
| 2 |
import { buildSubtreeManifest } from '@agent/lib/subtree-manifest'; |
| 3 |
|
| 4 |
// Mirrors TagBlocks::$ignored — a patch here would rewrite every loop item. |
| 5 |
const LOOP_BLOCKS = new Set([ |
| 6 |
'core/query', |
| 7 |
'core/post-template', |
| 8 |
'core/post-content', |
| 9 |
'core/comments', |
| 10 |
'core/comment-template', |
| 11 |
'woocommerce/product-collection', |
| 12 |
'woocommerce/product-template', |
| 13 |
]); |
| 14 |
|
| 15 |
const MAX_CANDIDATES = 25; |
| 16 |
|
| 17 |
const BLOCK_ID_ATTR = 'data-extendify-agent-block-id'; |
| 18 |
|
| 19 |
// An item can't add a sibling, nor stand in for the whole set. |
| 20 |
export const CONTAINER_TYPES = new Set([ |
| 21 |
'core/columns', |
| 22 |
'core/list', |
| 23 |
'core/group', |
| 24 |
]); |
| 25 |
|
| 26 |
const addressable = (type) => |
| 27 |
!LOOP_BLOCKS.has(type) && !IGNORED_BLOCKS.has(type); |
| 28 |
|
| 29 |
const containersFor = (matches, byId) => { |
| 30 |
const found = new Map(); |
| 31 |
for (const { blockId } of matches) { |
| 32 |
let node = document.querySelector( |
| 33 |
`[${BLOCK_ID_ATTR}="${CSS.escape(String(blockId))}"]`, |
| 34 |
)?.parentElement; |
| 35 |
while (node) { |
| 36 |
const id = node.getAttribute?.(BLOCK_ID_ATTR); |
| 37 |
const entry = id ? byId.get(id) : null; |
| 38 |
if (entry && CONTAINER_TYPES.has(entry.type)) { |
| 39 |
found.set(id, entry); |
| 40 |
break; |
| 41 |
} |
| 42 |
node = node.parentElement; |
| 43 |
} |
| 44 |
} |
| 45 |
return [...found.values()]; |
| 46 |
}; |
| 47 |
|
| 48 |
const matchesText = (blockText, text) => |
| 49 |
!text || (blockText ?? '').toLowerCase().includes(text.toLowerCase()); |
| 50 |
|
| 51 |
const matchesType = (type, blockTypes) => |
| 52 |
!blockTypes?.length || blockTypes.includes(type); |
| 53 |
|
| 54 |
export default ({ blockTypes, text } = {}) => { |
| 55 |
const root = document.querySelector('.wp-site-blocks') ?? document.body; |
| 56 |
const all = buildSubtreeManifest(root).filter(({ type }) => |
| 57 |
addressable(type), |
| 58 |
); |
| 59 |
const byId = new Map(all.map((entry) => [entry.blockId, entry])); |
| 60 |
const byType = all.filter(({ type }) => matchesType(type, blockTypes)); |
| 61 |
const matches = byType.filter(({ text: blockText }) => |
| 62 |
matchesText(blockText, text), |
| 63 |
); |
| 64 |
// "hero" is a position, not text: AND-ing it would match nothing. |
| 65 |
const textIgnored = |
| 66 |
Boolean(text) && |
| 67 |
Boolean(blockTypes?.length) && |
| 68 |
!matches.length && |
| 69 |
byType.length > 0; |
| 70 |
const found = textIgnored ? byType : matches; |
| 71 |
// Cap first, or a long match list truncates the containers away. |
| 72 |
const shown = found.slice(0, MAX_CANDIDATES); |
| 73 |
const containers = containersFor(shown, byId).filter( |
| 74 |
(entry) => !shown.some(({ blockId }) => blockId === entry.blockId), |
| 75 |
); |
| 76 |
return { |
| 77 |
blockSearch: { |
| 78 |
total: found.length, |
| 79 |
...(textIgnored && { ignoredText: text }), |
| 80 |
candidates: [...shown, ...containers], |
| 81 |
}, |
| 82 |
}; |
| 83 |
}; |
| 84 |
|