PluginProbe
Extendify / 3.2.0
Extendify v3.2.0
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.0, at src/Agent/lib/block-patch.js

197 lines 5.9 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 const routeColors = (attributes, patch, colorSlugs) => {
73 const slugs = new Set(colorSlugs ?? []);
74 const stripped = {};
75 let out = attributes;
76 for (const [named, key] of COLOR_CHANNELS) {
77 const value = patch?.[named];
78 if (value == null) continue;
79 if (slugs.has(value)) {
80 out = unsetPath(out, `style.color.${key}`);
81 continue;
82 }
83 const parsed = colord(value);
84 out = setPath(
85 out,
86 `style.color.${key}`,
87 parsed.isValid() ? parsed.toHex() : value,
88 );
89 out = unsetPath(out, named);
90 stripped[key] = true;
91 }
92 if (stripped.text || stripped.background) {
93 out = dropClasses(out, isPresetColorClass(stripped));
94 }
95 return out;
96 };
97
98 // fontSize / fontFamily mirror the color routing: known slug → named attribute
99 // (preset class), anything else → the inline style path.
100 const NAMED_PRESETS = [
101 {
102 named: 'fontSize',
103 path: 'style.typography.fontSize',
104 slugs: 'fontSize',
105 classRe: /^has-[\w-]+-font-size$/,
106 },
107 {
108 named: 'fontFamily',
109 path: 'style.typography.fontFamily',
110 slugs: 'fontFamily',
111 classRe: /^has-[\w-]+-font-family$/,
112 },
113 ];
114
115 const routeNamedPresets = (attributes, patch, presetSlugs) => {
116 const strippers = [];
117 let out = attributes;
118 for (const { named, path, slugs, classRe } of NAMED_PRESETS) {
119 const value = patch?.[named];
120 if (value == null) continue;
121 if (new Set(presetSlugs?.[slugs] ?? []).has(value)) {
122 out = unsetPath(out, path);
123 continue;
124 }
125 out = unsetPath(setPath(out, path, value), named);
126 strippers.push(classRe);
127 }
128 if (strippers.length) {
129 out = dropClasses(out, (cls) => strippers.some((re) => re.test(cls)));
130 }
131 return out;
132 };
133
134 // Which color side a cleared path removes, so its baked preset class goes too.
135 const CLEAR_COLOR_SIDE = {
136 backgroundColor: 'background',
137 'style.color.background': 'background',
138 textColor: 'text',
139 'style.color.text': 'text',
140 };
141
142 // A routed field lands in a named attr or an inline style; clearing must unset both.
143 const ROUTED_PAIRS = [
144 ['backgroundColor', 'style.color.background'],
145 ['textColor', 'style.color.text'],
146 ['gradient', 'style.color.gradient'],
147 ...NAMED_PRESETS.map(({ named, path }) => [named, path]),
148 ];
149 const CLEAR_ALIASES = Object.fromEntries(
150 ROUTED_PAIRS.flatMap((pair) => pair.map((p) => [p, pair])),
151 );
152
153 // null means "no change", so removal has its own channel: paths listed to reset.
154 const applyClears = (attributes, clear) => {
155 const sides = {};
156 let out = attributes;
157 for (const path of clear) {
158 for (const p of CLEAR_ALIASES[path] ?? [path]) out = unsetPath(out, p);
159 if (CLEAR_COLOR_SIDE[path]) sides[CLEAR_COLOR_SIDE[path]] = true;
160 }
161 if (sides.text || sides.background) {
162 out = dropClasses(out, isPresetColorClass(sides));
163 }
164 return out;
165 };
166
167 // The model always says `text`; blocks that keep theirs elsewhere need the remap.
168 const remapText = (block, patch) => {
169 if (patch?.text == null) return patch;
170 const attributes = getBlockType(block.name)?.attributes ?? {};
171 const real = RICH_TEXT_ATTRIBUTES.find((name) => attributes[name]);
172 if (!real || real === 'text') return patch;
173 const { text, ...rest } = patch;
174 return { ...rest, [real]: text };
175 };
176
177 // Re-serializing runs the block's own save(), which core/button needs to render
178 // the color/border styles it marks __experimentalSkipSerialization.
179 export const applyBlockPatch = (
180 serializedBlock,
181 patch,
182 clear = [],
183 presetSlugs = {},
184 ) => {
185 const blocks = parse(serializedBlock).map((block) => {
186 if (!block.name) return block;
187 const merged = deepMerge(block.attributes, remapText(block, patch));
188 const routed = routeNamedPresets(
189 routeColors(merged, patch, presetSlugs.color),
190 patch,
191 presetSlugs,
192 );
193 return { ...block, attributes: applyClears(routed, clear ?? []) };
194 });
195 return serialize(blocks);
196 };
197