# extendify/trunk/src/Agent/workflows/block-selector/tools/find-blocks.js

Extendify, version trunk. 141 lines.

- Page: https://pluginprobe.com/plugins/extendify/trunk/code/src/Agent/workflows/block-selector/tools/find-blocks.js
- Raw: https://pluginprobe.com/plugins/extendify/trunk/raw/src/Agent/workflows/block-selector/tools/find-blocks.js
- Modified: 2026-09-09T18:23:40+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/extendify/trunk/code/src/Agent/workflows/block-selector/tools/find-blocks.js#L10-L20`.

```javascript
import {
	blockIdOf,
	parseScopedId,
	resolveScopedId,
	scopedBlockId,
} from '@agent/lib/block-el';
import { IGNORED_BLOCKS } from '@agent/lib/classify-block-edit';
import { buildSubtreeManifest } from '@agent/lib/subtree-manifest';
import { DYNAMIC_BLOCK_TYPES } from '@quick-edit/lib/agent-gate';

// Reachable once selected — the logo's image bridges to the site setting — but
// never worth offering unprompted, since it has a workflow of its own.
const OWN_WORKFLOW_BLOCKS = new Set(['core/site-logo']);

// Page candidates come first, so one shared budget leaves the header none.
const MAX_PAGE_CANDIDATES = 15;
const MAX_PART_CANDIDATES = 5;

const PART_SLUG_ATTR = 'data-extendify-part-slug';
const PART_LABEL_ATTR = 'data-extendify-part';

// One root sees only its own part, so the page root misses header and footer.
const scopeRoots = (pageRoot) => {
	const roots = [pageRoot];
	for (const el of document.querySelectorAll(`[${PART_SLUG_ATTR}]`)) {
		const slug = el.getAttribute(PART_SLUG_ATTR);
		if (!slug) continue;
		// A part's top-level blocks are siblings; unrooted, all but the first go unwalked.
		const enclosing = el.parentElement?.closest(
			`[${PART_SLUG_ATTR}="${CSS.escape(slug)}"]`,
		);
		if (!enclosing) roots.push(el);
	}
	return roots;
};

const manifestFor = (root) => {
	const slug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null;
	const part = root?.getAttribute?.(PART_LABEL_ATTR) ?? null;
	return buildSubtreeManifest(root).map((entry) => ({
		...entry,
		blockId: scopedBlockId(entry.blockId, slug),
		...(part && { part }),
	}));
};

// An item can't add a sibling, nor stand in for the whole set.
export const CONTAINER_TYPES = new Set([
	'core/columns',
	'core/list',
	'core/group',
]);

const addressable = (type) =>
	!DYNAMIC_BLOCK_TYPES.has(type) &&
	!IGNORED_BLOCKS.has(type) &&
	!OWN_WORKFLOW_BLOCKS.has(type);

const containersFor = (matches, byId) => {
	const found = new Map();
	for (const { blockId } of matches) {
		let node = resolveScopedId(blockId)?.parentElement;
		while (node) {
			const slug = node.getAttribute?.(PART_SLUG_ATTR) ?? null;
			const raw = blockIdOf(node);
			const id = raw ? scopedBlockId(raw, slug) : null;
			const entry = id ? byId.get(id) : null;
			if (entry && CONTAINER_TYPES.has(entry.type)) {
				found.set(id, entry);
				break;
			}
			node = node.parentElement;
		}
	}
	return [...found.values()];
};

// A search for "footer" arrives as text and would otherwise match nothing.
const matchesText = ({ text: blockText, part }, text) => {
	if (!text) return true;
	const needle = text.toLowerCase();
	if ((blockText ?? '').toLowerCase().includes(needle)) return true;
	return Boolean(part) && needle.includes(part.toLowerCase());
};

const matchesType = (type, blockTypes) =>
	!blockTypes?.length || blockTypes.includes(type);

const matchesPart = (entry, part) =>
	!part || (entry.part ?? '').toLowerCase() === part.toLowerCase();

const capPerScope = (found) => {
	const taken = new Map();
	return found.filter(({ blockId }) => {
		const { partSlug } = parseScopedId(blockId);
		const cap = partSlug ? MAX_PART_CANDIDATES : MAX_PAGE_CANDIDATES;
		const used = taken.get(partSlug) ?? 0;
		if (used >= cap) return false;
		taken.set(partSlug, used + 1);
		return true;
	});
};

export default ({ blockTypes, text, part } = {}) => {
	const root = document.querySelector('.wp-site-blocks') ?? document.body;
	const all = scopeRoots(root)
		.flatMap(manifestFor)
		.filter((entry) => addressable(entry.type) && matchesPart(entry, part));
	const byId = new Map(all.map((entry) => [entry.blockId, entry]));
	const byType = all.filter(({ type }) => matchesType(type, blockTypes));
	const matches = byType.filter((entry) => matchesText(entry, text));
	// "hero" is a position, not text: AND-ing it would match nothing.
	const textIgnored =
		Boolean(text) &&
		Boolean(blockTypes?.length) &&
		!matches.length &&
		byType.length > 0;
	// core/navigation is unaddressable, so a menu search can match no type at all.
	const byText = all.filter((entry) => matchesText(entry, text));
	const typeIgnored =
		Boolean(text) &&
		Boolean(blockTypes?.length) &&
		!matches.length &&
		!byType.length &&
		byText.length > 0;
	const found = textIgnored ? byType : typeIgnored ? byText : matches;
	// Cap first, or a long match list truncates the containers away.
	const shown = capPerScope(found);
	const containers = containersFor(shown, byId).filter(
		(entry) => !shown.some(({ blockId }) => blockId === entry.blockId),
	);
	return {
		blockSearch: {
			total: found.length,
			...(textIgnored && { ignoredText: text }),
			...(typeIgnored && { ignoredBlockTypes: blockTypes }),
			candidates: [...shown, ...containers],
		},
	};
};

```
