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 / lib / subtree-manifest.js

subtree-manifest.js in Extendify 3.2.2, at src/Agent/lib/subtree-manifest.js

203 lines 6.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { BLOCK_ID_SEL, blockIdOf } from './block-el';
2 import { detectBlockType } from './block-type';
3 import { readComputedStyles } from './computed-styles';
4
5 // Signal-less but edit targets ("swap the columns", "round the image") — dropped, they're unaddressable.
6 const ALWAYS_KEPT_TYPES = new Set([
7 'core/column',
8 'core/columns',
9 'core/image',
10 'core/cover',
11 'core/site-logo',
12 ]);
13
14 const isTransparent = (value) =>
15 !value ||
16 value === 'transparent' ||
17 value.replace(/\s/g, '') === 'rgba(0,0,0,0)';
18
19 // core/button renders text + background in an inner link, so read styles off the
20 // link — only for buttons, or a container would report a nested button's styles.
21 const styleSource = (el) =>
22 el.classList?.contains('wp-block-button')
23 ? (el.querySelector('.wp-block-button__link') ?? el)
24 : el;
25
26 // Narrow by appearance — a token like `tertiary` says nothing about being yellow.
27 const renderedStyles = (el) => {
28 const styles = readComputedStyles(styleSource(el));
29 if (styles && isTransparent(styles.backgroundColor))
30 delete styles.backgroundColor;
31 return styles && Object.keys(styles).length ? styles : null;
32 };
33
34 // "A bit smaller" needs the picture's rendered width; the wrapper's is the row's.
35 const PICTURE_TYPES = new Set(['core/image', 'core/site-logo']);
36
37 const pictureWidth = (el) => {
38 const img = el.matches?.('img') ? el : el.querySelector?.('img');
39 if (!img) return null;
40 const width = img.ownerDocument.defaultView.getComputedStyle(img).width;
41 return width && width !== 'auto' && width !== '0px' ? width : null;
42 };
43
44 // style.css lives in a scoped `wp-custom-css-*` rule, invisible in the block code.
45 const customCss = (el) => {
46 const cls = [...(el.classList ?? [])].find((c) =>
47 c.startsWith('wp-custom-css-'),
48 );
49 if (!cls) return null;
50 for (const sheet of el.ownerDocument?.styleSheets ?? []) {
51 let rules;
52 try {
53 rules = sheet.cssRules;
54 } catch {
55 continue; // cross-origin sheet — can't read
56 }
57 for (const rule of rules) {
58 if (rule.selectorText?.includes(cls)) return rule.style?.cssText || null;
59 }
60 }
61 return null;
62 };
63
64 const PART_SLUG_ATTR = 'data-extendify-part-slug';
65
66 // One request carries one partSlug, so a nested part's blocks would resolve
67 // against the wrong post.
68 const partScope = (root) => {
69 const rootSlug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null;
70 return (el) => (el?.getAttribute?.(PART_SLUG_ATTR) ?? null) === rootSlug;
71 };
72
73 // Stops at nested tagged blocks so a container's text never bleeds from its children.
74 const ownSubtree = (el) => {
75 const clone = el.cloneNode(true);
76 for (const nested of clone.querySelectorAll(BLOCK_ID_SEL)) nested.remove();
77 return clone;
78 };
79
80 const ownText = (clone) => {
81 const readable = clone.cloneNode(true);
82 for (const hidden of readable.querySelectorAll('[aria-hidden="true"]')) {
83 hidden.remove();
84 }
85 const text = (readable.textContent || '').replace(/\s+/g, ' ').trim();
86 return text.length > 80 ? `${text.slice(0, 80)}…` : text;
87 };
88
89 const colorSlugs = (clone) => {
90 const out = {};
91 const nodes = [clone, ...clone.querySelectorAll('*')];
92 for (const node of nodes) {
93 for (const cls of node.classList ?? []) {
94 const bg = cls.match(/^has-(.+)-background-color$/);
95 if (bg) out.backgroundColor = bg[1];
96 else if (cls !== 'has-text-color') {
97 const text = cls.match(/^has-(.+)-color$/);
98 if (text) out.textColor = text[1];
99 }
100 }
101 }
102 return out;
103 };
104
105 // Menu and icon colours live on the container, never the link that was clicked.
106 // detectBlockType refuses these types so a click can't select the wrapper.
107 const STYLE_OWNERS = [
108 { selector: '.wp-block-navigation', type: 'core/navigation' },
109 { selector: '.wp-block-social-links', type: 'core/social-links' },
110 ];
111
112 export const ownerAncestorFor = (root) => {
113 if (!root?.parentElement) return null;
114 const inScope = partScope(root);
115 for (const { selector, type } of STYLE_OWNERS) {
116 const el = root.parentElement.closest(selector);
117 if (!el || !inScope(el)) continue;
118 const blockId = blockIdOf(el);
119 if (!blockId) continue;
120 const styles = renderedStyles(el);
121 return {
122 blockId,
123 type,
124 ...colorSlugs(ownSubtree(el)),
125 ...(styles && { styles }),
126 };
127 }
128 return null;
129 };
130
131 // Per-block summary of the selection — enough context for the agent to pick
132 // targets ("the red button"); schemas + full code are fetched after the pick.
133 export const buildSubtreeManifest = (root) => {
134 if (!root) return [];
135 const manifest = [];
136 const inScope = partScope(root);
137 // A directly-selected block is its own (and only) target, so the root is included.
138 const els = [root, ...root.querySelectorAll(BLOCK_ID_SEL)].filter(inScope);
139 for (const [index, el] of els.entries()) {
140 const blockId = blockIdOf(el);
141 if (!blockId) continue;
142 const type = detectBlockType(el);
143 if (!type) continue;
144 const clone = ownSubtree(el);
145 const text = ownText(clone);
146 const colors = colorSlugs(clone);
147 // Drop signal-less descendants, never the root (baseline for relative edits).
148 if (
149 index > 0 &&
150 !ALWAYS_KEPT_TYPES.has(type) &&
151 !text &&
152 !colors.backgroundColor &&
153 !colors.textColor
154 )
155 continue;
156 const width = PICTURE_TYPES.has(type) ? pictureWidth(el) : null;
157 const styles = width
158 ? { ...renderedStyles(el), width }
159 : renderedStyles(el);
160 const css = customCss(el);
161 manifest.push({
162 blockId,
163 type,
164 ...(text && { text }),
165 ...colors,
166 ...(styles && { styles }),
167 ...(css && { css }),
168 });
169 }
170 return manifest;
171 };
172
173 // Tagged blocks directly beneath el, past any untagged wrappers.
174 const childBlockEls = (el, inScope) => {
175 const out = [];
176 for (const child of el.children ?? []) {
177 if (!inScope(child)) continue;
178 if (blockIdOf(child)) out.push(child);
179 else out.push(...childBlockEls(child, inScope));
180 }
181 return out;
182 };
183
184 // Keeps every tagged block; the flat manifest drops signal-less ones.
185 const treeNodesFor = (el, inScope) =>
186 childBlockEls(el, inScope).flatMap((child) => {
187 const type = detectBlockType(child);
188 const children = treeNodesFor(child, inScope);
189 const blockId = blockIdOf(child);
190 if (!type || !blockId) return children;
191 return [
192 {
193 blockId,
194 type,
195 ...(children.length && { children }),
196 },
197 ];
198 });
199
200 // Nested block structure for the selection.
201 export const buildSubtreeTree = (root) =>
202 root ? treeNodesFor({ children: [root] }, partScope(root)) : [];
203