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 / block-patch.js

block-patch.js in Extendify 3.2.2, at src/Agent/lib/block-patch.js

311 lines 9.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { RICH_TEXT_ATTRIBUTES } from '@agent/lib/block-schema';
2 import { getBlockType, parse, serialize } from '@wordpress/blocks';
3 import { colord } from 'colord';
4
5 const isMergeable = (value) =>
6 value && typeof value === 'object' && !Array.isArray(value);
7
8 // Result aliases both inputs' subtrees — every helper below must not mutate.
9 const deepMerge = (base, patch) => {
10 const out = { ...base };
11 for (const [key, value] of Object.entries(patch)) {
12 if (value == null) continue;
13 out[key] =
14 isMergeable(value) && isMergeable(out[key])
15 ? deepMerge(out[key], value)
16 : value;
17 }
18 return out;
19 };
20
21 const setIn = (obj, parts, value) => {
22 const [head, ...rest] = parts;
23 if (!rest.length) return { ...obj, [head]: value };
24 const child = isMergeable(obj?.[head]) ? obj[head] : {};
25 return { ...obj, [head]: setIn(child, rest, value) };
26 };
27 const setPath = (attributes, path, value) =>
28 setIn(attributes, path.split('.'), value);
29
30 const unsetIn = (obj, parts) => {
31 const [head, ...rest] = parts;
32 if (!isMergeable(obj) || !(head in obj)) return obj;
33 if (!rest.length) {
34 const { [head]: _, ...kept } = obj;
35 return kept;
36 }
37 return { ...obj, [head]: unsetIn(obj[head], rest) };
38 };
39 const unsetPath = (attributes, path) => unsetIn(attributes, path.split('.'));
40
41 const dropClasses = (attributes, shouldDrop) => {
42 if (!attributes.className) return attributes;
43 const kept = attributes.className
44 .split(/\s+/)
45 .filter((cls) => cls && !shouldDrop(cls))
46 .join(' ');
47 if (kept) return { ...attributes, className: kept };
48 return unsetPath(attributes, 'className');
49 };
50
51 // Baked preset color classes (has-primary-color) carry !important and outrank
52 // the custom color; save() regenerates the generic markers, so only named ones go.
53 const isPresetColorClass =
54 ({ text, background }) =>
55 (cls) => {
56 const isBgPreset = /^has-[\w-]+-background-color$/.test(cls);
57 const isTextPreset =
58 /^has-[\w-]+-color$/.test(cls) &&
59 !isBgPreset &&
60 cls !== 'has-text-color' &&
61 cls !== 'has-link-color';
62 return Boolean((text && isTextPreset) || (background && isBgPreset));
63 };
64
65 const COLOR_CHANNELS = [
66 ['backgroundColor', 'background'],
67 ['textColor', 'text'],
68 ];
69
70 // A known slug maps to a theme preset class; anything else routes to inline
71 // style.color.* — so "yellow" (no matching token) never lands on a wrong slug.
72 // Kept inline, a token's own hex stops the block following the palette.
73 const slugForValue = (value, colorValues) => {
74 const wanted = colord(value);
75 if (!wanted.isValid()) return null;
76 const hex = wanted.toHex();
77 const match = Object.entries(colorValues ?? {}).find(([, preset]) => {
78 const parsed = colord(String(preset));
79 return parsed.isValid() && parsed.toHex() === hex;
80 });
81 return match?.[0] ?? null;
82 };
83
84 const routeColors = (attributes, patch, colorSlugs, colorValues) => {
85 const slugs = new Set(colorSlugs ?? []);
86 const stripped = {};
87 let out = attributes;
88 for (const [named, key] of COLOR_CHANNELS) {
89 const value = patch?.[named];
90 if (value == null) continue;
91 const named_slug = slugs.has(value)
92 ? value
93 : slugForValue(value, colorValues);
94 if (named_slug) {
95 if (named_slug !== value) out = setPath(out, named, named_slug);
96 out = unsetPath(out, `style.color.${key}`);
97 continue;
98 }
99 const parsed = colord(value);
100 out = setPath(
101 out,
102 `style.color.${key}`,
103 parsed.isValid() ? parsed.toHex() : value,
104 );
105 out = unsetPath(out, named);
106 stripped[key] = true;
107 }
108 if (stripped.text || stripped.background) {
109 out = dropClasses(out, isPresetColorClass(stripped));
110 }
111 return out;
112 };
113
114 // Colours WP stores as a slug attribute beside a custom-value attribute.
115 const PAIRED_COLORS = [
116 {
117 named: 'overlayColor',
118 custom: 'customOverlayColor',
119 flag: 'isUserOverlayColor',
120 },
121 // The icon renders from the value alone; the slug only adds a class.
122 { named: 'iconColor', custom: 'iconColorValue', valueForSlug: true },
123 {
124 named: 'iconBackgroundColor',
125 custom: 'iconBackgroundColorValue',
126 valueForSlug: true,
127 },
128 ];
129
130 const routePairedColors = (attributes, patch, colorSlugs, colorValues) => {
131 const slugs = new Set(colorSlugs ?? []);
132 let out = attributes;
133 for (const { named, custom, flag, valueForSlug } of PAIRED_COLORS) {
134 const value = patch?.[named];
135 if (value == null) continue;
136 const slug = slugs.has(value) ? value : slugForValue(value, colorValues);
137 if (slug) {
138 out = { ...out, [named]: slug };
139 const slugHex = colorValues?.[slug];
140 out =
141 valueForSlug && slugHex
142 ? { ...out, [custom]: colord(String(slugHex)).toHex() }
143 : unsetPath(out, custom);
144 } else {
145 const parsed = colord(value);
146 out = unsetPath(
147 { ...out, [custom]: parsed.isValid() ? parsed.toHex() : value },
148 named,
149 );
150 }
151 if (flag) out = { ...out, [flag]: true };
152 }
153 return out;
154 };
155
156 // fontSize / fontFamily mirror the color routing: known slug → named attribute
157 // (preset class), anything else → the inline style path.
158 const NAMED_PRESETS = [
159 {
160 named: 'fontSize',
161 path: 'style.typography.fontSize',
162 slugs: 'fontSize',
163 classRe: /^has-[\w-]+-font-size$/,
164 },
165 {
166 named: 'fontFamily',
167 path: 'style.typography.fontFamily',
168 slugs: 'fontFamily',
169 classRe: /^has-[\w-]+-font-family$/,
170 },
171 ];
172
173 const routeNamedPresets = (attributes, patch, presetSlugs) => {
174 const strippers = [];
175 let out = attributes;
176 for (const { named, path, slugs, classRe } of NAMED_PRESETS) {
177 const value = patch?.[named];
178 if (value == null) continue;
179 if (new Set(presetSlugs?.[slugs] ?? []).has(value)) {
180 out = unsetPath(out, path);
181 continue;
182 }
183 out = unsetPath(setPath(out, path, value), named);
184 strippers.push(classRe);
185 }
186 if (strippers.length) {
187 out = dropClasses(out, (cls) => strippers.some((re) => re.test(cls)));
188 }
189 return out;
190 };
191
192 // Which color side a cleared path removes, so its baked preset class goes too.
193 const CLEAR_COLOR_SIDE = {
194 backgroundColor: 'background',
195 'style.color.background': 'background',
196 textColor: 'text',
197 'style.color.text': 'text',
198 };
199
200 // A routed field lands in a named attr or an inline style; clearing must unset both.
201 const ROUTED_PAIRS = [
202 ['backgroundColor', 'style.color.background'],
203 ['textColor', 'style.color.text'],
204 ['gradient', 'style.color.gradient'],
205 ...NAMED_PRESETS.map(({ named, path }) => [named, path]),
206 ...PAIRED_COLORS.map(({ named, custom }) => [named, custom]),
207 ];
208 const CLEAR_ALIASES = Object.fromEntries(
209 ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])),
210 );
211
212 // null means "no change", so removal has its own channel: paths listed to reset.
213 const applyClears = (attributes, clear) => {
214 const sides = {};
215 let out = attributes;
216 for (const path of clear) {
217 for (const p of CLEAR_ALIASES[path] ?? [path]) out = unsetPath(out, p);
218 if (CLEAR_COLOR_SIDE[path]) sides[CLEAR_COLOR_SIDE[path]] = true;
219 }
220 if (sides.text || sides.background) {
221 out = dropClasses(out, isPresetColorClass(sides));
222 }
223 return out;
224 };
225
226 // The model always says `text`; blocks that keep theirs elsewhere need the remap.
227 const remapText = (block, patch) => {
228 if (patch?.text == null) return patch;
229 const attributes = getBlockType(block.name)?.attributes ?? {};
230 const real = RICH_TEXT_ATTRIBUTES.find((name) => attributes[name]);
231 if (!real || real === 'text') return patch;
232 const { text, ...rest } = patch;
233 return { ...rest, [real]: text };
234 };
235
236 // Re-serializing runs the block's own save(), which core/button needs to render
237 // the color/border styles it marks __experimentalSkipSerialization.
238 // Merging an empty value leaves a dead key and keeps the preset class.
239 const emptyLeafPaths = (patch, prefix = '') =>
240 Object.entries(patch ?? {}).flatMap(([key, value]) => {
241 const path = prefix ? `${prefix}.${key}` : key;
242 if (isMergeable(value)) return emptyLeafPaths(value, path);
243 return value === '' ? [path] : [];
244 });
245
246 // A block with no style would otherwise save the patch's null and emptied keys.
247 const withoutEmptyLeaves = (patch) =>
248 Object.fromEntries(
249 Object.entries(patch ?? {}).flatMap(([key, value]) => {
250 if (isMergeable(value)) {
251 const kept = withoutEmptyLeaves(value);
252 return Object.keys(kept).length ? [[key, kept]] : [];
253 }
254 return value === '' || value == null ? [] : [[key, value]];
255 }),
256 );
257
258 // Patch values arrive as strings; the parser drops "30" on a number attribute.
259 const coerceTypes = (block, patch) => {
260 const attributes = getBlockType(block.name)?.attributes ?? {};
261 let out = patch;
262 for (const [key, value] of Object.entries(patch ?? {})) {
263 if (typeof value !== 'string') continue;
264 const type = attributes[key]?.type;
265 if (type === 'boolean' && ['true', 'false'].includes(value)) {
266 out = { ...out, [key]: value === 'true' };
267 continue;
268 }
269 if (type !== 'number') continue;
270 const [, amount, unit] =
271 value.trim().match(/^(-?\d*\.?\d+)\s*([a-z%]*)$/i) ?? [];
272 if (amount == null) continue;
273 out = { ...out, [key]: Number(amount) };
274 // The model writes lengths like "60vh" into the number field.
275 const unitKey = `${key}Unit`;
276 if (unit && attributes[unitKey] && out[unitKey] == null) {
277 out = { ...out, [unitKey]: unit };
278 }
279 }
280 return out;
281 };
282
283 export const applyBlockPatch = (
284 serializedBlock,
285 patch,
286 clear = [],
287 presetSlugs = {},
288 ) => {
289 const blocks = parse(serializedBlock).map((block) => {
290 if (!block.name) return block;
291 const remapped = remapText(block, patch);
292 const filled = coerceTypes(block, withoutEmptyLeaves(remapped));
293 const merged = deepMerge(block.attributes, filled);
294 const colored = routePairedColors(
295 routeColors(merged, filled, presetSlugs.color, presetSlugs.colorValues),
296 filled,
297 presetSlugs.color,
298 presetSlugs.colorValues,
299 );
300 const routed = routeNamedPresets(colored, filled, presetSlugs);
301 return {
302 ...block,
303 attributes: applyClears(routed, [
304 ...(clear ?? []),
305 ...emptyLeafPaths(remapped),
306 ]),
307 };
308 });
309 return serialize(blocks);
310 };
311