PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
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 0.7.0 All 126 releases
extendify / src / Agent / lib / block-patch.js

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

195 lines 5.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { getBlockType, parse, serialize } from '@wordpress/blocks';
2 import { colord } from 'colord';
3
4 const isMergeable = (value) =>
5 value && typeof value === 'object' && !Array.isArray(value);
6
7 // Result aliases both inputs' subtrees — every helper below must not mutate.
8 const deepMerge = (base, patch) => {
9 const out = { ...base };
10 for (const [key, value] of Object.entries(patch)) {
11 if (value == null) continue;
12 out[key] =
13 isMergeable(value) && isMergeable(out[key])
14 ? deepMerge(out[key], value)
15 : value;
16 }
17 return out;
18 };
19
20 const setIn = (obj, parts, value) => {
21 const [head, ...rest] = parts;
22 if (!rest.length) return { ...obj, [head]: value };
23 const child = isMergeable(obj?.[head]) ? obj[head] : {};
24 return { ...obj, [head]: setIn(child, rest, value) };
25 };
26 const setPath = (attributes, path, value) =>
27 setIn(attributes, path.split('.'), value);
28
29 const unsetIn = (obj, parts) => {
30 const [head, ...rest] = parts;
31 if (!isMergeable(obj) || !(head in obj)) return obj;
32 if (!rest.length) {
33 const { [head]: _, ...kept } = obj;
34 return kept;
35 }
36 return { ...obj, [head]: unsetIn(obj[head], rest) };
37 };
38 const unsetPath = (attributes, path) => unsetIn(attributes, path.split('.'));
39
40 const dropClasses = (attributes, shouldDrop) => {
41 if (!attributes.className) return attributes;
42 const kept = attributes.className
43 .split(/\s+/)
44 .filter((cls) => cls && !shouldDrop(cls))
45 .join(' ');
46 if (kept) return { ...attributes, className: kept };
47 return unsetPath(attributes, 'className');
48 };
49
50 // Baked preset color classes (has-primary-color) carry !important and outrank
51 // the custom color; save() regenerates the generic markers, so only named ones go.
52 const isPresetColorClass =
53 ({ text, background }) =>
54 (cls) => {
55 const isBgPreset = /^has-[\w-]+-background-color$/.test(cls);
56 const isTextPreset =
57 /^has-[\w-]+-color$/.test(cls) &&
58 !isBgPreset &&
59 cls !== 'has-text-color' &&
60 cls !== 'has-link-color';
61 return Boolean((text && isTextPreset) || (background && isBgPreset));
62 };
63
64 const COLOR_CHANNELS = [
65 ['backgroundColor', 'background'],
66 ['textColor', 'text'],
67 ];
68
69 // A known slug maps to a theme preset class; anything else routes to inline
70 // style.color.* — so "yellow" (no matching token) never lands on a wrong slug.
71 const routeColors = (attributes, patch, colorSlugs) => {
72 const slugs = new Set(colorSlugs ?? []);
73 const stripped = {};
74 let out = attributes;
75 for (const [named, key] of COLOR_CHANNELS) {
76 const value = patch?.[named];
77 if (value == null) continue;
78 if (slugs.has(value)) {
79 out = unsetPath(out, `style.color.${key}`);
80 continue;
81 }
82 const parsed = colord(value);
83 out = setPath(
84 out,
85 `style.color.${key}`,
86 parsed.isValid() ? parsed.toHex() : value,
87 );
88 out = unsetPath(out, named);
89 stripped[key] = true;
90 }
91 if (stripped.text || stripped.background) {
92 out = dropClasses(out, isPresetColorClass(stripped));
93 }
94 return out;
95 };
96
97 // fontSize / fontFamily mirror the color routing: known slug → named attribute
98 // (preset class), anything else → the inline style path.
99 const NAMED_PRESETS = [
100 {
101 named: 'fontSize',
102 path: 'style.typography.fontSize',
103 slugs: 'fontSize',
104 classRe: /^has-[\w-]+-font-size$/,
105 },
106 {
107 named: 'fontFamily',
108 path: 'style.typography.fontFamily',
109 slugs: 'fontFamily',
110 classRe: /^has-[\w-]+-font-family$/,
111 },
112 ];
113
114 const routeNamedPresets = (attributes, patch, presetSlugs) => {
115 const strippers = [];
116 let out = attributes;
117 for (const { named, path, slugs, classRe } of NAMED_PRESETS) {
118 const value = patch?.[named];
119 if (value == null) continue;
120 if (new Set(presetSlugs?.[slugs] ?? []).has(value)) {
121 out = unsetPath(out, path);
122 continue;
123 }
124 out = unsetPath(setPath(out, path, value), named);
125 strippers.push(classRe);
126 }
127 if (strippers.length) {
128 out = dropClasses(out, (cls) => strippers.some((re) => re.test(cls)));
129 }
130 return out;
131 };
132
133 // Which color side a cleared path removes, so its baked preset class goes too.
134 const CLEAR_COLOR_SIDE = {
135 backgroundColor: 'background',
136 'style.color.background': 'background',
137 textColor: 'text',
138 'style.color.text': 'text',
139 };
140
141 // A routed field lands in a named attr or an inline style; clearing must unset both.
142 const ROUTED_PAIRS = [
143 ['backgroundColor', 'style.color.background'],
144 ['textColor', 'style.color.text'],
145 ['gradient', 'style.color.gradient'],
146 ...NAMED_PRESETS.map(({ named, path }) => [named, path]),
147 ];
148 const CLEAR_ALIASES = Object.fromEntries(
149 ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])),
150 );
151
152 // null means "no change", so removal has its own channel: paths listed to reset.
153 const applyClears = (attributes, clear) => {
154 const sides = {};
155 let out = attributes;
156 for (const path of clear) {
157 for (const p of CLEAR_ALIASES[path] ?? [path]) out = unsetPath(out, p);
158 if (CLEAR_COLOR_SIDE[path]) sides[CLEAR_COLOR_SIDE[path]] = true;
159 }
160 if (sides.text || sides.background) {
161 out = dropClasses(out, isPresetColorClass(sides));
162 }
163 return out;
164 };
165
166 // The model always says `text`; blocks whose rich text is `content` need the remap.
167 const remapText = (block, patch) => {
168 if (patch?.text == null) return patch;
169 const attributes = getBlockType(block.name)?.attributes ?? {};
170 if (attributes.text || !attributes.content) return patch;
171 const { text, ...rest } = patch;
172 return { ...rest, content: text };
173 };
174
175 // Re-serializing runs the block's own save(), which core/button needs to render
176 // the color/border styles it marks __experimentalSkipSerialization.
177 export const applyBlockPatch = (
178 serializedBlock,
179 patch,
180 clear = [],
181 presetSlugs = {},
182 ) => {
183 const blocks = parse(serializedBlock).map((block) => {
184 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,
190 );
191 return { ...block, attributes: applyClears(routed, clear ?? []) };
192 });
193 return serialize(blocks);
194 };
195