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

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

237 lines 7.2 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 // fontSize / fontFamily mirror the color routing: known slug → named attribute
115 // (preset class), anything else → the inline style path.
116 const NAMED_PRESETS = [
117 {
118 named: 'fontSize',
119 path: 'style.typography.fontSize',
120 slugs: 'fontSize',
121 classRe: /^has-[\w-]+-font-size$/,
122 },
123 {
124 named: 'fontFamily',
125 path: 'style.typography.fontFamily',
126 slugs: 'fontFamily',
127 classRe: /^has-[\w-]+-font-family$/,
128 },
129 ];
130
131 const routeNamedPresets = (attributes, patch, presetSlugs) => {
132 const strippers = [];
133 let out = attributes;
134 for (const { named, path, slugs, classRe } of NAMED_PRESETS) {
135 const value = patch?.[named];
136 if (value == null) continue;
137 if (new Set(presetSlugs?.[slugs] ?? []).has(value)) {
138 out = unsetPath(out, path);
139 continue;
140 }
141 out = unsetPath(setPath(out, path, value), named);
142 strippers.push(classRe);
143 }
144 if (strippers.length) {
145 out = dropClasses(out, (cls) => strippers.some((re) => re.test(cls)));
146 }
147 return out;
148 };
149
150 // Which color side a cleared path removes, so its baked preset class goes too.
151 const CLEAR_COLOR_SIDE = {
152 backgroundColor: 'background',
153 'style.color.background': 'background',
154 textColor: 'text',
155 'style.color.text': 'text',
156 };
157
158 // A routed field lands in a named attr or an inline style; clearing must unset both.
159 const ROUTED_PAIRS = [
160 ['backgroundColor', 'style.color.background'],
161 ['textColor', 'style.color.text'],
162 ['gradient', 'style.color.gradient'],
163 ...NAMED_PRESETS.map(({ named, path }) => [named, path]),
164 ];
165 const CLEAR_ALIASES = Object.fromEntries(
166 ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])),
167 );
168
169 // null means "no change", so removal has its own channel: paths listed to reset.
170 const applyClears = (attributes, clear) => {
171 const sides = {};
172 let out = attributes;
173 for (const path of clear) {
174 for (const p of CLEAR_ALIASES[path] ?? [path]) out = unsetPath(out, p);
175 if (CLEAR_COLOR_SIDE[path]) sides[CLEAR_COLOR_SIDE[path]] = true;
176 }
177 if (sides.text || sides.background) {
178 out = dropClasses(out, isPresetColorClass(sides));
179 }
180 return out;
181 };
182
183 // The model always says `text`; blocks that keep theirs elsewhere need the remap.
184 const remapText = (block, patch) => {
185 if (patch?.text == null) return patch;
186 const attributes = getBlockType(block.name)?.attributes ?? {};
187 const real = RICH_TEXT_ATTRIBUTES.find((name) => attributes[name]);
188 if (!real || real === 'text') return patch;
189 const { text, ...rest } = patch;
190 return { ...rest, [real]: text };
191 };
192
193 // Re-serializing runs the block's own save(), which core/button needs to render
194 // the color/border styles it marks __experimentalSkipSerialization.
195 // Merging an empty value leaves a dead key and keeps the preset class.
196 const emptyLeafPaths = (patch, prefix = '') =>
197 Object.entries(patch ?? {}).flatMap(([key, value]) => {
198 const path = prefix ? `${prefix}.${key}` : key;
199 if (isMergeable(value)) return emptyLeafPaths(value, path);
200 return value === '' ? [path] : [];
201 });
202
203 const withoutEmptyLeaves = (patch) =>
204 Object.fromEntries(
205 Object.entries(patch ?? {}).flatMap(([key, value]) => {
206 if (isMergeable(value)) return [[key, withoutEmptyLeaves(value)]];
207 return value === '' ? [] : [[key, value]];
208 }),
209 );
210
211 export const applyBlockPatch = (
212 serializedBlock,
213 patch,
214 clear = [],
215 presetSlugs = {},
216 ) => {
217 const blocks = parse(serializedBlock).map((block) => {
218 if (!block.name) return block;
219 const remapped = remapText(block, patch);
220 const filled = withoutEmptyLeaves(remapped);
221 const merged = deepMerge(block.attributes, filled);
222 const routed = routeNamedPresets(
223 routeColors(merged, filled, presetSlugs.color, presetSlugs.colorValues),
224 filled,
225 presetSlugs,
226 );
227 return {
228 ...block,
229 attributes: applyClears(routed, [
230 ...(clear ?? []),
231 ...emptyLeafPaths(remapped),
232 ]),
233 };
234 });
235 return serialize(blocks);
236 };
237