PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
← All changes | src/Agent/workflows/block-selector/tools/find-blocks.js +113 -28 3.1.5 → 3.2.2 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,70 @@
44 74 }
45 75 return [...found.values()];
46 76 };
47 77
48 -const matchesText = (blockText, text) =>
49 - !text || (blockText ?? '').toLowerCase().includes(text.toLowerCase());
78 +const bareAddress = (value) =>
79 + (value ?? '')
80 + .toLowerCase()
81 + .replace(/^https?:\/\//, '')
82 + .replace(/^www\./, '')
83 + .replace(/\/$/, '');
50 84
85 +// A link asked for by address carries only its label as text.
86 +const matchesAddress = ({ blockId, type }, text) => {
87 + if (type !== 'core/navigation-link') return false;
88 + const wanted = bareAddress(text);
89 + const href = resolveScopedId(blockId)
90 + ?.querySelector('a')
91 + ?.getAttribute('href');
92 + return Boolean(wanted && href) && bareAddress(href).includes(wanted);
93 +};
94 +
95 +// A search for "footer" arrives as text and would otherwise match nothing.
96 +const matchesText = (entry, text) => {
97 + if (!text) return true;
98 + const { text: blockText, part } = entry;
99 + const needle = text.toLowerCase();
100 + if ((blockText ?? '').toLowerCase().includes(needle)) return true;
101 + if (matchesAddress(entry, text)) return true;
102 + return Boolean(part) && needle.includes(part.toLowerCase());
103 +};
104 +
105 +// core/navigation never resolves to a type, so a menu search matches its links.
51 106 const matchesType = (type, blockTypes) =>
52 - !blockTypes?.length || blockTypes.includes(type);
107 + !blockTypes?.length ||
108 + blockTypes.includes(type) ||
109 + (type === 'core/navigation-link' && blockTypes.includes('core/navigation'));
53 110
54 -export default ({ blockTypes, text } = {}) => {
111 +const matchesPart = (entry, part) =>
112 + !part || (entry.part ?? '').toLowerCase() === part.toLowerCase();
113 +
114 +const capPerScope = (found) => {
115 + const taken = new Map();
116 + return found.filter(({ blockId }) => {
117 + const { partSlug } = parseScopedId(blockId);
118 + const cap = partSlug ? MAX_PART_CANDIDATES : MAX_PAGE_CANDIDATES;
119 + const used = taken.get(partSlug) ?? 0;
120 + if (used >= cap) return false;
121 + taken.set(partSlug, used + 1);
122 + return true;
123 + });
124 +};
125 +
126 +// Page blocks carry no part, so an invented name like "post-content" empties the search.
127 +const knownPart = (entries, part) =>
128 + entries.some((entry) => matchesPart(entry, part) && entry.part) ? part : null;
129 +
130 +export default ({ blockTypes, text, part } = {}) => {
55 131 const root = document.querySelector('.wp-site-blocks') ?? document.body;
56 - const all = buildSubtreeManifest(root).filter(({ type }) =>
57 - addressable(type),
132 + const manifest = scopeRoots(root).flatMap(manifestFor);
133 + const scope = knownPart(manifest, part);
134 + const all = manifest.filter(
135 + (entry) => addressable(entry.type) && matchesPart(entry, scope),
58 136 );
59 137 const byId = new Map(all.map((entry) => [entry.blockId, entry]));
60 138 const byType = all.filter(({ type }) => matchesType(type, blockTypes));
61 - const matches = byType.filter(({ text: blockText }) =>
62 - matchesText(blockText, text),
63 - );
139 + const matches = byType.filter((entry) => matchesText(entry, text));
64 140 // "hero" is a position, not text: AND-ing it would match nothing.
65 141 const textIgnored =
66 142 Boolean(text) &&
67 143 Boolean(blockTypes?.length) &&
@@ -66,11 +142,19 @@
66 142 Boolean(text) &&
67 143 Boolean(blockTypes?.length) &&
68 144 !matches.length &&
69 145 byType.length > 0;
70 - const found = textIgnored ? byType : matches;
146 + // core/navigation is unaddressable, so a menu search can match no type at all.
147 + const byText = all.filter((entry) => matchesText(entry, text));
148 + const typeIgnored =
149 + Boolean(text) &&
150 + Boolean(blockTypes?.length) &&
151 + !matches.length &&
152 + !byType.length &&
153 + byText.length > 0;
154 + const found = textIgnored ? byType : typeIgnored ? byText : matches;
71 155 // Cap first, or a long match list truncates the containers away.
72 - const shown = found.slice(0, MAX_CANDIDATES);
156 + const shown = capPerScope(found);
73 157 const containers = containersFor(shown, byId).filter(
74 158 (entry) => !shown.some(({ blockId }) => blockId === entry.blockId),
75 159 );
76 160 return {
@@ -76,8 +160,9 @@
76 160 return {
77 161 blockSearch: {
78 162 total: found.length,
79 163 ...(textIgnored && { ignoredText: text }),
164 + ...(typeIgnored && { ignoredBlockTypes: blockTypes }),
80 165 candidates: [...shown, ...containers],
81 166 },
82 167 };
83 168 };