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()]; }; const bareAddress = (value) => (value ?? '') .toLowerCase() .replace(/^https?:\/\//, '') .replace(/^www\./, '') .replace(/\/$/, ''); // A link asked for by address carries only its label as text. const matchesAddress = ({ blockId, type }, text) => { if (type !== 'core/navigation-link') return false; const wanted = bareAddress(text); const href = resolveScopedId(blockId) ?.querySelector('a') ?.getAttribute('href'); return Boolean(wanted && href) && bareAddress(href).includes(wanted); }; // A search for "footer" arrives as text and would otherwise match nothing. const matchesText = (entry, text) => { if (!text) return true; const { text: blockText, part } = entry; const needle = text.toLowerCase(); if ((blockText ?? '').toLowerCase().includes(needle)) return true; if (matchesAddress(entry, text)) return true; return Boolean(part) && needle.includes(part.toLowerCase()); }; // core/navigation never resolves to a type, so a menu search matches its links. const matchesType = (type, blockTypes) => !blockTypes?.length || blockTypes.includes(type) || (type === 'core/navigation-link' && blockTypes.includes('core/navigation')); 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; }); }; // Page blocks carry no part, so an invented name like "post-content" empties the search. const knownPart = (entries, part) => entries.some((entry) => matchesPart(entry, part) && entry.part) ? part : null; export default ({ blockTypes, text, part } = {}) => { const root = document.querySelector('.wp-site-blocks') ?? document.body; const manifest = scopeRoots(root).flatMap(manifestFor); const scope = knownPart(manifest, part); const all = manifest.filter( (entry) => addressable(entry.type) && matchesPart(entry, scope), ); 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], }, }; };