| 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 |
|