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