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
extendify / src / Agent / workflows / block-selector / tools / find-blocks.js

find-blocks.js in Extendify trunk, at src/Agent/workflows/block-selector/tools/find-blocks.js

141 lines 4.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 blockIdOf,
3 parseScopedId,
4 resolveScopedId,
5 scopedBlockId,
6 } from '@agent/lib/block-el';
7 import { IGNORED_BLOCKS } from '@agent/lib/classify-block-edit';
8 import { buildSubtreeManifest } from '@agent/lib/subtree-manifest';
9 import { DYNAMIC_BLOCK_TYPES } from '@quick-edit/lib/agent-gate';
10
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
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;
18
19 const PART_SLUG_ATTR = 'data-extendify-part-slug';
20 const PART_LABEL_ATTR = 'data-extendify-part';
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
47 // An item can't add a sibling, nor stand in for the whole set.
48 export const CONTAINER_TYPES = new Set([
49 'core/columns',
50 'core/list',
51 'core/group',
52 ]);
53
54 const addressable = (type) =>
55 !DYNAMIC_BLOCK_TYPES.has(type) &&
56 !IGNORED_BLOCKS.has(type) &&
57 !OWN_WORKFLOW_BLOCKS.has(type);
58
59 const containersFor = (matches, byId) => {
60 const found = new Map();
61 for (const { blockId } of matches) {
62 let node = resolveScopedId(blockId)?.parentElement;
63 while (node) {
64 const slug = node.getAttribute?.(PART_SLUG_ATTR) ?? null;
65 const raw = blockIdOf(node);
66 const id = raw ? scopedBlockId(raw, slug) : null;
67 const entry = id ? byId.get(id) : null;
68 if (entry && CONTAINER_TYPES.has(entry.type)) {
69 found.set(id, entry);
70 break;
71 }
72 node = node.parentElement;
73 }
74 }
75 return [...found.values()];
76 };
77
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 };
85
86 const matchesType = (type, blockTypes) =>
87 !blockTypes?.length || blockTypes.includes(type);
88
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 } = {}) => {
105 const root = document.querySelector('.wp-site-blocks') ?? document.body;
106 const all = scopeRoots(root)
107 .flatMap(manifestFor)
108 .filter((entry) => addressable(entry.type) && matchesPart(entry, part));
109 const byId = new Map(all.map((entry) => [entry.blockId, entry]));
110 const byType = all.filter(({ type }) => matchesType(type, blockTypes));
111 const matches = byType.filter((entry) => matchesText(entry, text));
112 // "hero" is a position, not text: AND-ing it would match nothing.
113 const textIgnored =
114 Boolean(text) &&
115 Boolean(blockTypes?.length) &&
116 !matches.length &&
117 byType.length > 0;
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;
127 // Cap first, or a long match list truncates the containers away.
128 const shown = capPerScope(found);
129 const containers = containersFor(shown, byId).filter(
130 (entry) => !shown.some(({ blockId }) => blockId === entry.blockId),
131 );
132 return {
133 blockSearch: {
134 total: found.length,
135 ...(textIgnored && { ignoredText: text }),
136 ...(typeIgnored && { ignoredBlockTypes: blockTypes }),
137 candidates: [...shown, ...containers],
138 },
139 };
140 };
141