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
← All changes | src/Agent/lib/block-patch.js +127 -11 3.1.5 → 3.2.2 View file →
@@ -1,4 +1,5 @@
1 +import { RICH_TEXT_ATTRIBUTES } from '@agent/lib/block-schema';
1 2 import { getBlockType, parse, serialize } from '@wordpress/blocks';
2 3 import { colord } from 'colord';
3 4
4 5 const isMergeable = (value) =>
@@ -67,9 +68,21 @@
67 68 ];
68 69
69 70 // A known slug maps to a theme preset class; anything else routes to inline
70 71 // style.color.* — so "yellow" (no matching token) never lands on a wrong slug.
71 -const routeColors = (attributes, patch, colorSlugs) => {
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) => {
72 85 const slugs = new Set(colorSlugs ?? []);
73 86 const stripped = {};
74 87 let out = attributes;
75 88 for (const [named, key] of COLOR_CHANNELS) {
@@ -74,9 +87,13 @@
74 87 let out = attributes;
75 88 for (const [named, key] of COLOR_CHANNELS) {
76 89 const value = patch?.[named];
77 90 if (value == null) continue;
78 - if (slugs.has(value)) {
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);
79 96 out = unsetPath(out, `style.color.${key}`);
80 97 continue;
81 98 }
82 99 const parsed = colord(value);
@@ -93,8 +110,50 @@
93 110 }
94 111 return out;
95 112 };
96 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 +
97 156 // fontSize / fontFamily mirror the color routing: known slug → named attribute
98 157 // (preset class), anything else → the inline style path.
99 158 const NAMED_PRESETS = [
100 159 {
@@ -143,8 +202,9 @@
143 202 ['backgroundColor', 'style.color.background'],
144 203 ['textColor', 'style.color.text'],
145 204 ['gradient', 'style.color.gradient'],
146 205 ...NAMED_PRESETS.map(({ named, path }) => [named, path]),
206 + ...PAIRED_COLORS.map(({ named, custom }) => [named, custom]),
147 207 ];
148 208 const CLEAR_ALIASES = Object.fromEntries(
149 209 ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])),
150 210 );
@@ -162,19 +222,65 @@
162 222 }
163 223 return out;
164 224 };
165 225
166 -// The model always says `text`; blocks whose rich text is `content` need the remap.
226 +// The model always says `text`; blocks that keep theirs elsewhere need the remap.
167 227 const remapText = (block, patch) => {
168 228 if (patch?.text == null) return patch;
169 229 const attributes = getBlockType(block.name)?.attributes ?? {};
170 - if (attributes.text || !attributes.content) return patch;
230 + const real = RICH_TEXT_ATTRIBUTES.find((name) => attributes[name]);
231 + if (!real || real === 'text') return patch;
171 232 const { text, ...rest } = patch;
172 - return { ...rest, content: text };
233 + return { ...rest, [real]: text };
173 234 };
174 235
175 236 // Re-serializing runs the block's own save(), which core/button needs to render
176 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 +
177 283 export const applyBlockPatch = (
178 284 serializedBlock,
179 285 patch,
180 286 clear = [],
@@ -181,14 +287,24 @@
181 287 presetSlugs = {},
182 288 ) => {
183 289 const blocks = parse(serializedBlock).map((block) => {
184 290 if (!block.name) return block;
185 - const merged = deepMerge(block.attributes, remapText(block, patch));
186 - const routed = routeNamedPresets(
187 - routeColors(merged, patch, presetSlugs.color),
188 - patch,
189 - presetSlugs,
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,
190 299 );
191 - return { ...block, attributes: applyClears(routed, clear ?? []) };
300 + const routed = routeNamedPresets(colored, filled, presetSlugs);
301 + return {
302 + ...block,
303 + attributes: applyClears(routed, [
304 + ...(clear ?? []),
305 + ...emptyLeafPaths(remapped),
306 + ]),
307 + };
192 308 });
193 309 return serialize(blocks);
194 310 };