PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 / lib / subtree-manifest.js

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

160 lines 4.8 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 // style.css lives in a scoped `wp-custom-css-*` rule, invisible in the block code.
35 const customCss = (el) => {
36 const cls = [...(el.classList ?? [])].find((c) =>
37 c.startsWith('wp-custom-css-'),
38 );
39 if (!cls) return null;
40 for (const sheet of el.ownerDocument?.styleSheets ?? []) {
41 let rules;
42 try {
43 rules = sheet.cssRules;
44 } catch {
45 continue; // cross-origin sheet — can't read
46 }
47 for (const rule of rules) {
48 if (rule.selectorText?.includes(cls)) return rule.style?.cssText || null;
49 }
50 }
51 return null;
52 };
53
54 const PART_SLUG_ATTR = 'data-extendify-part-slug';
55
56 // One request carries one partSlug, so a nested part's blocks would resolve
57 // against the wrong post.
58 const partScope = (root) => {
59 const rootSlug = root?.getAttribute?.(PART_SLUG_ATTR) ?? null;
60 return (el) => (el?.getAttribute?.(PART_SLUG_ATTR) ?? null) === rootSlug;
61 };
62
63 // Stops at nested tagged blocks so a container's text never bleeds from its children.
64 const ownSubtree = (el) => {
65 const clone = el.cloneNode(true);
66 for (const nested of clone.querySelectorAll(BLOCK_ID_SEL)) nested.remove();
67 return clone;
68 };
69
70 const ownText = (clone) => {
71 const text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
72 return text.length > 80 ? `${text.slice(0, 80)}` : text;
73 };
74
75 const colorSlugs = (clone) => {
76 const out = {};
77 const nodes = [clone, ...clone.querySelectorAll('*')];
78 for (const node of nodes) {
79 for (const cls of node.classList ?? []) {
80 const bg = cls.match(/^has-(.+)-background-color$/);
81 if (bg) out.backgroundColor = bg[1];
82 else if (cls !== 'has-text-color') {
83 const text = cls.match(/^has-(.+)-color$/);
84 if (text) out.textColor = text[1];
85 }
86 }
87 }
88 return out;
89 };
90
91 // Per-block summary of the selection — enough context for the agent to pick
92 // targets ("the red button"); schemas + full code are fetched after the pick.
93 export const buildSubtreeManifest = (root) => {
94 if (!root) return [];
95 const manifest = [];
96 const inScope = partScope(root);
97 // A directly-selected block is its own (and only) target, so the root is included.
98 const els = [root, ...root.querySelectorAll(BLOCK_ID_SEL)].filter(inScope);
99 for (const [index, el] of els.entries()) {
100 const blockId = blockIdOf(el);
101 if (!blockId) continue;
102 const type = detectBlockType(el);
103 if (!type) continue;
104 const clone = ownSubtree(el);
105 const text = ownText(clone);
106 const colors = colorSlugs(clone);
107 // Drop signal-less descendants, never the root (baseline for relative edits).
108 if (
109 index > 0 &&
110 !ALWAYS_KEPT_TYPES.has(type) &&
111 !text &&
112 !colors.backgroundColor &&
113 !colors.textColor
114 )
115 continue;
116 const styles = renderedStyles(el);
117 const css = customCss(el);
118 manifest.push({
119 blockId,
120 type,
121 ...(text && { text }),
122 ...colors,
123 ...(styles && { styles }),
124 ...(css && { css }),
125 });
126 }
127 return manifest;
128 };
129
130 // Tagged blocks directly beneath el, past any untagged wrappers.
131 const childBlockEls = (el, inScope) => {
132 const out = [];
133 for (const child of el.children ?? []) {
134 if (!inScope(child)) continue;
135 if (blockIdOf(child)) out.push(child);
136 else out.push(...childBlockEls(child, inScope));
137 }
138 return out;
139 };
140
141 // Keeps every tagged block; the flat manifest drops signal-less ones.
142 const treeNodesFor = (el, inScope) =>
143 childBlockEls(el, inScope).flatMap((child) => {
144 const type = detectBlockType(child);
145 const children = treeNodesFor(child, inScope);
146 const blockId = blockIdOf(child);
147 if (!type || !blockId) return children;
148 return [
149 {
150 blockId,
151 type,
152 ...(children.length && { children }),
153 },
154 ];
155 });
156
157 // Nested block structure for the selection.
158 export const buildSubtreeTree = (root) =>
159 root ? treeNodesFor({ children: [root] }, partScope(root)) : [];
160