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

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

169 lines 5.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 const bareAddress = (value) =>
79 (value ?? '')
80 .toLowerCase()
81 .replace(/^https?:\/\//, '')
82 .replace(/^www\./, '')
83 .replace(/\/$/, '');
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.
106 const matchesType = (type, blockTypes) =>
107 !blockTypes?.length ||
108 blockTypes.includes(type) ||
109 (type === 'core/navigation-link' && blockTypes.includes('core/navigation'));
110
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 } = {}) => {
131 const root = document.querySelector('.wp-site-blocks') ?? document.body;
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),
136 );
137 const byId = new Map(all.map((entry) => [entry.blockId, entry]));
138 const byType = all.filter(({ type }) => matchesType(type, blockTypes));
139 const matches = byType.filter((entry) => matchesText(entry, text));
140 // "hero" is a position, not text: AND-ing it would match nothing.
141 const textIgnored =
142 Boolean(text) &&
143 Boolean(blockTypes?.length) &&
144 !matches.length &&
145 byType.length > 0;
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;
155 // Cap first, or a long match list truncates the containers away.
156 const shown = capPerScope(found);
157 const containers = containersFor(shown, byId).filter(
158 (entry) => !shown.some(({ blockId }) => blockId === entry.blockId),
159 );
160 return {
161 blockSearch: {
162 total: found.length,
163 ...(textIgnored && { ignoredText: text }),
164 ...(typeIgnored && { ignoredBlockTypes: blockTypes }),
165 candidates: [...shown, ...containers],
166 },
167 };
168 };
169