PluginProbe
Extendify / trunk
Extendify vtrunk
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
← All changes | src/Agent/workflows/block-selector/tools/find-blocks.js +85 -28 3.1.5 → trunk View file →
@@ -1,22 +1,50 @@
1 +import {
2 + blockIdOf,
3 + parseScopedId,
4 + resolveScopedId,
5 + scopedBlockId,
6 +} from '@agent/lib/block-el';
1 7 import { IGNORED_BLOCKS } from '@agent/lib/classify-block-edit';
2 8 import { buildSubtreeManifest } from '@agent/lib/subtree-manifest';
9 +import { DYNAMIC_BLOCK_TYPES } from '@quick-edit/lib/agent-gate';
3 10
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 -]);
11 +// Reachable once selected — the logo's image bridges to the site setting — but
12 +// never worth offering unprompted, since it has a workflow of its own.
13 +const OWN_WORKFLOW_BLOCKS = new Set(['core/site-logo']);
14 14
15 -const MAX_CANDIDATES = 25;
15 +// Page candidates come first, so one shared budget leaves the header none.
16 +const MAX_PAGE_CANDIDATES = 15;
17 +const MAX_PART_CANDIDATES = 5;
16 18
17 -const BLOCK_ID_ATTR = 'data-extendify-agent-block-id';
19 +const PART_SLUG_ATTR = 'data-extendify-part-slug';
20 +const PART_LABEL_ATTR = 'data-extendify-part';
18 21
22 +// One root sees only its own part, so the page root misses header and footer.
23 +const scopeRoots = (pageRoot) => {
24 + const roots = [pageRoot];
25 + for (const el of document.querySelectorAll(`[${PART_SLUG_ATTR}]`)) {
26 + const slug = el.getAttribute(PART_SLUG_ATTR);
27 + if (!slug) continue;
28 + // A part's top-level blocks are siblings; unrooted, all but the first go unwalked.
29 + const enclosing = el.parentElement?.closest(
30 + `[${PART_SLUG_ATTR}="${CSS.escape(slug)}"]`,
31 + );
32 + if (!enclosing) roots.push(el);
33 + }
34 + return roots;
35 +};
36 +
37 +const manifestFor = (root) => {
38 + const slug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null;
39 + const part = root?.getAttribute?.(PART_LABEL_ATTR) ?? null;
40 + return buildSubtreeManifest(root).map((entry) => ({
41 + ...entry,
42 + blockId: scopedBlockId(entry.blockId, slug),
43 + ...(part && { part }),
44 + }));
45 +};
46 +
19 47 // An item can't add a sibling, nor stand in for the whole set.
20 48 export const CONTAINER_TYPES = new Set([
21 49 'core/columns',
22 50 'core/list',
@@ -23,18 +51,20 @@
23 51 'core/group',
24 52 ]);
25 53
26 54 const addressable = (type) =>
27 - !LOOP_BLOCKS.has(type) && !IGNORED_BLOCKS.has(type);
55 + !DYNAMIC_BLOCK_TYPES.has(type) &&
56 + !IGNORED_BLOCKS.has(type) &&
57 + !OWN_WORKFLOW_BLOCKS.has(type);
28 58
29 59 const containersFor = (matches, byId) => {
30 60 const found = new Map();
31 61 for (const { blockId } of matches) {
32 - let node = document.querySelector(
33 - `[${BLOCK_ID_ATTR}="${CSS.escape(String(blockId))}"]`,
34 - )?.parentElement;
62 + let node = resolveScopedId(blockId)?.parentElement;
35 63 while (node) {
36 - const id = node.getAttribute?.(BLOCK_ID_ATTR);
64 + const slug = node.getAttribute?.(PART_SLUG_ATTR) ?? null;
65 + const raw = blockIdOf(node);
66 + const id = raw ? scopedBlockId(raw, slug) : null;
37 67 const entry = id ? byId.get(id) : null;
38 68 if (entry && CONTAINER_TYPES.has(entry.type)) {
39 69 found.set(id, entry);
40 70 break;
@@ -44,24 +74,42 @@
44 74 }
45 75 return [...found.values()];
46 76 };
47 77
48 -const matchesText = (blockText, text) =>
49 - !text || (blockText ?? '').toLowerCase().includes(text.toLowerCase());
78 +// A search for "footer" arrives as text and would otherwise match nothing.
79 +const matchesText = ({ text: blockText, part }, text) => {
80 + if (!text) return true;
81 + const needle = text.toLowerCase();
82 + if ((blockText ?? '').toLowerCase().includes(needle)) return true;
83 + return Boolean(part) && needle.includes(part.toLowerCase());
84 +};
50 85
51 86 const matchesType = (type, blockTypes) =>
52 87 !blockTypes?.length || blockTypes.includes(type);
53 88
54 -export default ({ blockTypes, text } = {}) => {
89 +const matchesPart = (entry, part) =>
90 + !part || (entry.part ?? '').toLowerCase() === part.toLowerCase();
91 +
92 +const capPerScope = (found) => {
93 + const taken = new Map();
94 + return found.filter(({ blockId }) => {
95 + const { partSlug } = parseScopedId(blockId);
96 + const cap = partSlug ? MAX_PART_CANDIDATES : MAX_PAGE_CANDIDATES;
97 + const used = taken.get(partSlug) ?? 0;
98 + if (used >= cap) return false;
99 + taken.set(partSlug, used + 1);
100 + return true;
101 + });
102 +};
103 +
104 +export default ({ blockTypes, text, part } = {}) => {
55 105 const root = document.querySelector('.wp-site-blocks') ?? document.body;
56 - const all = buildSubtreeManifest(root).filter(({ type }) =>
57 - addressable(type),
58 - );
106 + const all = scopeRoots(root)
107 + .flatMap(manifestFor)
108 + .filter((entry) => addressable(entry.type) && matchesPart(entry, part));
59 109 const byId = new Map(all.map((entry) => [entry.blockId, entry]));
60 110 const byType = all.filter(({ type }) => matchesType(type, blockTypes));
61 - const matches = byType.filter(({ text: blockText }) =>
62 - matchesText(blockText, text),
63 - );
111 + const matches = byType.filter((entry) => matchesText(entry, text));
64 112 // "hero" is a position, not text: AND-ing it would match nothing.
65 113 const textIgnored =
66 114 Boolean(text) &&
67 115 Boolean(blockTypes?.length) &&
@@ -66,11 +114,19 @@
66 114 Boolean(text) &&
67 115 Boolean(blockTypes?.length) &&
68 116 !matches.length &&
69 117 byType.length > 0;
70 - const found = textIgnored ? byType : matches;
118 + // core/navigation is unaddressable, so a menu search can match no type at all.
119 + const byText = all.filter((entry) => matchesText(entry, text));
120 + const typeIgnored =
121 + Boolean(text) &&
122 + Boolean(blockTypes?.length) &&
123 + !matches.length &&
124 + !byType.length &&
125 + byText.length > 0;
126 + const found = textIgnored ? byType : typeIgnored ? byText : matches;
71 127 // Cap first, or a long match list truncates the containers away.
72 - const shown = found.slice(0, MAX_CANDIDATES);
128 + const shown = capPerScope(found);
73 129 const containers = containersFor(shown, byId).filter(
74 130 (entry) => !shown.some(({ blockId }) => blockId === entry.blockId),
75 131 );
76 132 return {
@@ -76,8 +132,9 @@
76 132 return {
77 133 blockSearch: {
78 134 total: found.length,
79 135 ...(textIgnored && { ignoredText: text }),
136 + ...(typeIgnored && { ignoredBlockTypes: blockTypes }),
80 137 candidates: [...shown, ...containers],
81 138 },
82 139 };
83 140 };